Actor:子智能体的生成与回收

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

作者 David更新于 第 18 篇(共 33 篇)

上游 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):

动作必填可选返回
spawnsubagent_type、description、promptmodel、task_id、command、context、output_schema立即返回 actor_id,结束后发通知
run同上另加 timeout_ms阻塞到结束,结果内联
statusactor_id—注册表快照,不阻塞
waitactor_idtimeout_ms(默认 600000)结束或超时后的快照
cancelactor_id—已结束则原样返回,幂等
sendto_actor_id、contentto_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 的 taskMiMo 的 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 起:

字段取值说明
modesubagent、peer、main同会话切片、独立子会话、会话本身的主智能体
statuspending、running、idle登记后、跑一轮中、空闲
lastOutcomesuccess、failure、cancelled最近一轮的结局,idle 且带结局才算终态
lifecycleephemeral、persistentsubagent 默认前者,peer 默认后者
contextModenone、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、runsubagent,父会话内视动作
命令触发的子任务(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).

分叉的完整设计见 检查点写手。

一轮工作怎么收尾

  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。

            `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 消息怎样落进收件箱、唤醒空闲会话并注入上下文。

本页目录