# Actor：子智能体的生成与回收

> 用 actor 注册表与 actor 工具取代上游的 task 子代理：同会话切片与独立子会话、前后台、上下文继承、结果回传、取消与停滞检测。

- 作者：David（道雾轩）
- 专栏：MiMo Code 源码解读（https://daiw.org/manual/mimo-code.md）
- 最后更新：2026-09-17
- 原文：https://daiw.org/manual/mimo-code/actors
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

上游 OpenCode 的 `task` 工具每委派一次就新建一个子会话，父会话原地等它跑完（见 [子代理、待办与提问](https://daiw.org/manual/opencode/task-and-todo)）。MiMo Code 把这一层整个换掉了：`packages/opencode/src/tool/task.ts` 这个路径还在，内容却成了 T1、T2 式的任务树（见 [树状任务](https://daiw.org/manual/mimo-code/tasks)）；委派改由新工具 `actor` 承担，背后是一个独立的 Actor 原语——一张 `actor_registry` 表、一个负责生成与取消的 `Actor` 服务、一个停滞看门狗。检查点写手、Workflow 的 `agent()`、Orchestrator 的子会话，用的都是这同一套机制。

## 怎么用

模型通过 `actor` 工具操作子智能体。JSON 调用把参数包在 `operation` 里，用 `action` 区分动作；配置 `tool.invocation_style` 为 `shell` 时换成命令行写法，如 `actor spawn explore "描述" "提示词" --context state`（见 [命令行式工具调用与 Token 精简](https://daiw.org/manual/mimo-code/token-efficient-shell)）。动作共七个（`packages/opencode/src/tool/actor.ts:39`）：

| 动作 | 必填 | 可选 | 返回 |
| --- | --- | --- | --- |
| `spawn` | `subagent_type`、`description`、`prompt` | `model`、`task_id`、`command`、`context`、`output_schema` | 立即返回 actor_id，结束后发通知 |
| `run` | 同上 | 另加 `timeout_ms` | 阻塞到结束，结果内联 |
| `status` | `actor_id` | — | 注册表快照，不阻塞 |
| `wait` | `actor_id` | `timeout_ms`（默认 600000） | 结束或超时后的快照 |
| `cancel` | `actor_id` | — | 已结束则原样返回，幂等 |
| `send` | `to_actor_id`、`content` | `to_session_id`、`type` | 入队即返回 inboxID |
| `models` | — | `vision`、`limit`（默认 50） | 可传给 `model` 的模型列表 |

- **spawn 是默认**：schema 描述把 `spawn` 写成 `THE DEFAULT`、`run` 写成 `RARE EXCEPTION`，判别联合里也特意把 `spawnSchema` 排在最前（`actor.ts:522`），因为模型按顺序读。
- **可选类型**：`subagent_type` 是运行时从 agent 注册表生成的枚举，只收 `mode === "subagent"` 且非 hidden 的 agent（`actor.ts:370`）。内置的是 `general` 与 `explore`，用户配置的子智能体也会列进来。
- **模型**：`model` 可以是模型组名（ultra、standard、lite）或 `provider/model`；不给就用 agent 配置的模型，再没有就沿用父消息的模型（`actor.ts:784`）。
- **续用**：想让已有的子智能体接着干，就对它 `send`，消息进入它自己的历史并唤醒它再跑一轮。上游那种“传回 ID 续用”的写法不再有效：`spawn` 与 `run` 的 schema 是严格对象，不收 `actor_id`；shell 模式的参数恢复逻辑还特意保留模型误传的 `actor_id`，让校验报错，而不是悄悄新建一个空白子智能体（`actor.ts:320`）。
- **权限**：执行时以子智能体类型为模式询问 `actor` 权限（`actor.ts:739`），这个配置键是 MiMo 新加的（`packages/opencode/src/config/permission.ts:43`）。但工具说明末尾的“可用类型”清单仍按上游的 `task` 键过滤（`packages/opencode/src/tool/registry.ts:352`），而 `task` 在 MiMo 里也是任务树工具的 ID，写 `"task": "deny"` 会把任务树工具一起从工具表里拿掉。

子智能体不能再派子智能体。判断集中在 `hasActorTool`（`packages/opencode/src/agent/config.ts:18`）：

```ts
export function hasActorTool(agent: Pick<Info, "name" | "mode" | "toolAllowlist"> | undefined) {
  if (!agent) return true
  if (agent.toolAllowlist && !agent.toolAllowlist.includes("actor")) return false
  return agent.mode !== "subagent" || SYSTEM_SPAWNED_AGENT_TYPES.has(agent.name)
}
```

`ToolRegistry.available` 据此把 `actor` 从所有 subagent 的工具表里去掉（`tool/registry.ts:436`），用户自定义的子智能体也不例外，只有运行时派生的 checkpoint-writer、dream、distill 豁免；执行时还有最后一道检查（`actor.ts:730`）。于是 `packages/opencode/src/tool/actor.txt:3` 那段 `Subagent restriction`（大意是子智能体只能用 `actor send` 联系父级），以及 `actor.ts:542` 为 exec 调用保留的同款放行，在正常运行时都碰不到：exec 分派工具时走的也是 `available` 过滤（`packages/opencode/src/tool/tool-script.ts:598`）。

## 与上游 task 的差别

| | 上游 v1.14.19 的 `task` | MiMo 的 `actor` |
| --- | --- | --- |
| 子智能体放在哪 | 新建子会话，`parentID` 指向父会话 | 默认留在父会话里，消息按 `agent_id` 分片 |
| 前台与后台 | 只有前台 | `spawn` 后台为默认，`run` 前台 |
| 续用 | 传回 `task_id`，即子会话 ID | 对 actor_id 发 `send`；`task_id` 改为绑定任务树编号 |
| 防嵌套 | 子会话追加 `task` 拒绝规则，agent 规则提到 `task` 就放行 | 所有 subagent 的工具表里都没有 `actor` |
| 结果 | `<task_result>` | `<actor_result>` 带状态与摘要，或收件箱通知 |

上游做法见 v1.14.19 的 `packages/opencode/src/tool/task.ts:60`（按规则判断能否嵌套）与 `task.ts:67`（复用或新建子会话）。

## 数据模型

每个 actor 是 `actor_registry` 表里的一行，主键是 `(session_id, actor_id)`（`packages/opencode/src/actor/actor.sql.ts:41`）。枚举定义在 `packages/opencode/src/actor/schema.ts:4` 起：

| 字段 | 取值 | 说明 |
| --- | --- | --- |
| `mode` | `subagent`、`peer`、`main` | 同会话切片、独立子会话、会话本身的主智能体 |
| `status` | `pending`、`running`、`idle` | 登记后、跑一轮中、空闲 |
| `lastOutcome` | `success`、`failure`、`cancelled` | 最近一轮的结局，`idle` 且带结局才算终态 |
| `lifecycle` | `ephemeral`、`persistent` | subagent 默认前者，peer 默认后者 |
| `contextMode` | `none`、`state`、`full` | 继承多少父上下文 |
| `tools` | 工具 ID 数组或 `INHERIT` | 数组时由主循环按白名单拒绝其他工具（`packages/opencode/src/session/prompt.ts:1502`） |

- **main 也有一行**：`Session.create` 建会话时顺手登记 `actorID: "main"`、`mode: "main"`（`packages/opencode/src/session/session.ts:518`），往会话主智能体投递消息时的存在性检查靠的就是它。
- **ID**：subagent 的 ID 由 `allocateActorID` 按“类型-序号”分配，如 `explore-1`（`packages/opencode/src/actor/registry.ts:431`）；peer 的 actor_id 就是子会话 ID。
- **切片**：同会话里的子智能体靠消息表新增的 `agent_id` 列隔开（默认 `main`，`packages/opencode/src/session/session.sql.ts:91`），主循环每轮只取自己切片的消息（`prompt.ts:3633`）。为了让多个切片并发，`SessionRunState` 的运行器改成按 `sessionID:agentID` 建键（`packages/opencode/src/session/run-state.ts:29`；v1.14.19 同一文件第 34 行是按会话建键），并且只有 main 的运行器会改动会话的 busy、idle 状态。
- **孤儿回收**：每行记下进程级随机的 `instance_id`；注册表服务初始化时，把其他进程留下的 pending、running 行统一改成 failure，错误写 `orphaned: process restarted`（`actor/registry.ts:459`）。

## 生成

```mermaid
flowchart TD
  S["Actor.spawn"] --> M{"mode"}
  M -->|subagent| A["allocateActorID，在父会话里登记"]
  M -->|peer| B["session.create 子会话，actor_id 即子会话 ID"]
  A --> F["forkWork：在 Actor 服务的作用域里 fork"]
  B --> F
  F --> T["runTurn：跑一轮主循环"]
  T --> P{"actor.preStop 要求再跑？"}
  P -->|是，至多 3 次| T
  P -->|否| G["完成闸门（仅限 gateEligible），至多 2 次"]
  G --> D["交付：兑现 outcome，后台则发通知"]
  D --> Q["actor.postStop：至多 3 次，结果不再回传"]
```

`Actor.spawn` 按 `mode` 分两条路（`packages/opencode/src/actor/spawn.ts:879`）。`spawnSubagent`（`spawn.ts:822`）分配 ID，`context` 为 full 时记下父会话 main 切片最后一条消息作水位，登记为 pending，再把工作 fork 出去。`spawnPeer`（`spawn.ts:758`）先建一个 `parentID` 指向发起方的子会话，登记 `session_id` 与 `actor_id` 都等于子会话 ID 的行；给了 `cwd` 就把工作 fiber 绑到那个目录的 Instance 上，文件工具与写入边界随之落在该目录，worktree 隔离就是这么实现的。工作被 fork 进 Actor 服务自己的作用域，不随调用方的 fiber 结束。

| 调用方 | 形态 | 前后台 |
| --- | --- | --- |
| actor 工具的 `spawn`、`run` | subagent，父会话内 | 视动作 |
| 命令触发的子任务（`prompt.ts:2061`） | 经 actor 工具 `run`，跳过权限询问 | 前台 |
| 检查点写手（`packages/opencode/src/session/checkpoint.ts:972`） | subagent，挂在专门的子会话下 | 后台 |
| Workflow 的 `agent()`（`packages/opencode/src/workflow/runtime.ts:816`） | subagent，Workflow 所在会话内 | 后台 |
| `session create`（`packages/opencode/src/tool/session.ts:823`） | peer | 后台 |
| `session ask` 与 TUI 的 `/btw`（`tool/session.ts:136`） | subagent，挂在一次性子会话下，只读工具 | 前台 |

普通子智能体（agent 设了 `completionGate`，或者没有自带提示词）会在任务末尾被追加一段标题为 `## Return format (required)` 的说明，要求最终消息以 `**Status**:` 和 `**Summary**:` 两行开头（`spawn.ts:49`）。判定条件在 `spawn.ts:854`：

```ts
      const gateEligible =
        agentInfo?.mode === "subagent" &&
        (agentInfo.completionGate === true || (!agentInfo.prompt && input.agentType !== "checkpoint-writer"))
      const taskWithFormat = gateEligible ? input.task + RETURN_FORMAT_INSTRUCTION : input.task
```

内置 `general` 设了 `completionGate: true`（`packages/opencode/src/agent/agent.ts:263`）；`explore` 自带提示词又没设，不追加。

## 上下文继承

- **none**（默认）：子智能体只看到提示词。
- **state**：actor 工具把父会话最新的检查点摘要包进 `<session-state>`，拼在提示词前面（`actor.ts:763`）；还没有检查点就等同 none。
- **full**：设计上是“分叉”，spawn 时冻结父请求的系统提示词、工具表与消息前缀，子智能体只往后追加自己的消息，以复用提示词缓存。主循环对它的判定在 `prompt.ts:4223`：

```ts
            const isForkAgent =
              actorRecord?.contextMode === "full" &&
              (actorRecord.mode === "subagent" || actorRecord.mode === "peer")
```

判为分叉后，主循环向 `Actor.getForkContext` 要冻结快照，拿不到就把该 actor 记为失败（`missing fork context`）并退出（`prompt.ts:4232`）。快照只能由调用方经 `SpawnInput.forkContext` 传入，目前传它的只有检查点写手与 `session ask`；actor 工具调用 `actor.spawn` 时并不带这个字段（`actor.ts:819`）。按代码读，模型经 actor 工具传 `context: "full"` 会落进这个失败分支，而参数说明（`actor.ts:396`）仍写着：

> 'full': child sees parent conversation (prefix cache sharing).

分叉的完整设计见 [检查点写手](https://daiw.org/manual/mimo-code/checkpoint-writer)。

## 一轮工作怎么收尾

1. **runTurn**（`packages/opencode/src/actor/turn.ts:13`）：整体包在 `Effect.uninterruptible` 里，只把真正的工作重新标成可中断。开跑写 running；结束时无论成败都写回 idle 加结局：成功为 success，仅因中断而结束为 cancelled，其余为 failure，并记下 `Cause.pretty` 的文本。
2. **actor.preStop 钩子**：插件返回 `continue` 与 `reason`，理由就作为新一轮输入让子智能体再跑，至多 `MAX_PRE_REACT` 即 3 次（`spawn.ts:39`）。
3. **完成闸门**：只对 gateEligible 的子智能体生效。它名下还有 open 或 in_progress 的任务，就注入提醒再跑，至多 `MAX_TASK_GATE_SUBAGENT_REACT` 即 2 次（`packages/opencode/src/task/gate.ts:10`）；仍未收尾就把自报状态降为 partial 或 blocked，并在正文后追加未完成任务列表（`spawn.ts:600`）。
4. **交付**：解析 Status 与 Summary 头（`packages/opencode/src/actor/return-header.ts:9`），后台 actor 发通知，然后兑现 `outcome` 这个 Deferred。
5. **actor.postStop 钩子**：同样至多 3 次（`MAX_POST_REACT`），但结果已经交出，新一轮的文本不再回传。内置的进度检查插件就挂在这里。

钩子是否作用于某个 actor 由 `matchesActor` 决定（`packages/opencode/src/plugin/matcher.ts:6`）：不写 matcher 时排除 `BUILT_IN_AGENTS`（main、general、build、explore 等，`packages/plugin/src/index.ts:232`）；`agentType` 可以是正则字符串（同样排除内置）、名称数组、`include` 加 `exclude`，或者匹配除所列类型之外一切类型（含内置）的 `excludeOnly`；`mode` 可限定 subagent 或 peer。

## 结果怎么回到父会话

**run** 等 `outcome` 兑现：子智能体失败，工具调用直接报错；成功时输出的主体如下（`actor.ts:900`），`status` 是经闸门校正的自报状态，没报就是 unknown，指定了 `output_schema` 时正文是校验过的 JSON。

```ts
            `actor_id: ${spawnResult.actorID} (to give this subagent more work, \`send\` it another message)`,
            "",
            `<actor_result status="${statusAttr}"${summaryAttr}>`,
            resultText,
            "</actor_result>",
```

`run` 的 `timeout_ms`（默认 600000）与中断监听值得留意。`run` 以 `background: false` 调 `actor.spawn`，而 `spawnSubagent` 在前台情形下要先 `Fiber.join` 整个工作 fiber 才返回（`spawn.ts:875`）；超时计时与 abort 监听都挂在 spawn 返回之后（`actor.ts:859`）。`outcome` 确实在 postStop 之前就已兑现，`actor.ts:850` 的注释讲的是这一点；但按代码顺序读，工具开始等待时子智能体连同 postStop 钩子都已跑完，这两道闸门实际管不到子智能体的运行。

**spawn** 立即返回，输出里有一句 `Background sub-session started.`。结束时 `forkWork` 给父级投一条 `actor_notification`，同时弹 toast，checkpoint-writer 除外（`spawn.ts:428`）；通知怎样变成父会话的下一条输入，是下一篇的内容。

**wait** 订阅 `actor.status` 事件等终态（`packages/opencode/src/actor/waiter.ts:103`）。ephemeral actor 回到 idle 就返回；persistent actor 只有结局不是 success 才返回（`waiter.ts:35`），所以对 peer 调 `wait`，它顺利跑完一轮时并不会醒，工具说明专门提醒过这一点。

## 取消与停滞检测

**取消**（`spawn.ts:934`）先递归取消 `parentActorID` 指向自己的子 actor，再把 `sessionID:actorID` 放进 `cancelling` 集合、中断运行器、写入 idle 加 cancelled，最后由 `notifyTerminal` 发出唯一一条以 `was cancelled.` 结尾的通知。放进集合这一步不能省：主循环的中断处理会返回最后一条 assistant 消息，工作 fiber 因而看到一次假“成功”，集合让它别再发 completed 通知。`graceful` 与 `forced` 两种模式目前只在递归时原样传递，处理上没有区别。

**停滞看门狗**：Actor 服务每 `WATCHDOG_SCAN_INTERVAL_MS` 即 45 秒扫一遍后台的 pending、running actor（`spawn.ts:1067`），用 `deriveLiveness` 分类（`schema.ts:176`）：

```ts
  if (actor.status === "running" || actor.status === "pending") {
    // ...
    const since = actor.lastActivityTime ?? actor.time.created
    if (now - since > abandonMs) return "idle"
    return now - since <= stallMs ? "progressing" : "stalled"
  }
  if (actor.lastOutcome === "success") return "success"
  if (actor.lastOutcome === "failure") return "failure"
  if (actor.lastOutcome === "cancelled") return "cancelled"
  return "idle"
```

依据是“最后一次活动”：每写入一个 part，投影器就更新该 actor 的 `last_activity_time`，并用 `ACTIVITY_COALESCE_MS`（5 秒）合并写入（`packages/opencode/src/session/projectors.ts:157`）。静默超过 `DEFAULT_LIVENESS_STALL_MS`（6 分钟）算 stalled，超过 `DEFAULT_LIVENESS_ABANDON_MS`（10 分钟）就不再相信它还在跑、按 idle 报。`schema.ts` 的注释记下了选值依据：在 172 个子智能体的 43120 次活动间隔里，p99.9 为 296.8 秒，原先 90 秒的阈值会把长时间跑测试的健康子智能体误报成停滞。刚变成 stalled 时向父级发一条 `appears stalled` 通知，恢复活动或结束后才重新布防。

注册表另有一个 60 秒一次的扫描，把 `last_turn_time` 超过 5 分钟的 running 行发成 `actor.stuck` 事件（`actor/registry.ts:16`、`actor/registry.ts:477`），源码里没有找到订阅它的地方。从检查点重建上下文时，`renderForAgent` 会把仍在跑的后台 actor 列成 `## Active Actors` 一节附进去（`actor/registry.ts:368`，调用在 `checkpoint.ts:1715`），主智能体重建后仍记得自己派出去的子智能体。

## TUI

`spawn` 在会话里渲染成一行 `Background <类型> Task — <描述>`，`run` 则没有 Background 前缀（`packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:3525`）。`spawn` 的工具部件一返回就是 completed，转不转圈由 `isActorToolRunning` 决定（`packages/opencode/src/cli/cmd/tui/routes/session/actor-tool-state.ts:1`）：部件还在 running 就转；已 completed 的部件里只有 `spawn` 会再查注册表，actor 仍是 running 或 pending 就接着转。点击这一行，路由跳到同一会话并带上 `agentID`（`index.tsx:3570`），看到的就是该子智能体自己的切片。

下一篇：[收件箱、团队与进度检查](https://daiw.org/manual/mimo-code/inbox-team)——后台 actor 的结果、权限转发与 `send` 消息怎样落进收件箱、唤醒空闲会话并注入上下文。
