子 Agent 与后台任务

task 工具怎样新建隐藏子会话,子 Agent 看得到什么,结果怎样以工具结果或后台唤醒回到父回合,以及任务表与 TUI 的 Tasks 面板。

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

MiniMax Code 的子 Agent 不是另起一个进程,而是一次工具调用:父回合调用 task,运行时新建一个隐藏的子会话,在里面跑一个完整回合,再把结果交回父回合。前台调用一直等到子回合结束,结果就是工具结果;后台调用立刻拿到任务号,子回合结束后,运行时往父会话“转向”一条隐藏消息把它叫醒。子 Agent 与后台 Bash 进程记在同一张任务表里。

实现分在三处:工具定义在 packages/agent-tools/src/desktop/,执行与任务表在第一代运行时的 packages/local-runtime/src/api/ 与 background-task/,工具目录门控、提示词与回合在 local-runtime-v2。buildLocalTurnToolSourcesForHost 的注释说得直白:第一代运行时只向 v2 宿主提供原始的产品域工具源,模型门控、MCP 披露与目录顺序归 v2 的 AgentRuntime(packages/local-runtime/src/api/host-turn-tools.ts:51)。

怎么用

模型这一侧有五个工具,定义从 packages/agent-tools/src/desktop/builtin-defs.ts:514 开始:

工具作用要点
task派生一个子 Agent必填 description(子会话标题)、prompt、agent_name;可选 model、effort、run_in_background
task_append给已有任务的子会话追加工作子会话空闲时开新回合,返回新的 task_id(activated);正在运行时并入当前回合(steered)
task_query列出或查询本会话的任务可按 status 过滤
task_output读任务输出offset 是字节偏移;wait_ms 超过 30000 按 30000 毫秒截断
task_stop停止任务排队的直接取消,运行中的中止子会话

agent_name 的内置目标是 mavis、explore、worker、verifier(packages/shared/src/subagent-roles.ts:26),mavis 用于不适合专职角色的宽泛工作。同文件的 SUBAGENT_ROLES 写了三个专职角色的用途:explore 只读摸底,可以用 Bash 做只读的 Git 与代码调查,但不能建文件或改文件;worker 做范围、归属、交付物与验收都说清楚的修改;verifier 独立核验已有交付物,不改项目文件。自定义 Agent 用稳定名引用,与保留名(三个角色加 main、mavis)撞名时写成 agent:<stable-name>。model 与 effort 的参数说明要求模型只在用户明确指定时才填,否则沿用目标 Agent 或继承来的设置(builtin-defs.ts:567)。

用户这一侧,TUI 提供:

  • Tasks 面板:/tasks 列出当前会话树里的子 Agent 与运行时任务(packages/tui/src/tui/commands/catalog.ts:285)。↑↓ 选择,回车对子 Agent 打开只读转录、对运行时任务显示详情,Esc 返回,Q 关闭。
  • 摘要行:有子 Agent 在跑,或有还没被读取的后台结果时,输入框上方出现一行以 Tasks 开头、以 /tasks details 结尾的计数(packages/tui/src/tui/shell/task-panel.ts:128)。
  • 一起停:按 Esc 中断当前回合时,TUI 顺带中止根会话下仍在运行的委派会话(packages/tui/src/tui/controller/run/abort-live-turn.ts:22)。
  • 权限冒泡:permissionSessionScope 把整棵委派子树算进当前会话的权限范围,子会话的权限请求在根会话界面上确认(packages/tui/src/tui/controller/delegation-flow.ts:318)。

子会话是内部会话,mcode exec 与 ACP 都拒绝打开,报错文本相同(packages/tui/src/headless/runner.ts:902):

Sub-agent Sessions are internal and cannot be opened.

另外,TUI 使用的 CLI 能力档关掉了原生 mavis 工具(见定时任务),task 说明里提到的 session send 在命令行里用不上,继续一个子 Agent 只能走 task_append。

谁能派生

task 与 task_append 不是谁都能拿到。v2 组装工具目录时把它们只留给“权威主档”(packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-tool-catalog.ts:320):

function isAuthoritativeMainProfile(profile: LocalTurnAgentProfileFacts | undefined): boolean {
  return (
    (profile?.surface === 'interactive' || profile?.surface === 'cli') &&
    profile.trustedBuiltin === true &&
    profile.canonicalRole === 'mavis'
  );
}

也就是可信的内置主 Agent mavis,运行在交互或命令行表面上。子会话的表面由 resolveAgentPromptSurface 判定:sessionKind 为 task,或者是有父会话、隐藏、purpose 以 local-task:、local-background-task:、team-plan: 开头的分支会话,都算 task-child(packages/local-runtime-v2/src/service/turn-system/agent-host/preparation/agent-prompt-surface.ts:14)。task-child 上还要去掉 task、task_append、todowrite、ask_user(local-turn-tool-catalog.ts:454),内置角色的子会话连整个 mavis 工具也拿不到(local-turn-tool-catalog.ts:311),线程目标工具同样不给(local-turn-tool-catalog.ts:436)。结果是委派只有一层:子 Agent 不能再派生子 Agent,也不能向用户提问。

三个角色的工具上限写在各自 agent.md 的 frontmatter 里(如 packages/local-runtime-v2/assets/agents/explore/agent.md:1):

角色工具功能开关
explorebash、read、grep、glob、web_fetchmavis、delegation 关
workerread、write、edit、bash、grep、glob、todowrite、web_fetch、website_deploy,外加 task_query、task_output、task_stopdelegation 关
verifierread、grep、glob、bash、web_fetch,外加三个任务控制工具mavis、delegation 关

worker 列了 todowrite,但在子会话表面上会被上面的规则滤掉。任务控制工具按调用者的会话过滤可见范围(packages/local-runtime/src/background-task/service.ts:472),子 Agent 又不能派生任务,所以它能查、能停的只有自己起的后台 Bash。

派生:一个隐藏的分支会话

前台是 runForegroundLocalTask(packages/local-runtime/src/api/local-task-runner.ts:39),后台是 startBackgroundLocalTask(packages/local-runtime/src/background-task/runner.ts:26,下文简写为 background-task/runner.ts),两者步骤相同:

  1. 解析目标:resolveLocalTaskAgentTarget 把 agent_name 解析成具体 Agent,解析不到就返回 Unknown agent,不建任务行。
  2. 建任务行:createBackgroundTaskId 生成 bg_ 加 UUID 的任务号(packages/local-runtime/src/background-task/domain.ts:222),子回合的 id 由它派生为 turn_task_bg_ 加任务号(packages/local-runtime/src/background-task/turn-id.ts:7)。前台调用也建行,这样前台运行同样有任务号、输出日志,重启后也能被标成 lost(packages/local-runtime/src/background-task/subagent-task-row.ts:13)。
  3. 建子会话并置为 running,见下面的代码。
  4. 提交子回合:runInjectedConversationTaskTurn 通过 v2 的会话入口 ingress.submit 把 prompt 作为子会话的第一条 user 消息提交,source 为 task 或 background-task,clientRequestId 为 task-turn: 加回合 id(packages/local-runtime/src/api/injected-conversation-task-turn.ts:63)。

建子会话的调用(local-task-runner.ts:84):

childSession = await input.host.agentRoutes.createSession({
  agentName,
  workspaceDir: input.parentSession.workspaceDir || input.host.resolveDefaultWorkspaceDir(),
  sessionType: 'branch',
  sessionKind: 'task',
  parentSessionId: input.parentSession.sessionId,
  title: taskInput.description,
  visibility: 'hidden',
  purpose: `local-task:${input.toolCtx.turnId}:${input.toolCtx.toolCallId ?? 'tool'}`,
  runLocation: input.parentSession.runLocation,
  appMode: input.parentSession.appMode,
  isDefaultWorkspace: input.parentSession.isDefaultWorkspace,
  ...(taskModelSelection ? { taskModelSelection } : {}),
});

后台版的 purpose 是 local-background-task: 加任务号(background-task/runner.ts:85)。子会话与父会话共用工作目录和运行位置,没有另开工作树。v2 在插入这行会话记录之前,就把子 Agent 的系统提示词、提示词快照、可用能力清单和模型一次性冻结(packages/local-runtime-v2/src/application/session/task-agent-binding-capture.ts:87),之后 task_append 开的新回合沿用同一份定义,不受中途改配置影响。

子 Agent 看得到什么

子会话是新会话,历史为空。task 的工具说明对模型讲明:子 Agent 没有父对话的历史,prompt 必须自成一体;Agent 契约、作用域内的上下文、适用的项目说明和暴露给它的工具照常生效(builtin-defs.ts:536)。prompt 参数的说明列出了这份简报该写什么:目标与原因、相关事实与已排除的路径、范围与文件归属、约束与不许做的事、交付物、验收标准,以及回复的格式与长度(builtin-defs.ts:562)。仓库里另有一份更长的委派指南 packages/local-runtime-v2/assets/agents/mavis/features/delegation.md.hbs,但 TUI 与桌面都走 v2 提示词,这条路径下 feature 文件一律不渲染(packages/local-runtime-v2/src/service/agent/builtin/catalog.ts:431),v2 的系统提示词里也没有委派规则,模型读到的就是工具说明这一份。

子会话一侧,v2 提示词的各层都从同一个 AGENT_CONTEXT.md.hbs 渲染(builtin/catalog.ts:299),任务子会话拿到的是分支层,里面有一段“Delegated Task Contract”。TUI 版的契约正文从 packages/local-runtime-v2/assets/agents/_v2/AGENT_CONTEXT.md.hbs:182 开始:

You are a hidden TUI child Agent working for a parent Agent, not speaking directly to the user.

- Complete only the assigned task and return a concise handoff to the parent.
- Use the supplied briefing and your own session history; you do not inherit the parent conversation.
- Do not delegate, manage parent tasks, or ask the user questions.

插件的 SubagentStart 钩子返回的附加上下文,会以 <plugin-hook-context> 块接在 prompt 末尾(local-task-runner.ts:162);钩子返回 continue: false 时子 Agent 不启动(packages/local-runtime-v2/src/service/turn-system/agent-host/execution/executor.ts:683)。任务会话关掉了 Agent 级记忆,用户与全局记忆不受这条规则影响(host-turn-tools.ts:64);不过 TUI 宿主本来就把记忆整体关了(packages/tui/src/runtime/embedded-host.ts:54)。

结果怎样回到父回合

前台:工具结果

runInjectedConversationTaskTurn 等子回合结束,把全部 assistant 消息的文本拼起来作为 finalText(injected-conversation-task-turn.ts:77)。task 工具把结果包进 task_result 标签,带上 task_id 与 session_id 两个属性(packages/agent-tools/src/desktop/local-task.ts:94),正文由 formatLocalTaskParentReport 排成固定字段(packages/agent-tools/src/desktop/task-verification.ts:119):

export function formatLocalTaskParentReport(result: LocalTaskRunResult): string {
  const verification = result.verification;
  const changedFiles = verification?.changedFiles ?? [];
  const observationNotes = verification?.observationNotes ?? [];

  return [
    `run_status: ${result.status}`,
    `requested_agent_name: ${result.requestedAgentName}`,
    `resolved_agent_name: ${result.resolvedAgentName ?? 'missing'}`,
    `model_verdict: ${verification?.modelVerdict ?? 'missing'}`,
    `file_change: ${verification?.fileChange ?? 'missing'}`,
    `file_change_limitations: ${FILE_CHANGE_OBSERVATION_NOTICE}`,
    `changed_files: ${changedFiles.length > 0 ? changedFiles.join(', ') : 'none'}`,
    `observation_notes: ${observationNotes.length > 0 ? observationNotes.join(' | ') : 'none'}`,
    'final_text:',
    result.finalText ?? '',
    `error_message: ${result.errorMessage ?? ''}`,
  ].join('\n');
}

model_verdict 只对内置 verifier 有值:从最终文本里找唯一的 VERDICT: 行,解析成 pass、fail 或 partial,多个候选互相矛盾就算缺失(task-verification.ts:19)。file_change 来自回合的文件改动观测,注释特意说明这只是尽力而为的观测,不是沙箱或安全边界(task-verification.ts:9)。前台任务行在返回前就记上 deliveredAt,之后不会再触发唤醒(local-task-runner.ts:173)。

并行要靠后台。task 声明为 executionMode: 'sequential'(builtin-defs.ts:608);agent-core 创建 pi 的 Agent 时虽然传了 toolExecution: 'parallel'(packages/agent-core/src/pi-turn-runner/agent.ts:80),但 pi 的 executeToolCalls 只要发现一批调用里有一个 sequential 工具,就整批串行执行(third_party/pi-mono/packages/agent/src/agent-loop.ts:385)。同一步里的多个前台 task 因此是一个接一个跑的。

后台:转向唤醒

后台 task 立刻返回 task_background 标签包着的回执(local-task.ts:41)。在运行时这一层,子回合用的中止信号只在运行时关闭时触发(background-task/runner.ts:34),父回合结束或被中止都不会连带取消它;TUI 按 Esc 时是另外逐个中止子会话。子回合结束后走下面这条链:

图表加载中…
  1. 写终态:persistLocalSubagentTaskTerminal 是所有子 Agent 终态的唯一写入口(packages/local-runtime/src/background-task/terminal.ts:49)。它先把最终文本以 final_result 流追加到输出日志,再在任务尚未终结时写状态;若 task_stop 抢先写了终态,晚到的结果只回填输出指针,并记一条警告。
  2. 转向父会话:交付调度器调用 ingress.steer,source 为 background-task,消息设了 hideUserMessage,幂等键是任务号,批量时换成任务号列表的哈希(packages/local-runtime/src/background-task/conversation-delivery.ts:54)。按会话契约,父会话空闲时这次转向会开一个新回合,即 activated;正在运行时并入当前回合,即 steered(packages/agent-modules/conversation-contract/src/index.ts:416)。
  3. 模型读结果:注入的消息只列任务号与状态,要求模型自己去读(conversation-delivery.ts:88):
export function buildBackgroundTaskDeliveryPrompt(tasks: readonly BackgroundTask[]): string {
  return [
    '<background-task-finished>',
    'Local background tasks have finished in this conversation.',
    ...tasks.map((task) => {
      const description = task.description
        ? ` description="${escapeXmlAttribute(task.description)}"`
        : '';
      const endedAt = task.endedAt === undefined ? '' : ` ended_at_ms="${task.endedAt}"`;
      return `  <task task_id="${escapeXmlAttribute(task.taskId)}" status="${task.status}"${description}${endedAt}/>`;
    }),
    'Use task_output with each task_id to read the results, then report the relevant results to the user as a normal assistant reply in this conversation.',
    'Do not mention this internal notice unless it is necessary to explain the results.',
    '</background-task-finished>',
  ].join('\n');
}

调度器的节流参数(packages/local-runtime/src/background-task/delivery.ts:8):一条通知最多 32 个任务、序列化后不超过 8 KiB(packages/local-runtime/src/background-task/delivery-batch.ts:4);同一父会话 60 秒窗口内最多 3 次交付回合;失败或会话忙时按 1、2、5 秒重试,之后进入冷却,从 60 秒起翻倍,封顶 300 秒。

读取也有收尾。模型在某个回合里成功读到一个已终结任务的 task_output,回合提交后宿主会确认这次读取,任务行记上 deliveredAt,并在历史里追加一条对模型不可见的结算消息(packages/local-runtime-v2/src/service/turn-system/agent-host/local-agent-host.ts:500)。还没读的终结任务会以 <background-task-completion-reminder> 系统提醒反复出现:出现新的终结任务就提醒,否则每隔 15 次 assistant 迭代提醒一次(packages/local-runtime-v2/src/service/turn-system/execution/reminder/background-cadence-reminder.ts:86,间隔常量见 packages/local-runtime-v2/src/service/turn-system/compaction/compat.ts:10)。

任务表

仓库里有两份 background-task 代码。packages/agent-modules/background-task(包名 @mavis/background-task)按头注释是不做 IO 的共享词汇表,附带内存版的存储与 DefaultBackgroundTaskManager(packages/agent-modules/background-task/src/index.ts:1);但除了 agent-tools 用它的类型和 isActiveTaskStatus(packages/agent-tools/src/desktop/local-task-control.ts:2),代码里找不到对这个管理器和内存存储的调用。真正在用的是 packages/local-runtime/src/background-task/,它的 domain.ts 把同一套类型又定义了一遍,并没有从共享包导入(domain.ts:3)。

方面实现
种类类型上有 bash、subagent、workflow、custom,本地只写入前两种
状态queued、running、stopping 为活动态;succeeded、failed、canceled、lost 为终态,终态不能再转移(domain.ts:234)
存储第一代运行时的 SQLite 表 local_runtime_background_tasks 与事件表 local_runtime_background_task_events(packages/local-runtime/src/background-task/schema.ts:7),见存储
输出<数据目录>/background-tasks/<任务号>/output.log 与 summary.txt(packages/local-runtime/src/background-task/store.ts:312),单次读取默认 64 KiB(store.ts:34)
读游标省略 offset 时按会话记在内存里,重启后从 0 重放(service.ts:66)

v2 通过兼容层使用这套服务:绑定运行时所有者、启动时回收(packages/local-runtime-v2/src/compat/v1/runtime.ts:527),以及给 TUI 列任务(runtime.ts:735,每次最多 100 条,默认 50)。task_stop 先把状态置为 stopping(service.ts:298),再由宿主的 stopRuntime 回调停掉 Bash 进程、按回合 id 精确中止子会话(packages/local-runtime/src/api/host.ts:666),最后落 canceled。进程重启后,所有者进程已不在的活动任务会被标成 lost,错误码 TASK_LOST_ON_STARTUP(packages/local-runtime/src/background-task/startup-recovery.ts:85)。后台 Bash 的执行细节见文件与 Shell 工具。

TUI:agent-team 与 background-work

TUI 不读任务表来认子 Agent,而是扫会话列表。TuiDelegationAccess 分页取全部会话,含隐藏与归档的,每页 200 条、最多 20 页(packages/tui/src/runtime/adapters/delegation-access.ts:14);isTuiDelegatedSession 认出有父会话、sessionKind 为 task 或 purpose 带三个前缀之一的分支会话,再从根会话往下收集整棵子树(packages/tui/src/runtime/delegation.ts:13)。

TuiDelegationFlow 在此基础上做三件事:

  • 跟踪活动:对运行中的子会话订阅 watchSessionTurn 事件流,投影出思考中、调用工具、回复中等阶段和工具计数(delegation-flow.ts:524)。
  • 轮询任务表:根会话在跑,或还有活动中、尚未被读取的后台任务时,每 1 秒拉一次运行时任务(delegation-flow.ts:25)。
  • 打开面板:/tasks 推出 TuiBackgroundWorkPanel,把子 Agent 与运行时任务排在一张列表里(packages/tui/src/tui/background-work/panel.ts:42);打开子 Agent 转录时最多取 200 条消息(delegation-flow.ts:24)。

停止走 TuiDelegationAccess 的 stop:先中止根会话,再对仍在活动的成员逐个发中止,最多三轮,最后回报停掉了哪些、哪些没停干净(delegation-access.ts:34)。

delegation-flow.ts 里还有一个 showTeam(),能推出可跳转到子会话的 TuiAgentTeamPanel(delegation-flow.ts:219),agent-team/summary.ts 也备好了“Agent team”转录块的渲染;但代码里找不到调用 showTeam() 的地方,也找不到生成 agent-team 转录单元的地方。公开的 TUI 里,子 Agent 只通过摘要行和 /tasks 面板露面。

被剔除的 @mavis/team

docs/architecture.md 说未使用的 @mavis/team 循环引擎不在公开投影里,TUI 的委派走当前运行时的任务服务(docs/architecture.md:14)。代码对得上:packages/agent-modules/team/ 登记在退役源码清单里,check:source 要求它不存在(scripts/lib/retired-sources.mjs:10)。留下的只是痕迹:会话来源类型里还有 team(packages/agent-modules/conversation-contract/src/index.ts:6),team-plan: 前缀仍被当作子会话识别;第一代运行时里的 routeLocalTeamApi(packages/local-runtime/src/team/api.ts:75)和宿主的私有方法 dispatchLocalTeamTask(host.ts:1650)都没有调用方。能派生子 Agent 的,只有本篇讲的 task 系列工具。

下一篇:定时任务——定时任务在命令行版里为什么是关着的,以及它在代码里怎样把一个时间点变成一次回合。

本页目录