---
title: "harness - 子代理定义与任务委派"
---


子代理（Subagent）是 Harness 的"任务执行单元"：主代理通过 `task` / `multitask` 工具把子任务委派给它们，子代理在**隔离的会话**中独立推理，只把最终结果回传。本文说明当前版本子代理"怎么定义、怎么被加载、怎么被委派"，以及委派过程中父子事件的可见性语义。

### 1、子代理的来源与发现机制

子代理定义（`.md` 文件）来自三类位置：

1. **内置挂载**：引擎构造时自动注册的 `@harness-agents`（类型 `AGENTS`，来源是 classpath 的 `META-INF/solon/ai/harness/agents/`），包含 `bash`、`explore`、`general`、`git-summary`、`plan` 五个内置子代理。
2. **目录挂载**：`harnessHome` 下的 `agents/` 或全局共享目录，通过 `MountType.AGENTS` 的挂载接入（如 `@global-agents`）。
3. **运行时注册**：`generate` 工具动态创建，或代码调用 `engine.getAgentManager().addAgent(...)`。

子代理目录（`AgentCatalog`）只扫描**类型为 `AGENTS` 且启用**的挂载：

* 递归深度上限 3、只取文件；跳过隐藏目录下的文件、以 `.` 开头的文件、以及非 `.md` 文件；
* 同名子代理保留先被发现者（`putIfAbsent`）；
* 每个 md 必须以 front matter 的 `name` 作为身份（见下节）。

`AgentManager.getAgents()`（也就是主代理提示词里的"当前可用子代理"列表）会过滤掉 `hidden: true` 的定义，所以内置的 `git-summary` 不会出现在该列表中；`getAgent(name)` 在缓存与目录里都找不到时会抛 `IllegalArgumentException`。

### 2、子代理定义（YAML front matter 与 AgentDefinition）

以内置的 `bash` 代理为例：

```markdown
---
name: "bash"
description: "Bash 命令执行专家，专门执行 git 操作、命令行任务和终端操作（没有文件写权限）"
tools: ["list", "read", "bash"]
---

你是一个命令行执行专家……
```

front matter 字段与当前语义：

| 字段 | 状态 | 说明 |
|------|------|------|
| `name` | **必需** | 子代理唯一标识。缺失或为空时该 md 会被跳过（解析期抛 `IOException`）。名称只取自 front matter，不再用文件名兜底 |
| `description` | 建议 | 展示在主代理提示词的 `<available_agents>` 中，影响主代理的选择偏好 |
| `tools` | 可选 | 允许的工具名列表，支持具体工具名与 `*` / `**` / `pi` 通配 |
| `disallowedTools` | 可选 | 禁用工具；优先于 `tools`，全局禁用工具同样优先生效 |
| `model` | 可选 | 指定模型名；未指定时用会话选中模型，再回退默认模型 |
| `permissionRules` | 可选 | 代理级权限规则增量（`toolName` / `behavior` / `pattern` / `priority`） |
| `enabled` | 可选，默认 `true` | 是否启用 |
| `hidden` | 可选，默认 `false` | 是否隐藏；隐藏代理不出现在主代理的可用子代理列表里 |
| `primary` | 可选，默认 `false` | 是否主代理定义；影响 `todo` 工具的实现方式（主代理用文件模式，子代理用内存规划模式） |
| `skills`、`mcpServers`、`hooks`、`memory`、`background`、`isolation`、`teamName` | **已 `@Deprecated`** | 仅为兼容旧定义保留的字段，当前不参与运行（`hooks` 明确标注"暂不解析"）。新定义不应再使用 |

解析与序列化入口：

* `AgentDefinition.fromParsedMarkdown(Markdown)`：当前推荐入口，接收已解析的 Markdown 文档（Markdown 的来源解析由来源描述负责）。
* `AgentDefinition.fromMarkdown(String)` / `fromMarkdown(List<String>)`：已 `@Deprecated`，内部转调新入口。
* `AgentDefinition.toMarkdown()`：把 metadata 写回 YAML front matter，再接上 systemPrompt 正文；`permissionRules` 因序列化限制单独手工追加。

Markdown 的 front matter 会直接绑定到 `AgentDefinition.Metadata`；`permissionRules` 需要运行时转换（`pattern` 为空时等价于工具级规则），因此由 `fromParsedMarkdown` 单独读取并转换为 `PermissionRule` 列表。

另外，`AgentDefinition` 提供三个常用常量：`AGENT_MAIN`（`"main"`）、`AGENT_GENERAL`（`"general"`）、`ATTR_PERMISSION_CONTEXT`（`"__permissionContext"`）。

### 3、代码方式定义子代理

运行时可以用 builder 构造定义并交给 `AgentManager` 管理：

```java
AgentSession session = engine.getSession("default");

// 动态定义智能体
AgentDefinition definition = AgentDefinition.builder()
        .name("reviewer")
        .description("代码审查专家（只读）")
        .systemPrompt("你是一个代码审查专家……")
        .toolsAdd("read", "grep", "glob")     // 也接受 Collection<String>
        .disallowedToolsAdd("bash")            // 禁用工具优先于 tools
        .hidden(false)
        .build();

engine.getAgentManager().addAgent(definition);          // 注册（同名覆盖）
engine.getAgentManager().addAgentIfAbsent(definition);  // 仅在不存在时注册

// 构建并直接调用（可进一步定制）
ReActAgent subagent = engine.createSubagent(definition)
        .defaultToolAdd(new OrderTool())
        .build();

subagent.prompt("审查 src/Main.java")
        .session(session)                              // 没有，则为临时会话
        .options(o -> o.toolContextPut(HarnessEngine.ATTR_CWD, engine.getWorkspace()))
        .call();
```

`AgentDefinition.builder()` 支持 `name` / `description` / `systemPrompt` / `primary` / `hidden` / `toolsAdd(...)` / `disallowedToolsAdd(...)`，以及 `metadata(Consumer<Metadata>)` 直达元数据（例如 `metadata(m -> m.addTools(ToolName.TOOL_BASH))`）。`engine.createSubagent(definition)` 返回 `ReActAgent.Builder`，仍可自由定制。

工具名解析由 `AgentFactory` 完成，要点：

* `*` = 公共工具集；`**` = 全量工具集（含 mcp/openapi/hitl/memory 等）；`pi` = `read` / `write` / `edit` / `bash`（含异步变体）；
* `edit` 会隐式带上 `read` 与 `write`；`ls` 与 `list` 归一为 `ls`；
* `task` / `generate` 受 `subagentEnabled` 开关控制；`hitl` 受 `hitlEnabled` 控制；
* 代理级 `disallowedTools` 与引擎级全局禁用都会在执行前短路；
* `permissionRules` 会以 `ATTR_PERMISSION_CONTEXT` 属性注入代理，供权限/HITL 引擎在运行时与全局规则合并。

### 4、任务委派：task 与 multitask

`TaskTalent` 把子代理能力暴露为两个工具：

| 工具 | 说明 | 参数 |
|------|------|------|
| `task` | 委派**单个**子任务（不支持并行） | `agent_name`、`prompt`、`description` |
| `multitask` | 并行执行多个互不依赖的子任务 | `tasks` 列表，每项比 `task` 多一个 `index`（默认 1，用于结果匹配） |

委派时的关键行为：

* **上下文隔离**：每个子任务都新建 `ReActAgent`（由定义 + 父会话选中的模型构建）与 `InMemoryAgentSession`，只带 `prompt` 以及 toolContext 中的 `__cwd`、`__sessionId`，因此子代理看不到父会话的历史，主代理必须在 prompt 中提供完整背景。
* **执行方式**：无论父级是流式还是同步，`TaskTalent` 内部都调用子代理的 `.stream().blockLast()` 阻塞等待其终态；两种父模式的差别只在"是否把子事件包装成 `TaskWrapEvent` 注入父流"（见第 6 节）。
* **并发与上限**：`multitask` 用 `CompletableFuture` + IO 线程池并行执行；单批建议数量取 `engine.getMultitaskMaxTasks()`（默认 5，0 表示不限制），该值会写进工具指令里。
* **结果格式**：全部完成后按顺序拼成 `<multitask_results>`，每个子结果是 `<task_result>` 片段，含 `<index>`（仅 multitask）、`<description>`、`<agent_name>`、`<result_status>`（`success` / `failure`）与 `<result_content><![CDATA[...]]></result_content>`。
* **失败处理**：子代理执行异常不会向上抛，而是被转换成 `ERROR: 任务执行失败: ...` 的失败结果；父 ReAct 是否继续由后续运行决定。未知名的 `agent_name` 会由 `AgentManager` 抛 `IllegalArgumentException`。
* **成功文本**：取子代理终态 `RunEndEvent.getText()`，同时把子代理的 `Metrics` 累加到父 trace。

### 5、动态生成子代理：generate

`GenerateTalent` 提供 `generate` 工具，用于在"当前可用子代理列表无匹配项"时创建一个垂直领域的虚拟专家：

```text
generate(name, description, systemPrompt, tools?, skills?, saveToFile?)
```

* `name` 必须匹配 `^[a-zA-Z0-9_-]+$`，否则直接返回 ERROR 文本；
* 实现上先**拷贝内置 `general` 定义**，再覆盖 `systemPrompt` / `name` / `description` / `enabled` / `tools`，随后以 `addAgentIfAbsent` 注册（同名已存在则不覆盖）；
* `saveToFile=true` 时写入 `<当前工作目录>/.soloncode/agents/<name>.md`（内容是 `toMarkdown()` 的 front matter + 正文），下次可被目录重新发现；
* `skills` 参数写入的是已 `@Deprecated` 的 `metadata.skills` 字段，仅作兼容保留；新定义应改用 `tools`。

### 6、委派时的父子事件可见性

父代理的流式入口是：

```java
Flux<AgentEvent> events = engine.prompt(prompt)
        .session(session)
        .stream();
```

`engine.prompt(...)` 返回 `ReActRequest`，`.stream()` 返回父代理的 `Flux<AgentEvent>`。4.1 不再以旧版块对象作为流元素，也不存在从 `HarnessEngine` 直接开启流的入口。

`TaskTalent` 执行子任务时，内部始终通过子代理的 `.stream()` 运行并阻塞等待其终态，但子事件是否对父调用方可见取决于父请求：

* **父请求使用 `.stream()`**：父 `ReActTrace` 持有 `FluxSink`。`TaskTalent` 把子流中的每个 `AgentEvent` 包装为 `TaskWrapEvent`，再写入父流；调用方可以实时观察子代理过程。
* **父请求使用 `.call()`**：父链路没有可供外部消费的 `FluxSink`。`TaskTalent` 仍在内部消费子 stream 并等待结果，但不会包装、转发子事件；父代理只取得 `task` / `multitask` 的最终工具结果。

因此，"内部使用子 stream"不等于"父调用方一定能看到子事件"；只有父级 `.stream()` 才建立这条可见链路。

### 7、完整事件时序

正常的单个 `task` 调用可观察为：

```text
RunStartEvent                                      父代理开始（未包装）
  -> [ContextSizeEvent]                            父上下文事件（可选）
  -> ReasonStartEvent                              父 Reason 开始
  -> ReasonDeltaEvent*                             父模型事件
  -> ReasonEndEvent                                父 Reason 结束并决定调用 task
  -> ActionStartEvent                              父 Action 开始
  -> ToolCallStartEvent(task)                      父调度工具开始（未包装）
       -> TaskWrapEvent(RunStartEvent)              子代理开始
       -> TaskWrapEvent([ContextSizeEvent])         子上下文事件（可选）
       -> TaskWrapEvent(ReasonStartEvent)
       -> TaskWrapEvent(ReasonDeltaEvent)*
       -> TaskWrapEvent(ReasonEndEvent)
       -> [TaskWrapEvent(ActionStartEvent)
           -> TaskWrapEvent(ToolCallStartEvent)
           -> TaskWrapEvent(ToolCallEndEvent)
           -> ...
           -> TaskWrapEvent(ActionEndEvent)]*       子代理可有多轮 Reason / Action
       -> TaskWrapEvent(RunEndEvent)                子任务正常终态
  -> ToolCallEndEvent(task)                         子任务结果回到父 ReAct（未包装）
  -> ActionEndEvent
  -> ...父代理继续 Reason / Action
  -> RunEndEvent                                    父代理正常终态（未包装）
  -> onComplete
```

上图给出正常 ReAct 主路径。子代理实际产生的其他事件也同样逐个包装，例如 `PlanEvent`、`HITLPendingEvent`、`HITLDecidedEvent` 和 `ContextSizeEvent`；异常或取消路径不保证出现后续阶段事件或结束事件。

`multitask` 的各子任务并行运行，不同 `taskId` 的 `TaskWrapEvent` 可以交错；同一个 `taskId` 内仍保持该子代理自身的事件顺序。`TaskTalent` 对共享 `FluxSink` 的 `next` 调用加锁，以避免多个子任务并发写入同一 sink。

注意两个不同的结束信号：

* **包装的子终态**：`TaskWrapEvent.getRealEvent()` 是 `RunEndEvent`，只表示该子任务结束。
* **未包装的父终态**：父流中直接出现的 `RunEndEvent`，才表示整个 Harness 主请求正常结束。

父级 `task` / `multitask` 的 `ToolCallStartEvent`、`ToolCallEndEvent` 也是未包装事件，它们表示调度工具本身的处理边界，不是子代理内部工具的边界。

### 8、TaskWrapEvent 字段与解包规则

| API | 含义 |
|---|---|
| `getRealEvent()` | 被包装的子代理原始 `AgentEvent`；读取具体类型和载荷时必须先解包 |
| `getParentRunId()` | 调度子任务的父 ReAct `runId` |
| `getRunId()` | 子代理事件自己的 `runId`，不是父 `runId` |
| `getTaskId()` | 本次子任务的唯一 ID |
| `getTaskIndex()` | `multitask` 的任务序号（取自任务入参 `index`，默认 `1`）；单个 `task` 恒为 `1` |
| `getTaskAgentName()` | 子代理名称，例如 `explore`、`bash` |
| `getTaskDescription()` | 子任务摘要 |
| `isMultitask()` | 是否来自 `multitask` |
| `getText()` | 委托给被包装事件的文本投影 |

`TaskWrapEvent` 不会把真实事件的 Java 类型"摊平"。例如，包装了 `RunEndEvent` 的对象仍然只是 `TaskWrapEvent`，`event instanceof RunEndEvent` 为 `false`。包装器自身的元数据也不能替代原事件元数据。需要读取 `ReasonDeltaEvent.getChatEvent()`、工具 `callId`、Trace、Metrics 或子终态时，必须先调用 `getRealEvent()`。

父子关联建议使用 `parentRunId + taskId`；`agentName` 可能重复，不能单独作为并行任务标识。

解包与投影的基本规则（完整的消费端适配示例见《harness - 子代理流式输出适配》）：

```java
AgentEvent event = outerEvent;
TaskWrapEvent taskEvent = null;

if (outerEvent instanceof TaskWrapEvent) {
    taskEvent = (TaskWrapEvent) outerEvent;
    event = taskEvent.getRealEvent();   // 必须解包后再判断具体类型
}

if (event instanceof ReasonDeltaEvent) {
    ReasonDeltaEvent delta = (ReasonDeltaEvent) event;
    ChatEvent chatEvent = delta.getChatEvent();
    if (chatEvent != null && chatEvent.is(ChatEventType.TEXT_DELTA)) {
        // 正文增量
    } else if (chatEvent != null && chatEvent.is(ChatEventType.THINKING_DELTA)) {
        // 思考增量
    }
} else if (event instanceof ToolCallStartEvent) {
    // 工具开始
} else if (event instanceof ToolCallEndEvent) {
    // 工具结束
} else if (event instanceof RunEndEvent) {
    // taskEvent != null → 子任务终态；否则为父请求终态
}
```

4.1 的投影要点：

* 流元素使用 `AgentEvent` 和具体事件类；
* 正文严格匹配 `ChatEventType.TEXT_DELTA`，思考严格匹配 `ChatEventType.THINKING_DELTA`；仅用 `isThinking()` 只能区分思考分组，无法排除 `THINKING_START` / `THINKING_END` 边界；
* 文本分片读取 `ReasonDeltaEvent.getText()`，工具结束文本读取 `ToolCallEndEvent.getText()`（完整结果对象用 `getResult()`）；
* 最终响应读取 `RunEndEvent.getResponse()`，最终文本可读取 `getText()`。

### 9、同步终态、异常与取消

不能对完整事件流直接 `blockLast()` 后强制转换，也不能假定最后一个已收到对象一定是终态。同步等待父终态时，应先过滤未包装的父 `RunEndEvent`：

```java
RunEndEvent end = engine.prompt(prompt)
        .session(session)
        .stream()
        .ofType(RunEndEvent.class)
        .blockFirst();

if (end != null) {
    String answer = end.getText();
    boolean abnormal = end.isAbnormal();
}
```

由于子终态外层类型是 `TaskWrapEvent`，上面的 `ofType(RunEndEvent.class)` 只会匹配未包装的父终态。若只需要最终结果且不需要任何过程事件，则直接使用：

```java
ReActResponse response = engine.prompt(prompt)
        .session(session)
        .call();
```

三种结束情形必须分别处理：

1. **正常或可展示的异常终态**：流收到未包装的父 `RunEndEvent`，随后 `onComplete`。通过 `RunEndEvent.isAbnormal()` 区分正常结果和 Agent 已转换的异常终态。包装的子 `RunEndEvent.isAbnormal()` 只描述对应子代理。
2. **未处理异常**：通过 Reactor `onError` 结束，不会再补发父 `RunEndEvent`。子代理未处理异常也不保证产生包装的子 `RunEndEvent`；`TaskTalent` 会把任务失败转换成 `task` / `multitask` 的工具结果，父 ReAct 是否继续取决于后续运行。
3. **主动取消**：取消订阅后不再转发子事件，也不会补发包装的子终态或未包装的父终态。`take(...)` 等操作符可能让派生流正常完成，但对原始生产者仍属于 cancel。

```java
engine.prompt(prompt)
        .session(session)
        .stream()
        .subscribe(
                this::handleEvent,
                error -> handleStreamError(error),
                this::handleComplete);
```

`onComplete` 只表示 Reactor 流完成；业务成功与否仍应以未包装父 `RunEndEvent` 是否存在及其 `isAbnormal()` 为准。审计工具执行时，还应同时观察 `ToolCallEndEvent.getError()` 和工具结果文本，因为部分工具错误会被转换为 observation，不一定成为 Reactor `onError`。

### 10、实现建议

SolonCode 的 `WebStreamBuilder` 可作为生产级适配参考。实现自己的输出层时建议：

* 首先识别 `TaskWrapEvent`，随后用 `getRealEvent()` 解包；
* DTO 显式携带 `parentRunId`、子 `runId`、`taskId`、`taskIndex`、`taskAgentName`、`taskDescription` 和 `multitask`；
* 使用 `parentRunId + taskId` 对并行子任务分组，不依赖事件的全局相邻关系；
* 只把底层 `TEXT_DELTA` 投影为正文，只把 `THINKING_DELTA` 投影为思考；
* 将包装的子 `RunEndEvent` 作为子任务完成信号，将未包装的父 `RunEndEvent` 作为主请求完成信号；
* 单独处理 `isAbnormal()`、Reactor `onError` 和 cancel，不伪造缺失的结束事件。
