子 Agent 与后台任务
task 工具怎样新建隐藏子会话,子 Agent 看得到什么,结果怎样以工具结果或后台唤醒回到父回合,以及任务表与 TUI 的 Tasks 面板。
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):
| 角色 | 工具 | 功能开关 |
|---|---|---|
explore | bash、read、grep、glob、web_fetch | mavis、delegation 关 |
worker | read、write、edit、bash、grep、glob、todowrite、web_fetch、website_deploy,外加 task_query、task_output、task_stop | delegation 关 |
verifier | read、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),两者步骤相同:
- 解析目标:
resolveLocalTaskAgentTarget把agent_name解析成具体 Agent,解析不到就返回Unknown agent,不建任务行。 - 建任务行:
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)。 - 建子会话并置为 running,见下面的代码。
- 提交子回合:
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 时是另外逐个中止子会话。子回合结束后走下面这条链:
- 写终态:
persistLocalSubagentTaskTerminal是所有子 Agent 终态的唯一写入口(packages/local-runtime/src/background-task/terminal.ts:49)。它先把最终文本以final_result流追加到输出日志,再在任务尚未终结时写状态;若task_stop抢先写了终态,晚到的结果只回填输出指针,并记一条警告。 - 转向父会话:交付调度器调用
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)。 - 模型读结果:注入的消息只列任务号与状态,要求模型自己去读(
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 系列工具。
下一篇:定时任务——定时任务在命令行版里为什么是关着的,以及它在代码里怎样把一个时间点变成一次回合。