# 子 Agent 与后台任务

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

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

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` 工具（见[定时任务](https://daiw.net/manual/minimax-code/cron)），`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`）：

```ts
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`），两者步骤相同：

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`）：

```ts
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` 开始：

```text
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`）：

```ts
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 时是另外逐个中止子会话。子回合结束后走下面这条链：

```mermaid
sequenceDiagram
  participant P as 父会话
  participant R as 任务服务
  participant C as 子会话
  participant D as 交付调度器
  P->>R: task，run_in_background 为 true，建任务行
  R->>C: 建隐藏子会话，提交子回合
  R-->>P: 回执 task_background 与 task_id
  C-->>R: 子回合结束，写 final_result 与终态
  R->>D: 安排交付
  D->>P: 转向一条隐藏消息 background-task-finished
  P->>R: task_output 读取结果
```

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`）：

```ts
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`），见[存储](https://daiw.net/manual/minimax-code/storage) |
| 输出 | `<数据目录>/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 工具](https://daiw.net/manual/minimax-code/file-and-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` 系列工具。

下一篇：[定时任务](https://daiw.net/manual/minimax-code/cron)——定时任务在命令行版里为什么是关着的，以及它在代码里怎样把一个时间点变成一次回合。
