Skip to content

Commit 3b6f962

Browse files
authored
Support nested sub agents (#262)
* Add hierarchical sub-agent support - Enhanced service.py to handle sub-agent message filtering - Added langgraph_supervisor_hierarchy_agent.py with 3-layer hierarchy example - Added comprehensive test for hierarchical agent message flow - Updated test fixtures for better import handling This builds on upstream's basic subgraph support (2e6c622) by adding: - Sophisticated node detection for supervisors and sub-agents - Proper message handling for handback tools and results - Support for nested agent hierarchies with proper naming conventions * Add hierarchical sub-agent UI support Enhanced Streamlit UI to properly display nested agent hierarchies: - Different visual indicators for sub-agents (💼) vs tools (🛠️) - Recursive handling of nested sub-agent transfers - Proper status container management for multi-level hierarchies - Support for transfer_back_to handoff messages - Expanded status containers for better visibility - Updated tests to match new UI labels This complements the service layer changes by providing a clear visual representation of complex agent hierarchies in the UI. * Add comprehensive UI tests for hierarchical sub-agents Added test fixtures and test cases to validate: - Multi-agent message fixtures for reusable test data - Hierarchical sub-agent UI rendering with proper status containers - Visual indicators (💼 for sub-agents, 🛠️ for tools) - Popover functionality for tool calls within sub-agents - Proper message flow through transfer_to/transfer_back_to patterns These tests ensure the UI correctly displays complex agent hierarchies with proper visual organization and user experience. * Add comprehensive hierarchical sub-agent UI test suite Added three critical test patterns for hierarchical sub-agents: 1. test_app_streaming_single_sub_agent: - Tests single sub-agent with multiple tool calls - Validates popover functionality for tools within sub-agents - Ensures proper status container organization 2. test_app_streaming_sequential_sub_agents: - Tests supervisor -> agent A -> supervisor -> agent C -> supervisor flow - Validates sequential agent handoffs with proper UI separation - Ensures multiple status containers are handled correctly 3. test_app_streaming_nested_sub_agents: - Tests true nesting: supervisor -> agent A -> agent B -> agent A -> supervisor - Validates recursive status container nesting - Ensures proper visual hierarchy for deeply nested agents These tests provide comprehensive coverage of all hierarchical patterns and ensure the UI correctly handles complex multi-agent workflows. * Remove unnecessary test * Unneeded extra check * Update streamlit version + agent desc * remove unneeded hardcoded behaviour * Revert unneeded change * Run precommit
1 parent 28e3df9 commit 3b6f962

12 files changed

+3002
-2437
lines changed

.dockerignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ env
1010
venv
1111
.venv
1212
*.db
13-
privatecredentials/*
13+
privatecredentials/*

docs/File_Based_Credentials.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# File Based Crendentials
1+
# File Based Crendentials
22

33
As you develop your agents, you might discover that you have credentials that you need to store on disk that you don't want stored in your Git Repo or baked into your container image.
44

@@ -7,13 +7,13 @@ Examples:
77
- Certificates or private keys needed for communication with external APIs
88

99

10-
The `privatecredentials/` folder give you a quick place to put these files in development.
10+
The `privatecredentials/` folder give you a quick place to put these files in development.
1111

1212

1313
## How it works
1414

1515
*Protection*
16-
- The .dockerignore file excludes the entire folder to keep it out of the build process.
16+
- The .dockerignore file excludes the entire folder to keep it out of the build process.
1717
- The .gitignore files only allows the `.gitkeep` file -- since git doesn't track empty folders.
1818

1919

@@ -34,7 +34,7 @@ The syncing feature of Docker Watch isn't used for these reasons:
3434

3535
For each file based credential, do the following:
3636
1. Put the file (e.g. `example-creds.txt`) into the `privatecredentials/` folder
37-
2. In your `.env` file, create an environment variable for the credential (e.g `EXAMPLE_CREDENTIAL=/privatecredentials/example-creds.txt`) that your agent will use to reference the location at runtime
37+
2. In your `.env` file, create an environment variable for the credential (e.g `EXAMPLE_CREDENTIAL=/privatecredentials/example-creds.txt`) that your agent will use to reference the location at runtime
3838
3. In your agent, use the environment variable wherever you need the path to the credential
3939

4040

@@ -72,5 +72,3 @@ There are a number of approaches:
7272
- Use the secrets management feature of your cloud hosting environment (Google Cloud Secrets, AWS Secrets Manager, etc)
7373
- Use a 3rd party secrets management platform
7474
- Manually place the credentials on your Docker hosts and mount volumes to map the credentials to the container (Less secure)
75-
76-

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ dependencies = [
5252
"pyowm ~=3.3.0",
5353
"python-dotenv ~=1.0.1",
5454
"setuptools ~=75.6.0",
55-
"streamlit ~=1.40.1",
55+
"streamlit ~=1.46.0",
5656
"tiktoken >=0.8.0",
5757
"uvicorn ~=0.32.1",
5858

@@ -77,7 +77,7 @@ client = [
7777
"httpx~=0.27.2",
7878
"pydantic ~=2.10.1",
7979
"python-dotenv ~=1.0.1",
80-
"streamlit~=1.40.1",
80+
"streamlit~=1.46.0",
8181
]
8282

8383
[tool.ruff]

src/agents/agents.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from agents.interrupt_agent import interrupt_agent
1010
from agents.knowledge_base_agent import kb_agent
1111
from agents.langgraph_supervisor_agent import langgraph_supervisor_agent
12+
from agents.langgraph_supervisor_hierarchy_agent import langgraph_supervisor_hierarchy_agent
1213
from agents.rag_assistant import rag_assistant
1314
from agents.research_assistant import research_assistant
1415
from schema import AgentInfo
@@ -40,6 +41,10 @@ class Agent:
4041
"langgraph-supervisor-agent": Agent(
4142
description="A langgraph supervisor agent", graph=langgraph_supervisor_agent
4243
),
44+
"langgraph-supervisor-hierarchy-agent": Agent(
45+
description="A langgraph supervisor agent with a nested hierarchy of agents",
46+
graph=langgraph_supervisor_hierarchy_agent,
47+
),
4348
"interrupt-agent": Agent(description="An agent the uses interrupts.", graph=interrupt_agent),
4449
"knowledge-base-agent": Agent(
4550
description="A retrieval-augmented generation agent using Amazon Bedrock Knowledge Base",

src/agents/langgraph_supervisor_agent.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ def web_search(query: str) -> str:
3131
math_agent = create_react_agent(
3232
model=model,
3333
tools=[add, multiply],
34-
name="math_expert",
34+
name="sub-agent-math_expert",
3535
prompt="You are a math expert. Always use one tool at a time.",
3636
).with_config(tags=["skip_stream"])
3737

3838
research_agent = create_react_agent(
3939
model=model,
4040
tools=[web_search],
41-
name="research_expert",
41+
name="sub-agent-research_expert",
4242
prompt="You are a world class researcher with access to web search. Do not do any math.",
4343
).with_config(tags=["skip_stream"])
4444

@@ -51,7 +51,9 @@ def web_search(query: str) -> str:
5151
"For current events, use research_agent. "
5252
"For math problems, use math_agent."
5353
),
54-
add_handoff_back_messages=False,
54+
add_handoff_back_messages=True,
55+
# UI now expects this to be True so we don't have to guess when a handoff back occurs
56+
output_mode="full_history", # otherwise when reloading conversations, the sub-agents' messages are not included
5557
)
5658

5759
langgraph_supervisor_agent = workflow.compile()
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from langgraph.prebuilt import create_react_agent
2+
from langgraph_supervisor import create_supervisor
3+
4+
from agents.langgraph_supervisor_agent import add, multiply, web_search
5+
from core import get_model, settings
6+
7+
model = get_model(settings.DEFAULT_MODEL)
8+
9+
10+
def workflow(chosen_model):
11+
math_agent = create_react_agent(
12+
model=chosen_model,
13+
tools=[add, multiply],
14+
name="sub-agent-math_expert", # Identify the graph node as a sub-agent
15+
prompt="You are a math expert. Always use one tool at a time.",
16+
).with_config(tags=["skip_stream"])
17+
18+
research_agent = (
19+
create_supervisor(
20+
[math_agent],
21+
model=chosen_model,
22+
tools=[web_search],
23+
prompt="You are a world class researcher with access to web search. Do not do any math, you have a math expert for that. ",
24+
supervisor_name="supervisor-research_expert", # Identify the graph node as a supervisor to the math agent
25+
)
26+
.compile(
27+
name="sub-agent-research_expert"
28+
) # Identify the graph node as a sub-agent to the main supervisor
29+
.with_config(tags=["skip_stream"])
30+
) # Stream tokens are ignored for sub-agents in the UI
31+
32+
# Create supervisor workflow
33+
return create_supervisor(
34+
[research_agent],
35+
model=chosen_model,
36+
prompt=(
37+
"You are a team supervisor managing a research expert with math capabilities."
38+
"For current events, use research_agent. "
39+
),
40+
add_handoff_back_messages=True,
41+
# UI now expects this to be True so we don't have to guess when a handoff back occurs
42+
output_mode="full_history", # otherwise when reloading conversations, the sub-agents' messages are not included
43+
) # default name for supervisor is "supervisor".
44+
45+
46+
langgraph_supervisor_hierarchy_agent = workflow(model).compile()

src/service/service.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -233,17 +233,17 @@ async def message_generator(
233233
updates = updates or {}
234234
update_messages = updates.get("messages", [])
235235
# special cases for using langgraph-supervisor library
236-
if node == "supervisor":
237-
# Get only the last ToolMessage since is it added by the
238-
# langgraph lib and not actual AI output so it won't be an
239-
# independent event
236+
if "supervisor" in node or "sub-agent" in node:
237+
# the only tools that come from the actual agent are the handoff and handback tools
240238
if isinstance(update_messages[-1], ToolMessage):
241-
update_messages = [update_messages[-1]]
239+
if "sub-agent" in node and len(update_messages) > 1:
240+
# If this is a sub-agent, we want to keep the last 2 messages - the handback tool, and it's result
241+
update_messages = update_messages[-2:]
242+
else:
243+
# If this is a supervisor, we want to keep the last message only - the handoff result. The tool comes from the 'agent' node.
244+
update_messages = [update_messages[-1]]
242245
else:
243246
update_messages = []
244-
245-
if node in ("research_expert", "math_expert"):
246-
update_messages = []
247247
new_messages.extend(update_messages)
248248

249249
if stream_mode == "custom":

src/streamlit_app.py

Lines changed: 71 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -325,19 +325,30 @@ async def draw_messages(
325325
# correct status container.
326326
call_results = {}
327327
for tool_call in msg.tool_calls:
328+
# Use different labels for transfer vs regular tool calls
329+
if "transfer_to" in tool_call["name"]:
330+
label = f"""💼 Sub Agent: {tool_call["name"]}"""
331+
else:
332+
label = f"""🛠️ Tool Call: {tool_call["name"]}"""
333+
328334
status = st.status(
329-
f"""Tool Call: {tool_call["name"]}""",
335+
label,
330336
state="running" if is_new else "complete",
331337
)
332338
call_results[tool_call["id"]] = status
333-
status.write("Input:")
334-
status.write(tool_call["args"])
335339

336340
# Expect one ToolMessage for each tool call.
337341
for tool_call in msg.tool_calls:
338342
if "transfer_to" in tool_call["name"]:
339-
await handle_agent_msgs(messages_agen, call_results, is_new)
343+
status = call_results[tool_call["id"]]
344+
status.update(expanded=True)
345+
await handle_sub_agent_msgs(messages_agen, status, is_new)
340346
break
347+
348+
# Only non-transfer tool calls reach this point
349+
status = call_results[tool_call["id"]]
350+
status.write("Input:")
351+
status.write(tool_call["args"])
341352
tool_result: ChatMessage = await anext(messages_agen)
342353

343354
if tool_result.type != "tool":
@@ -417,58 +428,90 @@ async def handle_feedback() -> None:
417428
st.toast("Feedback recorded", icon=":material/reviews:")
418429

419430

420-
async def handle_agent_msgs(messages_agen, call_results, is_new):
431+
async def handle_sub_agent_msgs(messages_agen, status, is_new):
421432
"""
422433
This function segregates agent output into a status container.
423434
It handles all messages after the initial tool call message
424435
until it reaches the final AI message.
436+
437+
Enhanced to support nested multi-agent hierarchies with handoff back messages.
438+
439+
Args:
440+
messages_agen: Async generator of messages
441+
status: the status container for the current agent
442+
is_new: Whether messages are new or replayed
425443
"""
426444
nested_popovers = {}
427-
# looking for the Success tool call message
445+
446+
# looking for the transfer Success tool call message
428447
first_msg = await anext(messages_agen)
429448
if is_new:
430449
st.session_state.messages.append(first_msg)
431-
status = call_results.get(getattr(first_msg, "tool_call_id", None))
432-
# Process first message
433-
if status and first_msg.content:
434-
status.write(first_msg.content)
435-
# Continue reading until finish_reason='stop'
450+
451+
# Continue reading until we get an explicit handoff back
436452
while True:
437-
# Check for completion on current message
438-
finish_reason = getattr(first_msg, "response_metadata", {}).get("finish_reason")
439-
# Break out of status container if finish_reason is anything other than "tool_calls"
440-
if finish_reason is not None and finish_reason != "tool_calls":
441-
if status:
442-
status.update(state="complete")
443-
break
444453
# Read next message
445454
sub_msg = await anext(messages_agen)
455+
446456
# this should only happen is skip_stream flag is removed
447457
# if isinstance(sub_msg, str):
448458
# continue
459+
449460
if is_new:
450461
st.session_state.messages.append(sub_msg)
451462

463+
# Handle tool results with nested popovers
452464
if sub_msg.type == "tool" and sub_msg.tool_call_id in nested_popovers:
453465
popover = nested_popovers[sub_msg.tool_call_id]
454466
popover.write("**Output:**")
455467
popover.write(sub_msg.content)
456-
first_msg = sub_msg
457468
continue
458-
# Display content and tool calls using the same status
469+
470+
# Handle transfer_back_to tool calls - these indicate a sub-agent is returning control
471+
if (
472+
hasattr(sub_msg, "tool_calls")
473+
and sub_msg.tool_calls
474+
and any("transfer_back_to" in tc.get("name", "") for tc in sub_msg.tool_calls)
475+
):
476+
# Process transfer_back_to tool calls
477+
for tc in sub_msg.tool_calls:
478+
if "transfer_back_to" in tc.get("name", ""):
479+
# Read the corresponding tool result
480+
transfer_result = await anext(messages_agen)
481+
if is_new:
482+
st.session_state.messages.append(transfer_result)
483+
484+
# After processing transfer back, we're done with this agent
485+
if status:
486+
status.update(state="complete")
487+
break
488+
489+
# Display content and tool calls in the same nested status
459490
if status:
460491
if sub_msg.content:
461492
status.write(sub_msg.content)
493+
462494
if hasattr(sub_msg, "tool_calls") and sub_msg.tool_calls:
463495
for tc in sub_msg.tool_calls:
464-
popover = status.popover(f"{tc['name']}", icon="🛠️")
465-
popover.write(f"**Tool:** {tc['name']}")
466-
popover.write("**Input:**")
467-
popover.write(tc["args"])
468-
# Store the popover reference using the tool call ID
469-
nested_popovers[tc["id"]] = popover
470-
# Update first_msg for next iteration
471-
first_msg = sub_msg
496+
# Check if this is a nested transfer/delegate
497+
if "transfer_to" in tc["name"]:
498+
# Create a nested status container for the sub-agent
499+
nested_status = status.status(
500+
f"""💼 Sub Agent: {tc["name"]}""",
501+
state="running" if is_new else "complete",
502+
expanded=True,
503+
)
504+
505+
# Recursively handle sub-agents of this sub-agent
506+
await handle_sub_agent_msgs(messages_agen, nested_status, is_new)
507+
else:
508+
# Regular tool call - create popover
509+
popover = status.popover(f"{tc['name']}", icon="🛠️")
510+
popover.write(f"**Tool:** {tc['name']}")
511+
popover.write("**Input:**")
512+
popover.write(tc["args"])
513+
# Store the popover reference using the tool call ID
514+
nested_popovers[tc["id"]] = popover
472515

473516

474517
if __name__ == "__main__":

0 commit comments

Comments
 (0)