Skip to content

Commit bbd07eb

Browse files
bochencwxraychen911
authored andcommitted
feat: 动态创建子Agent支持在运行时产生的事件直接转发到父流程的事件流中的能力
1 parent 89c44ce commit bbd07eb

13 files changed

Lines changed: 885 additions & 42 deletions

File tree

docs/mkdocs/en/sub_agent.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ A **short-lived sub-agent** is a natural fit for these problems: a fresh context
2121

2222
The difference is *who defines the role*: the developer (Spawn) or the LLM (Dynamic).
2323

24+
### How this differs from other multi-agent mechanisms
25+
26+
The framework already offers several ways to compose agents (see [Multi Agents](multi_agents.md)). Spawned Sub-Agents solve a different problem:
27+
28+
| Mechanism | Agents involved | Who decides when to invoke | Context | Typical use |
29+
| --- | --- | --- | --- | --- |
30+
| **Chain / Parallel / Cycle Agent** | **Pre-built** fixed agent instances | **Deterministic** orchestration — run in list order / in parallel / in a loop, regardless of input | Each agent independent | Fixed multi-step workflows |
31+
| **Sub Agents (transfer)** | Pre-registered agents | Parent **transfers control** at runtime; the sub-agent then takes over the conversation | Shared session | **Hand off** the whole conversation to a better-suited agent |
32+
| **AgentTool** | Wraps **an existing agent instance** as a tool | Parent LLM calls it on demand | Shares/syncs state & artifacts back to parent | Reuse a **specific, already-built** agent |
33+
| **Spawned Sub-Agents** | **Created on the fly** per call, destroyed after | Parent LLM calls it on demand | **Strictly isolated**: fresh ephemeral session, history/state not shared by default | Delegate a **one-off** subtask while keeping the parent context clean |
34+
35+
In one line: **Chain/Parallel/Cycle** deterministically orchestrate a fixed set of agents; **transfer** hands the conversation off; **AgentTool** reuses one existing agent as a tool; while **Spawned Sub-Agents** create an **isolated, short-lived** sub-agent on the spot for a single task and discard it afterward — the emphasis is on **on-demand runtime creation** and **context isolation**, not reusing an existing agent or transferring control.
36+
2437
## Quick Start
2538

2639
```python
@@ -173,8 +186,19 @@ class SubAgentConfig:
173186

174187
max_turns: int | None = None
175188
"""Max LLM calls the sub-agent may make. None = unlimited."""
189+
190+
forward_events: bool = False
191+
"""Whether to forward the sub-agent's execution events to the parent
192+
runner's consumer as progress updates.
193+
194+
True: the orchestrator can display the sub-agent's execution live (model
195+
output, tool calls, tool results); the parent agent's LLM still receives
196+
only the sub-agent's final result. False (default): the sub-agent runs
197+
silently and only its final result is returned."""
176198
```
177199

200+
Forwarded events reach the consumer as progress events; they are **not** written to the parent session and **never** enter the parent agent's LLM context. Consumers identify them via `tool_progress=True` on `event.custom_metadata` and read the execution from `payload` (`author` / `partial` / `content`, plus optional `error` / `usage`).
201+
178202
## Usage
179203

180204
### SpawnSubAgentTool
@@ -270,3 +294,4 @@ orchestrator = LlmAgent(
270294
- **Session isolation**: sub-agents run in a fresh ephemeral session. Parent history is not shared by default; opt in via `include_parent_history=True`.
271295
- **Nesting**: 1-level hard cap. Sub-agents cannot spawn further sub-agents.
272296
- **Result shape**: the sub-agent's final text is returned as the tool result string.
297+
- **Live execution (`forward_events`)**: set `SubAgentConfig(forward_events=True)` to stream the sub-agent's execution to the parent runner's consumer for display. Forwarded events are progress events — they never enter the parent LLM's context, which still receives only the final result. Consumers detect them via `tool_progress=True` on `event.custom_metadata` and read `payload`. See `examples/dynamic_subagent` and `examples/spawn_subagent` for a working consumer.

docs/mkdocs/zh/sub_agent.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@
2121

2222
区别在于**谁定义角色**:开发者(Spawn)还是 LLM(Dynamic)。
2323

24+
### 与框架其他多 agent 机制的区别
25+
26+
框架已有几种组合 agent 的方式(详见 [Multi Agents](multi_agents.md))。Spawned Sub-Agents 与它们解决的是不同问题:
27+
28+
| 机制 | 参与的 agent | 谁决定何时调用 | 上下文 | 典型用途 |
29+
| --- | --- | --- | --- | --- |
30+
| **Chain / Parallel / Cycle Agent** | **预先构建**的固定 agent 实例 | **确定性**编排——按列表顺序/并行/循环执行,与输入无关 | 各 agent 独立 | 固定的多步工作流 |
31+
| **Sub Agents(transfer)** | 预先注册的 agent | 父 agent 运行时**转移控制权**,之后由子 agent 接管对话 | 共享同一会话 | 把整段对话**移交**给更合适的 agent |
32+
| **AgentTool** |**某个已有 agent 实例**包成工具 | 父 LLM 按需调用 | 会共享/同步 state 与 artifact 回父 | 复用一个**具体的、已存在的** agent |
33+
| **Spawned Sub-Agents** | **调用时临时创建**、用完即销毁 | 父 LLM 按需调用 | **严格隔离**:全新临时会话,默认不共享历史/state | 委派**一次性**子任务,保持父上下文干净 |
34+
35+
一句话概括:**Chain/Parallel/Cycle** 是"确定性编排一组固定 agent";**transfer** 是"把对话交出去";**AgentTool** 是"把一个已有 agent 当工具复用";而 **Spawned Sub-Agents** 是"为单次任务**现场造一个隔离的、短命的**子 agent,跑完就丢"——强调的是**运行时按需创建****上下文隔离**,而非复用既有 agent 或转移控制。
36+
2437
## Quick Start
2538

2639
```python
@@ -173,8 +186,17 @@ class SubAgentConfig:
173186

174187
max_turns: int | None = None
175188
"""子 agent 最多可发起的 LLM 调用次数。None = 不限制。"""
189+
190+
forward_events: bool = False
191+
"""是否将子 agent 的执行事件转发给父 runner 的消费者,作为进度更新。
192+
193+
True:编排层可实时展示子 agent 的执行(模型输出、工具调用、工具结果),
194+
父 agent 的 LLM 仍只收到子 agent 的最终结果。
195+
False(默认):子 agent 静默执行,只回传最终结果。"""
176196
```
177197

198+
转发的事件以进度事件形式到达消费者,**不会**写入父会话、也**不会**进入父 agent 的 LLM 上下文;消费者通过 `event.custom_metadata` 上的 `tool_progress=True` 识别它们,并从 `payload` 读取执行内容(`author` / `partial` / `content` / 可选 `error` / `usage`)。
199+
178200
## 使用方式
179201

180202
### SpawnSubAgentTool
@@ -270,3 +292,4 @@ orchestrator = LlmAgent(
270292
- **会话隔离**:子 agent 在全新临时会话中运行,默认不共享父会话历史。通过 `include_parent_history=True` 可注入。
271293
- **嵌套限制**:1 层硬限,子 agent 无法再次 spawn。
272294
- **结果形态**:子 agent 的最终文本作为 tool result 字符串返回。
295+
- **实时执行(`forward_events`**:设置 `SubAgentConfig(forward_events=True)` 可将子 agent 的执行流转发给父 runner 的消费者用于展示。转发事件为进度事件——它们不会进入父 agent 的 LLM 上下文(父仍只收到最终结果)。消费者通过 `event.custom_metadata` 上的 `tool_progress=True` 识别它们并读取 `payload`。可运行示例见 `examples/dynamic_subagent``examples/spawn_subagent`

examples/dynamic_subagent/agent/agent.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ def create_minimal_agent() -> LlmAgent:
4545
temperature=0.7,
4646
max_output_tokens=2000,
4747
),
48-
tools=workspace_tools + [DynamicSubAgentTool()],
48+
tools=workspace_tools + [
49+
DynamicSubAgentTool(
50+
# Stream the sub-agent's execution to the parent consumer.
51+
agent_config=SubAgentConfig(forward_events=True),
52+
),
53+
],
4954
)
5055

5156

@@ -69,6 +74,8 @@ def create_bounded_agent() -> LlmAgent:
6974
temperature=0.3,
7075
max_output_tokens=1000,
7176
),
77+
# Stream the sub-agent's execution to the parent consumer.
78+
forward_events=True,
7279
),
7380
),
7481
],

examples/dynamic_subagent/run_agent.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,45 @@
3333

3434
def _truncate(text: str, max_len: int = 200) -> str:
3535
"""Truncate long tool output for display."""
36-
return text
3736
if not isinstance(text, str):
3837
text = str(text)
3938
if len(text) <= max_len:
4039
return text
4140
return text[:max_len] + f"\n... (truncated, total {len(text)} chars)"
4241

4342

43+
def _print_subagent_progress(payload: dict) -> None:
44+
"""Render one forwarded sub-agent execution event.
45+
46+
``payload`` is a :class:`SubAgentProgress` dict: ``author`` / ``partial``,
47+
the framework-native ``content`` dump (``parts`` with ``function_call`` /
48+
``function_response`` / ``text`` / ``thought``), and optional ``error`` /
49+
``usage``. Indented under the parent output so the sub-agent's steps are
50+
visually distinct from the orchestrator's.
51+
"""
52+
name = payload.get("author") or "subagent"
53+
# Errors first — always surface them, even on an otherwise-partial event.
54+
err = payload.get("error")
55+
if err:
56+
print(f"\n \U0001F9E9 [{name}] !! error {err.get('code')}: {err.get('message')}")
57+
if payload.get("partial"):
58+
# Skip streaming text deltas to keep the demo output readable; the
59+
# non-partial steps below already summarize the sub-agent's work.
60+
return
61+
parts = (payload.get("content") or {}).get("parts") or []
62+
has_calls = any(p.get("function_call") or p.get("function_response") for p in parts)
63+
for p in parts:
64+
fc = p.get("function_call")
65+
if fc:
66+
print(f"\n \U0001F9E9 [{name}] -> tool {fc.get('name')}({_truncate(fc.get('args'))})")
67+
fr = p.get("function_response")
68+
if fr:
69+
print(f" \U0001F9E9 [{name}] <- {_truncate(fr.get('response'))}")
70+
text = p.get("text")
71+
if text and not p.get("thought") and not has_calls:
72+
print(f"\n \U0001F9E9 [{name}] {_truncate(text)}")
73+
74+
4475
_QUERIES = {
4576
"minimal": [
4677
# Simple task: orchestrator may call word_count directly.
@@ -93,6 +124,18 @@ async def run_demo(mode: str):
93124
session_id=current_session_id,
94125
new_message=user_content,
95126
):
127+
# Forwarded sub-agent execution events (SubAgentConfig
128+
# forward_events=True). These are partial progress events carrying
129+
# the sub-agent's own steps under custom_metadata.payload; they
130+
# never reach the parent LLM's context. This demo registers only the
131+
# sub-agent tool, so tool_progress alone identifies them; an app with
132+
# several progress tools would also branch on meta["tool_name"].
133+
meta = event.custom_metadata or {}
134+
payload = meta.get("payload")
135+
if meta.get("tool_progress") and isinstance(payload, dict):
136+
_print_subagent_progress(payload)
137+
continue
138+
96139
if event.content and event.content.parts and event.author != "user":
97140
if event.partial:
98141
for part in event.content.parts:

examples/spawn_subagent/agent/agent.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from trpc_agent_sdk.agents.sub_agent import EXPLORE_AGENT
2626
from trpc_agent_sdk.agents.sub_agent import PLAN_AGENT
2727
from trpc_agent_sdk.agents.sub_agent import SubAgentArchetype
28+
from trpc_agent_sdk.agents.sub_agent import SubAgentConfig
2829
from trpc_agent_sdk.tools import SpawnSubAgentTool
2930
from trpc_agent_sdk.models import LLMModel
3031
from trpc_agent_sdk.models import OpenAIModel
@@ -52,7 +53,11 @@ def create_default_agent() -> LlmAgent:
5253
description="Coding assistant with spawn_subagent in zero-config mode.",
5354
model=_create_model(),
5455
instruction=INSTRUCTION,
55-
tools=[ReadTool(), GlobTool(), GrepTool(), SpawnSubAgentTool()],
56+
tools=[ReadTool(), GlobTool(), GrepTool(),
57+
SpawnSubAgentTool(
58+
# Stream the sub-agent's execution to the parent consumer.
59+
agent_config=SubAgentConfig(forward_events=True),
60+
)],
5661
)
5762

5863

@@ -88,7 +93,11 @@ def create_code_agent() -> LlmAgent:
8893
instruction=INSTRUCTION,
8994
tools=[
9095
ReadTool(), GlobTool(), GrepTool(),
91-
SpawnSubAgentTool(agents=[_SECURITY_AUDITOR, EXPLORE_AGENT, PLAN_AGENT]),
96+
SpawnSubAgentTool(
97+
agents=[_SECURITY_AUDITOR, EXPLORE_AGENT, PLAN_AGENT],
98+
# Stream the sub-agent's execution to the parent consumer.
99+
agent_config=SubAgentConfig(forward_events=True),
100+
),
92101
],
93102
)
94103

@@ -109,7 +118,11 @@ def create_md_agent() -> LlmAgent:
109118
instruction=INSTRUCTION,
110119
tools=[
111120
ReadTool(), GlobTool(), GrepTool(),
112-
SpawnSubAgentTool(agents=[EXPLORE_AGENT, PLAN_AGENT], agent_paths=[_AGENTS_PATH]),
121+
SpawnSubAgentTool(
122+
agents=[EXPLORE_AGENT, PLAN_AGENT], agent_paths=[_AGENTS_PATH],
123+
# Stream the sub-agent's execution to the parent consumer.
124+
agent_config=SubAgentConfig(forward_events=True),
125+
),
113126
],
114127
)
115128

examples/spawn_subagent/run_agent.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,38 @@ def _truncate(text: str, max_len: int = 200) -> str:
4545
return text[:max_len] + f"\n... (truncated, total {len(text)} chars)"
4646

4747

48+
def _print_subagent_progress(payload: dict) -> None:
49+
"""Render one forwarded sub-agent execution event.
50+
51+
``payload`` is a :class:`SubAgentProgress` dict: ``author`` / ``partial``,
52+
the framework-native ``content`` dump (``parts`` with ``function_call`` /
53+
``function_response`` / ``text`` / ``thought``), and optional ``error`` /
54+
``usage``. Indented under the parent output so the sub-agent's steps are
55+
visually distinct from the assistant's.
56+
"""
57+
name = payload.get("author") or "subagent"
58+
# Errors first — always surface them, even on an otherwise-partial event.
59+
err = payload.get("error")
60+
if err:
61+
print(f"\n \U0001F9E9 [{name}] !! error {err.get('code')}: {err.get('message')}")
62+
if payload.get("partial"):
63+
# Skip streaming text deltas to keep the demo output readable; the
64+
# non-partial steps below already summarize the sub-agent's work.
65+
return
66+
parts = (payload.get("content") or {}).get("parts") or []
67+
has_calls = any(p.get("function_call") or p.get("function_response") for p in parts)
68+
for p in parts:
69+
fc = p.get("function_call")
70+
if fc:
71+
print(f"\n \U0001F9E9 [{name}] -> tool {fc.get('name')}({_truncate(fc.get('args'))})")
72+
fr = p.get("function_response")
73+
if fr:
74+
print(f" \U0001F9E9 [{name}] <- {_truncate(fr.get('response'))}")
75+
text = p.get("text")
76+
if text and not p.get("thought") and not has_calls:
77+
print(f"\n \U0001F9E9 [{name}] {_truncate(text)}")
78+
79+
4880
# Queries per mode — simple tasks (parent handles directly) vs complex
4981
# tasks (delegated to a sub-agent).
5082
_SHARED_AGENT_QUERIES = [
@@ -111,6 +143,18 @@ async def run_demo(mode: str):
111143
session_id=current_session_id,
112144
new_message=user_content,
113145
):
146+
# Forwarded sub-agent execution events (SubAgentConfig
147+
# forward_events=True). These are partial progress events carrying
148+
# the sub-agent's own steps under custom_metadata.payload; they
149+
# never reach the parent LLM's context. This demo registers only the
150+
# sub-agent tool, so tool_progress alone identifies them; an app with
151+
# several progress tools would also branch on meta["tool_name"].
152+
meta = event.custom_metadata or {}
153+
payload = meta.get("payload")
154+
if meta.get("tool_progress") and isinstance(payload, dict):
155+
_print_subagent_progress(payload)
156+
continue
157+
114158
if event.content and event.content.parts and event.author != "user":
115159
if event.partial:
116160
for part in event.content.parts:

tests/agents/sub_agent/test_dynamic_sub_agent_tool.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,57 @@ def test_constructor_with_config() -> None:
3939
def test_constructor_skip_summarization() -> None:
4040
t = DynamicSubAgentTool(skip_summarization=True)
4141
assert t._skip_summarization is True
42+
# Exposed as a property for the progress-streaming execution path.
43+
assert t.skip_summarization is True
44+
45+
46+
def test_is_progress_streaming_default_off() -> None:
47+
"""Without forward_events, the tool runs on the non-streaming path."""
48+
assert DynamicSubAgentTool().is_progress_streaming is False
49+
assert DynamicSubAgentTool(agent_config=SubAgentConfig()).is_progress_streaming is False
50+
51+
52+
def test_is_progress_streaming_on_when_forwarding() -> None:
53+
t = DynamicSubAgentTool(agent_config=SubAgentConfig(forward_events=True))
54+
assert t.is_progress_streaming is True
55+
56+
57+
@pytest.mark.asyncio
58+
async def test_run_streaming_empty_prompt_yields_error() -> None:
59+
"""run_streaming surfaces the prompt validation error as its only value."""
60+
t = DynamicSubAgentTool(agent_config=SubAgentConfig(forward_events=True))
61+
ctx = MagicMock()
62+
yielded = [v async for v in t.run_streaming(tool_context=ctx, args={"prompt": " "})]
63+
assert len(yielded) == 1
64+
assert yielded[0]["status"] == "error"
65+
assert "prompt" in yielded[0]["message"]
66+
67+
68+
@pytest.mark.asyncio
69+
async def test_run_streaming_forwards_projections_then_result() -> None:
70+
"""run_streaming delegates to run_subagent_streaming, yielding its values in order."""
71+
from unittest.mock import patch
72+
73+
t = DynamicSubAgentTool(agent_config=SubAgentConfig(forward_events=True))
74+
ctx = MagicMock()
75+
76+
async def _fake_stream(**kwargs):
77+
yield {"author": "subagent_dynamic", "partial": True, "content": {"parts": [{"text": "step 1"}]}}
78+
yield "final result"
79+
80+
with patch(
81+
"trpc_agent_sdk.agents.sub_agent._dynamic_sub_agent_tool.run_subagent_streaming",
82+
_fake_stream,
83+
):
84+
yielded = [v async for v in t.run_streaming(
85+
tool_context=ctx,
86+
args={"instruction": "You are a helper.", "prompt": "do it"},
87+
)]
88+
89+
assert yielded == [
90+
{"author": "subagent_dynamic", "partial": True, "content": {"parts": [{"text": "step 1"}]}},
91+
"final result",
92+
]
4293

4394

4495
def test_constructor_custom_name() -> None:

0 commit comments

Comments
 (0)