Actor:子智能体的生成与回收
用 actor 注册表与 actor 工具取代上游的 task 子代理:同会话切片与独立子会话、前后台、上下文继承、结果回传、取消与停滞检测。
上游 OpenCode 的 task 工具每委派一次就新建一个子会话,父会话原地等它跑完(见 子代理、待办与提问)。MiMo Code 把这一层整个换掉了:packages/opencode/src/tool/task.ts 这个路径还在,内容却成了 T1、T2 式的任务树(见 树状任务);委派改由新工具 actor 承担,背后是一个独立的 Actor 原语——一张 actor_registry 表、一个负责生成与取消的 Actor 服务、一个停滞看门狗。检查点写手、Workflow 的 agent()、Orchestrator 的子会话,用的都是这同一套机制。
怎么用
模型通过 actor 工具操作子智能体。JSON 调用把参数包在 operation 里,用 action 区分动作;配置 tool.invocation_style 为 shell 时换成命令行写法,如 actor spawn explore "描述" "提示词" --context state(见 命令行式工具调用与 Token 精简)。动作共七个(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):
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)。
生成
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:
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:
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).
分叉的完整设计见 检查点写手。
一轮工作怎么收尾
- runTurn(
packages/opencode/src/actor/turn.ts:13):整体包在Effect.uninterruptible里,只把真正的工作重新标成可中断。开跑写 running;结束时无论成败都写回 idle 加结局:成功为 success,仅因中断而结束为 cancelled,其余为 failure,并记下Cause.pretty的文本。 - actor.preStop 钩子:插件返回
continue与reason,理由就作为新一轮输入让子智能体再跑,至多MAX_PRE_REACT即 3 次(spawn.ts:39)。 - 完成闸门:只对 gateEligible 的子智能体生效。它名下还有 open 或 in_progress 的任务,就注入提醒再跑,至多
MAX_TASK_GATE_SUBAGENT_REACT即 2 次(packages/opencode/src/task/gate.ts:10);仍未收尾就把自报状态降为 partial 或 blocked,并在正文后追加未完成任务列表(spawn.ts:600)。 - 交付:解析 Status 与 Summary 头(
packages/opencode/src/actor/return-header.ts:9),后台 actor 发通知,然后兑现outcome这个 Deferred。 - 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。
`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):
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),看到的就是该子智能体自己的切片。
下一篇:收件箱、团队与进度检查——后台 actor 的结果、权限转发与 send 消息怎样落进收件箱、唤醒空闲会话并注入上下文。