From 10d0f75fe7646a35ab18d8dff2042d2d8909cb7f Mon Sep 17 00:00:00 2001 From: bochencwx Date: Fri, 3 Jul 2026 15:29:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=8A=A8=E6=80=81?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E5=AD=90agent=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mkdocs/en/multi_agents.md | 7 + docs/mkdocs/en/sub_agent.md | 272 +++++ docs/mkdocs/zh/multi_agents.md | 4 + docs/mkdocs/zh/sub_agent.md | 272 +++++ examples/dynamic_subagent/.env | 4 + examples/dynamic_subagent/README.md | 64 ++ examples/dynamic_subagent/agent/__init__.py | 7 + examples/dynamic_subagent/agent/agent.py | 78 ++ examples/dynamic_subagent/agent/config.py | 22 + examples/dynamic_subagent/agent/prompts.py | 38 + examples/dynamic_subagent/agent/tools.py | 108 ++ examples/dynamic_subagent/run_agent.py | 128 +++ examples/spawn_subagent/.env | 4 + .../.trpc_agents/security-auditor.md | 10 + examples/spawn_subagent/README.md | 58 ++ examples/spawn_subagent/agent/__init__.py | 7 + examples/spawn_subagent/agent/agent.py | 117 +++ examples/spawn_subagent/agent/config.py | 22 + examples/spawn_subagent/agent/prompts.py | 9 + examples/spawn_subagent/run_agent.py | 142 +++ examples/spawn_subagent/sample_repo/README.md | 14 + examples/spawn_subagent/sample_repo/app.py | 29 + examples/spawn_subagent/sample_repo/auth.py | 15 + examples/spawn_subagent/sample_repo/cart.py | 15 + examples/spawn_subagent/sample_repo/db.py | 15 + tests/agents/sub_agent/__init__.py | 6 + tests/agents/sub_agent/test_archetype.py | 103 ++ tests/agents/sub_agent/test_defaults.py | 90 ++ tests/agents/sub_agent/test_description.py | 117 +++ .../sub_agent/test_dynamic_sub_agent_tool.py | 315 ++++++ tests/agents/sub_agent/test_imports.py | 86 ++ tests/agents/sub_agent/test_loader.py | 440 ++++++++ tests/agents/sub_agent/test_registry.py | 77 ++ tests/agents/sub_agent/test_runner.py | 958 ++++++++++++++++++ .../sub_agent/test_spawn_sub_agent_tool.py | 269 +++++ trpc_agent_sdk/agents/sub_agent/__init__.py | 52 + trpc_agent_sdk/agents/sub_agent/_archetype.py | 89 ++ trpc_agent_sdk/agents/sub_agent/_constants.py | 30 + trpc_agent_sdk/agents/sub_agent/_defaults.py | 195 ++++ .../agents/sub_agent/_description.py | 74 ++ .../sub_agent/_dynamic_sub_agent_tool.py | 229 +++++ trpc_agent_sdk/agents/sub_agent/_loader.py | 229 +++++ trpc_agent_sdk/agents/sub_agent/_registry.py | 55 + trpc_agent_sdk/agents/sub_agent/_runner.py | 342 +++++++ .../agents/sub_agent/_spawn_sub_agent_tool.py | 208 ++++ .../agents/sub_agent/_sub_agent_config.py | 46 + trpc_agent_sdk/tools/__init__.py | 29 + 47 files changed, 5500 insertions(+) create mode 100644 docs/mkdocs/en/sub_agent.md create mode 100644 docs/mkdocs/zh/sub_agent.md create mode 100644 examples/dynamic_subagent/.env create mode 100644 examples/dynamic_subagent/README.md create mode 100644 examples/dynamic_subagent/agent/__init__.py create mode 100644 examples/dynamic_subagent/agent/agent.py create mode 100644 examples/dynamic_subagent/agent/config.py create mode 100644 examples/dynamic_subagent/agent/prompts.py create mode 100644 examples/dynamic_subagent/agent/tools.py create mode 100644 examples/dynamic_subagent/run_agent.py create mode 100644 examples/spawn_subagent/.env create mode 100644 examples/spawn_subagent/.trpc_agents/security-auditor.md create mode 100644 examples/spawn_subagent/README.md create mode 100644 examples/spawn_subagent/agent/__init__.py create mode 100644 examples/spawn_subagent/agent/agent.py create mode 100644 examples/spawn_subagent/agent/config.py create mode 100644 examples/spawn_subagent/agent/prompts.py create mode 100644 examples/spawn_subagent/run_agent.py create mode 100644 examples/spawn_subagent/sample_repo/README.md create mode 100644 examples/spawn_subagent/sample_repo/app.py create mode 100644 examples/spawn_subagent/sample_repo/auth.py create mode 100644 examples/spawn_subagent/sample_repo/cart.py create mode 100644 examples/spawn_subagent/sample_repo/db.py create mode 100644 tests/agents/sub_agent/__init__.py create mode 100644 tests/agents/sub_agent/test_archetype.py create mode 100644 tests/agents/sub_agent/test_defaults.py create mode 100644 tests/agents/sub_agent/test_description.py create mode 100644 tests/agents/sub_agent/test_dynamic_sub_agent_tool.py create mode 100644 tests/agents/sub_agent/test_imports.py create mode 100644 tests/agents/sub_agent/test_loader.py create mode 100644 tests/agents/sub_agent/test_registry.py create mode 100644 tests/agents/sub_agent/test_runner.py create mode 100644 tests/agents/sub_agent/test_spawn_sub_agent_tool.py create mode 100644 trpc_agent_sdk/agents/sub_agent/__init__.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_archetype.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_constants.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_defaults.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_description.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_dynamic_sub_agent_tool.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_loader.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_registry.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_runner.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_spawn_sub_agent_tool.py create mode 100644 trpc_agent_sdk/agents/sub_agent/_sub_agent_config.py diff --git a/docs/mkdocs/en/multi_agents.md b/docs/mkdocs/en/multi_agents.md index a6eb3c13..ff0991fd 100644 --- a/docs/mkdocs/en/multi_agents.md +++ b/docs/mkdocs/en/multi_agents.md @@ -311,6 +311,13 @@ Coordinator Agent (Main Entry Point) | `disallow_transfer_to_peers` | `False` | Set to `True` to prevent a child Agent from transferring control to peer Agents | | `default_transfer_message` | `None` | Custom transfer instruction that overrides the default transfer prompt | +#### Spawned Sub-Agents + +As an alternative to persistent `sub_agents` (transfer-based), you can spawn +short-lived sub-agents at run time via ``SpawnSubAgentTool`` (pick from a +pre-registered catalog) or ``DynamicSubAgentTool`` (LLM defines the role on the +fly). See [Sub-Agent Tools](sub_agent.md). + ## Compose Patterns (Compose Agents) Different orchestration patterns can be flexibly combined, connecting results of different stages via `output_key` to create more complex workflows: diff --git a/docs/mkdocs/en/sub_agent.md b/docs/mkdocs/en/sub_agent.md new file mode 100644 index 00000000..00e8d79f --- /dev/null +++ b/docs/mkdocs/en/sub_agent.md @@ -0,0 +1,272 @@ +# Spawned Sub-Agents + +Complex tasks often require delegated subtasks — computing results, searching codebases, auditing for security issues. Doing everything in the parent agent's own context causes several problems: + +- **Context pollution**: exploratory searches, tool outputs, and intermediate steps fill the context window, crowding out what matters. +- **Tool sprawl**: the parent carries every tool all the time, even though most subtasks only need a subset. +- **No role isolation**: the parent has one system prompt; it cannot adopt a different persona or constraints per subtask. +- **No outside perspective**: an agent reviewing its own work is inherently biased — it's unlikely to spot its own mistakes. A fresh context acts as a second pair of eyes, auditing code, challenging a design, or verifying a claim without the parent's assumptions and reasoning shortcuts. + +A **short-lived sub-agent** is a natural fit for these problems: a fresh context per delegation, only the tools it needs, a dedicated system prompt. It runs, returns its result, and is destroyed — keeping the parent conversation clean and focused. + +**Spawned Sub-Agents** give the parent agent two tools for creating short-lived sub-agents at run time: + +- **`SpawnSubAgentTool`** — choose from a **pre-defined catalog** of standardized specialists. Instruction, tool set, and model are locked by the archetype at construction time. + + Use this when you have a fixed set of expert roles (security auditor, code explorer, planner) and want the LLM to pick the right one per task. The parent LLM selects via `subagent_type` and writes a task-specific `prompt`, but cannot alter the sub-agent's instruction or tools. + +- **`DynamicSubAgentTool`** — the LLM **invents the specialist on the fly**, writing the instruction at call time. No pre-registration needed. + + Use this when you cannot predict all the specialist types you'll need ahead of time. Every call can define a different role — the LLM decides what expertise, constraints, and tool subset each task requires. + +The difference is *who defines the role*: the developer (Spawn) or the LLM (Dynamic). + +## Quick Start + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.tools import SpawnSubAgentTool, DynamicSubAgentTool + +# Spawn: pick from a catalog of pre-defined specialists +agent_with_spawn = LlmAgent( + name="orchestrator", + tools=[SpawnSubAgentTool()], # built-in `default` archetype +) + +# Dynamic: LLM writes the specialist's role at call time +agent_with_dynamic = LlmAgent( + name="orchestrator", + tools=[DynamicSubAgentTool()], # sub-agent inherits all parent tools +) +``` + +## Two Tools + +| | `SpawnSubAgentTool` | `DynamicSubAgentTool` | +| --- | --- | --- | +| **Pattern** | Pick from a pre-defined catalog | LLM invents role at call time | +| **Who defines the role** | Developer, two modes:
① `SubAgentArchetype` in code
② Markdown file (YAML frontmatter + body) | LLM (via `instruction` parameter) | +| **Best for** | Standardized, repeatable specialists | Roles you can't pre-register | +| **Role flexibility** | Locked — only `prompt` varies | Full — every call can be different | +| **Tool surface** | Locked by archetype | Inherits parent tools; LLM can narrow via `tools` | + +### `SpawnSubAgentTool` + +Dispatches tasks to pre-registered archetypes. The parent LLM picks the right specialist via `subagent_type`; its instruction and tools are fixed. + +```python +class SpawnSubAgentTool(BaseTool): + def __init__( + self, + agents: list[SubAgentArchetype] | None = None, + agent_paths: list[str | os.PathLike] | None = None, + tool_mapping: dict[str, Any] | None = None, + with_default: bool = True, + agent_config: SubAgentConfig | None = None, + skip_summarization: bool = False, + filters_name: list[str] | None = None, + filters: list[BaseFilter] | None = None, + ) -> None: ... +``` + +| parameter | meaning | +| --- | --- | +| `agents` | Additional archetypes to register. | +| `agent_paths` | Directories of `*.md` files to load archetypes from disk. | +| `tool_mapping` | Custom tool name → tool class mapping for resolving MD frontmatter. | +| `with_default` | Whether to register the built-in `default` archetype. Default `True`. | +| `agent_config` | `SubAgentConfig` applied to every spawned sub-agent. | +| `skip_summarization` | When `True`, skip the parent's summarization turn after the sub-agent returns. | + +**Three ways to configure:** + +```python +# Zero config — only the built-in `default` archetype +SpawnSubAgentTool() + +# Code-defined archetypes +SpawnSubAgentTool(agents=[security_auditor, EXPLORE_AGENT, PLAN_AGENT]) + +# Load from Markdown files +SpawnSubAgentTool(agent_paths=[".trpc_agents/"]) +``` + +#### `SubAgentArchetype` + +A frozen template that describes *one kind of sub-agent the parent is allowed to spawn*. It locks down the dangerous knobs (instruction, tools, model) so prompt-injected calls cannot reshape the sub-agent. + +```python +@dataclass(frozen=True) +class SubAgentArchetype: + name: str # registry key + the value LLM passes as `subagent_type` + description: str # what the LLM reads to pick this archetype + instruction: str | InstructionProvider + tools: tuple | None = None # None = inherit all parent tools + model: Any = None # None = inherit via SubAgentConfig or parent's model +``` + +- **`description`** — read by the **parent LLM** when selecting which archetype to spawn. Third-person, selection-focused. +- **`instruction`** — the **sub-agent's** system prompt. Second-person, execution-focused. Supports both strings and `InstructionProvider` callables. + +#### Built-in Archetypes + +| name | tools | typical use | +| --- | --- | --- | +| `default` | `None` (inherits all parent tools) | **Neutral task executor.** Does not impose a specific role. **Auto-registered.** | +| `general-purpose` | `None` (inherits all parent tools) | **Researcher / explorer** with soft "NEVER create files" constraints. Opt-in only. | +| `Explore` | `Read` / `Glob` / `Grep` / `WebFetch` | Read-only search: locate files, grep symbols. | +| `Plan` | `Read` / `Glob` / `Grep` | Design implementation plans without modifying code. | + +Only `default` is auto-registered. `general-purpose`, `Explore`, and `Plan` must be explicitly added via the `agents` parameter. + +### `DynamicSubAgentTool` + +The LLM writes the sub-agent's `instruction` at call time, creating any specialist on the fly. By default the sub-agent inherits all parent tools. + +```python +class DynamicSubAgentTool(BaseTool): + def __init__( + self, + name: str = "dynamic_subagent", + description: str | None = None, + tools: tuple | None = None, + expose_tool_selection: bool = True, + agent_config: SubAgentConfig | None = None, + skip_summarization: bool = False, + filters_name: list[str] | None = None, + filters: list[BaseFilter] | None = None, + ) -> None: ... +``` + +| parameter | meaning | +| --- | --- | +| `name` | Tool name. Default `"dynamic_subagent"`. | +| `description` | Tool description. | +| `tools` | Fixed tool set for the sub-agent. `None` (default) = inherit all parent tools. | +| `expose_tool_selection` | When `True` (default), the `tools` field is exposed so the LLM can narrow the tool surface per call. | +| `agent_config` | `SubAgentConfig` applied to every spawned sub-agent. | +| `skip_summarization` | When `True`, skip the parent's summarization turn after the sub-agent returns. | + +## Shared Configuration + +### `SubAgentConfig` + +Unified construction-time defaults for every spawned sub-agent. `None` means "inherit from the parent agent". + +```python +@dataclass(frozen=True) +class SubAgentConfig: + model: LLMModel | None = None + """Model for the sub-agent. None inherits the parent's model.""" + + generate_content_config: GenerateContentConfig | None = None + """Generation config (temperature, top_p, etc.). None inherits from parent.""" + + parallel_tool_calls: bool | None = None + """Whether the sub-agent may issue parallel tool calls. None inherits from parent.""" + + include_parent_history: bool = False + """Whether to inject parent conversation history into the sub-agent's session.""" + + max_parent_history_turns: int | None = None + """Max parent turns to inject. None = unlimited. Only used when include_parent_history=True.""" + + max_turns: int | None = None + """Max LLM calls the sub-agent may make. None = unlimited.""" +``` + +## Usage + +### SpawnSubAgentTool + +**Zero config** — only the built-in `default` archetype: + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.tools import SpawnSubAgentTool + +orchestrator = LlmAgent( + name="main", + model=opus_model, + instruction="When a task benefits from isolated context, spawn a sub-agent via spawn_subagent.", + tools=[SpawnSubAgentTool()], +) +``` + +**Code-defined archetypes**: + +```python +from trpc_agent_sdk.agents.sub_agent import SubAgentArchetype +from trpc_agent_sdk.tools import SpawnSubAgentTool + +security_auditor = SubAgentArchetype( + name="security-auditor", + description="Use for security code audit. **IMPORTANT:** This agent is read-only.", + instruction="You are a security auditor...", + tools=(ReadTool, GrepTool, GlobTool), +) + +orchestrator = LlmAgent( + tools=[SpawnSubAgentTool(agents=[security_auditor])], +) +``` + +**Loading archetypes from Markdown files**: + +Place `.md` files in a directory with YAML frontmatter: + +```markdown +--- +name: security-auditor +description: Use for security code audit. +tools: + - Read + - Glob + - Grep +--- + +You are a security auditor... +``` + +```python +tools=[SpawnSubAgentTool(agent_paths=[".trpc_agents/"])] +``` + +### DynamicSubAgentTool + +**Unbounded (default)** — the sub-agent inherits all parent tools. The LLM narrows the tool set per call via `tools`: + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.tools import DynamicSubAgentTool + +orchestrator = LlmAgent( + name="main", + model=opus_model, + instruction="When you need a specialist, create one via dynamic_subagent. Narrow tools as needed.", + tools=[DynamicSubAgentTool()], +) +``` + +**Bounded** — the sub-agent uses a fixed tool set. The parent agent has no direct access to those tools; every task must be delegated. This is useful for keeping dangerous tools behind the sub-agent boundary: + +```python +orchestrator = LlmAgent( + name="main", + model=opus_model, + instruction="You can only use tools by delegating via dynamic_subagent. Do not attempt direct calls.", + tools=[ + DynamicSubAgentTool( + tools=(calculator, word_count), + expose_tool_selection=False, + ), + ], +) +``` + +## Additional Notes + +- **Tool inheritance**: `DynamicSubAgentTool()` inherits all parent tools by default; pass `tools=(...)` to give the sub-agent a fixed set instead. For `SpawnSubAgentTool`, the archetype's `tools` field decides — `None` means inherit, `(ReadTool, ...)` means that exact set. In all cases, spawn tools are stripped from the sub-agent to prevent recursion. +- **Session isolation**: sub-agents run in a fresh ephemeral session. Parent history is not shared by default; opt in via `include_parent_history=True`. +- **Nesting**: 1-level hard cap. Sub-agents cannot spawn further sub-agents. +- **Result shape**: the sub-agent's final text is returned as the tool result string. diff --git a/docs/mkdocs/zh/multi_agents.md b/docs/mkdocs/zh/multi_agents.md index bae4a70f..eb02297f 100644 --- a/docs/mkdocs/zh/multi_agents.md +++ b/docs/mkdocs/zh/multi_agents.md @@ -311,6 +311,10 @@ Route customer inquiries: | `disallow_transfer_to_peers` | `False` | 设为 `True` 禁止子 Agent 将控制权转给同级 Agent | | `default_transfer_message` | `None` | 自定义转移指令,覆盖默认的转移提示语 | +#### Spawned Sub-Agents + +除持久化的 `sub_agents`(基于 transfer)之外,还可以在运行时通过 ``SpawnSubAgentTool``(从预注册目录中选择)或 ``DynamicSubAgentTool``(LLM 现场定义角色)创建短期子 agent。详见 [子 Agent 工具](sub_agent.md)。 + ## 组合模式(Compose Agents) 不同的编排模式可以灵活组合,通过 `output_key` 连接不同阶段的结果,创建更复杂的工作流: diff --git a/docs/mkdocs/zh/sub_agent.md b/docs/mkdocs/zh/sub_agent.md new file mode 100644 index 00000000..568bbf11 --- /dev/null +++ b/docs/mkdocs/zh/sub_agent.md @@ -0,0 +1,272 @@ +# Spawned Sub-Agents + +复杂任务往往需要委派子任务 —— 计算结果、搜索代码库、安全审计。直接在父 agent 的上下文中完成会带来几个问题: + +- **上下文污染**:探索性搜索、工具输出、中间结果填满上下文窗口,把真正有用的信息挤走。 +- **工具泛滥**:父 agent 一直携带所有工具,而大多数子任务只需其中一小部分。 +- **角色无法隔离**:父 agent 只有一个 system prompt,无法为不同子任务切换不同人设或约束。 +- **缺乏旁观视角**:自己写的代码很难自己发现问题。独立的上下文如同"第二双眼睛",可以客观审计、质疑方案、验证结论,不受父 agent 推理路径的干扰。 + +**短期子 agent** 天然适合应对这些问题:每次委派都是独立上下文、只带需要的工具、有专属 system prompt。运行完返回结果即销毁,父 agent 始终保持干净聚焦。 + +**Spawned Sub-Agents** 为父 agent 提供两种在运行时创建短期子 agent 的工具: + +- **`SpawnSubAgentTool`** — 从**预定义目录**中选择标准化专家。instruction、工具集和模型在构造期由 archetype 锁定。 + + 适用于固定专家角色集合(安全审计员、代码探索者、方案规划者),让 LLM 按任务选择最合适的人选。父 LLM 通过 `subagent_type` 选择角色并写入任务 `prompt`,但无法修改子 agent 的 instruction 或工具。 + +- **`DynamicSubAgentTool`** — LLM **现场创造专家**,在调用时写入 instruction。无需预注册。 + + 适用于无法事先穷举所有专家类型的场景。每次调用都能定义不同角色 —— LLM 自行决定每次任务需要什么专长、约束和工具子集。 + +区别在于**谁定义角色**:开发者(Spawn)还是 LLM(Dynamic)。 + +## Quick Start + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.tools import SpawnSubAgentTool, DynamicSubAgentTool + +# Spawn:从预定义目录中选择标准化专家 +agent_with_spawn = LlmAgent( + name="orchestrator", + tools=[SpawnSubAgentTool()], # 内置 `default` archetype +) + +# Dynamic:LLM 现场定义专家角色 +agent_with_dynamic = LlmAgent( + name="orchestrator", + tools=[DynamicSubAgentTool()], # 子 agent 继承父 agent 全部工具 +) +``` + +## 两种工具 + +| | `SpawnSubAgentTool` | `DynamicSubAgentTool` | +| --- | --- | --- | +| **模式** | 从预定义目录中选择 | LLM 现场写 instruction | +| **谁定义角色** | 开发者,支持两种方式:
① 代码构造 `SubAgentArchetype`
② Markdown 文件(YAML 头 + body) | LLM(通过 `instruction` 参数) | +| **适用场景** | 标准化、可复用的专家 | 无法预注册的临时角色 | +| **角色灵活性** | 锁定 —— 仅 `prompt` 可变 | 完全灵活 —— 每次调用可不同 | +| **工具面** | 由 archetype 锁定 | 继承父工具;LLM 可通过 `tools` 缩窄 | + +### `SpawnSubAgentTool` + +从预注册 archetype 目录派发任务。父 LLM 通过 `subagent_type` 选择合适的专家;instruction 和工具集由 archetype 锁定。 + +```python +class SpawnSubAgentTool(BaseTool): + def __init__( + self, + agents: list[SubAgentArchetype] | None = None, + agent_paths: list[str | os.PathLike] | None = None, + tool_mapping: dict[str, Any] | None = None, + with_default: bool = True, + agent_config: SubAgentConfig | None = None, + skip_summarization: bool = False, + filters_name: list[str] | None = None, + filters: list[BaseFilter] | None = None, + ) -> None: ... +``` + +| 参数 | 含义 | +| --- | --- | +| `agents` | 额外注册的 archetype 列表。 | +| `agent_paths` | 包含 `*.md` 文件的目录,从磁盘加载 archetype。 | +| `tool_mapping` | 自定义工具名到工具类的映射,用于解析 MD 文件中的工具名。 | +| `with_default` | 是否注册内置 `default` archetype。默认 `True`。 | +| `agent_config` | 应用于每个子 agent 的 `SubAgentConfig`。 | +| `skip_summarization` | 为 `True` 时,子 agent 返回后跳过父 agent 的总结回合。 | + +**三种接入方式:** + +```python +# 零配置 —— 仅内置 `default` archetype +SpawnSubAgentTool() + +# 代码定义 archetype +SpawnSubAgentTool(agents=[security_auditor, EXPLORE_AGENT, PLAN_AGENT]) + +# 从 Markdown 文件加载 +SpawnSubAgentTool(agent_paths=[".trpc_agents/"]) +``` + +#### `SubAgentArchetype`(子 agent 原型) + +一个不可变模板,描述**父 agent 被允许创建的某一种子 agent**。将 instruction / tools / model 锁定,防止被 prompt 注入越权改写。 + +```python +@dataclass(frozen=True) +class SubAgentArchetype: + name: str # registry key,也是 LLM 传入的 `subagent_type` 值 + description: str # 父 LLM 选择时读到的判断标准 + instruction: str | InstructionProvider + tools: tuple | None = None # None = 继承父 agent 全部工具 + model: Any = None # None = 通过 SubAgentConfig 或继承父 agent 模型 +``` + +- **`description`** — 父 LLM 在选择 archetype 时读到,第三人称、面向选择决策。 +- **`instruction`** — 子 agent 的 system prompt,第二人称、面向执行。支持字符串或 `InstructionProvider` 可调用对象。 + +#### 内置 Archetype + +| name | tools | 典型用途 | +| --- | --- | --- | +| `default` | `None`(继承父 agent 全部工具) | **中性任务执行者**。不塑造特定人格。**默认注册。** | +| `general-purpose` | `None`(继承父 agent 全部工具) | **研究员人格**,带"NEVER create files"等软约束。需手动注册。 | +| `Explore` | `Read` / `Glob` / `Grep` / `WebFetch` | 只读搜索:定位文件、grep 符号。 | +| `Plan` | `Read` / `Glob` / `Grep` | 设计实现方案,不修改代码。 | + +仅 `default` 默认注册。`general-purpose` / `Explore` / `Plan` 需手动通过 `agents` 参数注册。 + +### `DynamicSubAgentTool` + +LLM 在调用时写 instruction,现场创造任意专家。默认子 agent 继承父 agent 全部工具。 + +```python +class DynamicSubAgentTool(BaseTool): + def __init__( + self, + name: str = "dynamic_subagent", + description: str | None = None, + tools: tuple | None = None, + expose_tool_selection: bool = True, + agent_config: SubAgentConfig | None = None, + skip_summarization: bool = False, + filters_name: list[str] | None = None, + filters: list[BaseFilter] | None = None, + ) -> None: ... +``` + +| 参数 | 含义 | +| --- | --- | +| `name` | 工具名称。默认 `"dynamic_subagent"`。 | +| `description` | 工具描述。 | +| `tools` | 子 agent 的固定工具集。`None`(默认)= 继承父 agent 全部工具。 | +| `expose_tool_selection` | 为 `True`(默认)时暴露 `tools` 字段,LLM 可按需缩窄工具面。 | +| `agent_config` | 应用于每个子 agent 的 `SubAgentConfig`。 | +| `skip_summarization` | 为 `True` 时,子 agent 返回后跳过父 agent 的总结回合。 | + +## 共享配置 + +### `SubAgentConfig` + +每个子 agent 的统一构造期默认值。`None` 表示继承父 agent 的对应配置。 + +```python +@dataclass(frozen=True) +class SubAgentConfig: + model: LLMModel | None = None + """子 agent 使用的模型。None 继承父 agent 模型。""" + + generate_content_config: GenerateContentConfig | None = None + """生成配置(temperature、top_p 等)。None 继承父 agent 配置。""" + + parallel_tool_calls: bool | None = None + """子 agent 是否可并行调用工具。None 继承父 agent 配置。""" + + include_parent_history: bool = False + """是否将父 agent 的会话历史注入子 agent。""" + + max_parent_history_turns: int | None = None + """注入的最大父会话轮数。None = 不限制。仅在 include_parent_history=True 时生效。""" + + max_turns: int | None = None + """子 agent 最多可发起的 LLM 调用次数。None = 不限制。""" +``` + +## 使用方式 + +### SpawnSubAgentTool + +**零配置**——仅内置 `default` archetype: + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.tools import SpawnSubAgentTool + +orchestrator = LlmAgent( + name="main", + model=opus_model, + instruction="当任务适合在隔离上下文中处理时,通过 spawn_subagent 创建子 agent。", + tools=[SpawnSubAgentTool()], +) +``` + +**代码定义 Archetype**: + +```python +from trpc_agent_sdk.agents.sub_agent import SubAgentArchetype +from trpc_agent_sdk.tools import SpawnSubAgentTool + +security_auditor = SubAgentArchetype( + name="security-auditor", + description="Use for security code audit. **IMPORTANT:** This agent is read-only.", + instruction="You are a security auditor...", + tools=(ReadTool, GrepTool, GlobTool), +) + +orchestrator = LlmAgent( + tools=[SpawnSubAgentTool(agents=[security_auditor])], +) +``` + +**从 Markdown 文件加载 Archetype**: + +在目录下放置 `.md` 文件,YAML 前置元数据声明 name / description 和可选 tools: + +```markdown +--- +name: security-auditor +description: Use for security code audit. +tools: + - Read + - Glob + - Grep +--- + +You are a security auditor... +``` + +```python +tools=[SpawnSubAgentTool(agent_paths=[".trpc_agents/"])] +``` + +### DynamicSubAgentTool + +**无边界(默认)**——子 agent 继承父 agent 全部工具,LLM 按需缩窄: + +```python +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.tools import DynamicSubAgentTool + +orchestrator = LlmAgent( + name="main", + model=opus_model, + instruction="当需要临时专家时,通过 dynamic_subagent 创建子 agent。按需通过 tools 缩窄工具集。", + tools=[DynamicSubAgentTool()], +) +``` + +**有边界**——子 agent 只能使用指定的工具集,父 agent 无法直接调用这些工具。适合将危险工具封装在子 agent 内部,父 agent 只能通过委派间接使用: + +```python +orchestrator = LlmAgent( + name="main", + model=opus_model, + instruction="你只能通过 dynamic_subagent 调用工具,不要尝试直接调用。", + tools=[ + DynamicSubAgentTool( + tools=(calculator, word_count), + expose_tool_selection=False, + ), + ], +) +``` + +## 补充说明 + +- **工具继承**:`DynamicSubAgentTool()` 默认子 agent 继承父 agent 全部工具;通过 `tools=(...)` 可限定子 agent 只能使用指定工具。`SpawnSubAgentTool` 的工具集由 archetype 决定(`tools=None` 时继承,否则使用 archetype 指定的工具)。无论哪种方式,spawn 工具始终从子 agent 中移除,防止递归。 +- **会话隔离**:子 agent 在全新临时会话中运行,默认不共享父会话历史。通过 `include_parent_history=True` 可注入。 +- **嵌套限制**:1 层硬限,子 agent 无法再次 spawn。 +- **结果形态**:子 agent 的最终文本作为 tool result 字符串返回。 diff --git a/examples/dynamic_subagent/.env b/examples/dynamic_subagent/.env new file mode 100644 index 00000000..dc791393 --- /dev/null +++ b/examples/dynamic_subagent/.env @@ -0,0 +1,4 @@ +# Set TRPC_AGENT_API_KEY、TRPC_AGENT_BASE_URL、TRPC_AGENT_MODEL_NAME +TRPC_AGENT_API_KEY=your-api-key +TRPC_AGENT_BASE_URL=your-base-url +TRPC_AGENT_MODEL_NAME=your-model-name diff --git a/examples/dynamic_subagent/README.md b/examples/dynamic_subagent/README.md new file mode 100644 index 00000000..c7b5d537 --- /dev/null +++ b/examples/dynamic_subagent/README.md @@ -0,0 +1,64 @@ +# DynamicSubAgentTool 使用示例 + +演示 `DynamicSubAgentTool` 的用法——在调用时动态定义子 Agent 角色,无需预注册专家类型。 + +## 两种模式 + +- **minimal**:父 Agent 与子 Agent 共享工具面,LLM 通过 `tools` 参数按需缩窄 +- **bounded**:工具全部封装在 `DynamicSubAgentTool` 内部,父 Agent 无法直接调用 + +无论哪种模式,子 Agent 的工具面始终在代码定义的能力边界内,LLM 只能缩小、不可越界。 + +## 运行 + +```bash +# minimal(默认)—— 父 Agent 与子 Agent 共享工具 +python run_agent.py + +# bounded —— 工具封装在 dynamic_subagent 内部 +python run_agent.py --mode bounded +``` + +## 结构 + +``` +minimal: +orchestrator (LlmAgent) +├── tools: calculator, current_time, word_count +└── tools: DynamicSubAgentTool + └── 子 Agent 继承父 Agent 的全部工具 + +bounded: +orchestrator (LlmAgent) +└── tools: DynamicSubAgentTool(tools=[calculator, current_time, word_count]) + └── 子 Agent 拥有固定工具集,父 Agent 不可直接使用 +``` + +## 关键代码 + +```python +from trpc_agent_sdk.tools import DynamicSubAgentTool + +workspace_tools = [calculator, current_time, word_count] + +# minimal —— 子 Agent 继承父 Agent 工具面 +DynamicSubAgentTool(skip_summarization=True) + +# bounded —— 工具限定在 capability surface 内 +DynamicSubAgentTool( + tools=tuple(workspace_tools), + skip_summarization=True, +) +``` + +## `dynamic_subagent` 调用参数 + +- `prompt`(必填)— 子 Agent 的完整任务描述 +- `instruction`(可选)— 本次调用的角色 / 系统提示 +- `tools`(可选)— 授予的精确工具名列表,省略则允许全部 + +## 适用场景 + +- 工具池固定,但每次任务需要不同的子集 +- 需要 LLM 现场决定子 Agent 角色和工具组合 +- 不想为每种组合预注册专家 Agent diff --git a/examples/dynamic_subagent/agent/__init__.py b/examples/dynamic_subagent/agent/__init__.py new file mode 100644 index 00000000..241e05c6 --- /dev/null +++ b/examples/dynamic_subagent/agent/__init__.py @@ -0,0 +1,7 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +""" Agent package for the dynamic_subagent example.""" diff --git a/examples/dynamic_subagent/agent/agent.py b/examples/dynamic_subagent/agent/agent.py new file mode 100644 index 00000000..af661a84 --- /dev/null +++ b/examples/dynamic_subagent/agent/agent.py @@ -0,0 +1,78 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Orchestrator configurations demonstrating DynamicSubAgentTool. + +Two configurations are provided, selectable via ``--mode`` on the command line: + +- ``minimal`` — workspace tools and ``dynamic_subagent`` are both registered on + the orchestrator; the sub-agent inherits the parent surface and the model + narrows tools per call. +- ``bounded`` — only ``dynamic_subagent`` is registered; workspace tools live + behind ``DynamicSubAgentTool(tools=...)`` as the capability surface. +""" + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.tools import DynamicSubAgentTool +from trpc_agent_sdk.agents.sub_agent import SubAgentConfig +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.types import GenerateContentConfig + +from .config import get_model_config +from .prompts import BOUNDED_ORCHESTRATOR_INSTRUCTION +from .prompts import MINIMAL_ORCHESTRATOR_INSTRUCTION +from .tools import create_workspace_tools + + +def _create_model() -> LLMModel: + api_key, url, model_name = get_model_config() + return OpenAIModel(model_name=model_name, api_key=api_key, base_url=url) + + +def create_minimal_agent() -> LlmAgent: + """Orchestrator with workspace tools + dynamic_subagent (Go ``-mode=minimal``).""" + workspace_tools = create_workspace_tools() + return LlmAgent( + name="orchestrator", + description="Orchestrator that delegates focused subtasks to short-lived sub-agents.", + model=_create_model(), + instruction=MINIMAL_ORCHESTRATOR_INSTRUCTION, + generate_content_config=GenerateContentConfig( + temperature=0.7, + max_output_tokens=2000, + ), + tools=workspace_tools + [DynamicSubAgentTool()], + ) + + +def create_bounded_agent() -> LlmAgent: + """Orchestrator with only dynamic_subagent (Go ``-mode=bounded``).""" + workspace_tools = create_workspace_tools() + return LlmAgent( + name="orchestrator", + description="Orchestrator that delegates every subtask via dynamic_subagent.", + model=_create_model(), + instruction=BOUNDED_ORCHESTRATOR_INSTRUCTION, + generate_content_config=GenerateContentConfig( + temperature=0.7, + max_output_tokens=2000, + ), + tools=[ + DynamicSubAgentTool( + tools=tuple(workspace_tools), + agent_config=SubAgentConfig( + generate_content_config=GenerateContentConfig( + temperature=0.3, + max_output_tokens=1000, + ), + ), + ), + ], + ) + + +root_agent = create_minimal_agent() diff --git a/examples/dynamic_subagent/agent/config.py b/examples/dynamic_subagent/agent/config.py new file mode 100644 index 00000000..11c593ad --- /dev/null +++ b/examples/dynamic_subagent/agent/config.py @@ -0,0 +1,22 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Agent config module.""" + +import os + + +def get_model_config() -> tuple[str, str, str]: + """Get model config from environment variables.""" + api_key = os.getenv('TRPC_AGENT_API_KEY', '') + url = os.getenv('TRPC_AGENT_BASE_URL', '') + model_name = os.getenv('TRPC_AGENT_MODEL_NAME', '') + if not api_key or not url or not model_name: + raise ValueError( + 'TRPC_AGENT_API_KEY, TRPC_AGENT_BASE_URL, and ' + 'TRPC_AGENT_MODEL_NAME must be set in environment variables' + ) + return api_key, url, model_name diff --git a/examples/dynamic_subagent/agent/prompts.py b/examples/dynamic_subagent/agent/prompts.py new file mode 100644 index 00000000..24fc6d13 --- /dev/null +++ b/examples/dynamic_subagent/agent/prompts.py @@ -0,0 +1,38 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Orchestrator instructions for the dynamic_subagent example.""" + +MINIMAL_ORCHESTRATOR_INSTRUCTION = """\ +You are an orchestrator. You have three direct tools (calculator, current_time, \ +word_count) and a special 'dynamic_subagent' tool that runs a short-lived sub-agent. + +Use direct tools for simple one-step answers. Use 'dynamic_subagent' according to \ +its tool description when a task should be delegated to a focused child run. + +When you call 'dynamic_subagent': +- Put EVERYTHING the sub-agent needs into 'prompt'; by default it cannot see \ +this conversation. +- Use 'tools' to grant only the minimal tools the subtask needs (by exact name). +- Use 'instruction' to give the sub-agent a clear role for that task. + +If the user asks for two independent subtasks, you may run two separate \ +sub-agents. After a sub-agent returns, summarize its result for the user.""" + +BOUNDED_ORCHESTRATOR_INSTRUCTION = """\ +You are an orchestrator. Your ONLY tool is 'dynamic_subagent', which runs a \ +short-lived sub-agent. You cannot call calculator, current_time, or word_count \ +directly; delegate every subtask by spawning a sub-agent. + +When you call 'dynamic_subagent': +- Put EVERYTHING the sub-agent needs into 'prompt'; by default it cannot see \ +this conversation. +- Use 'tools' to grant only the minimal tools the subtask needs, choosing from \ +the names offered by the 'tools' field (by exact name). +- Use 'instruction' to give the sub-agent a clear role for that task. + +If the user asks for two independent subtasks, you may run two separate \ +sub-agents. After a sub-agent returns, summarize its result for the user.""" diff --git a/examples/dynamic_subagent/agent/tools.py b/examples/dynamic_subagent/agent/tools.py new file mode 100644 index 00000000..da5e592f --- /dev/null +++ b/examples/dynamic_subagent/agent/tools.py @@ -0,0 +1,108 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Workspace tools for the dynamic_subagent demo.""" + +from __future__ import annotations + +from datetime import datetime +from zoneinfo import ZoneInfo + +from trpc_agent_sdk.tools import FunctionTool + + +def calculator(operation: str, a: float, b: float) -> dict: + """Perform one basic arithmetic operation (add, subtract, multiply, divide) on two numbers. + + Args: + operation: The operation to perform (add, subtract, multiply, divide). + a: First number. + b: Second number. + + Returns: + A dictionary with the operands, operation, and result (or error). + """ + if operation == "add": + result = a + b + elif operation == "subtract": + result = a - b + elif operation == "multiply": + result = a * b + elif operation == "divide": + if b == 0: + return { + "operation": operation, + "a": a, + "b": b, + "error": "Division by zero", + } + result = a / b + else: + return { + "operation": operation, + "a": a, + "b": b, + "error": f"Unknown operation: {operation!r}", + } + return { + "operation": operation, + "a": a, + "b": b, + "result": result, + } + + +def current_time(timezone: str = "") -> dict: + """Get the current time and date for a timezone (UTC, EST, PST, CST, or local). + + Args: + timezone: Timezone name (UTC, EST, PST, CST) or leave empty for local. + + Returns: + Current time, date, and weekday for the requested timezone. + """ + tz_map = { + "UTC": ZoneInfo("UTC"), + "EST": ZoneInfo("America/New_York"), + "PST": ZoneInfo("America/Los_Angeles"), + "CST": ZoneInfo("America/Chicago"), + } + key = timezone.strip().upper() + if key and key not in tz_map: + raise ValueError(f"unsupported timezone {timezone!r}") + now = datetime.now(tz_map.get(key) if key else None) + return { + "timezone": timezone or "local", + "time": now.strftime("%H:%M:%S"), + "date": now.strftime("%Y-%m-%d"), + "weekday": now.strftime("%A"), + } + + +def word_count(text: str) -> dict: + """Count the words and characters in a piece of text. + + Args: + text: The text to analyze. + + Returns: + Word and character counts for the input text. + """ + trimmed = text.strip() + words = len(trimmed.split()) if trimmed else 0 + return { + "words": words, + "characters": len(text), + } + + +def create_workspace_tools() -> list[FunctionTool]: + """Return the three workspace tools used by the orchestrator and sub-agents.""" + return [ + FunctionTool(calculator), + FunctionTool(current_time), + FunctionTool(word_count), + ] diff --git a/examples/dynamic_subagent/run_agent.py b/examples/dynamic_subagent/run_agent.py new file mode 100644 index 00000000..d0350007 --- /dev/null +++ b/examples/dynamic_subagent/run_agent.py @@ -0,0 +1,128 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Run the dynamic_subagent demo. + +Usage:: + + python run_agent.py # minimal mode (default) + python run_agent.py --mode bounded # bounded / progressive disclosure +""" + +import argparse +import asyncio +import os +import sys +import uuid + +from dotenv import load_dotenv +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + +load_dotenv() + +EXAMPLE_DIR = os.path.dirname(os.path.abspath(__file__)) +if EXAMPLE_DIR not in sys.path: + sys.path.insert(0, EXAMPLE_DIR) + + +def _truncate(text: str, max_len: int = 200) -> str: + """Truncate long tool output for display.""" + return text + if not isinstance(text, str): + text = str(text) + if len(text) <= max_len: + return text + return text[:max_len] + f"\n... (truncated, total {len(text)} chars)" + + +_QUERIES = { + "minimal": [ + # Simple task: orchestrator may call word_count directly. + 'Count the words in: "the quick brown fox".', + # Delegate self-contained subtasks via dynamic_subagent. + "Use a sub-agent to compute (123 * 456) + 789. Grant it only the calculator.", + "Use a sub-agent to tell me the current time in UTC.", + ], + "bounded": [ + "Use a sub-agent to compute (123 * 456) + 789. Grant it only the calculator.", + 'Use one sub-agent to compute 50 * 12, and a separate sub-agent to count ' + 'the words in "the quick brown fox jumps". Grant each only the tool it needs.', + ], +} + + +async def run_demo(mode: str): + app_name = "dynamic_subagent_demo" + + if mode == "bounded": + from agent.agent import create_bounded_agent + agent = create_bounded_agent() + else: + from agent.agent import create_minimal_agent + agent = create_minimal_agent() + + session_service = InMemorySessionService() + runner = Runner(app_name=app_name, agent=agent, session_service=session_service) + + user_id = "demo_user" + queries = _QUERIES.get(mode, _QUERIES["minimal"]) + + for query in queries: + current_session_id = str(uuid.uuid4()) + await session_service.create_session( + app_name=app_name, + user_id=user_id, + session_id=current_session_id, + ) + + print(f"\n{'=' * 60}") + print(f"\U0001F194 Mode: {mode} | Session ID: {current_session_id[:8]}...") + print(f"{'-' * 60}") + print(f"\U0001F4DD User: {query}") + + user_content = Content(parts=[Part.from_text(text=query)]) + print("\U0001F916 Assistant: ", end="", flush=True) + async for event in runner.run_async( + user_id=user_id, + session_id=current_session_id, + new_message=user_content, + ): + if event.content and event.content.parts and event.author != "user": + if event.partial: + for part in event.content.parts: + if part.text: + print(part.text, end="", flush=True) + else: + for part in event.content.parts: + if part.thought: + continue + if part.function_call: + print( + f"\n\n\U0001F527 [Invoke Tool:: {part.function_call.name}" + f"{_truncate(part.function_call.args)}]\n" + ) + elif part.function_response: + print( + f"\n\U0001F4CA [Tool Result: " + f"{_truncate(part.function_response.response)}]\n" + ) + + print(f"\n{'─' * 60}\n") + + await runner.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="DynamicSubAgentTool demo") + parser.add_argument( + "--mode", choices=["minimal", "bounded"], default="minimal", + help="minimal: workspace tools + dynamic_subagent; bounded: only dynamic_subagent", + ) + args = parser.parse_args() + asyncio.run(run_demo(args.mode)) diff --git a/examples/spawn_subagent/.env b/examples/spawn_subagent/.env new file mode 100644 index 00000000..dc791393 --- /dev/null +++ b/examples/spawn_subagent/.env @@ -0,0 +1,4 @@ +# Set TRPC_AGENT_API_KEY、TRPC_AGENT_BASE_URL、TRPC_AGENT_MODEL_NAME +TRPC_AGENT_API_KEY=your-api-key +TRPC_AGENT_BASE_URL=your-base-url +TRPC_AGENT_MODEL_NAME=your-model-name diff --git a/examples/spawn_subagent/.trpc_agents/security-auditor.md b/examples/spawn_subagent/.trpc_agents/security-auditor.md new file mode 100644 index 00000000..dd3313a0 --- /dev/null +++ b/examples/spawn_subagent/.trpc_agents/security-auditor.md @@ -0,0 +1,10 @@ +--- +name: security-auditor +description: Dedicated security specialist with deep expertise in vulnerability assessment. Delegate security tasks to this agent — it has systematic knowledge of OWASP Top 10, CWE Top 25, and common exploit patterns that you lack. Use this instead of reviewing code yourself for any security audit, secret detection, or auth review. +tools: + - Read + - Glob + - Grep +--- + +You are a security auditor. Review the relevant code for security issues: injection risks, hardcoded secrets, unsafe API usage, missing authentication/authorization checks. Report findings concisely with severity (low/medium/high/critical). Do NOT modify files. diff --git a/examples/spawn_subagent/README.md b/examples/spawn_subagent/README.md new file mode 100644 index 00000000..3894bb69 --- /dev/null +++ b/examples/spawn_subagent/README.md @@ -0,0 +1,58 @@ +# SpawnSubAgentTool 使用示例 + +演示 `SpawnSubAgentTool` 的用法——将复杂子任务派发给标准化的短期专家 Agent 处理。 + +## 三种接入方式 + +- **零配置**:`SpawnSubAgentTool()` — 仅 `default` 子 Agent(中性任务执行者,继承主 Agent 工具) +- **代码定义**:`SpawnSubAgentTool(agents=[security_auditor, EXPLORE_AGENT])` — 代码中定义子 Agent +- **MD 文件定义**:`SpawnSubAgentTool(agent_paths=[".trpc_agents/"])` — 从 Markdown 文件加载子 Agent + +无论哪种方式,子 Agent 的 instruction、工具集、模型都被锁定——LLM 只能选择 `subagent_type` 和写 `prompt`,无法在调用时改写子 Agent 的角色。 + +## 运行 + +```bash +# 零配置(仅 default 子 Agent) +python run_agent.py + +# 代码定义子 Agent(security-auditor + Explore + Plan) +python run_agent.py --mode code + +# MD 文件定义子 Agent +python run_agent.py --mode md +``` + +## 结构 + +``` +coding_assistant (LlmAgent) +├── tools: ReadTool, GlobTool, GrepTool +├── tools: SpawnSubAgentTool +│ ├── default(内置,默认注册,继承主 Agent 工具) +│ ├── Explore / Plan(内置,按需注册,只读工具集) +│ └── security-auditor(代码或 MD 自定义) +└── sample_repo/(共享微型示例代码库) +``` + +## 关键代码 + +```python +from trpc_agent_sdk.agents.sub_agent import EXPLORE_AGENT, PLAN_AGENT +from trpc_agent_sdk.tools import SpawnSubAgentTool + +# 零配置 —— 仅 default 子 Agent +SpawnSubAgentTool() + +# 追加内置子 Agent +SpawnSubAgentTool(agents=[EXPLORE_AGENT, PLAN_AGENT]) + +# 从 MD 文件加载 +SpawnSubAgentTool(agent_paths=[".trpc_agents/"]) +``` + +## 适用场景 + +- 有预定义的标准化专家角色(安全审计、代码探索、架构规划),且每次派发都是独立的一次性任务 +- 需要非开发者通过 MD 文件维护专家定义,但专家角色本身需要被锁定 +- 需要 instruction/tools 锁定的安全护栏,防止 LLM 在调用时越权改写子 Agent 角色 diff --git a/examples/spawn_subagent/agent/__init__.py b/examples/spawn_subagent/agent/__init__.py new file mode 100644 index 00000000..681c1c74 --- /dev/null +++ b/examples/spawn_subagent/agent/__init__.py @@ -0,0 +1,7 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Agent package for the spawn_subagent example.""" diff --git a/examples/spawn_subagent/agent/agent.py b/examples/spawn_subagent/agent/agent.py new file mode 100644 index 00000000..e5e7ebc7 --- /dev/null +++ b/examples/spawn_subagent/agent/agent.py @@ -0,0 +1,117 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Coding assistant configurations demonstrating SpawnSubAgentTool. + +Three configurations are provided, selectable via ``--mode`` on the +command line: + +- ``default`` — zero-config: ``SpawnSubAgentTool()``. The ``default`` + archetype (neutral task executor, inherits the assistant's tools) is the only + auto-registered archetype. +- ``code`` — ``security-auditor`` defined in code via ``SubAgentArchetype``, + alongside built-in ``Explore`` / ``Plan``. +- ``md`` — ``security-auditor`` loaded from ``.trpc_agents/security-auditor.md``, + alongside built-in ``Explore`` / ``Plan``, showing how MD-defined and + built-in archetypes co-exist. +""" + +import os + +from trpc_agent_sdk.agents import LlmAgent +from trpc_agent_sdk.agents.sub_agent import EXPLORE_AGENT +from trpc_agent_sdk.agents.sub_agent import PLAN_AGENT +from trpc_agent_sdk.agents.sub_agent import SubAgentArchetype +from trpc_agent_sdk.tools import SpawnSubAgentTool +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.models import OpenAIModel +from trpc_agent_sdk.tools import GlobTool +from trpc_agent_sdk.tools import GrepTool +from trpc_agent_sdk.tools import ReadTool + +from .config import get_model_config +from .prompts import INSTRUCTION + + +def _create_model() -> LLMModel: + api_key, url, model_name = get_model_config() + return OpenAIModel(model_name=model_name, api_key=api_key, base_url=url) + + +def create_default_agent() -> LlmAgent: + """Zero-config coding assistant: only the ``default`` archetype is registered. + + Simple tasks are handled directly. Complex tasks are dispatched + to the ``default`` sub-agent, which inherits the assistant's tools. + """ + return LlmAgent( + name="coding_assistant", + description="Coding assistant with spawn_subagent in zero-config mode.", + model=_create_model(), + instruction=INSTRUCTION, + tools=[ReadTool(), GlobTool(), GrepTool(), SpawnSubAgentTool()], + ) + + +_SECURITY_AUDITOR = SubAgentArchetype( + name="security-auditor", + description=( + "Specialized security auditor for code vulnerability analysis. " + "Use this for ANY security-related task: code audits, secret " + "detection, auth review. Checks for OWASP Top 10 risks, CWE " + "patterns, rates severity, and produces structured reports." + ), + instruction=( + "You are a security auditor. Review the relevant code for security " + "issues: injection risks, hardcoded secrets, unsafe API usage, " + "missing authentication/authorization checks. Report findings " + "concisely with severity (low/medium/high/critical). Do NOT modify files." + ), + tools=(ReadTool, GlobTool, GrepTool), +) + + +def create_code_agent() -> LlmAgent: + """Coding assistant with code-defined security-auditor + built-in Explore/Plan. + + Simple tasks are handled directly. Security review tasks are auto-routed + to ``security-auditor``, code exploration to ``Explore``, and planning + tasks to ``Plan``. ``default`` serves as fallback. + """ + return LlmAgent( + name="coding_assistant", + description="Coding assistant with Explore, Plan, and custom archetypes.", + model=_create_model(), + instruction=INSTRUCTION, + tools=[ + ReadTool(), GlobTool(), GrepTool(), + SpawnSubAgentTool(agents=[_SECURITY_AUDITOR, EXPLORE_AGENT, PLAN_AGENT]), + ], + ) + + +_AGENTS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".trpc_agents") + + +def create_md_agent() -> LlmAgent: + """Coding assistant with MD-defined security-auditor + built-in Explore/Plan. + + Simple tasks are handled directly. Security review tasks are auto-routed + to the MD-defined ``security-auditor``. ``default`` serves as fallback. + """ + return LlmAgent( + name="coding_assistant", + description="Coding assistant with Explore, Plan, and MD-defined archetype.", + model=_create_model(), + instruction=INSTRUCTION, + tools=[ + ReadTool(), GlobTool(), GrepTool(), + SpawnSubAgentTool(agents=[EXPLORE_AGENT, PLAN_AGENT], agent_paths=[_AGENTS_PATH]), + ], + ) + + +root_agent = create_default_agent() diff --git a/examples/spawn_subagent/agent/config.py b/examples/spawn_subagent/agent/config.py new file mode 100644 index 00000000..90edcc2f --- /dev/null +++ b/examples/spawn_subagent/agent/config.py @@ -0,0 +1,22 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +""" Agent config module.""" + +import os + + +def get_model_config() -> tuple[str, str, str]: + """Get model config from environment variables.""" + api_key = os.getenv('TRPC_AGENT_API_KEY', '') + url = os.getenv('TRPC_AGENT_BASE_URL', '') + model_name = os.getenv('TRPC_AGENT_MODEL_NAME', '') + if not api_key or not url or not model_name: + raise ValueError( + 'TRPC_AGENT_API_KEY, TRPC_AGENT_BASE_URL, and ' + 'TRPC_AGENT_MODEL_NAME must be set in environment variables' + ) + return api_key, url, model_name diff --git a/examples/spawn_subagent/agent/prompts.py b/examples/spawn_subagent/agent/prompts.py new file mode 100644 index 00000000..243104bb --- /dev/null +++ b/examples/spawn_subagent/agent/prompts.py @@ -0,0 +1,9 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Parent agent instruction for the spawn_subagent example.""" + +INSTRUCTION = "You are a software engineer helping with codebase questions." diff --git a/examples/spawn_subagent/run_agent.py b/examples/spawn_subagent/run_agent.py new file mode 100644 index 00000000..ca72b96a --- /dev/null +++ b/examples/spawn_subagent/run_agent.py @@ -0,0 +1,142 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Run the spawn_subagent demo. + +Usage:: + + python run_agent.py # default mode (default archetype only) + python run_agent.py --mode code # code-defined security-auditor + Explore/Plan + python run_agent.py --mode md # MD-defined security-auditor + Explore/Plan + +The demo points sub-agents at the shared sample repo (``./sample_repo/``) +so output is fast, predictable, and never depends on the larger +trpc-agent-python codebase. +""" + +import argparse +import asyncio +import os +import sys +import uuid + +from dotenv import load_dotenv +from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + +load_dotenv() + +EXAMPLE_DIR = os.path.dirname(os.path.abspath(__file__)) +SAMPLE_REPO = os.path.join(EXAMPLE_DIR, "sample_repo") +if EXAMPLE_DIR not in sys.path: + sys.path.insert(0, EXAMPLE_DIR) + +def _truncate(text: str, max_len: int = 200) -> str: + """Truncate long tool output for display.""" + if not isinstance(text, str): + text = str(text) + if len(text) <= max_len: + return text + return text[:max_len] + f"\n... (truncated, total {len(text)} chars)" + + +# Queries per mode — simple tasks (parent handles directly) vs complex +# tasks (delegated to a sub-agent). +_SHARED_AGENT_QUERIES = [ + # Triggers: security-auditor + "I need a security code audit of the authentication system in " + "auth.py and app.py. Check for vulnerabilities, hardcoded secrets, " + "and missing authorization checks.", + # Triggers: Explore (built-in archetype) + "How does authentication and user identity work in this codebase? " + "I need to understand every file and function involved across " + "multiple naming conventions.", +] + +_QUERIES = { + "default": [ + # Simple task: parent handles directly (ReadTool), no sub-agent. + "What does the file auth.py do? Give me a one-sentence summary.", + # Triggers: default archetype (explicit "Use a sub-agent" in the query). + "Use a sub-agent to explore this codebase: find all functions that " + "accept a 'user_id' parameter, and report which files they are in " + "and what they do.", + ], + "code": _SHARED_AGENT_QUERIES, + "md": _SHARED_AGENT_QUERIES, +} + + +async def run_demo(mode: str): + app_name = "spawn_subagent_demo" + + if mode == "code": + from agent.agent import create_code_agent + agent = create_code_agent() + elif mode == "md": + from agent.agent import create_md_agent + agent = create_md_agent() + else: + from agent.agent import create_default_agent + agent = create_default_agent() + + session_service = InMemorySessionService() + runner = Runner(app_name=app_name, agent=agent, session_service=session_service) + + user_id = "demo_user" + queries = _QUERIES.get(mode, _QUERIES["default"]) + + for query in queries: + current_session_id = str(uuid.uuid4()) + await session_service.create_session( + app_name=app_name, + user_id=user_id, + session_id=current_session_id, + ) + + print(f"\n{'=' * 60}") + print(f"\U0001F194 Mode: {mode} | Session ID: {current_session_id[:8]}...") + print(f"{'-' * 60}") + print(f"\U0001F4DD User: {query}") + + user_content = Content(parts=[Part.from_text(text=query)]) + print("\U0001F916 Assistant: ", end="", flush=True) + async for event in runner.run_async( + user_id=user_id, + session_id=current_session_id, + new_message=user_content, + ): + if event.content and event.content.parts and event.author != "user": + if event.partial: + for part in event.content.parts: + if part.text: + print(part.text, end="", flush=True) + else: + for part in event.content.parts: + if part.thought: + continue + if part.function_call: + print(f"\n\n\U0001F527 [Invoke Tool:: {part.function_call.name}{(_truncate(part.function_call.args))}]\n") + elif part.function_response: + print(f"\n\U0001F4CA [Tool Result: {_truncate(part.function_response.response)}]\n") + + print(f"\n{'─' * 60}\n") + + await runner.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="SpawnSubAgentTool demo") + parser.add_argument( + "--mode", choices=["default", "code", "md"], default="default", + help="Which agent configuration to run (default: default)" + ) + args = parser.parse_args() + + os.chdir(SAMPLE_REPO) + asyncio.run(run_demo(args.mode)) diff --git a/examples/spawn_subagent/sample_repo/README.md b/examples/spawn_subagent/sample_repo/README.md new file mode 100644 index 00000000..dd9f6c3e --- /dev/null +++ b/examples/spawn_subagent/sample_repo/README.md @@ -0,0 +1,14 @@ +# shop — tiny sample repo + +A miniature Python project shared by the `dynamic_subagent` examples (`basic/` +and `with_md/`). Sub-agents explore and discuss this small codebase instead of the real `trpc-agent-python` +repo, so the demo runs fast and produces predictable output. + +Modules: +- `app.py`: HTTP-style request handlers (login / checkout / refund). +- `auth.py`: token verification helpers. +- `cart.py`: cart math. +- `db.py`: in-memory store stub. + +There is exactly one TODO comment in this repo (in `cart.py`), so the +"summarize TODOs" demo query has a deterministic answer. diff --git a/examples/spawn_subagent/sample_repo/app.py b/examples/spawn_subagent/sample_repo/app.py new file mode 100644 index 00000000..237014e8 --- /dev/null +++ b/examples/spawn_subagent/sample_repo/app.py @@ -0,0 +1,29 @@ +"""Top-level handlers for the sample shop app.""" + +from auth import user_of, verify_token +from cart import total +from db import delete_order, load_order, save_order + + +def login(token: str) -> dict: + if not verify_token(token): + return {"ok": False, "reason": "bad token"} + return {"ok": True, "user": user_of(token)} + + +def checkout(token: str, items: list[dict], order_id: str) -> dict: + if not verify_token(token): + return {"ok": False, "reason": "bad token"} + amount = total(items) + save_order(order_id, {"user": user_of(token), "items": items, "amount": amount}) + return {"ok": True, "order_id": order_id, "amount": amount} + + +def refund(token: str, order_id: str) -> dict: + if not verify_token(token): + return {"ok": False, "reason": "bad token"} + order = load_order(order_id) + if order is None: + return {"ok": False, "reason": "no such order"} + delete_order(order_id) + return {"ok": True, "refunded": order["amount"]} diff --git a/examples/spawn_subagent/sample_repo/auth.py b/examples/spawn_subagent/sample_repo/auth.py new file mode 100644 index 00000000..43d9f6eb --- /dev/null +++ b/examples/spawn_subagent/sample_repo/auth.py @@ -0,0 +1,15 @@ +"""Auth helpers for the sample shop app.""" + +VALID_TOKENS = {"alice-token", "bob-token"} + + +def verify_token(token: str) -> bool: + return token in VALID_TOKENS + + +def user_of(token: str) -> str: + if token == "alice-token": + return "alice" + if token == "bob-token": + return "bob" + raise ValueError(f"unknown token: {token}") diff --git a/examples/spawn_subagent/sample_repo/cart.py b/examples/spawn_subagent/sample_repo/cart.py new file mode 100644 index 00000000..8a91c5aa --- /dev/null +++ b/examples/spawn_subagent/sample_repo/cart.py @@ -0,0 +1,15 @@ +"""Cart math for the sample shop app.""" + + +def subtotal(items: list[dict]) -> float: + return sum(it["price"] * it["qty"] for it in items) + + +def tax(subtotal_amount: float, rate: float = 0.08) -> float: + # TODO: tax rules vary by region — wire this to the regional rate table. + return round(subtotal_amount * rate, 2) + + +def total(items: list[dict], rate: float = 0.08) -> float: + s = subtotal(items) + return round(s + tax(s, rate), 2) diff --git a/examples/spawn_subagent/sample_repo/db.py b/examples/spawn_subagent/sample_repo/db.py new file mode 100644 index 00000000..7f4e5247 --- /dev/null +++ b/examples/spawn_subagent/sample_repo/db.py @@ -0,0 +1,15 @@ +"""In-memory data store for the sample shop app.""" + +ORDERS: dict[str, dict] = {} + + +def save_order(order_id: str, payload: dict) -> None: + ORDERS[order_id] = payload + + +def load_order(order_id: str) -> dict | None: + return ORDERS.get(order_id) + + +def delete_order(order_id: str) -> bool: + return ORDERS.pop(order_id, None) is not None diff --git a/tests/agents/sub_agent/__init__.py b/tests/agents/sub_agent/__init__.py new file mode 100644 index 00000000..c6bc9ba0 --- /dev/null +++ b/tests/agents/sub_agent/__init__.py @@ -0,0 +1,6 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# diff --git a/tests/agents/sub_agent/test_archetype.py b/tests/agents/sub_agent/test_archetype.py new file mode 100644 index 00000000..801e0706 --- /dev/null +++ b/tests/agents/sub_agent/test_archetype.py @@ -0,0 +1,103 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Tests for SubAgentArchetype validation and tool-list handling.""" + +from __future__ import annotations + +import pytest + +from trpc_agent_sdk.agents.sub_agent import SubAgentArchetype +from trpc_agent_sdk.tools import ReadTool + + +def _make(**overrides) -> SubAgentArchetype: + base = dict( + name="my_archetype", + description="A useful description.", + instruction="Be helpful.", + tools=(ReadTool,), + ) + base.update(overrides) + return SubAgentArchetype(**base) + + +def test_construct_with_factory_tools() -> None: + a = _make() + assert a.name == "my_archetype" + assert a.tools == (ReadTool,) + + +def test_construct_with_instance_tools() -> None: + inst = ReadTool() + a = _make(tools=(inst,)) + assert a.tools == (inst,) + + +def test_tools_coerced_to_tuple() -> None: + a = _make(tools=[ReadTool]) + assert isinstance(a.tools, tuple) + + +def test_reject_empty_name() -> None: + with pytest.raises(ValueError): + _make(name="") + + +def test_reject_invalid_name() -> None: + with pytest.raises(ValueError): + _make(name="bad name with spaces") + + +def test_reject_empty_description() -> None: + with pytest.raises(ValueError): + _make(description=" ") + + +def test_reject_empty_instruction() -> None: + with pytest.raises(ValueError): + _make(instruction=" ") + + +def test_accepts_claude_code_style_names() -> None: + """Names like 'general-purpose' and 'Explore' must validate.""" + _make(name="general-purpose") + _make(name="Explore") + _make(name="claude-code-guide") + + +def test_frozen_dataclass_is_immutable() -> None: + a = _make() + with pytest.raises(Exception): + a.name = "renamed" + + +def test_model_or_returns_self_when_set() -> None: + a = SubAgentArchetype( + name="with-model", + description="Has a model.", + instruction="Be helpful.", + model="my_model", + ) + assert a.model_or("fallback") == "my_model" + + +def test_model_or_returns_fallback_when_none() -> None: + a = _make() + assert a.model is None + assert a.model_or("fallback") == "fallback" + + +def test_callable_instruction_accepted() -> None: + def dynamic_instruction(ctx): + return "instruction from callable" + + a = SubAgentArchetype( + name="callable-instr", + description="Uses callable instruction.", + instruction=dynamic_instruction, + ) + assert a.instruction is dynamic_instruction diff --git a/tests/agents/sub_agent/test_defaults.py b/tests/agents/sub_agent/test_defaults.py new file mode 100644 index 00000000..2618908c --- /dev/null +++ b/tests/agents/sub_agent/test_defaults.py @@ -0,0 +1,90 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Tests for the built-in default archetypes.""" + +from __future__ import annotations + +from trpc_agent_sdk.agents.sub_agent import DEFAULT_AGENT +from trpc_agent_sdk.agents.sub_agent import EXPLORE_AGENT +from trpc_agent_sdk.agents.sub_agent import GENERAL_PURPOSE_AGENT +from trpc_agent_sdk.agents.sub_agent import PLAN_AGENT +from trpc_agent_sdk.tools import BashTool +from trpc_agent_sdk.tools import EditTool +from trpc_agent_sdk.tools import GlobTool +from trpc_agent_sdk.tools import GrepTool +from trpc_agent_sdk.tools import ReadTool +from trpc_agent_sdk.tools import WebFetchTool +from trpc_agent_sdk.tools import WriteTool + + +def test_archetype_names() -> None: + assert DEFAULT_AGENT.name == "default" + assert GENERAL_PURPOSE_AGENT.name == "general-purpose" + assert EXPLORE_AGENT.name == "Explore" + assert PLAN_AGENT.name == "Plan" + + +def test_default_tools_is_none() -> None: + """DEFAULT_AGENT.tools should be None — it inherits parent tools at spawn time.""" + assert DEFAULT_AGENT.tools is None + + +def test_general_purpose_tools_is_none() -> None: + """GENERAL_PURPOSE_AGENT.tools should be None — it inherits parent tools at spawn time.""" + assert GENERAL_PURPOSE_AGENT.tools is None + + +def test_explore_is_read_only() -> None: + assert EXPLORE_AGENT.tools == (ReadTool, GlobTool, GrepTool, WebFetchTool) + assert WriteTool not in EXPLORE_AGENT.tools + assert EditTool not in EXPLORE_AGENT.tools + assert BashTool not in EXPLORE_AGENT.tools + + +def test_plan_is_read_only_no_web() -> None: + assert PLAN_AGENT.tools == (ReadTool, GlobTool, GrepTool) + + +def test_description_does_not_preinclude_tools_suffix() -> None: + """The renderer is responsible for appending '(Tools: ...)' — description must not.""" + for arc in (DEFAULT_AGENT, GENERAL_PURPOSE_AGENT, EXPLORE_AGENT, PLAN_AGENT): + assert "(Tools:" not in arc.description, ( + f"archetype {arc.name!r} pre-includes Tools suffix in description; " + "the renderer is supposed to add it" + ) + + +def test_explore_and_plan_instructions_share_readonly_preamble() -> None: + """Both read-only archetypes carry the CRITICAL READ-ONLY preamble.""" + assert "CRITICAL: You are in READ-ONLY mode" in EXPLORE_AGENT.instruction + assert "CRITICAL: You are in READ-ONLY mode" in PLAN_AGENT.instruction + + +def test_default_instruction_is_neutral() -> None: + """DEFAULT_AGENT's instruction does not impose a specific role. + + Unlike GENERAL_PURPOSE_AGENT which is shaped as a researcher with + 'search broadly first / never create files' biases, DEFAULT_AGENT + intentionally leaves the role to the task at hand. + """ + instr = DEFAULT_AGENT.instruction + assert "READ-ONLY mode" not in instr + # No researcher-specific role framing. + assert "research" not in instr.lower() + assert "Search broadly" not in instr + # No persona-style "## Strengths" / "## Guidelines" sections. + assert "## Strengths" not in instr + + +def test_general_purpose_instruction_is_not_read_only() -> None: + assert "READ-ONLY mode" not in GENERAL_PURPOSE_AGENT.instruction + + +def test_default_models_are_unset() -> None: + """Defaults have model=None so they inherit SubAgentConfig.model / parent.model.""" + for arc in (DEFAULT_AGENT, GENERAL_PURPOSE_AGENT, EXPLORE_AGENT, PLAN_AGENT): + assert arc.model is None diff --git a/tests/agents/sub_agent/test_description.py b/tests/agents/sub_agent/test_description.py new file mode 100644 index 00000000..61b4de7e --- /dev/null +++ b/tests/agents/sub_agent/test_description.py @@ -0,0 +1,117 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Tests for the dynamic_subagent tool description rendering.""" + +from __future__ import annotations + +from trpc_agent_sdk.agents.sub_agent import EXPLORE_AGENT +from trpc_agent_sdk.agents.sub_agent import GENERAL_PURPOSE_AGENT +from trpc_agent_sdk.agents.sub_agent import SubAgentArchetype +from trpc_agent_sdk.agents.sub_agent import SubAgentRegistry +from trpc_agent_sdk.agents.sub_agent._description import render_archetype_block +from trpc_agent_sdk.agents.sub_agent._description import render_tool_description +from trpc_agent_sdk.agents.sub_agent._description import tool_names_of +from trpc_agent_sdk.tools import GrepTool +from trpc_agent_sdk.tools import ReadTool + + +def test_tool_names_from_class_refs() -> None: + assert tool_names_of(EXPLORE_AGENT) == ["Read", "Glob", "Grep", "webfetch"] + + +def test_tool_names_from_instances() -> None: + arc = SubAgentArchetype( + name="custom", + description="d", + instruction="i", + tools=(ReadTool(), GrepTool()), + ) + names = tool_names_of(arc) + # BaseTool instances expose `.name`, which differs from the class name. + assert len(names) == 2 + assert all(isinstance(n, str) and n for n in names) + + +def test_archetype_block_includes_tools_suffix() -> None: + block = render_archetype_block(EXPLORE_AGENT) + assert block.startswith("- Explore:") + assert "(Tools: Read, Glob, Grep, webfetch)" in block + + +def test_full_render_contains_all_archetypes() -> None: + reg = SubAgentRegistry() + reg.register(GENERAL_PURPOSE_AGENT) + reg.register(EXPLORE_AGENT) + out = render_tool_description(reg) + assert "Available subagent types:" in out + assert "- general-purpose:" in out + assert "- Explore:" in out + assert "IMPORTANT:" in out + + +def test_render_is_deterministic() -> None: + reg = SubAgentRegistry() + reg.register(GENERAL_PURPOSE_AGENT) + reg.register(EXPLORE_AGENT) + assert render_tool_description(reg) == render_tool_description(reg) + + +def test_archetype_with_no_tools() -> None: + arc = SubAgentArchetype( + name="bare", + description="d", + instruction="i", + tools=(), + ) + block = render_archetype_block(arc) + assert "(Tools: (none))" in block + + +def test_tool_names_of_none_tools() -> None: + arc = SubAgentArchetype(name="inherit", description="d", instruction="i", tools=None) + assert tool_names_of(arc) == ["(all)"] + + +def test_tool_names_of_toolset_instance() -> None: + from trpc_agent_sdk.tools import BaseToolSet + + class _FakeToolSet(BaseToolSet): + async def get_tools(self, invocation_context=None): + return [] + + toolset = _FakeToolSet() + arc = SubAgentArchetype(name="ts", description="d", instruction="i", tools=(toolset,)) + assert tool_names_of(arc) == ["_FakeToolSet"] + + +def test_tool_names_of_plain_callable() -> None: + def my_custom_tool(): + return ReadTool() + + arc = SubAgentArchetype(name="call", description="d", instruction="i", tools=(my_custom_tool,)) + assert tool_names_of(arc) == ["my_custom_tool"] + + +def test_render_archetype_block_tools_none() -> None: + arc = SubAgentArchetype(name="gp", description="GP agent.", instruction="i", tools=None) + block = render_archetype_block(arc) + assert "(Tools: (all))" in block + + +def test_tool_names_of_unrecognized_item() -> None: + """Non-tool, non-class, non-callable items fall back to type name.""" + arc = SubAgentArchetype(name="odd", description="d", instruction="i", tools=(42,)) + assert tool_names_of(arc) == ["int"] + + +def test_tool_names_of_callable_without_name() -> None: + """Callable without __name__ falls back to repr.""" + arc = SubAgentArchetype(name="odd", description="d", instruction="i", tools=(lambda x: x,)) + names = tool_names_of(arc) + # lambda has no __name__, so repr(t) is used. + assert len(names) == 1 + assert "lambda" in names[0] diff --git a/tests/agents/sub_agent/test_dynamic_sub_agent_tool.py b/tests/agents/sub_agent/test_dynamic_sub_agent_tool.py new file mode 100644 index 00000000..a48f0d87 --- /dev/null +++ b/tests/agents/sub_agent/test_dynamic_sub_agent_tool.py @@ -0,0 +1,315 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Tests for DynamicSubAgentTool — on-the-fly sub-agent creation with LLM-written instruction.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from trpc_agent_sdk.agents.sub_agent import DynamicSubAgentTool +from trpc_agent_sdk.agents.sub_agent import SubAgentConfig +from trpc_agent_sdk.agents.sub_agent._dynamic_sub_agent_tool import _tool_names +from trpc_agent_sdk.tools import BaseToolSet +from trpc_agent_sdk.tools import ReadTool + + +def _make_tool_context(): + return MagicMock() + + +def test_constructor_minimal() -> None: + """DynamicSubAgentTool() should construct with no arguments.""" + t = DynamicSubAgentTool() + assert t.name == "dynamic_subagent" + assert t._agent_config is None + assert t._skip_summarization is False + + +def test_constructor_with_config() -> None: + t = DynamicSubAgentTool(agent_config=SubAgentConfig(parallel_tool_calls=True)) + assert t._agent_config.parallel_tool_calls is True + + +def test_constructor_skip_summarization() -> None: + t = DynamicSubAgentTool(skip_summarization=True) + assert t._skip_summarization is True + + +def test_constructor_custom_name() -> None: + t = DynamicSubAgentTool(name="my_dynamic") + assert t.name == "my_dynamic" + + +def test_constructor_custom_description() -> None: + t = DynamicSubAgentTool(description="A custom tool description.") + assert t.description == "A custom tool description." + + +def test_declaration_schema_shape() -> None: + t = DynamicSubAgentTool() + decl = t._get_declaration() + assert decl.name == "dynamic_subagent" + props = decl.parameters.properties + assert decl.parameters.required == ["prompt"] + assert "instruction" in props + assert "prompt" in props + assert "description" not in props + + +def test_description_contains_key_text() -> None: + t = DynamicSubAgentTool() + assert "Run one short-lived sub-agent" in t.description + assert "created on the fly" in t.description + assert "IMPORTANT" in t.description + + +@pytest.mark.asyncio +async def test_empty_instruction_falls_back_to_default() -> None: + """Empty/whitespace instruction falls back to default, proceeds to run_subagent.""" + t = DynamicSubAgentTool() + ctx = _make_tool_context() + result = await t._run_async_impl( + tool_context=ctx, + args={"instruction": " ", "prompt": "do something"}, + ) + # Should NOT be an instruction validation error — falls back and tries to run. + assert not (isinstance(result, dict) + and result.get("status") == "error" + and "instruction" in str(result.get("message"))) + + +@pytest.mark.asyncio +async def test_empty_prompt_returns_error() -> None: + t = DynamicSubAgentTool() + ctx = _make_tool_context() + result = await t._run_async_impl( + tool_context=ctx, + args={"instruction": "You are a helpful agent.", "prompt": " "}, + ) + assert result["status"] == "error" + assert "prompt" in result["message"] + + +@pytest.mark.asyncio +async def test_missing_instruction_uses_default() -> None: + """Missing instruction uses fallback instead of returning error.""" + t = DynamicSubAgentTool() + ctx = _make_tool_context() + result = await t._run_async_impl( + tool_context=ctx, + args={"prompt": "do something"}, + ) + # Should NOT be a validation error — falls back and tries to run. + assert not (isinstance(result, dict) + and result.get("status") == "error" + and "instruction" in str(result.get("message"))) + + +@pytest.mark.asyncio +async def test_valid_args_creates_synthetic_archetype() -> None: + """Valid call creates a synthetic SubAgentArchetype and passes to run_subagent.""" + t = DynamicSubAgentTool() + ctx = _make_tool_context() + # With a mock context, run_subagent will raise; we just verify + # the error is NOT a validation error — meaning the synthetic + # archetype was created and run_subagent was called. + result = await t._run_async_impl( + tool_context=ctx, + args={ + "instruction": "You are a database expert.", + "prompt": "Analyze the schema.", + }, + ) + # Should NOT be a validation error. + assert not (isinstance(result, dict) + and result.get("status") == "error" + and "non-empty" in str(result.get("message"))) + + +def test_has_no_registry() -> None: + """DynamicSubAgentTool should not have a registry — it uses synthetic archetypes.""" + t = DynamicSubAgentTool() + assert not hasattr(t, "registry") + assert not hasattr(t, "_registry") + + +@pytest.mark.asyncio +async def test_process_request_with_parent_history() -> None: + """process_request appends history-aware instruction when include_parent_history=True.""" + t = DynamicSubAgentTool(agent_config=SubAgentConfig(include_parent_history=True)) + llm_request = MagicMock() + llm_request.append_instructions = MagicMock() + ctx = _make_tool_context() + + await t.process_request(tool_context=ctx, llm_request=llm_request) + + llm_request.append_instructions.assert_called_once() + instruction = llm_request.append_instructions.call_args[0][0][0] + assert "can see the" in instruction + assert "current conversation" in instruction + + +@pytest.mark.asyncio +async def test_process_request_without_parent_history() -> None: + """process_request appends no-history instruction when agent_config=None.""" + t = DynamicSubAgentTool() + llm_request = MagicMock() + llm_request.append_instructions = MagicMock() + ctx = _make_tool_context() + + await t.process_request(tool_context=ctx, llm_request=llm_request) + + llm_request.append_instructions.assert_called_once() + instruction = llm_request.append_instructions.call_args[0][0][0] + assert "has no memory" in instruction + + +@pytest.mark.asyncio +async def test_skip_summarization_sets_event_action() -> None: + """When skip_summarization=True, _run_async_impl sets skip_summarization on event_actions.""" + t = DynamicSubAgentTool(skip_summarization=True) + ctx = _make_tool_context() + ctx.event_actions.skip_summarization = False + + await t._run_async_impl( + tool_context=ctx, + args={"prompt": " "}, + ) + assert ctx.event_actions.skip_summarization is True + + +# --- expose_tool_selection=False ----------------------------------------------- + + +def test_declaration_without_tool_selection() -> None: + """When expose_tool_selection=False, the 'tools' field is omitted from schema.""" + t = DynamicSubAgentTool(expose_tool_selection=False) + decl = t._get_declaration() + assert "tools" not in decl.parameters.properties + + +# --- tools=tuple with expose_tool_selection=True -------------------------------- + + +def test_declaration_with_fixed_tools_includes_tool_names() -> None: + """When tools=tuple and expose_tool_selection=True, description lists tool names.""" + t = DynamicSubAgentTool(tools=(ReadTool(),), expose_tool_selection=True) + decl = t._get_declaration() + tools_prop = decl.parameters.properties["tools"] + assert "Available tool names:" in tools_prop.description + assert "Read" in tools_prop.description + + +def test_declaration_with_fixed_tools_empty_tuple() -> None: + """When tools=() empty tuple, no tool names appended to description.""" + t = DynamicSubAgentTool(tools=(), expose_tool_selection=True) + decl = t._get_declaration() + tools_prop = decl.parameters.properties["tools"] + assert "Available tool names:" not in tools_prop.description + + +# --- _tool_names --------------------------------------------------------------- + + +def test_tool_names_with_basetool_instance() -> None: + names = _tool_names((ReadTool(),)) + assert names == ["Read"] + + +def test_tool_names_with_basetoolset_instance() -> None: + class _FakeToolSet(BaseToolSet): + async def get_tools(self, invocation_context=None): + return [] + + names = _tool_names((_FakeToolSet(),)) + assert names == ["_FakeToolSet"] + + +def test_tool_names_with_class_reference() -> None: + names = _tool_names((ReadTool,)) + assert names == ["Read"] + + +def test_tool_names_with_callable_no_name() -> None: + """Callable without __name__ is skipped (getattr with None default).""" + + class _CallableNoName: + def __call__(self): + return ReadTool() + + names = _tool_names((_CallableNoName(),)) + assert names == [] + + +def test_tool_names_with_unrecognized_item() -> None: + """Non-tool, non-callable items are skipped.""" + names = _tool_names(("not-a-tool",)) + assert names == [] + + +# --- LLM-provided tools arg in _run_async_impl --------------------------------- + + +@pytest.mark.asyncio +async def test_run_async_with_tool_filter_from_llm() -> None: + """When expose_tool_selection=True and args has 'tools' list, tool_filter is set.""" + t = DynamicSubAgentTool() + ctx = _make_tool_context() + # The mock context will cause run_subagent to raise, but we verify + # that tool_filter forwarding doesn't break anything. + result = await t._run_async_impl( + tool_context=ctx, + args={ + "instruction": "You are helpful.", + "prompt": "Do something.", + "tools": ["Read", "Grep"], + }, + ) + # Should not be a validation error. + assert not (isinstance(result, dict) + and result.get("status") == "error" + and "non-empty" in str(result.get("message"))) + + +@pytest.mark.asyncio +async def test_run_async_ignores_non_list_tools_arg() -> None: + """When 'tools' arg is not a list, tool_filter remains None.""" + t = DynamicSubAgentTool() + ctx = _make_tool_context() + result = await t._run_async_impl( + tool_context=ctx, + args={ + "instruction": "You are helpful.", + "prompt": "Do something.", + "tools": "not-a-list", + }, + ) + # Should not be a validation error. + assert not (isinstance(result, dict) + and result.get("status") == "error" + and "non-empty" in str(result.get("message"))) + + +@pytest.mark.asyncio +async def test_run_async_without_tool_selection_ignores_tools_arg() -> None: + """When expose_tool_selection=False, 'tools' arg is ignored.""" + t = DynamicSubAgentTool(expose_tool_selection=False) + ctx = _make_tool_context() + result = await t._run_async_impl( + tool_context=ctx, + args={ + "instruction": "You are helpful.", + "prompt": "Do something.", + "tools": ["Read"], + }, + ) + # Should not be a validation error. + assert not (isinstance(result, dict) + and result.get("status") == "error" + and "non-empty" in str(result.get("message"))) diff --git a/tests/agents/sub_agent/test_imports.py b/tests/agents/sub_agent/test_imports.py new file mode 100644 index 00000000..625c477b --- /dev/null +++ b/tests/agents/sub_agent/test_imports.py @@ -0,0 +1,86 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Smoke tests for the dynamic sub-agent package import surface.""" + +from __future__ import annotations + +import sys + + +def test_public_imports() -> None: + from trpc_agent_sdk.agents.sub_agent import ( + SpawnSubAgentTool, + DEFAULT_AGENT, + EXPLORE_AGENT, + GENERAL_PURPOSE_AGENT, + PLAN_AGENT, + DynamicSubAgentTool, + SubAgentArchetype, + SubAgentRegistry, + ) + assert SpawnSubAgentTool is not None + assert DynamicSubAgentTool is not None + assert SubAgentArchetype is not None + assert SubAgentRegistry is not None + assert DEFAULT_AGENT.name == "default" + assert GENERAL_PURPOSE_AGENT.name == "general-purpose" + assert EXPLORE_AGENT.name == "Explore" + assert PLAN_AGENT.name == "Plan" + + +def test_dynamic_not_loaded_when_only_agents_imported() -> None: + """Importing trpc_agent_sdk.agents must not eagerly pull in ``sub_agent``. + + The sub_agent subsystem brings in file_tools / web tools — keep those off + the default agents import path. + """ + # Drop any cached entries for a clean check; sub-modules already loaded + # by other tests in this run would otherwise pollute the result. + saved = {} + for mod in list(sys.modules): + if mod.startswith("trpc_agent_sdk.agents.sub_agent"): + saved[mod] = sys.modules.pop(mod) + + try: + import trpc_agent_sdk.agents # noqa: F401 + assert "trpc_agent_sdk.agents.sub_agent" not in sys.modules + finally: + # Restore deleted modules so subsequent tests don't get stale + # class objects from re-imports (e.g. _BorrowedToolSet). + sys.modules.update(saved) + + +def test_lazy_import_dynamic_sub_agent_tool_from_tools() -> None: + """DynamicSubAgentTool is lazily re-exported from trpc_agent_sdk.tools.""" + from trpc_agent_sdk.tools import DynamicSubAgentTool + from trpc_agent_sdk.agents.sub_agent import DynamicSubAgentTool as DirectTool + assert DynamicSubAgentTool is DirectTool + + +def test_lazy_import_spawn_sub_agent_tool_from_tools() -> None: + """SpawnSubAgentTool is lazily re-exported from trpc_agent_sdk.tools.""" + from trpc_agent_sdk.tools import SpawnSubAgentTool + from trpc_agent_sdk.agents.sub_agent import SpawnSubAgentTool as DirectTool + assert SpawnSubAgentTool is DirectTool + + +def test_tools_dir_includes_dynamic_subagents() -> None: + """__dir__ of trpc_agent_sdk.tools includes lazy re-exports.""" + import trpc_agent_sdk.tools + names = dir(trpc_agent_sdk.tools) + assert "DynamicSubAgentTool" in names + assert "SpawnSubAgentTool" in names + + +def test_tools_getattr_unknown_raises() -> None: + """__getattr__ raises AttributeError for unknown names.""" + import trpc_agent_sdk.tools + try: + _ = trpc_agent_sdk.tools.__getattr__("NonExistentTool") + assert False, "should have raised" + except AttributeError: + pass diff --git a/tests/agents/sub_agent/test_loader.py b/tests/agents/sub_agent/test_loader.py new file mode 100644 index 00000000..ca675e1a --- /dev/null +++ b/tests/agents/sub_agent/test_loader.py @@ -0,0 +1,440 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Tests for _loader.py — MD-based archetype loading.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from trpc_agent_sdk.agents.sub_agent._loader import load_archetype_from_file +from trpc_agent_sdk.agents.sub_agent._loader import load_archetypes_from_dir +from trpc_agent_sdk.agents.sub_agent._loader import _split_frontmatter +from trpc_agent_sdk.agents.sub_agent._loader import _WHITELIST_NAMES + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _write(tmp_path: Path, filename: str, content: str) -> Path: + p = tmp_path / filename + p.write_text(textwrap.dedent(content), encoding="utf-8") + return p + + +# --------------------------------------------------------------------------- +# _split_frontmatter +# --------------------------------------------------------------------------- + +def test_split_frontmatter_empty_text(): + fm, body = _split_frontmatter("") + assert fm == "" + assert body == "" + + +def test_split_frontmatter_no_delimiter(): + fm, body = _split_frontmatter("plain text") + assert fm == "" + assert body == "plain text" + + +def test_split_frontmatter_unclosed_delimiter(): + fm, body = _split_frontmatter("---\nsome content\n") + assert fm == "" + assert body == "---\nsome content\n" + + +def test_split_frontmatter_normal(): + fm, body = _split_frontmatter("---\nname: test\ndescription: desc\n---\nBody text.\n") + assert "name: test" in fm + assert "description: desc" in fm + assert "Body text." in body + + +# --------------------------------------------------------------------------- +# load_archetype_from_file — happy path +# --------------------------------------------------------------------------- + +def test_minimal_md(tmp_path): + p = _write(tmp_path, "researcher.md", """\ + --- + name: researcher + description: Use for research tasks. + --- + + You are a researcher. + """) + a = load_archetype_from_file(p) + assert a.name == "researcher" + assert a.description == "Use for research tasks." + assert "You are a researcher." in a.instruction + assert a.model is None + # no tools specified — inherits all parent tools + assert a.tools is None + + +def test_explicit_tools(tmp_path): + p = _write(tmp_path, "reader.md", """\ + --- + name: reader + description: Only reads files. + tools: + - Read + - Grep + --- + + You read files. + """) + a = load_archetype_from_file(p) + tool_names = {t().name for t in a.tools} + assert tool_names == {"Read", "Grep"} + + +def test_instruction_multiline(tmp_path): + p = _write(tmp_path, "multi.md", """\ + --- + name: multi + description: Multi-line instruction test. + --- + + Line one. + Line two. + Line three. + """) + a = load_archetype_from_file(p) + assert "Line one." in a.instruction + assert "Line three." in a.instruction + + +# --------------------------------------------------------------------------- +# load_archetype_from_file — error cases +# --------------------------------------------------------------------------- + +def test_file_read_oserror_raises(tmp_path): + """Passing a directory path triggers IsADirectoryError (OSError subclass).""" + with pytest.raises(ValueError, match="cannot read file"): + load_archetype_from_file(tmp_path) + + +def test_missing_frontmatter_raises(tmp_path): + p = _write(tmp_path, "bad.md", "Just a plain body, no frontmatter.\n") + with pytest.raises(ValueError, match="missing YAML frontmatter"): + load_archetype_from_file(p) + + +def test_missing_name_raises(tmp_path): + p = _write(tmp_path, "bad.md", """\ + --- + description: Something. + --- + Body. + """) + with pytest.raises(ValueError, match="'name'"): + load_archetype_from_file(p) + + +def test_missing_description_raises(tmp_path): + p = _write(tmp_path, "bad.md", """\ + --- + name: my-agent + --- + Body. + """) + with pytest.raises(ValueError, match="'description'"): + load_archetype_from_file(p) + + +def test_empty_body_raises(tmp_path): + p = _write(tmp_path, "bad.md", """\ + --- + name: empty-body + description: Something. + --- + + """) + with pytest.raises(ValueError, match="instruction body"): + load_archetype_from_file(p) + + +def test_unknown_tool_raises(tmp_path): + p = _write(tmp_path, "bad.md", """\ + --- + name: unknown-tool + description: Something. + tools: + - Read + - NotARealTool + --- + Body. + """) + with pytest.raises(ValueError, match="unknown tool.*NotARealTool"): + load_archetype_from_file(p) + + +def test_tools_not_list_raises(tmp_path): + p = _write(tmp_path, "bad.md", """\ + --- + name: bad-tools + description: Something. + tools: Read + --- + Body. + """) + with pytest.raises(ValueError, match="'tools' must be a YAML list"): + load_archetype_from_file(p) + + +def test_tool_entry_not_string_raises(tmp_path): + p = _write(tmp_path, "bad.md", """\ + --- + name: bad-tools + description: Something. + tools: + - Read + - 123 + --- + Body. + """) + with pytest.raises(ValueError, match="each tool entry must be a string"): + load_archetype_from_file(p) + + +def test_invalid_yaml_raises(tmp_path): + p = tmp_path / "bad.md" + p.write_text("---\nname: [unclosed\n---\nBody.\n", encoding="utf-8") + with pytest.raises(ValueError, match="invalid YAML"): + load_archetype_from_file(p) + + +def test_invalid_name_raises(tmp_path): + p = _write(tmp_path, "bad.md", """\ + --- + name: "123-invalid" + description: Something. + --- + Body. + """) + with pytest.raises(ValueError): + load_archetype_from_file(p) + + +# --------------------------------------------------------------------------- +# load_archetypes_from_dir +# --------------------------------------------------------------------------- + +def test_load_dir_empty(tmp_path): + result = load_archetypes_from_dir(tmp_path) + assert result == [] + + +def test_load_dir_multiple_sorted(tmp_path): + _write(tmp_path, "z-agent.md", """\ + --- + name: z-agent + description: Last. + --- + Z body. + """) + _write(tmp_path, "a-agent.md", """\ + --- + name: a-agent + description: First. + --- + A body. + """) + result = load_archetypes_from_dir(tmp_path) + assert [a.name for a in result] == ["a-agent", "z-agent"] + + +def test_load_dir_nonexistent_raises(): + with pytest.raises(ValueError, match="does not exist"): + load_archetypes_from_dir("/nonexistent/path/xyz") + + +def test_load_dir_not_a_directory_raises(tmp_path): + f = tmp_path / "file.txt" + f.write_text("hello") + with pytest.raises(ValueError, match="not a directory"): + load_archetypes_from_dir(f) + + +def test_load_dir_bad_file_raises(tmp_path): + _write(tmp_path, "good.md", """\ + --- + name: good + description: Good one. + --- + Good body. + """) + _write(tmp_path, "bad.md", "No frontmatter here.\n") + with pytest.raises(ValueError, match="missing YAML frontmatter"): + load_archetypes_from_dir(tmp_path) + + +def test_load_dir_ignores_non_md(tmp_path): + (tmp_path / "notes.txt").write_text("ignore me") + _write(tmp_path, "valid.md", """\ + --- + name: valid + description: Valid. + --- + Valid body. + """) + result = load_archetypes_from_dir(tmp_path) + assert len(result) == 1 + assert result[0].name == "valid" + + +# --------------------------------------------------------------------------- +# SpawnSubAgentTool integration +# --------------------------------------------------------------------------- + +def test_archetype_tool_agent_paths(tmp_path): + _write(tmp_path, "custom.md", """\ + --- + name: custom + description: Custom agent. + tools: + - Read + --- + You are custom. + """) + + from trpc_agent_sdk.agents.sub_agent import SpawnSubAgentTool + tool = SpawnSubAgentTool(agent_paths=[tmp_path]) + assert tool.registry.names() == ["default", "custom"] + + +def test_archetype_tool_agent_paths_duplicate_raises(tmp_path): + _write(tmp_path, "aaa.md", """\ + --- + name: dup + description: First. + --- + Aaa. + """) + _write(tmp_path, "zzz.md", """\ + --- + name: dup + description: Second. + --- + Zzz. + """) + + from trpc_agent_sdk.agents.sub_agent import SpawnSubAgentTool + with pytest.raises(ValueError, match="collides"): + SpawnSubAgentTool(agent_paths=[tmp_path]) + + +# --------------------------------------------------------------------------- +# Whitelist completeness smoke-test +# --------------------------------------------------------------------------- + +def test_whitelist_names_all_importable(): + from trpc_agent_sdk.agents.sub_agent._loader import _tool_whitelist + wl = _tool_whitelist() + assert set(wl.keys()) == _WHITELIST_NAMES + + +# --------------------------------------------------------------------------- +# tool_mapping +# --------------------------------------------------------------------------- + + +def test_tool_mapping_resolves_custom_tool(tmp_path): + from trpc_agent_sdk.tools import ReadTool + p = _write(tmp_path, "custom.md", """\ + --- + name: custom + description: Custom tool test. + tools: + - Read + - MyTool + --- + You are custom. + """) + a = load_archetype_from_file(p, tool_mapping={"MyTool": ReadTool}) + tool_names = {t().name for t in a.tools} + assert len(a.tools) == 2 + assert tool_names == {"Read"} + + +def test_tool_mapping_overrides_builtin(tmp_path): + from trpc_agent_sdk.tools import GrepTool + p = _write(tmp_path, "custom.md", """\ + --- + name: custom + description: Override Read. + tools: + - Read + --- + You are custom. + """) + a = load_archetype_from_file(p, tool_mapping={"Read": GrepTool}) + tool_names = {t().name for t in a.tools} + assert tool_names == {"Grep"} + + +def test_tool_mapping_unknown_still_errors(tmp_path): + p = _write(tmp_path, "bad.md", """\ + --- + name: bad + description: Unknown tool. + tools: + - NotARealTool + --- + Body. + """) + with pytest.raises(ValueError, match="unknown tool.*NotARealTool"): + load_archetype_from_file(p, tool_mapping={"MyTool": type}) + + +def test_frontmatter_non_dict_raises(tmp_path): + """YAML frontmatter that parses to a list raises ValueError.""" + p = _write(tmp_path, "list.md", """\ + --- + - item1 + - item2 + --- + Body text here. + """) + with pytest.raises(ValueError, match="must be a YAML mapping"): + load_archetype_from_file(p) + + +def test_frontmatter_scalar_raises(tmp_path): + """YAML frontmatter that parses to a scalar raises ValueError.""" + p = _write(tmp_path, "scalar.md", """\ + --- + just a string + --- + Body text here. + """) + with pytest.raises(ValueError, match="must be a YAML mapping"): + load_archetype_from_file(p) + + +def test_tool_mapping_error_message_includes_custom(tmp_path): + """Error message should include custom tool names from tool_mapping.""" + p = _write(tmp_path, "bad.md", """\ + --- + name: bad + description: Unknown tool. + tools: + - MyTool + - NotReal + --- + Body. + """) + with pytest.raises(ValueError) as exc_info: + load_archetype_from_file(p, tool_mapping={"MyTool": type}) + msg = str(exc_info.value) + assert "MyTool" in msg + assert "NotReal" in msg diff --git a/tests/agents/sub_agent/test_registry.py b/tests/agents/sub_agent/test_registry.py new file mode 100644 index 00000000..cc31807b --- /dev/null +++ b/tests/agents/sub_agent/test_registry.py @@ -0,0 +1,77 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Tests for SubAgentRegistry.""" + +from __future__ import annotations + +import pytest + +from trpc_agent_sdk.agents.sub_agent import SubAgentArchetype +from trpc_agent_sdk.agents.sub_agent import SubAgentRegistry +from trpc_agent_sdk.tools import ReadTool + + +def _arc(name: str) -> SubAgentArchetype: + return SubAgentArchetype( + name=name, + description=f"archetype {name}", + instruction="be helpful", + tools=(ReadTool,), + ) + + +def test_register_and_get() -> None: + reg = SubAgentRegistry() + a = _arc("alpha") + reg.register(a) + assert reg.get("alpha") is a + assert "alpha" in reg + assert len(reg) == 1 + + +def test_insertion_order_preserved() -> None: + reg = SubAgentRegistry() + for n in ["zeta", "alpha", "mu"]: + reg.register(_arc(n)) + assert reg.names() == ["zeta", "alpha", "mu"] + + +def test_duplicate_registration_rejected() -> None: + reg = SubAgentRegistry() + reg.register(_arc("alpha")) + with pytest.raises(ValueError): + reg.register(_arc("alpha")) + + +def test_missing_get_raises() -> None: + reg = SubAgentRegistry() + with pytest.raises(KeyError): + reg.get("nope") + + +def test_archetypes_returns_in_order() -> None: + reg = SubAgentRegistry() + a = _arc("a") + b = _arc("b") + reg.register(a) + reg.register(b) + assert reg.archetypes() == [a, b] + + +def test_iter_yields_archetypes() -> None: + reg = SubAgentRegistry() + items = [_arc("x"), _arc("y")] + for it in items: + reg.register(it) + assert list(reg) == items + + +def test_contains_only_string_keys() -> None: + reg = SubAgentRegistry() + reg.register(_arc("hello")) + assert "hello" in reg + assert 123 not in reg # type: ignore[operator] diff --git a/tests/agents/sub_agent/test_runner.py b/tests/agents/sub_agent/test_runner.py new file mode 100644 index 00000000..4645c050 --- /dev/null +++ b/tests/agents/sub_agent/test_runner.py @@ -0,0 +1,958 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Tests for sub-agent construction (synchronous building blocks). + +We use a MockLLMModel registered with ModelRegistry so LlmAgent's +``model_post_init`` (which resolves string model names via the registry) +succeeds for the test model names. Real Runner-driven spawning is out of +scope here — that requires a stub LLM and is better suited to integration +tests. +""" + +from __future__ import annotations + +from typing import List +from unittest.mock import MagicMock + +import pytest + +from trpc_agent_sdk.agents.sub_agent import GENERAL_PURPOSE_AGENT +from trpc_agent_sdk.agents.sub_agent import SubAgentArchetype +from trpc_agent_sdk.agents.sub_agent import SubAgentConfig +from trpc_agent_sdk.agents.sub_agent._constants import ISOLATION_DEFAULTS +from trpc_agent_sdk.agents.sub_agent._runner import _BorrowedToolSet +from trpc_agent_sdk.agents.sub_agent._runner import _build_sub_agent +from trpc_agent_sdk.agents.sub_agent._runner import _collect_parent_events +from trpc_agent_sdk.agents.sub_agent._runner import _event_is_model_visible +from trpc_agent_sdk.agents.sub_agent._runner import _extract_final_text +from trpc_agent_sdk.agents.sub_agent._runner import _forward_artifacts +from trpc_agent_sdk.agents.sub_agent._runner import _is_user_text_event +from trpc_agent_sdk.agents.sub_agent._runner import _materialize_tools +from trpc_agent_sdk.agents.sub_agent._runner import _resolve_model +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.models import LlmResponse +from trpc_agent_sdk.models import ModelRegistry +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.tools import BaseToolSet +from trpc_agent_sdk.tools import GrepTool +from trpc_agent_sdk.tools import ReadTool + + +class MockLLMModel(LLMModel): + @classmethod + def supported_models(cls) -> List[str]: + return [r"test-dynamic-.*"] + + async def _generate_async_impl(self, request, stream=False, ctx=None): + yield LlmResponse(content=None) + + def validate_request(self, request): + pass + + +@pytest.fixture(scope="module", autouse=True) +def _register_mock_model(): + original = ModelRegistry._registry.copy() + ModelRegistry.register(MockLLMModel) + yield + ModelRegistry._registry = original + + +def _parent_ctx_with_model(model: str) -> MagicMock: + parent_ctx = MagicMock() + parent_agent = MagicMock() + parent_agent.model = model + parent_agent.generate_content_config = None + parent_agent.parallel_tool_calls = False + parent_ctx.agent = parent_agent + return parent_ctx + + +# --- _materialize_tools ---------------------------------------------------- + + +def test_materialize_tools_factories_to_instances() -> None: + out = _materialize_tools((ReadTool,)) + assert len(out) == 1 + assert isinstance(out[0], BaseTool) + + +def test_materialize_tools_passes_instances_through() -> None: + inst = ReadTool() + out = _materialize_tools((inst,)) + assert out == [inst] + + +def test_materialize_tools_rejects_garbage() -> None: + with pytest.raises(TypeError): + _materialize_tools(("not-a-tool",)) + + +# --- _resolve_model ------------------------------------------------- + + +def test_resolve_model_from_agent_config() -> None: + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + config = SubAgentConfig(model="from-config") + assert _resolve_model(config, parent_ctx) == "from-config" + + +def test_resolve_model_falls_back_to_parent() -> None: + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + assert _resolve_model(None, parent_ctx) == "test-dynamic-parent" + + +def test_resolve_model_raises_when_missing() -> None: + parent_ctx = _parent_ctx_with_model("") + with pytest.raises(ValueError, match="sub-agent: cannot resolve model"): + _resolve_model(None, parent_ctx) + + +# --- _build_sub_agent ------------------------------------------------------ + + +def test_build_sub_agent_uses_agent_config_model() -> None: + """SubAgentConfig.model is used when set.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + agent = _build_sub_agent( + GENERAL_PURPOSE_AGENT, parent_ctx, + agent_config=SubAgentConfig(model="test-dynamic-default"), + ) + assert isinstance(agent.model, LLMModel) + + +def test_build_sub_agent_falls_back_to_parent_model() -> None: + """Falls back to parent model when agent_config.model is not set.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + agent = _build_sub_agent(GENERAL_PURPOSE_AGENT, parent_ctx) + assert isinstance(agent.model, LLMModel) + + +def test_build_sub_agent_inherits_parallel_tool_calls_from_parent() -> None: + """parallel_tool_calls inherits from parent when not in agent_config.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.parallel_tool_calls = True + agent = _build_sub_agent(GENERAL_PURPOSE_AGENT, parent_ctx) + assert agent.parallel_tool_calls is True + + +def test_build_sub_agent_applies_isolation_defaults() -> None: + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + agent = _build_sub_agent(GENERAL_PURPOSE_AGENT, parent_ctx) + for field, expected in ISOLATION_DEFAULTS.items(): + actual = getattr(agent, field) + assert actual == expected, f"{field}: expected {expected!r}, got {actual!r}" + + +def test_build_sub_agent_name_format() -> None: + """Hyphens in archetype.name are normalized to underscores so the + LlmAgent name remains a valid Python identifier.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + agent = _build_sub_agent(GENERAL_PURPOSE_AGENT, parent_ctx) + assert agent.name == "subagent_general_purpose" + + +def test_build_sub_agent_no_callbacks() -> None: + """Strict isolation: parent callbacks are not inherited.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + agent = _build_sub_agent(GENERAL_PURPOSE_AGENT, parent_ctx) + assert agent.before_model_callback is None + assert agent.after_model_callback is None + assert agent.before_tool_callback is None + assert agent.after_tool_callback is None + assert agent.before_agent_callback is None + assert agent.after_agent_callback is None + + +def test_build_sub_agent_no_output_key() -> None: + """Strict isolation: sub-agent must not write into parent state.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + agent = _build_sub_agent(GENERAL_PURPOSE_AGENT, parent_ctx) + assert agent.output_key is None + + +def test_build_sub_agent_filters_out_nesting_tools() -> None: + """Neither SpawnSubAgentTool nor DynamicSubAgentTool must reach the sub-agent. + + When tools=None (inherit parent), the parent may have either tool. The + 1-level cap must strip both. + """ + from trpc_agent_sdk.agents.sub_agent import DynamicSubAgentTool + from trpc_agent_sdk.agents.sub_agent import SpawnSubAgentTool + + arc = SubAgentArchetype(name="custom", description="d", instruction="i", tools=None) + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.tools = [ + ReadTool(), + SpawnSubAgentTool(with_default=False), + DynamicSubAgentTool(), + ] + agent = _build_sub_agent(arc, parent_ctx) + tool_names = [type(t).__name__ for t in agent.tools] + assert "DynamicSubAgentTool" not in tool_names + assert "SpawnSubAgentTool" not in tool_names + assert "ReadTool" in tool_names # inherited tool still present + + +# --- _BorrowedToolSet ------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_borrowed_toolset_proxies_get_tools() -> None: + """_BorrowedToolSet.get_tools() delegates to the inner toolset.""" + + class _FakeToolSet(BaseToolSet): + async def get_tools(self, invocation_context=None): + return [ReadTool()] + + inner = _FakeToolSet() + borrowed = _BorrowedToolSet(inner) + tools = await borrowed.get_tools() + assert len(tools) == 1 + assert isinstance(tools[0], ReadTool) + + +@pytest.mark.asyncio +async def test_borrowed_toolset_close_is_noop() -> None: + """_BorrowedToolSet.close() must not close the inner toolset.""" + closed = [] + + class _FakeToolSet(BaseToolSet): + async def get_tools(self, invocation_context=None): + return [] + + async def close(self): + closed.append(True) + + inner = _FakeToolSet() + borrowed = _BorrowedToolSet(inner) + await borrowed.close() + assert closed == [], "inner toolset must not be closed via _BorrowedToolSet" + + +def test_agent_config_applied_to_sub_agent() -> None: + """agent_config fields are forwarded to the LlmAgent constructor.""" + from trpc_agent_sdk.types import GenerateContentConfig + + gen_config = GenerateContentConfig(temperature=0.1) + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + agent = _build_sub_agent( + GENERAL_PURPOSE_AGENT, parent_ctx, + agent_config=SubAgentConfig( + generate_content_config=gen_config, + parallel_tool_calls=True, + ), + ) + assert agent.generate_content_config is gen_config + assert agent.parallel_tool_calls is True + + +# --- skill_repository tracks SkillToolSet --------------------------------- + + +def test_build_sub_agent_skill_repo_none_without_skill_toolset() -> None: + """When tools contain no SkillToolSet, sub-agent's skill_repository is None.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.tools = [ReadTool()] + agent = _build_sub_agent(GENERAL_PURPOSE_AGENT, parent_ctx) + assert agent.skill_repository is None + + +def test_build_sub_agent_skill_repo_from_inherited_skill_toolset() -> None: + """When parent has a SkillToolSet, sub-agent inherits its repository.""" + pytest.importorskip("trpc_agent_sdk.skills") + from trpc_agent_sdk.skills import SkillToolSet + + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_skill_toolset = SkillToolSet() + parent_ctx.agent.tools = [ReadTool(), parent_skill_toolset] + + agent = _build_sub_agent(GENERAL_PURPOSE_AGENT, parent_ctx) + assert agent.skill_repository is parent_skill_toolset.repository + + +def test_build_sub_agent_skill_repo_from_archetype_skill_toolset() -> None: + """When archetype.tools contains a SkillToolSet, its repository is used.""" + pytest.importorskip("trpc_agent_sdk.skills") + from trpc_agent_sdk.skills import SkillToolSet + + archetype_skill_toolset = SkillToolSet() + arc = SubAgentArchetype( + name="custom", + description="d", + instruction="i", + tools=(ReadTool, archetype_skill_toolset), + ) + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.tools = [] # parent has no SkillToolSet + + agent = _build_sub_agent(arc, parent_ctx) + assert agent.skill_repository is archetype_skill_toolset.repository + + +def test_agent_config_does_not_override_instruction() -> None: + """agent_config cannot override archetype instruction.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + agent = _build_sub_agent( + GENERAL_PURPOSE_AGENT, parent_ctx, + agent_config=SubAgentConfig(), + ) + assert agent.instruction == GENERAL_PURPOSE_AGENT.instruction + + +def test_isolation_defaults_always_win() -> None: + """ISOLATION_DEFAULTS override agent_config.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + agent = _build_sub_agent( + GENERAL_PURPOSE_AGENT, parent_ctx, + agent_config=SubAgentConfig(), + ) + assert agent.output_key is None + + +def test_agent_config_non_none_is_passed() -> None: + """Non-None agent_config values override LlmAgent defaults.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + agent = _build_sub_agent( + GENERAL_PURPOSE_AGENT, parent_ctx, + agent_config=SubAgentConfig(parallel_tool_calls=True), + ) + assert agent.parallel_tool_calls is True + + +def test_collect_parent_events_empty_session() -> None: + """Empty session returns empty list.""" + from trpc_agent_sdk.agents.sub_agent._runner import _collect_parent_events + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.session.events = [] + assert _collect_parent_events(parent_ctx, max_parent_history_turns=3) == [] + + +def test_collect_parent_events_no_max_turns() -> None: + """max_parent_history_turns=0 returns empty list.""" + from trpc_agent_sdk.agents.sub_agent._runner import _collect_parent_events + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.session.events = [MagicMock(content=MagicMock(), author="user", is_model_visible=True)] + assert _collect_parent_events(parent_ctx, max_parent_history_turns=0) == [] + + +def test_event_is_model_visible_calls_method() -> None: + """_event_is_model_visible calls is_model_visible() if it's callable.""" + from trpc_agent_sdk.agents.sub_agent._runner import _event_is_model_visible + called = [] + event = MagicMock(is_model_visible=lambda: called.append(True) or True) + assert _event_is_model_visible(event) is True + assert called # method was actually called + + +def test_collect_parent_events_all_turns() -> None: + """max_parent_history_turns=None returns all visible events with content.""" + from trpc_agent_sdk.agents.sub_agent._runner import _collect_parent_events + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + e1 = MagicMock(content=MagicMock(), author="user", is_model_visible=True) + e2 = MagicMock(content=MagicMock(), author="model", is_model_visible=True) + parent_ctx.session.events = [e1, e2] + result = _collect_parent_events(parent_ctx, max_parent_history_turns=None) + assert len(result) == 2 + + +def test_build_sub_agent_history_fields_not_forwarded_to_llm_agent() -> None: + """include_parent_history and max_parent_history_turns are not passed to LlmAgent.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + agent = _build_sub_agent( + GENERAL_PURPOSE_AGENT, parent_ctx, + agent_config=SubAgentConfig(include_parent_history=True, max_parent_history_turns=3), + ) + assert not hasattr(agent, "include_parent_history") + assert not hasattr(agent, "max_parent_history_turns") + + +def test_build_sub_agent_no_parent_history_has_no_effect() -> None: + """include_parent_history=False should not affect LlmAgent construction.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + agent = _build_sub_agent( + GENERAL_PURPOSE_AGENT, parent_ctx, + agent_config=SubAgentConfig(include_parent_history=False), + ) + assert agent.name == "subagent_general_purpose" # builds without error + + +def test_build_sub_agent_wraps_parent_toolsets_when_tools_none() -> None: + """When archetype.tools is None, BaseToolSet instances from the parent are + wrapped in _BorrowedToolSet so sub_runner.close() cannot close them.""" + + class _FakeToolSet(BaseToolSet): + async def get_tools(self, invocation_context=None): + return [] + + fake_toolset = _FakeToolSet() + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.tools = [ReadTool(), fake_toolset] + + agent = _build_sub_agent(GENERAL_PURPOSE_AGENT, parent_ctx) + + toolset_items = [t for t in agent.tools if isinstance(t, BaseToolSet)] + assert len(toolset_items) == 1 + assert isinstance(toolset_items[0], _BorrowedToolSet) + + +def test_build_sub_agent_max_turns_not_forwarded_to_llm_agent() -> None: + """max_turns is not an LlmAgent parameter and should not leak.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + agent = _build_sub_agent( + GENERAL_PURPOSE_AGENT, parent_ctx, + agent_config=SubAgentConfig(max_turns=5), + ) + assert not hasattr(agent, "max_turns") + + +def test_build_sub_agent_max_turns_none_has_no_effect() -> None: + """max_turns=None should not affect LlmAgent construction.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + agent = _build_sub_agent( + GENERAL_PURPOSE_AGENT, parent_ctx, + agent_config=SubAgentConfig(max_turns=None), + ) + assert agent.name == "subagent_general_purpose" + + +# --- _is_user_text_event ----------------------------------------------------- + + +def test_is_user_text_event_true() -> None: + event = MagicMock() + event.author = "user" + event.content.parts = [MagicMock(text="hello")] + assert _is_user_text_event(event) is True + + +def test_is_user_text_event_wrong_author() -> None: + event = MagicMock() + event.author = "model" + event.content.parts = [MagicMock(text="hello")] + assert _is_user_text_event(event) is False + + +def test_is_user_text_event_no_content() -> None: + event = MagicMock() + event.author = "user" + event.content = None + assert _is_user_text_event(event) is False + + +def test_is_user_text_event_no_parts() -> None: + event = MagicMock() + event.author = "user" + event.content.parts = [] + assert _is_user_text_event(event) is False + + +def test_is_user_text_event_no_text_in_parts() -> None: + event = MagicMock() + event.author = "user" + event.content.parts = [MagicMock(text=None)] + assert _is_user_text_event(event) is False + + +# --- _extract_final_text ----------------------------------------------------- + + +def test_extract_final_text_concatenates_parts() -> None: + event = MagicMock() + event.content.parts = [MagicMock(text="Hello"), MagicMock(text="World")] + assert _extract_final_text(event) == "Hello\nWorld" + + +def test_extract_final_text_none_event() -> None: + assert _extract_final_text(None) == "" + + +def test_extract_final_text_no_content() -> None: + event = MagicMock() + event.content = None + assert _extract_final_text(event) == "" + + +def test_extract_final_text_no_parts() -> None: + event = MagicMock() + event.content.parts = [] + assert _extract_final_text(event) == "" + + +# --- _collect_parent_events — turn counting ---------------------------------- + + +def test_collect_parent_events_counts_turns() -> None: + """max_parent_history_turns=1 returns only the last turn's events.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + + e1 = MagicMock(author="user", content=MagicMock(parts=[MagicMock(text="first")])) + e1.is_model_visible = True + e2 = MagicMock(author="model", content=MagicMock(parts=[MagicMock(text="reply")])) + e2.is_model_visible = True + e3 = MagicMock(author="user", content=MagicMock(parts=[MagicMock(text="second")])) + e3.is_model_visible = True + e4 = MagicMock(author="model", content=MagicMock(parts=[MagicMock(text="reply2")])) + e4.is_model_visible = True + + parent_ctx.session.events = [e1, e2, e3, e4] + result = _collect_parent_events(parent_ctx, max_parent_history_turns=1) + # Only events from the last turn (2nd user message onward) should be included. + assert result == [e3, e4] + + +def test_collect_parent_events_all_turns_with_model_and_system() -> None: + """Events include system messages that aren't user text — they still count as visible.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + + e1 = MagicMock(author="user", content=MagicMock(parts=[MagicMock(text="hi")])) + e1.is_model_visible = True + e2 = MagicMock(author="model", content=MagicMock(parts=[MagicMock(text="ok")])) + e2.is_model_visible = True + + parent_ctx.session.events = [e1, e2] + result = _collect_parent_events(parent_ctx, max_parent_history_turns=None) + assert result == [e1, e2] + + +# --- _forward_artifacts ------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_forward_artifacts_no_artifact_service() -> None: + """When sub_runner has no artifact_service, nothing happens.""" + sub_runner = MagicMock() + sub_runner.artifact_service = None + sub_session = MagicMock() + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + + await _forward_artifacts(sub_runner, sub_session, parent_ctx) + # Should return silently without errors + parent_ctx.save_artifact.assert_not_called() + + +@pytest.mark.asyncio +async def test_forward_artifacts_copies_files() -> None: + sub_runner = MagicMock() + sub_session = MagicMock() + sub_session.app_name = "sub_app" + sub_session.user_id = "sub_user" + sub_session.id = "sub_session_id" + + from unittest.mock import AsyncMock + + async def _list_keys(artifact_id=None): + return ["file1.txt", "file2.txt"] + + sub_runner.artifact_service.list_artifact_keys = _list_keys + sub_runner.artifact_service.load_artifact = AsyncMock(return_value="artifact_data") + + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.save_artifact = AsyncMock() + + await _forward_artifacts(sub_runner, sub_session, parent_ctx) + assert sub_runner.artifact_service.load_artifact.call_count == 2 + assert parent_ctx.save_artifact.call_count == 2 + + +# --- run_subagent ------------------------------------------------------------ + + +@pytest.mark.asyncio +async def test_run_subagent_with_mocked_runner() -> None: + """run_subagent spawns a Runner, iterates events, returns final text.""" + from unittest.mock import AsyncMock + from unittest.mock import patch + + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.tools = [ReadTool()] + parent_ctx.session.app_name = "test_app" + parent_ctx.artifact_service = None + + # Build a fake event stream: a partial event followed by a final model event. + partial_event = MagicMock() + partial_event.content = MagicMock(role="model") + partial_event.partial = True + partial_event.is_error = MagicMock(return_value=False) + + final_event = MagicMock() + final_event.content = MagicMock(role="model", parts=[MagicMock(text="Sub-agent answer.")]) + final_event.partial = False + final_event.is_error = MagicMock(return_value=False) + + event_stream = [partial_event, final_event] + + async def _fake_run_async(*args, **kwargs): + for event in event_stream: + yield event + + mock_runner_cls = MagicMock() + mock_runner_instance = MagicMock() + mock_runner_instance.run_async = _fake_run_async + mock_runner_instance.session_service = MagicMock() + mock_runner_instance.session_service.create_session = AsyncMock() + mock_runner_instance.session_service.append_event = AsyncMock() + mock_runner_instance.artifact_service = None + mock_runner_instance.close = AsyncMock() + mock_runner_cls.return_value = mock_runner_instance + + with patch("trpc_agent_sdk.runners.Runner", mock_runner_cls): + from trpc_agent_sdk.agents.sub_agent._runner import run_subagent + result = await run_subagent( + parent_ctx=parent_ctx, + archetype=GENERAL_PURPOSE_AGENT, + prompt="Do something.", + ) + + assert result == "Sub-agent answer." + mock_runner_instance.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_run_subagent_cancelled_returns_marker() -> None: + from unittest.mock import AsyncMock + from unittest.mock import patch + + from trpc_agent_sdk.exceptions import RunCancelledException + + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.tools = [ReadTool()] + parent_ctx.session.app_name = "test_app" + parent_ctx.artifact_service = None + + mock_runner_instance = MagicMock() + mock_runner_instance.session_service = MagicMock() + mock_runner_instance.session_service.create_session = AsyncMock() + mock_runner_instance.artifact_service = None + mock_runner_instance.close = AsyncMock() + mock_runner_instance.run_async = MagicMock() + mock_runner_instance.run_async.side_effect = RunCancelledException() + + mock_runner_cls = MagicMock(return_value=mock_runner_instance) + + with patch("trpc_agent_sdk.runners.Runner", mock_runner_cls): + from trpc_agent_sdk.agents.sub_agent._runner import run_subagent + result = await run_subagent( + parent_ctx=parent_ctx, + archetype=GENERAL_PURPOSE_AGENT, + prompt="Do something.", + ) + + assert result == "[sub-agent cancelled]" + mock_runner_instance.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_run_subagent_max_turns_enforced() -> None: + """max_turns stops the sub-agent and appends a note.""" + from unittest.mock import AsyncMock + from unittest.mock import patch + + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.tools = [ReadTool()] + parent_ctx.session.app_name = "test_app" + parent_ctx.artifact_service = None + + # First event counts as turn 1; second event exceeds max_turns=1. + event = MagicMock() + event.content = MagicMock(role="model", parts=[MagicMock(text="Iteration 1.")]) + event.partial = False + event.is_error = MagicMock(return_value=False) + + async def _fake_run_async(*args, **kwargs): + yield event + + mock_runner_instance = MagicMock() + mock_runner_instance.run_async = _fake_run_async + mock_runner_instance.session_service = MagicMock() + mock_runner_instance.session_service.create_session = AsyncMock() + mock_runner_instance.session_service.append_event = AsyncMock() + mock_runner_instance.artifact_service = None + mock_runner_instance.close = AsyncMock() + + mock_runner_cls = MagicMock(return_value=mock_runner_instance) + + with patch("trpc_agent_sdk.runners.Runner", mock_runner_cls): + from trpc_agent_sdk.agents.sub_agent._runner import run_subagent + result = await run_subagent( + parent_ctx=parent_ctx, + archetype=GENERAL_PURPOSE_AGENT, + prompt="Do something.", + agent_config=SubAgentConfig(max_turns=1), + ) + + assert "[sub-agent stopped: max turns reached]" in result + mock_runner_instance.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_run_subagent_build_error_returns_error_dict() -> None: + """When _build_sub_agent raises, run_subagent catches it and returns an error dict.""" + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.model = None # force resolve_model to raise + parent_ctx.agent.tools = [] + parent_ctx.session.app_name = "test_app" + + from trpc_agent_sdk.agents.sub_agent._runner import run_subagent + result = await run_subagent( + parent_ctx=parent_ctx, + archetype=GENERAL_PURPOSE_AGENT, + prompt="Do something.", + ) + + assert isinstance(result, dict) + assert result["status"] == "error" + + +@pytest.mark.asyncio +async def test_run_subagent_injects_parent_history() -> None: + """When include_parent_history=True, parent events are injected into sub-session.""" + from unittest.mock import AsyncMock + from unittest.mock import patch + + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.tools = [ReadTool()] + parent_ctx.session.app_name = "test_app" + parent_ctx.artifact_service = None + + event = MagicMock() + event.content = MagicMock(role="model", parts=[MagicMock(text="Done.")]) + event.partial = False + event.is_error = MagicMock(return_value=False) + + parent_event = MagicMock() + parent_event.author = "user" + parent_event.content = MagicMock(parts=[MagicMock(text="parent history")]) + parent_event.is_model_visible = True + parent_ctx.session.events = [parent_event] + + async def _fake_run_async(*args, **kwargs): + yield event + + mock_runner_instance = MagicMock() + mock_runner_instance.run_async = _fake_run_async + mock_runner_instance.session_service = MagicMock() + mock_runner_instance.session_service.create_session = AsyncMock() + mock_runner_instance.session_service.append_event = AsyncMock() + mock_runner_instance.artifact_service = None + mock_runner_instance.close = AsyncMock() + + mock_runner_cls = MagicMock(return_value=mock_runner_instance) + + with patch("trpc_agent_sdk.runners.Runner", mock_runner_cls): + from trpc_agent_sdk.agents.sub_agent._runner import run_subagent + await run_subagent( + parent_ctx=parent_ctx, + archetype=GENERAL_PURPOSE_AGENT, + prompt="Do something.", + agent_config=SubAgentConfig(include_parent_history=True), + ) + + mock_runner_instance.session_service.append_event.assert_called() + + +# --- _build_sub_agent with tool_filter --------------------------------------- + + +def test_build_sub_agent_with_tool_filter() -> None: + """tool_filter restricts tools by name — matching tools kept, others dropped.""" + from trpc_agent_sdk.agents.sub_agent._runner import _build_sub_agent + + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.tools = [ReadTool(), GrepTool()] + + arc = SubAgentArchetype(name="filtered", description="d", instruction="i", tools=None) + agent = _build_sub_agent(arc, parent_ctx, tool_filter=["Read"]) + + tool_names = [getattr(t, "name", None) for t in agent.tools] + assert "Read" in tool_names + assert "Grep" not in tool_names + + +def test_build_sub_agent_with_tool_filter_no_matches() -> None: + """When no tools match the filter, only _BorrowedToolSet instances remain.""" + from trpc_agent_sdk.agents.sub_agent._runner import _build_sub_agent + + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.tools = [ReadTool()] + + arc = SubAgentArchetype(name="filtered", description="d", instruction="i", tools=None) + agent = _build_sub_agent(arc, parent_ctx, tool_filter=["NonExistent"]) + + # Only non-BorrowedToolSet tools (ReadTool) get filtered; no matches → empty. + regular_tools = [t for t in agent.tools if not isinstance(t, _BorrowedToolSet)] + assert len(regular_tools) == 0 + + +def test_build_sub_agent_with_tool_filter_and_fixed_tools() -> None: + """tool_filter works with archetype-provided fixed tools (not inherited).""" + from trpc_agent_sdk.agents.sub_agent._runner import _build_sub_agent + + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.tools = [] # parent tools are irrelevant when archetype has its own + + arc = SubAgentArchetype( + name="filtered", + description="d", + instruction="i", + tools=(ReadTool(), GrepTool()), + ) + agent = _build_sub_agent(arc, parent_ctx, tool_filter=["Grep"]) + + tool_names = [getattr(t, "name", None) for t in agent.tools] + assert "Grep" in tool_names + assert "Read" not in tool_names + + +def test_build_sub_agent_tool_filter_preserves_borrowed_toolsets() -> None: + """When parent has BaseToolSet instances (wrapped in _BorrowedToolSet) + and tool_filter is applied, those wrappers are always kept.""" + from trpc_agent_sdk.agents.sub_agent._runner import _build_sub_agent + + class _FakeToolSet(BaseToolSet): + async def get_tools(self, invocation_context=None): + return [] + + fake_toolset = _FakeToolSet() + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.tools = [ReadTool(), fake_toolset] + + arc = SubAgentArchetype(name="filtered", description="d", instruction="i", tools=None) + agent = _build_sub_agent(arc, parent_ctx, tool_filter=["Read"]) + + # ReadTool should be kept (in filter), _BorrowedToolSet should be kept (always preserved) + tool_names = [getattr(t, "name", None) for t in agent.tools] + assert "Read" in tool_names + borrowed = [t for t in agent.tools if isinstance(t, _BorrowedToolSet)] + assert len(borrowed) == 1 + + +# --- run_subagent error paths ------------------------------------------------- + + +@pytest.mark.asyncio +async def test_run_subagent_runtime_exception() -> None: + """Non-build, non-cancelled exceptions during run_async return error dict.""" + from unittest.mock import AsyncMock + from unittest.mock import patch + + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.tools = [ReadTool()] + parent_ctx.session.app_name = "test_app" + parent_ctx.artifact_service = None + + mock_runner_instance = MagicMock() + mock_runner_instance.session_service = MagicMock() + mock_runner_instance.session_service.create_session = AsyncMock() + mock_runner_instance.artifact_service = None + mock_runner_instance.close = AsyncMock() + + async def _failing_run(*args, **kwargs): + if False: + yield # make this an async generator + raise RuntimeError("simulated runtime failure") + + mock_runner_instance.run_async = _failing_run + + mock_runner_cls = MagicMock(return_value=mock_runner_instance) + + with patch("trpc_agent_sdk.runners.Runner", mock_runner_cls): + from trpc_agent_sdk.agents.sub_agent._runner import run_subagent + result = await run_subagent( + parent_ctx=parent_ctx, + archetype=GENERAL_PURPOSE_AGENT, + prompt="Do something.", + ) + + assert isinstance(result, dict) + assert result["status"] == "error" + assert "simulated runtime failure" in result["message"] + mock_runner_instance.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_run_subagent_close_failure() -> None: + """Exception from sub_runner.close() is caught and logged, not propagated.""" + from unittest.mock import AsyncMock + from unittest.mock import patch + + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.tools = [ReadTool()] + parent_ctx.session.app_name = "test_app" + parent_ctx.artifact_service = None + + final_event = MagicMock() + final_event.content = MagicMock(role="model", parts=[MagicMock(text="Done.")]) + final_event.partial = False + final_event.is_error = MagicMock(return_value=False) + + async def _fake_run(*args, **kwargs): + yield final_event + + mock_runner_instance = MagicMock() + mock_runner_instance.run_async = _fake_run + mock_runner_instance.session_service = MagicMock() + mock_runner_instance.session_service.create_session = AsyncMock() + mock_runner_instance.session_service.append_event = AsyncMock() + mock_runner_instance.artifact_service = None + mock_runner_instance.close = AsyncMock(side_effect=RuntimeError("close failed")) + + mock_runner_cls = MagicMock(return_value=mock_runner_instance) + + with patch("trpc_agent_sdk.runners.Runner", mock_runner_cls): + from trpc_agent_sdk.agents.sub_agent._runner import run_subagent + result = await run_subagent( + parent_ctx=parent_ctx, + archetype=GENERAL_PURPOSE_AGENT, + prompt="Do something.", + ) + + # The result should still be the final text — close errors don't affect output. + assert result == "Done." + mock_runner_instance.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_run_subagent_max_turns_no_last_event() -> None: + """When max_turns reached but no event was produced, returns the stop marker.""" + from unittest.mock import AsyncMock + from unittest.mock import patch + + parent_ctx = _parent_ctx_with_model("test-dynamic-parent") + parent_ctx.agent.tools = [ReadTool()] + parent_ctx.session.app_name = "test_app" + parent_ctx.artifact_service = None + + # Produce a single model event so max_turns=1 triggers immediately. + event = MagicMock() + event.content = MagicMock(role="model", parts=[]) + event.partial = False + event.is_error = MagicMock(return_value=False) + + async def _fake_run(*args, **kwargs): + yield event + + mock_runner_instance = MagicMock() + mock_runner_instance.run_async = _fake_run + mock_runner_instance.session_service = MagicMock() + mock_runner_instance.session_service.create_session = AsyncMock() + mock_runner_instance.session_service.append_event = AsyncMock() + mock_runner_instance.artifact_service = None + mock_runner_instance.close = AsyncMock() + + mock_runner_cls = MagicMock(return_value=mock_runner_instance) + + with patch("trpc_agent_sdk.runners.Runner", mock_runner_cls): + from trpc_agent_sdk.agents.sub_agent._runner import run_subagent + result = await run_subagent( + parent_ctx=parent_ctx, + archetype=GENERAL_PURPOSE_AGENT, + prompt="Do something.", + agent_config=SubAgentConfig(max_turns=1), + ) + + assert "[sub-agent stopped: max turns reached]" in result diff --git a/tests/agents/sub_agent/test_spawn_sub_agent_tool.py b/tests/agents/sub_agent/test_spawn_sub_agent_tool.py new file mode 100644 index 00000000..6758b428 --- /dev/null +++ b/tests/agents/sub_agent/test_spawn_sub_agent_tool.py @@ -0,0 +1,269 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Tests for SpawnSubAgentTool — catalog-based sub-agent dispatch.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from trpc_agent_sdk.agents.sub_agent import SpawnSubAgentTool +from trpc_agent_sdk.agents.sub_agent import DEFAULT_AGENT +from trpc_agent_sdk.agents.sub_agent import EXPLORE_AGENT +from trpc_agent_sdk.agents.sub_agent import GENERAL_PURPOSE_AGENT +from trpc_agent_sdk.agents.sub_agent import PLAN_AGENT +from trpc_agent_sdk.agents.sub_agent import SubAgentArchetype +from trpc_agent_sdk.agents.sub_agent import SubAgentConfig +from trpc_agent_sdk.tools import ReadTool + + +def _custom_archetype(name: str = "custom") -> SubAgentArchetype: + return SubAgentArchetype( + name=name, + description=f"a custom archetype {name}", + instruction="be helpful", + tools=(ReadTool,), + ) + + +def _make_tool_context(): + return MagicMock() + + +def test_default_construction_registers_default() -> None: + t = SpawnSubAgentTool() + assert t.registry.names() == ["default"] + + +def test_agents_appended() -> None: + t = SpawnSubAgentTool(agents=[_custom_archetype()]) + assert t.registry.names() == ["default", "custom"] + + +def test_agent_name_collision_rejected() -> None: + with pytest.raises(ValueError, match="collides"): + SpawnSubAgentTool(agents=[_custom_archetype("default")]) + + +def test_general_purpose_is_not_auto_registered() -> None: + """``general-purpose`` is opt-in via ``agents=[GENERAL_PURPOSE_AGENT]``.""" + t = SpawnSubAgentTool() + assert "general-purpose" not in t.registry.names() + + +def test_general_purpose_can_be_added_explicitly() -> None: + t = SpawnSubAgentTool(agents=[GENERAL_PURPOSE_AGENT]) + assert t.registry.names() == ["default", "general-purpose"] + + +def test_agent_paths_appended(tmp_path) -> None: + md = tmp_path / "explorer.md" + md.write_text( + "---\nname: explorer\ndescription: An explorer agent.\n---\n\nExplore." + ) + t = SpawnSubAgentTool(agent_paths=[tmp_path]) + assert t.registry.names() == ["default", "explorer"] + + +def test_agent_paths_collision_raises(tmp_path) -> None: + md = tmp_path / "clash.md" + md.write_text( + "---\nname: default\ndescription: Collides with built-in.\n---\n\nClash." + ) + with pytest.raises(ValueError, match="collides"): + SpawnSubAgentTool(agent_paths=[tmp_path]) + + +def test_with_default_false_is_empty() -> None: + t = SpawnSubAgentTool(with_default=False) + assert t.registry.names() == [] + + +def test_with_default_false_with_agents(tmp_path) -> None: + t = SpawnSubAgentTool(agents=[_custom_archetype()], with_default=False) + assert t.registry.names() == ["custom"] + + +def test_with_default_false_with_agent_paths(tmp_path) -> None: + md = tmp_path / "explorer.md" + md.write_text( + "---\nname: explorer\ndescription: An explorer agent.\n---\n\nExplore." + ) + t = SpawnSubAgentTool(agent_paths=[tmp_path], with_default=False) + assert t.registry.names() == ["explorer"] + + +def test_declaration_schema_shape() -> None: + t = SpawnSubAgentTool(agents=[_custom_archetype()]) + decl = t._get_declaration() + assert decl.name == "spawn_subagent" + props = decl.parameters.properties + assert set(decl.parameters.required) == {"prompt", "description"} + assert props["subagent_type"].enum == ["default", "custom"] + + +def test_description_contains_default() -> None: + t = SpawnSubAgentTool() + assert "- default:" in t.description + + +def test_explicit_defaults_can_register_all_four() -> None: + t = SpawnSubAgentTool( + agents=[GENERAL_PURPOSE_AGENT, EXPLORE_AGENT, PLAN_AGENT], + with_default=False, + ) + assert t.registry.names() == ["general-purpose", "Explore", "Plan"] + + +@pytest.mark.asyncio +async def test_unknown_subagent_type_returns_error_when_no_default() -> None: + t = SpawnSubAgentTool(with_default=False) + ctx = _make_tool_context() + result = await t._run_async_impl( + tool_context=ctx, + args={"subagent_type": "nope", "prompt": "hi", "description": "x"}, + ) + assert result["status"] == "error" + assert "unknown subagent_type" in result["message"] + + +@pytest.mark.asyncio +async def test_missing_subagent_type_falls_back_to_default() -> None: + t = SpawnSubAgentTool() + ctx = _make_tool_context() + result = await t._run_async_impl( + tool_context=ctx, + args={"prompt": "hi", "description": "x"}, + ) + # Falls back to default, tries to run sub-agent. + # Since ctx is a mock, it will raise an error from run_subagent, + # but it should NOT be the "unknown subagent_type" error. + assert not ( + isinstance(result, dict) + and result.get("status") == "error" + and "unknown subagent_type" in str(result.get("message")) + ) + + +@pytest.mark.asyncio +async def test_empty_prompt_returns_error() -> None: + t = SpawnSubAgentTool() + ctx = _make_tool_context() + result = await t._run_async_impl( + tool_context=ctx, + args={"subagent_type": "default", "prompt": " ", "description": "x"}, + ) + assert result["status"] == "error" + assert "non-empty" in result["message"] + + +def test_default_agent_tools_is_none() -> None: + """DEFAULT_AGENT.tools should be None (inherit parent tools).""" + assert DEFAULT_AGENT.tools is None + + +def test_archetype_tools_none_ok() -> None: + """SubAgentArchetype should accept tools=None.""" + a = SubAgentArchetype( + name="test-none", + description="tools=None archetype", + instruction="be helpful", + tools=None, + ) + assert a.tools is None + + +def test_description_shows_all_for_none_tools() -> None: + """When tools=None, the description should show (Tools: (all)).""" + t = SpawnSubAgentTool() + assert "(Tools: (all))" in t.description + + +def test_tool_mapping_custom_tool_in_md(tmp_path) -> None: + """MD-defined archetype with a custom tool resolved via tool_mapping.""" + md = tmp_path / "custom.md" + md.write_text( + "---\nname: custom\ndescription: Custom tool.\ntools:\n - MyTool\n---\n\nBe helpful." + ) + t = SpawnSubAgentTool(agent_paths=[tmp_path], tool_mapping={"MyTool": ReadTool}) + archetype = t.registry.get("custom") + assert archetype is not None + assert archetype.tools == (ReadTool,) + + +def test_tool_mapping_unknown_in_md_still_errors(tmp_path) -> None: + """Unknown tool name raises ValueError even with unrelated tool_mapping.""" + md = tmp_path / "bad.md" + md.write_text( + "---\nname: bad\ndescription: Bad.\ntools:\n - NotReal\n---\n\nBody." + ) + with pytest.raises(ValueError, match="unknown tool"): + SpawnSubAgentTool(agent_paths=[tmp_path], tool_mapping={"MyTool": ReadTool}) + + +def test_md_archetype_no_tools_inherits(tmp_path) -> None: + """MD-defined archetype without tools: should get tools=None.""" + md = tmp_path / "explorer.md" + md.write_text( + "---\nname: explorer\ndescription: No tools specified.\n---\n\nExplore stuff." + ) + t = SpawnSubAgentTool(agent_paths=[tmp_path]) + archetype = t.registry.get("explorer") + assert archetype is not None + assert archetype.tools is None + + +def test_agent_config_accepted_by_constructor() -> None: + """SpawnSubAgentTool accepts SubAgentConfig without error.""" + t = SpawnSubAgentTool(agent_config=SubAgentConfig(parallel_tool_calls=True)) + assert t._agent_config.parallel_tool_calls is True + + +@pytest.mark.asyncio +async def test_process_request_with_parent_history() -> None: + """process_request appends history-aware instruction when include_parent_history=True.""" + t = SpawnSubAgentTool(agent_config=SubAgentConfig(include_parent_history=True)) + llm_request = MagicMock() + llm_request.append_instructions = MagicMock() + ctx = _make_tool_context() + + await t.process_request(tool_context=ctx, llm_request=llm_request) + + llm_request.append_instructions.assert_called_once() + instruction = llm_request.append_instructions.call_args[0][0][0] + assert "can see the" in instruction + assert "current conversation" in instruction + + +@pytest.mark.asyncio +async def test_process_request_without_parent_history() -> None: + """process_request appends no-history instruction when include_parent_history=False.""" + t = SpawnSubAgentTool() + llm_request = MagicMock() + llm_request.append_instructions = MagicMock() + ctx = _make_tool_context() + + await t.process_request(tool_context=ctx, llm_request=llm_request) + + llm_request.append_instructions.assert_called_once() + instruction = llm_request.append_instructions.call_args[0][0][0] + assert "has no memory" in instruction + + +@pytest.mark.asyncio +async def test_skip_summarization_sets_event_action() -> None: + """When skip_summarization=True, _run_async_impl sets skip_summarization on event_actions.""" + t = SpawnSubAgentTool(with_default=False, skip_summarization=True) + ctx = _make_tool_context() + ctx.event_actions.skip_summarization = False + + await t._run_async_impl( + tool_context=ctx, + args={"subagent_type": "nope", "prompt": "hi", "description": "x"}, + ) + assert ctx.event_actions.skip_summarization is True diff --git a/trpc_agent_sdk/agents/sub_agent/__init__.py b/trpc_agent_sdk/agents/sub_agent/__init__.py new file mode 100644 index 00000000..c77fe978 --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/__init__.py @@ -0,0 +1,52 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Dynamic sub-agent subsystem. + +Public API: + - ``DynamicSubAgentTool`` — LLM-defined sub-agent: specify ``instruction`` at + call time to create any specialist on the fly. Inherits parent tools. + - ``SpawnSubAgentTool`` — catalog-based dispatch: LLM selects from + pre-registered archetypes (each with locked instruction and tool set). + Supports MD-file authoring via ``agent_paths``. + - ``SubAgentArchetype`` / ``SubAgentRegistry`` — define and register archetypes. + - ``DEFAULT_AGENT`` — neutral built-in archetype, auto-registered by + ``SpawnSubAgentTool``. + - ``GENERAL_PURPOSE_AGENT`` / ``EXPLORE_AGENT`` / ``PLAN_AGENT`` — + opt-in built-in archetypes (researcher / read-only search / read-only + planning). Pass them via ``agents=[...]`` to ``SpawnSubAgentTool``. + - ``load_archetypes_from_dir`` / ``load_archetype_from_file`` — load archetypes + from ``.md`` files on disk (pass ``agent_paths`` to ``SpawnSubAgentTool``). + +This package is **not** re-exported from ``trpc_agent_sdk.agents`` to keep the +default agents import path free of file_tools / web tools dependencies. +""" + +from ._archetype import SubAgentArchetype +from ._defaults import DEFAULT_AGENT +from ._defaults import EXPLORE_AGENT +from ._defaults import GENERAL_PURPOSE_AGENT +from ._defaults import PLAN_AGENT +from ._dynamic_sub_agent_tool import DynamicSubAgentTool +from ._loader import load_archetype_from_file +from ._loader import load_archetypes_from_dir +from ._registry import SubAgentRegistry +from ._spawn_sub_agent_tool import SpawnSubAgentTool +from ._sub_agent_config import SubAgentConfig + +__all__ = [ + "DynamicSubAgentTool", + "SpawnSubAgentTool", + "SubAgentArchetype", + "SubAgentRegistry", + "DEFAULT_AGENT", + "GENERAL_PURPOSE_AGENT", + "EXPLORE_AGENT", + "PLAN_AGENT", + "SubAgentConfig", + "load_archetype_from_file", + "load_archetypes_from_dir", +] diff --git a/trpc_agent_sdk/agents/sub_agent/_archetype.py b/trpc_agent_sdk/agents/sub_agent/_archetype.py new file mode 100644 index 00000000..4fa85c43 --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_archetype.py @@ -0,0 +1,89 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Frozen archetype template describing one kind of sub-agent. + +The archetype locks down instruction / tools / model so a call cannot reshape +the sub-agent into something arbitrary. When used with ``SpawnSubAgentTool``, +only ``prompt`` varies at call time; the rest is fixed at registration. When +used with ``DynamicSubAgentTool``, a new archetype is constructed per call. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any +from typing import Awaitable +from typing import Callable +from typing import Optional +from typing import Union + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.tools import BaseToolSet + +InstructionProvider = Callable[[InvocationContext], Union[str, Awaitable[str]]] +ToolItem = Union[BaseTool, BaseToolSet, Callable[[], Union[BaseTool, BaseToolSet]]] + +# Permitted name characters: letters, digits, hyphen, underscore. Allows +# both identifier-style ("plan", "ops_audit") and hyphenated style +# ("general-purpose", "code-guide") so users can pick whichever convention +# matches their archetype catalog. +_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$") + + +@dataclass(frozen=True) +class SubAgentArchetype: + """Template for a kind of sub-agent the parent agent may spawn. + + The two prompt-shaped fields target different audiences and are kept + distinct so each can be written in the right voice: + + - ``description`` is read by the **parent LLM** when it decides which + archetype to spawn. The framework may render it into the spawn tool + description with a trailing ``(Tools: ...)`` suffix for the parent + LLM to read. Phrase it third-person, focused on selection criteria: + "Use it for ... Do NOT use it for ... **IMPORTANT:** ...". + + - ``instruction`` is the **sub-agent's** system prompt. Phrase it + second-person: "You are X. Your role is ... Constraints: ...". + + Other fields: + + - ``tools``: ``None`` = inherit all parent-agent tools at spawn time + (minus spawn tools, which are always stripped). + Otherwise, a tuple of ``BaseTool`` / ``BaseToolSet`` instances OR + zero-arg factory callables (e.g. class references). Factories avoid + import-time side effects and keep tool state per-spawn. + - ``model``: ``None`` = always inherited (resolved via + ``SubAgentConfig.model`` > parent's model at spawn time). + """ + + name: str + description: str + instruction: Union[str, InstructionProvider] + tools: Optional[tuple] = None + model: Any = None + + def __post_init__(self) -> None: + if not isinstance(self.name, str) or not _NAME_RE.match(self.name): + raise ValueError(f"SubAgentArchetype.name must match {_NAME_RE.pattern!r}, got {self.name!r}") + if not isinstance(self.description, str) or not self.description.strip(): + raise ValueError("SubAgentArchetype.description must be a non-empty string") + if isinstance(self.instruction, str) and not self.instruction.strip(): + raise ValueError("SubAgentArchetype.instruction must be a non-empty string") + + # Coerce tools to a tuple if a list was passed (frozen dataclass + immutability hint). + if self.tools is not None and not isinstance(self.tools, tuple): + object.__setattr__(self, "tools", tuple(self.tools)) + + def model_or(self, fallback: Any) -> Any: + """Return ``self.model`` if set, otherwise ``fallback``.""" + return self.model if self.model else fallback + + +__all__ = ["SubAgentArchetype", "InstructionProvider", "ToolItem"] diff --git a/trpc_agent_sdk/agents/sub_agent/_constants.py b/trpc_agent_sdk/agents/sub_agent/_constants.py new file mode 100644 index 00000000..5794d6fa --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_constants.py @@ -0,0 +1,30 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Constants and isolation defaults for the dynamic sub-agent subsystem.""" + +from __future__ import annotations + +SUBAGENT_APP_NAME_SUFFIX = "_trpc_subagent_" +SUBAGENT_USER_ID = "subagent_user" + +# LlmAgent fields that must be flattened on every sub-agent so callbacks / +# transfer hints / output sinks from the parent never leak in. Adding a new +# archetype does not require remembering which fields to wipe — they live here. +ISOLATION_DEFAULTS: dict = { + "sub_agents": [], + "parent_agent": None, + "default_transfer_message": "", + "output_schema": None, + "input_schema": None, + "output_key": None, + "before_agent_callback": None, + "after_agent_callback": None, + "before_model_callback": None, + "after_model_callback": None, + "before_tool_callback": None, + "after_tool_callback": None, +} diff --git a/trpc_agent_sdk/agents/sub_agent/_defaults.py b/trpc_agent_sdk/agents/sub_agent/_defaults.py new file mode 100644 index 00000000..3a2da406 --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_defaults.py @@ -0,0 +1,195 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Built-in default archetypes shipped with v1. + +Four archetypes cover the common spawn cases out of the box: + +- ``default`` — neutral task executor inheriting all parent tools. + **This is the only archetype auto-registered by ``SpawnSubAgentTool``; + the others must be added explicitly via ``agents=[...]``.** +- ``general-purpose`` — opinionated researcher persona for multi-step code + search and investigation, inheriting all parent tools. +- ``Explore`` — read-only code search and file reading. +- ``Plan`` — read-only software architect for designing implementation plans. + +Tools are stored as **class references** (factories), not instances, so +importing this module does not eagerly construct file_tools / web tools, +and each spawned sub-agent gets its own tool instances. + +When ``tools`` is ``None``, the sub-agent inherits the full tool surface +of its parent agent (minus spawn tools, which are always stripped +to prevent recursive spawning). +""" + +from __future__ import annotations + +from trpc_agent_sdk.tools import GlobTool +from trpc_agent_sdk.tools import GrepTool +from trpc_agent_sdk.tools import ReadTool +from trpc_agent_sdk.tools import WebFetchTool + +from ._archetype import SubAgentArchetype + +# Shared preamble for read-only archetypes (Explore / Plan). Locks the +# sub-agent into a read-only mode at the prompt level (a defense-in-depth +# on top of the narrowed tool surface). +_READ_ONLY_PREAMBLE = """\ +CRITICAL: You are in READ-ONLY mode. You are STRICTLY PROHIBITED from: +- Using Edit, Write, or NotebookEdit tools +- Creating, modifying, or deleting any files +- Using Bash for any write operations (no mkdir, touch, rm, cp, mv, \ +git add, git commit, npm install, pip install, or any file \ +creation/modification) +- Using redirect operators (>, >>) or heredocs in Bash +- Installing packages or dependencies + +You may ONLY use Bash for read-only operations: ls, git status, git log, \ +git diff, find, cat, head, tail. + +Any attempt to modify files will fail and waste your limited turns.""" + +_DEFAULT_INSTRUCTION = """\ +You are a focused sub-agent spawned by a parent agent to handle one \ +specific task. Use the tools available to complete the task described \ +in the prompt. The parent agent only sees your final message — your \ +intermediate tool calls and reasoning are not visible to it — so make \ +the final message self-contained: thorough enough to answer the task, \ +concise enough to be useful.""" + +_GENERAL_PURPOSE_INSTRUCTION = """\ +You are a general-purpose sub-agent. Given the user's message, you should \ +use the tools available to complete the task. Complete the task fully — \ +don't gold-plate, but don't leave it half-done. + +## Strengths + +- Searching code, configs, and patterns across large codebases +- Analyzing multiple files to understand architecture +- Investigating complex questions that need multi-file context +- Multi-step research and implementation tasks + +## Guidelines + +- Search broadly first when the location of relevant code is unknown +- Use Read for specific known paths; use Glob and Grep for discovery +- Start broad, then narrow down to specifics +- Be thorough — check multiple locations and naming conventions +- NEVER create files unless it is absolutely necessary for achieving \ +your goal +- NEVER proactively create documentation files (*.md) or README files \ +unless explicitly requested + +Your response should be a concise report covering what was done and key \ +findings.""" + +_EXPLORE_INSTRUCTION = f"""\ +{_READ_ONLY_PREAMBLE} + +You are a file search specialist. Your role is to rapidly find files, \ +search code, and analyze file contents. + +## Strengths + +- Rapidly finding files using glob patterns +- Searching code with regex patterns +- Reading and analyzing file contents + +## Guidelines + +- Use Glob for broad file pattern matching +- Use Grep for content search with regex +- Use Read when you know a specific file path +- Adapt search approach based on the thoroughness level specified in \ +the prompt (quick / medium / very thorough) +- Make efficient use of tools — issue multiple parallel tool calls \ +for grepping and reading files when possible +- Communicate your findings as a regular message — do NOT attempt to \ +create files +- Complete the search request efficiently and report findings clearly""" + +_PLAN_INSTRUCTION = f"""\ +{_READ_ONLY_PREAMBLE} + +You are a software architect and planning specialist. Your role is to \ +explore the codebase, understand the architecture, and design \ +implementation plans. + +## Process + +1. **Understand Requirements**: Focus on the requirements and the \ +assigned perspective in the prompt. +2. **Explore Thoroughly**: Read provided files, find existing patterns, \ +understand architecture, identify similar features, trace code paths. \ +Use Grep for patterns. +3. **Design Solution**: Create an approach based on the assigned \ +perspective. Consider trade-offs and architectural decisions. Follow \ +existing patterns. +4. **Detail the Plan**: Provide a step-by-step strategy. Identify \ +dependencies and sequencing. Anticipate challenges. + +## Required Output + +End your response with: + +### Critical Files for Implementation +List the 3-5 most important files that will need to be created or \ +modified, with a brief note on the purpose of each change. + +REMINDER: You can ONLY explore and plan. You CANNOT write, edit, or \ +modify any files. You do NOT have access to file editing tools.""" + +DEFAULT_AGENT = SubAgentArchetype( + name="default", + description=("Default sub-agent for implementation and execution tasks. Use " + "for writing code, editing files, running commands, debugging, " + "and other action-oriented work. Inherits the parent agent's full " + "tool surface. If a specialized archetype (Explore, Plan, etc.) " + "fits the task better, prefer it for predictability."), + instruction=_DEFAULT_INSTRUCTION, + tools=None, +) + +GENERAL_PURPOSE_AGENT = SubAgentArchetype( + name="general-purpose", + description=("General-purpose agent for researching complex questions, searching " + "for code, and executing multi-step tasks. When you are searching " + "for a keyword or file and are not confident that you will find the " + "right match in the first few tries use this agent to perform the " + "search for you."), + instruction=_GENERAL_PURPOSE_INSTRUCTION, + tools=None, +) + +EXPLORE_AGENT = SubAgentArchetype( + name="Explore", + description=("Fast agent specialized for exploring codebases. Use this when you " + "need to quickly find files by patterns (eg. \"src/components/**/*.tsx\"), " + "search code for keywords (eg. \"API endpoints\"), or answer questions " + "about the codebase (eg. \"how do API endpoints work?\"). When calling " + "this agent, specify the desired thoroughness level: \"quick\" for basic " + "searches, \"medium\" for moderate exploration, or \"very thorough\" for " + "comprehensive analysis across multiple locations and naming conventions."), + instruction=_EXPLORE_INSTRUCTION, + tools=(ReadTool, GlobTool, GrepTool, WebFetchTool), +) + +PLAN_AGENT = SubAgentArchetype( + name="Plan", + description=("Software architect agent for designing implementation plans. Use " + "this when you need to plan the implementation strategy for a task. " + "Returns step-by-step plans, identifies critical files, and considers " + "architectural trade-offs."), + instruction=_PLAN_INSTRUCTION, + tools=(ReadTool, GlobTool, GrepTool), +) + +__all__ = [ + "DEFAULT_AGENT", + "GENERAL_PURPOSE_AGENT", + "EXPLORE_AGENT", + "PLAN_AGENT", +] diff --git a/trpc_agent_sdk/agents/sub_agent/_description.py b/trpc_agent_sdk/agents/sub_agent/_description.py new file mode 100644 index 00000000..834928fa --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_description.py @@ -0,0 +1,74 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Render the spawn_subagent tool description from a SubAgentRegistry. + +The rendered description embeds each archetype's name, description text, +and a ``(Tools: ...)`` suffix derived from its tool list — giving the +parent LLM both the selection guidance and the capability boundary in a +single block. +""" + +from __future__ import annotations + +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.tools import BaseToolSet + +from ._archetype import SubAgentArchetype +from ._registry import SubAgentRegistry + +_HEADER = """\ +Launch a new sub-agent to handle complex, multi-step tasks. +Each sub-agent type has specific capabilities and tools available to it. + +Available subagent types: +""" + +_FOOTER = """ + +IMPORTANT: The sub-agent cannot spawn further sub-agents. +""" + + +def tool_names_of(archetype: SubAgentArchetype) -> list[str]: + """Extract human-readable tool names from an archetype's tool list. + + - ``BaseTool`` instance: use ``instance.name``. + - ``BaseToolSet`` instance: use the class name (v1 simplification — we do + not expand the toolset to its individual tool names because that would + require an async call). + - Class reference (factory): instantiate to get the real ``name``. + - Other callable: use ``__name__`` if available, else ``repr``. + """ + out: list[str] = [] + if archetype.tools is None: + return ["(all)"] + for t in archetype.tools: + if isinstance(t, BaseTool): + out.append(t.name) + elif isinstance(t, BaseToolSet): + out.append(type(t).__name__) + elif isinstance(t, type) and issubclass(t, BaseTool): + out.append(t().name) + elif callable(t): + out.append(getattr(t, "__name__", repr(t))) + else: + out.append(type(t).__name__) + return out + + +def render_archetype_block(archetype: SubAgentArchetype) -> str: + tool_names = tool_names_of(archetype) + tools_suffix = ", ".join(tool_names) if tool_names else "(none)" + return f"- {archetype.name}: {archetype.description}\n (Tools: {tools_suffix})" + + +def render_tool_description(registry: SubAgentRegistry) -> str: + blocks = "\n".join(render_archetype_block(a) for a in registry.archetypes()) + return _HEADER + blocks + _FOOTER + + +__all__ = ["render_tool_description", "render_archetype_block", "tool_names_of"] diff --git a/trpc_agent_sdk/agents/sub_agent/_dynamic_sub_agent_tool.py b/trpc_agent_sdk/agents/sub_agent/_dynamic_sub_agent_tool.py new file mode 100644 index 00000000..d1dec7f9 --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_dynamic_sub_agent_tool.py @@ -0,0 +1,229 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""DynamicSubAgentTool — on-the-fly sub-agent creation where the LLM defines the role.""" + +from __future__ import annotations + +from typing import Any +from typing import List +from typing import Optional +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.filter import BaseFilter +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.types import FunctionDeclaration +from trpc_agent_sdk.types import Schema +from trpc_agent_sdk.types import Type + +from ._archetype import SubAgentArchetype +from ._runner import run_subagent +from ._sub_agent_config import SubAgentConfig + +_DESCRIPTION = ("Run one short-lived sub-agent for a single focused task and return " + "its result. The sub-agent is created on the fly for this call only " + "and is destroyed afterward. It does NOT transfer control, does NOT " + "run a pre-registered agent by name, and does NOT start a background " + "task. To run several tasks, call this tool multiple times. Its tools " + "stay within a code-defined capability boundary, which by default is " + "derived from what the current agent is already allowed to use (or " + "set explicitly in code), and it cannot select arbitrary agents, " + "models, or executors. IMPORTANT: The sub-agent cannot spawn further " + "sub-agents.") + +_FALLBACK_INSTRUCTION = """\ +You are a focused sub-agent. Use the tools available to complete the task \ +described in the prompt. Be thorough but concise; return a single result \ +that the parent agent can act on directly.""" + + +class DynamicSubAgentTool(BaseTool): + """Run a short-lived sub-agent whose role is defined at call time via + ``instruction``. By default the sub-agent inherits the parent agent's + full tool surface; pass ``tools`` to use a fixed tool set. + + Use this when you cannot predict all the specialist types you'll need + ahead of time — the LLM invents the right role for each task. + + Args: + name: Name of the tool as seen by the LLM. Defaults to + ``"dynamic_subagent"``. + description: Tool description as seen by the LLM. Defaults to + a pre-built description. + tools: Tools available to the sub-agent. ``None`` (default) means + inherit all parent tools. Pass a tuple of ``BaseTool`` instances + or factory callables to use a fixed tool set instead. + expose_tool_selection: When ``True`` (default), the ``tools`` field is + exposed in the schema so the model can restrict which tools the + sub-agent may use. When ``False``, the model cannot narrow the tool + surface. + agent_config: :class:`SubAgentConfig` applied to every spawned + sub-agent. Only non-``None`` fields are forwarded to the + ``LlmAgent`` constructor. + skip_summarization: When ``True``, the parent agent's LLM loop exits + immediately after the sub-agent returns. + filters_name: Filter instance names forwarded to :class:`BaseTool`. + filters: Filter instances forwarded to :class:`BaseTool`. + """ + + def __init__( + self, + name: str = "dynamic_subagent", + description: Optional[str] = None, + tools: Optional[tuple] = None, + expose_tool_selection: bool = True, + agent_config: Optional[SubAgentConfig] = None, + skip_summarization: bool = False, + filters_name: Optional[List[str]] = None, + filters: Optional[List[BaseFilter]] = None, + ) -> None: + self._tools = tools + self._agent_config = agent_config + self._skip_summarization = skip_summarization + self._expose_tool_selection = expose_tool_selection + super().__init__(name=name, description=description or _DESCRIPTION, filters_name=filters_name, filters=filters) + + @override + def _get_declaration(self) -> FunctionDeclaration: + properties: dict = { + "prompt": + Schema( + type=Type.STRING, + description=("The task for the sub-agent. Include all the " + "context it needs to complete the task on its own."), + ), + "instruction": + Schema( + type=Type.STRING, + description=("Optional role, constraints, and execution guidance " + "for this sub-agent invocation. It acts as the " + "sub-agent's system prompt for this run."), + ), + } + + if self._expose_tool_selection: + tools_desc = "Optional exact tool names this sub-agent may use. " \ + "Omit to allow all permitted tools." + if self._tools is not None: + names = _tool_names(self._tools) + if names: + tools_desc += " Available tool names: " + ", ".join(names) + "." + properties["tools"] = Schema( + type=Type.ARRAY, + description=tools_desc, + items=Schema(type=Type.STRING), + ) + + return FunctionDeclaration( + name=self.name, + description=self.description, + parameters=Schema( + type=Type.OBJECT, + properties=properties, + required=["prompt"], + ), + response=Schema(type=Type.STRING), + ) + + @override + async def process_request( + self, + *, + tool_context: InvocationContext, + llm_request: LlmRequest, + ) -> None: + await super().process_request(tool_context=tool_context, llm_request=llm_request) + include_parent_history = (self._agent_config is not None and self._agent_config.include_parent_history) + if include_parent_history: + instruction = (f"When using `{self.name}`: The sub-agent can see the " + "current conversation's history. Use it when delegated " + "tool work should run in a child invocation while " + "continuing from the current conversation. Describe the " + "task in `prompt`, and optionally set `instruction` to " + "give the sub-agent a role or constraints.") + else: + instruction = (f"When using `{self.name}`: The sub-agent has no memory " + "of this conversation. Use it for self-contained tool " + "work or any task where delegating keeps the parent " + "conversation focused. Put everything it needs in `prompt`. " + "Optionally set `instruction` to give the sub-agent a role " + "or constraints for this run.") + llm_request.append_instructions([instruction]) + + @override + async def _run_async_impl( + self, + *, + tool_context: InvocationContext, + args: dict[str, Any], + ) -> Any: + if self._skip_summarization: + tool_context.event_actions.skip_summarization = True + + instruction = args.get("instruction") + prompt = args.get("prompt") + + if not isinstance(instruction, str) or not instruction.strip(): + instruction = _FALLBACK_INSTRUCTION + if not isinstance(prompt, str) or not prompt.strip(): + return {"status": "error", "message": "prompt must be a non-empty string"} + + # Resolve tools. + # self._tools: user-configured capability ceiling. + # None → inherit parent tools + # tuple → fixed tool set + # tools_arg (LLM call-time): optional name-based narrowing, only + # honored when expose_tool_selection is True. + # not provided → no filter + # list of names → filter by name (handled in _build_sub_agent) + tool_filter = None + if self._expose_tool_selection: + tools_arg = args.get("tools") + tool_filter = tools_arg if isinstance(tools_arg, list) else None + + synthetic = SubAgentArchetype( + name="dynamic", + description="A focused sub-agent created dynamically for a specific task.", + instruction=instruction, + tools=self._tools, # None=inherit, tuple=fixed set + ) + return await run_subagent( + parent_ctx=tool_context, + archetype=synthetic, + prompt=prompt, + agent_config=self._agent_config, + tool_filter=tool_filter, + ) + + +def _tool_names(tools: tuple) -> list[str]: + """Extract declaration names from a tuple of tool items. + + Handles ``BaseTool`` instances, ``BaseToolSet`` instances, and factory + callables (e.g. class references). + """ + from trpc_agent_sdk.tools import BaseToolSet + + names: list[str] = [] + for t in tools: + if isinstance(t, BaseTool): + name = getattr(t, 'name', None) + elif isinstance(t, BaseToolSet): + name = type(t).__name__ + elif isinstance(t, type) and issubclass(t, BaseTool): + name = t().name + elif callable(t): + name = getattr(t, "__name__", None) + else: + continue + if name: + names.append(name) + return names + + +__all__ = ["DynamicSubAgentTool"] diff --git a/trpc_agent_sdk/agents/sub_agent/_loader.py b/trpc_agent_sdk/agents/sub_agent/_loader.py new file mode 100644 index 00000000..1f88d5ce --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_loader.py @@ -0,0 +1,229 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Load SubAgentArchetype definitions from Markdown files. + +File format (YAML frontmatter + Markdown body):: + + --- + name: my-researcher + description: Use this agent for deep research tasks. + tools: # optional; defaults to inheriting all parent tools + - Read + - websearch + --- + + You are a research specialist. Your task is to … + (this section becomes the sub-agent's system instruction) + +Required frontmatter fields: ``name``, ``description``. +Optional: ``tools``. +Body (instruction) must be non-empty after stripping whitespace. + +If ``tools`` is omitted, the archetype inherits the full tool surface of +the parent agent at spawn time (minus spawn tools, which are always +stripped to prevent recursive spawning). + +When specified, tools are referenced by their actual tool ``name`` +(e.g. ``Read``, ``Bash``, ``websearch``). Any name not in the whitelist +raises ``ValueError`` at load time (fail-fast). +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any +from typing import List + +import yaml + +from ._archetype import SubAgentArchetype + +# --------------------------------------------------------------------------- +# Built-in tool whitelist — maps actual tool name → factory class reference. +# Populated lazily to avoid import-time side effects; see _tool_whitelist(). +# --------------------------------------------------------------------------- + +# Maps tool.name → class reference, e.g. "Read" -> ReadTool, "Bash" -> BashTool +_WHITELIST_NAMES = { + "Bash", + "Edit", + "Glob", + "Grep", + "Read", + "webfetch", + "websearch", + "Write", +} + + +def _tool_whitelist() -> dict[str, Any]: + from trpc_agent_sdk.tools import BashTool + from trpc_agent_sdk.tools import EditTool + from trpc_agent_sdk.tools import GlobTool + from trpc_agent_sdk.tools import GrepTool + from trpc_agent_sdk.tools import ReadTool + from trpc_agent_sdk.tools import WebFetchTool + from trpc_agent_sdk.tools import WebSearchTool + from trpc_agent_sdk.tools import WriteTool + + return { + "Bash": BashTool, + "Edit": EditTool, + "Glob": GlobTool, + "Grep": GrepTool, + "Read": ReadTool, + "webfetch": WebFetchTool, + "websearch": WebSearchTool, + "Write": WriteTool, + } + + +# --------------------------------------------------------------------------- +# Frontmatter parsing +# --------------------------------------------------------------------------- + +_FM_DELIMITER = "---" + + +def _split_frontmatter(text: str) -> tuple[str, str]: + """Return ``(frontmatter_yaml, body)`` or ``("", text)`` if no frontmatter.""" + lines = text.splitlines(keepends=True) + if not lines or lines[0].strip() != _FM_DELIMITER: + return "", text + + end = None + for i, line in enumerate(lines[1:], start=1): + if line.strip() == _FM_DELIMITER: + end = i + break + + if end is None: + # Unclosed frontmatter — treat whole file as body (no frontmatter). + return "", text + + fm_yaml = "".join(lines[1:end]) + body = "".join(lines[end + 1:]) + return fm_yaml, body + + +# --------------------------------------------------------------------------- +# Single-file loader +# --------------------------------------------------------------------------- + + +def load_archetype_from_file(path: Path, tool_mapping: dict[str, Any] | None = None) -> SubAgentArchetype: + """Parse a single ``.md`` file and return a ``SubAgentArchetype``. + + Args: + path: Path to the ``.md`` file. + tool_mapping: Optional name-to-class mapping for resolving custom + tool names referenced in the frontmatter. Merged with the + built-in whitelist; custom entries take precedence. + + Raises ``ValueError`` with a path-prefixed message on any parse or + validation error so callers get precise diagnostics. + """ + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise ValueError(f"{path}: cannot read file: {exc}") from exc + + fm_yaml, body = _split_frontmatter(text) + + if not fm_yaml.strip(): + raise ValueError(f"{path}: missing YAML frontmatter. " + "File must start with '---' followed by at least 'name' and 'description'.") + + try: + fm: dict = yaml.safe_load(fm_yaml) or {} + except yaml.YAMLError as exc: + raise ValueError(f"{path}: invalid YAML frontmatter: {exc}") from exc + + if not isinstance(fm, dict): + raise ValueError(f"{path}: frontmatter must be a YAML mapping, got {type(fm).__name__}") + + # --- required fields --- + name = fm.get("name") + if not isinstance(name, str) or not name.strip(): + raise ValueError(f"{path}: frontmatter 'name' must be a non-empty string") + + description = fm.get("description") + if not isinstance(description, str) or not description.strip(): + raise ValueError(f"{path}: frontmatter 'description' must be a non-empty string") + + # --- instruction (body) --- + instruction = body.strip() + if not instruction: + raise ValueError(f"{path}: instruction body (text after the closing '---') must be non-empty") + + # --- optional: tools --- + raw_tools = fm.get("tools") + if raw_tools is None: + tools = None + else: + if not isinstance(raw_tools, list): + raise ValueError(f"{path}: frontmatter 'tools' must be a YAML list, got {type(raw_tools).__name__}") + whitelist = _tool_whitelist() + if tool_mapping: + whitelist = {**whitelist, **tool_mapping} + resolved = [] + for item in raw_tools: + if not isinstance(item, str): + raise ValueError(f"{path}: each tool entry must be a string, got {type(item).__name__!r}") + if item not in whitelist: + allowed = sorted(set(_WHITELIST_NAMES) | set(tool_mapping or ())) + raise ValueError(f"{path}: unknown tool {item!r}. " + f"Allowed: {allowed}") + resolved.append(whitelist[item]) + tools = tuple(resolved) + + return SubAgentArchetype( + name=name.strip(), + description=description.strip(), + instruction=instruction, + tools=tools, + ) + + +# --------------------------------------------------------------------------- +# Directory loader +# --------------------------------------------------------------------------- + + +def load_archetypes_from_dir(directory: os.PathLike, + tool_mapping: dict[str, Any] | None = None) -> List[SubAgentArchetype]: + """Load all ``*.md`` files in *directory* as ``SubAgentArchetype`` objects. + + Files are sorted alphabetically so the registration order is deterministic. + Raises ``ValueError`` if *directory* does not exist or if any file fails + to parse (fail-fast; all errors are reported with full file paths). + """ + dirpath = Path(directory) + if not dirpath.exists(): + raise ValueError(f"agents_path does not exist: {dirpath}") + if not dirpath.is_dir(): + raise ValueError(f"agents_path is not a directory: {dirpath}") + + md_files = sorted(dirpath.glob("*.md")) + + archetypes = [] + errors: list[str] = [] + for md_file in md_files: + try: + archetypes.append(load_archetype_from_file(md_file, tool_mapping=tool_mapping)) + except ValueError as exc: + errors.append(str(exc)) + + if errors: + joined = "\n ".join(errors) + raise ValueError(f"Failed to load archetypes from {dirpath}:\n {joined}") + + return archetypes + + +__all__ = ["load_archetype_from_file", "load_archetypes_from_dir"] diff --git a/trpc_agent_sdk/agents/sub_agent/_registry.py b/trpc_agent_sdk/agents/sub_agent/_registry.py new file mode 100644 index 00000000..4aff0f94 --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_registry.py @@ -0,0 +1,55 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Insertion-ordered registry of sub-agent archetypes.""" + +from __future__ import annotations + +from typing import Iterator + +from ._archetype import SubAgentArchetype + + +class SubAgentRegistry: + """A catalog of archetypes the SpawnSubAgentTool may instantiate. + + The registry preserves insertion order so the rendered tool description + is deterministic. Duplicate names are rejected; lookups for unknown + names raise ``KeyError``. + """ + + def __init__(self) -> None: + self._items: dict[str, SubAgentArchetype] = {} + + def register(self, archetype: SubAgentArchetype) -> None: + """Register a new archetype. Raises ``ValueError`` on name collision.""" + if archetype.name in self._items: + raise ValueError(f"archetype name {archetype.name!r} already registered") + self._items[archetype.name] = archetype + + def get(self, name: str) -> SubAgentArchetype: + """Return the archetype with the given name. Raises ``KeyError`` if absent.""" + if name not in self._items: + raise KeyError(f"archetype {name!r} not found in registry") + return self._items[name] + + def names(self) -> list[str]: + return list(self._items.keys()) + + def archetypes(self) -> list[SubAgentArchetype]: + return list(self._items.values()) + + def __contains__(self, name: object) -> bool: + return isinstance(name, str) and name in self._items + + def __iter__(self) -> Iterator[SubAgentArchetype]: + return iter(self._items.values()) + + def __len__(self) -> int: + return len(self._items) + + +__all__ = ["SubAgentRegistry"] diff --git a/trpc_agent_sdk/agents/sub_agent/_runner.py b/trpc_agent_sdk/agents/sub_agent/_runner.py new file mode 100644 index 00000000..79302901 --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_runner.py @@ -0,0 +1,342 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Sub-agent construction and execution via a nested Runner. + +This mirrors the pattern used by ``trpc_agent_sdk.tools._agent_tool.AgentTool`` +but applies stricter isolation: the parent's session/state/memory/callbacks are +**not** shared into the sub-agent. Artifacts are forwarded back to the parent +context so files produced by the sub-agent remain accessible to the orchestrator. + +Sub-agent metadata (``_is_subagent`` / ``_subagent_type`` / ``_parent_invocation_id``) +is threaded into the spawned run via ``Runner.run_async(..., agent_context=...)``. +``AgentContext.with_metadata`` is the existing mechanism — no new fields added. +""" + +from __future__ import annotations + +from typing import Any +from typing import Optional +from typing import Union + +from trpc_agent_sdk.abc import ArtifactId +from trpc_agent_sdk.agents._llm_agent import LlmAgent +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.exceptions import RunCancelledException +from trpc_agent_sdk.log import logger +from trpc_agent_sdk.memory import InMemoryMemoryService +from trpc_agent_sdk.sessions import InMemorySessionService +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.tools import BaseToolSet +from trpc_agent_sdk.types import Content +from trpc_agent_sdk.types import Part + +from ._archetype import SubAgentArchetype +from ._constants import ISOLATION_DEFAULTS +from ._constants import SUBAGENT_APP_NAME_SUFFIX +from ._constants import SUBAGENT_USER_ID + + +class _BorrowedToolSet(BaseToolSet): + """Wraps a parent-owned ToolSet for use in a sub-agent. + + Proxies get_tools() to the inner instance but makes close() a no-op, + preventing sub_runner.close() from tearing down the parent's connections. + """ + + def __init__(self, inner: BaseToolSet) -> None: + super().__init__() + self._inner = inner + + async def get_tools(self, invocation_context=None): + return await self._inner.get_tools(invocation_context) + + async def close(self) -> None: + pass # borrowed — lifecycle owned by parent runner + + +def _materialize_tools(tools: tuple) -> list: + """Convert archetype tool items (instances or factories) to instances.""" + out: list = [] + for t in tools: + if isinstance(t, (BaseTool, BaseToolSet)): + out.append(t) + elif callable(t): + out.append(t()) + else: + raise TypeError(f"archetype tool item {t!r} is neither a BaseTool/BaseToolSet " + f"instance nor a zero-arg factory") + return out + + +def _resolve_model(agent_config, parent_ctx: InvocationContext) -> Any: + if agent_config is not None and agent_config.model: + return agent_config.model + parent_model = getattr(parent_ctx.agent, "model", None) + if parent_model: + return parent_model + raise ValueError("sub-agent: cannot resolve model. Provide " + "SubAgentConfig.model, or set a model on the parent agent.") + + +def _resolve_skill_repository(tools: list) -> Any: + """Find a SkillToolSet in *tools* and return its repository, else None. + + Couples skill_repository to the presence of SkillToolSet so that skill + capability and skill metadata travel together. Looks through + _BorrowedToolSet wrappers so inherited skill toolsets work too. + """ + from trpc_agent_sdk.skills import SkillToolSet + for t in tools: + inner = t._inner if isinstance(t, _BorrowedToolSet) else t + if isinstance(inner, SkillToolSet): + return inner.repository + return None + + +def _is_user_text_event(event: Any) -> bool: + """Return True if *event* is a user message containing plain text.""" + if getattr(event, "author", None) != "user": + return False + parts = getattr(event.content, "parts", None) if getattr(event, "content", None) else None + if not parts: + return False + return any(getattr(p, "text", None) for p in parts) + + +def _event_is_model_visible(event: Any) -> bool: + """Return True if *event* is model-visible. + + ``Event.is_model_visible`` is a method, so it must be called. + """ + vis = getattr(event, "is_model_visible", None) + if callable(vis): + return vis() + return bool(vis) if vis is not None else True + + +def _collect_parent_events(parent_ctx: InvocationContext, max_parent_history_turns: Optional[int]) -> list: + """Collect model-visible parent events, limited to the last *max_parent_history_turns* turns. + + A turn starts with a user text message. If *max_parent_history_turns* is ``None``, + all available events are returned. + """ + events = getattr(parent_ctx.session, "events", None) or [] + if not events: + return [] + + # Keep only model-visible events that have content. + visible = [e for e in events if _event_is_model_visible(e) and getattr(e, "content", None)] + + if max_parent_history_turns is None: + return visible + if not max_parent_history_turns: + return [] + + # Count turns backward from the end. + turn_start_idx = 0 + turn_count = 0 + for i in range(len(visible) - 1, -1, -1): + if _is_user_text_event(visible[i]): + turn_count += 1 + if turn_count >= max_parent_history_turns: + turn_start_idx = i + break + + return visible[turn_start_idx:] + + +def _build_sub_agent( + archetype: SubAgentArchetype, + parent_ctx: InvocationContext, + agent_config=None, + tool_filter: Optional[list] = None, +) -> LlmAgent: + if archetype.tools is None: + # Inherit the full tool surface of the parent agent. BaseTool instances + # are shared directly (stateless). BaseToolSet instances are wrapped in + # _BorrowedToolSet so sub_runner.close() cannot tear down the parent's + # connections (e.g. MCPToolset sessions). + parent_tools = getattr(parent_ctx.agent, 'tools', []) or [] + tools = [_BorrowedToolSet(t) if isinstance(t, BaseToolSet) else t for t in parent_tools] + else: + tools = _materialize_tools(archetype.tools) + + # Always strip SpawnSubAgentTool and DynamicSubAgentTool from the sub-agent's + # tool surface, preventing sub-agents from spawning further sub-agents + # (1-level cap). + tools = [t for t in tools if type(t).__name__ not in ("DynamicSubAgentTool", "SpawnSubAgentTool")] + + # Apply optional name-based tool filter from the LLM. BaseToolSet wrappers + # are always kept (they are infrastructure, not selectable by name). + if tool_filter is not None: + name_map = {} + base_sets: list = [] + for t in tools: + if isinstance(t, _BorrowedToolSet): + base_sets.append(t) + continue + name = getattr(t, 'name', None) + if name: + name_map[name] = t + filtered = [name_map[n] for n in tool_filter if n in name_map] + tools = filtered + base_sets + + # archetype.name may contain hyphens (e.g. "general-purpose"); LlmAgent.name + # must be a Python identifier, so normalize hyphens to underscores. + safe_name = archetype.name.replace("-", "_") + + parent = parent_ctx.agent + + llm_kwargs: dict = {} + llm_kwargs["name"] = f"subagent_{safe_name}" + llm_kwargs["description"] = archetype.description + llm_kwargs["instruction"] = archetype.instruction + llm_kwargs["model"] = _resolve_model(agent_config, parent_ctx) + llm_kwargs["tools"] = tools + + if agent_config is not None and agent_config.generate_content_config is not None: + llm_kwargs["generate_content_config"] = agent_config.generate_content_config + else: + llm_kwargs["generate_content_config"] = getattr(parent, "generate_content_config", None) + + if agent_config is not None and agent_config.parallel_tool_calls is not None: + llm_kwargs["parallel_tool_calls"] = agent_config.parallel_tool_calls + else: + llm_kwargs["parallel_tool_calls"] = getattr(parent, "parallel_tool_calls", False) + + # Detect SkillToolSet in tools to populate skill_repository. + llm_kwargs["skill_repository"] = _resolve_skill_repository(tools) + + llm_kwargs.update(ISOLATION_DEFAULTS) + return LlmAgent(**llm_kwargs) + + +async def _forward_artifacts(sub_runner, sub_session, parent_ctx: InvocationContext) -> None: + """Copy artifacts produced by the sub-agent into the parent context. + + Mirrors the artifact forwarding done by AgentTool — without it, files + written by the sub-agent become unreachable once the sub-runner closes. + """ + if not sub_runner.artifact_service: + return + artifact_id = ArtifactId( + app_name=sub_session.app_name, + user_id=sub_session.user_id, + session_id=sub_session.id, + ) + keys = await sub_runner.artifact_service.list_artifact_keys(artifact_id=artifact_id) + for filename in keys: + artifact = await sub_runner.artifact_service.load_artifact(artifact_id=ArtifactId( + app_name=sub_session.app_name, + user_id=sub_session.user_id, + session_id=sub_session.id, + filename=filename, + ), ) + if artifact: + await parent_ctx.save_artifact(filename=filename, artifact=artifact) + + +def _extract_final_text(last_event) -> str: + if not last_event or not last_event.content or not last_event.content.parts: + return "" + return "\n".join(p.text for p in last_event.content.parts if getattr(p, "text", None)) + + +async def run_subagent( + *, + parent_ctx: InvocationContext, + archetype: SubAgentArchetype, + prompt: str, + agent_config=None, + tool_filter: Optional[list] = None, +) -> Union[str, dict]: + """Run an isolated sub-agent and return its final assistant text. + + Returns: + Final assistant text on success, ``"[sub-agent cancelled]"`` if the + run was cancelled, or ``{"status": "error", "message": ...}`` on + unexpected exceptions. Errors are not raised back to the parent so + the orchestrator can decide how to react. + """ + # Imported lazily to mirror AgentTool and avoid a circular import at module load. + from trpc_agent_sdk.runners import Runner + + try: + sub_agent = _build_sub_agent(archetype, parent_ctx, agent_config=agent_config, tool_filter=tool_filter) + except Exception as ex: # noqa: BLE001 + logger.error("sub-agent build failed: %s", ex, exc_info=True) + return {"status": "error", "message": str(ex)} + + parent_app_name = getattr(parent_ctx.session, "app_name", "trpc_app") + sub_app_name = f"{parent_app_name}{SUBAGENT_APP_NAME_SUFFIX}{archetype.name}" + + sub_runner = Runner( + app_name=sub_app_name, + agent=sub_agent, + session_service=InMemorySessionService(), + memory_service=InMemoryMemoryService(), + artifact_service=parent_ctx.artifact_service, + enable_post_turn_processing=False, + ) + + last_event = None + max_turns_reached = False + try: + sub_session = await sub_runner.session_service.create_session( + app_name=sub_app_name, + user_id=SUBAGENT_USER_ID, + state={}, + ) + + # Inject parent conversation history if configured. + if agent_config is not None and agent_config.include_parent_history: + parent_events = _collect_parent_events(parent_ctx, agent_config.max_parent_history_turns) + for event in parent_events: + await sub_runner.session_service.append_event(sub_session, event) + + max_turns = agent_config.max_turns if agent_config is not None else None + turn_count = 0 + + content = Content(role="user", parts=[Part.from_text(text=prompt)]) + async for event in sub_runner.run_async( + user_id=sub_session.user_id, + session_id=sub_session.id, + new_message=content, + ): + last_event = event + # Count LLM calls (one non-partial event per request, including + # those with tool calls). Aligns with claw-code-agent. + if event.content and not event.partial and not event.is_error(): + if event.content.role == "model": + turn_count += 1 + if max_turns is not None and turn_count >= max_turns: + max_turns_reached = True + break + # Strict isolation: do NOT propagate event.actions.state_delta + # to the parent context (this is the deliberate divergence from + # AgentTool's behavior). + + await _forward_artifacts(sub_runner, sub_session, parent_ctx) + except RunCancelledException: + return "[sub-agent cancelled]" + except Exception as ex: # noqa: BLE001 + logger.error("sub-agent run failed: %s", ex, exc_info=True) + return {"status": "error", "message": str(ex)} + finally: + try: + await sub_runner.close() + except Exception as close_ex: # noqa: BLE001 + logger.warning("sub-agent runner close failed: %s", close_ex) + + result = _extract_final_text(last_event) + if max_turns_reached: + note = "[sub-agent stopped: max turns reached]" + return f"{result}\n\n{note}" if result else note + return result + + +__all__ = ["run_subagent"] diff --git a/trpc_agent_sdk/agents/sub_agent/_spawn_sub_agent_tool.py b/trpc_agent_sdk/agents/sub_agent/_spawn_sub_agent_tool.py new file mode 100644 index 00000000..b49f2e3c --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_spawn_sub_agent_tool.py @@ -0,0 +1,208 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""SpawnSubAgentTool — spawn sub-agents from pre-registered archetype templates.""" + +from __future__ import annotations + +import os +from typing import Any +from typing import List +from typing import Optional +from typing import Union +from typing_extensions import override + +from trpc_agent_sdk.context import InvocationContext +from trpc_agent_sdk.filter import BaseFilter +from trpc_agent_sdk.models import LlmRequest +from trpc_agent_sdk.tools import BaseTool +from trpc_agent_sdk.types import FunctionDeclaration +from trpc_agent_sdk.types import Schema +from trpc_agent_sdk.types import Type + +from ._archetype import SubAgentArchetype +from ._defaults import DEFAULT_AGENT +from ._description import render_tool_description +from ._loader import load_archetypes_from_dir +from ._registry import SubAgentRegistry +from ._runner import run_subagent +from ._sub_agent_config import SubAgentConfig + + +class SpawnSubAgentTool(BaseTool): + """Tool for spawning pre-defined archetype-based sub-agents. + + Each archetype has a locked instruction, tool set, and model — the LLM + selects which archetype to use and writes the task prompt, but cannot + redefine the archetype's role or capabilities at call time. + + Pre-built archetypes (``DEFAULT_AGENT``, ``GENERAL_PURPOSE_AGENT``, + ``EXPLORE_AGENT``, ``PLAN_AGENT``) are exported from the package for + manual composition; only ``default`` is auto-registered. + + Archetypes can be loaded from ``*.md`` files:: + + --- + name: my-researcher + description: Use this agent for deep research tasks. + tools: # optional; if omitted, sub-agent inherits parent tools + - Read + - websearch + --- + + You are a research specialist. Your task is to … + + Args: + agents: Additional archetypes to register (or override ``default`` + with a custom version). + agent_paths: One or more directories of ``*.md`` files to load + archetypes from disk. + tool_mapping: Optional name-to-class mapping for resolving custom + tool names in MD frontmatter (``agent_paths``). Merged with the + built-in whitelist; custom entries take precedence. + with_default: Whether to register the built-in ``default`` + archetype as a universal fallback. Defaults to ``True``; + set to ``False`` when you want full control over the archetype catalog. + agent_config: :class:`SubAgentConfig` applied to every spawned + sub-agent. Only non-``None`` fields are forwarded to the + ``LlmAgent`` constructor. + skip_summarization: When ``True``, the parent agent's LLM loop exits + immediately after the sub-agent returns, saving the token cost of + a final summarization turn. + filters_name: Filter instance names forwarded to :class:`BaseTool`. + filters: Filter instances forwarded to :class:`BaseTool`. + """ + + def __init__( + self, + agents: Optional[List[SubAgentArchetype]] = None, + agent_paths: Optional[List[Union[str, os.PathLike]]] = None, + tool_mapping: Optional[dict[str, Any]] = None, + with_default: bool = True, + agent_config: Optional[SubAgentConfig] = None, + skip_summarization: bool = False, + filters_name: Optional[List[str]] = None, + filters: Optional[List[BaseFilter]] = None, + ) -> None: + registry = SubAgentRegistry() + if with_default: + registry.register(DEFAULT_AGENT) + for archetype in agents or []: + if archetype.name in registry: + raise ValueError(f"archetype name {archetype.name!r} collides with an " + "already-registered archetype") + registry.register(archetype) + if agent_paths is not None: + for path in agent_paths: + for archetype in load_archetypes_from_dir(path, tool_mapping=tool_mapping): + if archetype.name in registry: + raise ValueError(f"archetype name {archetype.name!r} from {path!r} " + "collides with an already-registered archetype") + registry.register(archetype) + + self._registry = registry + self._skip_summarization = skip_summarization + self._agent_config = agent_config + rendered = render_tool_description(registry) + super().__init__(name="spawn_subagent", description=rendered, filters_name=filters_name, filters=filters) + + @property + def registry(self) -> SubAgentRegistry: + return self._registry + + @override + def _get_declaration(self) -> FunctionDeclaration: + return FunctionDeclaration( + name=self.name, + description=self.description, + parameters=Schema( + type=Type.OBJECT, + properties={ + "subagent_type": + Schema( + type=Type.STRING, + enum=self._registry.names(), + description=("The type of specialized agent to use for this task. " + "See tool description for capabilities of each."), + ), + "prompt": + Schema( + type=Type.STRING, + description=("The task for the sub-agent. Include all the " + "context it needs to complete the task on its own."), + ), + "description": + Schema( + type=Type.STRING, + description=("Short label (3-7 words) of what this sub-agent will do."), + ), + }, + required=["prompt", "description"], + ), + response=Schema(type=Type.STRING), + ) + + @override + async def process_request( + self, + *, + tool_context: InvocationContext, + llm_request: LlmRequest, + ) -> None: + await super().process_request(tool_context=tool_context, llm_request=llm_request) + include_parent_history = (self._agent_config is not None and self._agent_config.include_parent_history) + if include_parent_history: + instruction = ("When using `spawn_subagent`: The sub-agent can see the " + "current conversation's history. Use it when delegated " + "tool work should run in a child invocation while " + "continuing from the current conversation. Still describe " + "the task in `prompt`.") + else: + instruction = ("When using `spawn_subagent`: The sub-agent has no memory " + "of this conversation. Use it for self-contained tool " + "work, multiple independent subtasks, or any task where " + "delegating keeps the parent conversation focused instead " + "of filling it with tool details and intermediate steps. " + "Put everything it needs in `prompt`.") + llm_request.append_instructions([instruction]) + + @override + async def _run_async_impl( + self, + *, + tool_context: InvocationContext, + args: dict[str, Any], + ) -> Any: + if self._skip_summarization: + tool_context.event_actions.skip_summarization = True + + subagent_type = args.get("subagent_type") + prompt = args.get("prompt") + + # Resolve subagent_type, falling back to default if missing or unknown. + if isinstance(subagent_type, str) and subagent_type in self._registry: + resolved_type = subagent_type + elif "default" in self._registry: + resolved_type = "default" + else: + return { + "status": "error", + "message": (f"unknown subagent_type: {subagent_type!r}. " + f"Available: {self._registry.names()}"), + } + if not isinstance(prompt, str) or not prompt.strip(): + return {"status": "error", "message": "prompt must be a non-empty string"} + + archetype = self._registry.get(resolved_type) + return await run_subagent( + parent_ctx=tool_context, + archetype=archetype, + prompt=prompt, + agent_config=self._agent_config, + ) + + +__all__ = ["SpawnSubAgentTool"] diff --git a/trpc_agent_sdk/agents/sub_agent/_sub_agent_config.py b/trpc_agent_sdk/agents/sub_agent/_sub_agent_config.py new file mode 100644 index 00000000..075c9871 --- /dev/null +++ b/trpc_agent_sdk/agents/sub_agent/_sub_agent_config.py @@ -0,0 +1,46 @@ +# Tencent is pleased to support the open source community by making trpc-agent-python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# trpc-agent-python is licensed under the Apache License Version 2.0. +# +"""Construction-time defaults applied to every spawned sub-agent.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from trpc_agent_sdk.models import LLMModel +from trpc_agent_sdk.types import GenerateContentConfig + + +@dataclass(frozen=True) +class SubAgentConfig: + """Configuration for every spawned sub-agent. + + ``None`` means "inherit from the parent agent or use the default". + """ + + model: Optional[LLMModel] = None + """Model the sub-agent uses. ``None`` inherits the parent's model.""" + + generate_content_config: Optional[GenerateContentConfig] = None + """Generation configuration (temperature, top_p, etc.). ``None`` inherits from parent.""" + + parallel_tool_calls: Optional[bool] = None + """Whether the sub-agent may issue parallel tool calls. ``None`` inherits from parent.""" + + include_parent_history: bool = False + """Whether to inject parent conversation history into the sub-agent's session.""" + + max_parent_history_turns: Optional[int] = None + """Max parent turns to inject. ``None`` = unlimited. + Only used when ``include_parent_history`` is ``True``.""" + + max_turns: Optional[int] = None + """Max LLM calls the sub-agent may make. ``None`` = unlimited. + Each LLM request counts as one turn, including those with tool calls.""" + + +__all__ = ["SubAgentConfig"] diff --git a/trpc_agent_sdk/tools/__init__.py b/trpc_agent_sdk/tools/__init__.py index 52512406..715da600 100644 --- a/trpc_agent_sdk/tools/__init__.py +++ b/trpc_agent_sdk/tools/__init__.py @@ -5,9 +5,16 @@ # tRPC-Agent-Python is licensed under Apache-2.0. """Tools module for TRPC Agent framework.""" +from typing import TYPE_CHECKING + from trpc_agent_sdk.abc import ToolPredicate from trpc_agent_sdk.abc import ToolSetABC as BaseToolSet +if TYPE_CHECKING: + # Lazy re-export — see ``_LAZY_REEXPORTS`` below. + from trpc_agent_sdk.agents.sub_agent import DynamicSubAgentTool as DynamicSubAgentTool # noqa: F401 + from trpc_agent_sdk.agents.sub_agent import SpawnSubAgentTool as SpawnSubAgentTool # noqa: F401 + from ._agent_tool import AGENT_TOOL_APP_NAME_SUFFIX from ._agent_tool import AgentTool from ._base_tool import BaseTool @@ -187,3 +194,25 @@ "parse_schema_from_parameter", "register_checker", ] + +# Lazy re-exports: implemented elsewhere (avoids circular imports and keeps +# the tools package free of optional file/web tool dependencies) but exposed +# here for discoverability. Not in ``__all__`` so ``import *`` stays lazy. +_LAZY_REEXPORTS = { + "DynamicSubAgentTool": ("trpc_agent_sdk.agents.sub_agent", "DynamicSubAgentTool"), + "SpawnSubAgentTool": ("trpc_agent_sdk.agents.sub_agent", "SpawnSubAgentTool"), +} + + +def __getattr__(name): + if name in _LAZY_REEXPORTS: + import importlib + module_name, attr = _LAZY_REEXPORTS[name] + obj = getattr(importlib.import_module(module_name), attr) + globals()[name] = obj # cache: subsequent accesses skip __getattr__ + return obj + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + return sorted(set(list(globals()) + list(_LAZY_REEXPORTS)))