子代理

task 工具生成共享工作区的子代理——交付文件 artifacts,支持并行委派与用量追踪

子代理:全新上下文,复用父级沙箱

task 工具使代理能够生成临时子代理,这些子代理自主执行子任务并交付结构化的文件 artifacts。每个子代理以全新上下文运行 ——无对话历史、无父级运行时状态、拥有自己的工具集与步数限制——但与父级 1:1 共享 sandbox 与 workspace(与 Agent 团队成员同一模型)。这使独立工作流可以并行运行,又不会让编排器的上下文膨胀。

该工具位于 @zapvol/backend/src/tools/tools/task.tool.ts

架构

task 工具 — 子代理 (Subagent) 委派 编排器生成全新上下文的子代理,共享工作区,通过 complete() 交付文件 artifacts 编排器 (Orchestrator) MAIN_INSTRUCTIONS · 主对话线程 task({ subagent_type, description, prompt }) 并行 fan-out Subagent A type: "research" tools: tavily_search, fs workspace: 共享 1:1 prompt: TASK_INSTRUCTIONS complete({ summary, paths }) → artifacts[] Subagent B type: "general" tools: 标准工具集 workspace: 共享 1:1 prompt: TASK_INSTRUCTIONS complete({ summary, paths }) → artifacts[] Subagent C type: "coding" tools: fs, execute workspace: 共享 1:1 prompt: TASK_INSTRUCTIONS complete({ summary, paths }) → artifacts[] { summary, artifacts[], status } 整合 — read_file artifacts 文件持久存在于共享工作区,覆盖整个任务生命周期 子代理与父级 1:1 共享工作区(无 chroot);工作区根由 NodeSandbox.isUnderRoot 强制。 禁止嵌套 task;无 ask_user_question / confirm —— 子代理没有用户通道。

委派流程

  1. 生成——编排器调用 task({ subagent_type, description, prompt }) 启动子代理
  2. 上下文——全新的 RuntimeContext 1:1 复用父级的 sandboxworkspace(无 chroot、无子目录)
  3. 执行——子代理经 runAgentLoop({ input: { kind: "subagent" } }) 自主执行,拥有自有工具集、步数限制与 TASK_INSTRUCTIONS 系统提示词
  4. 交付——子代理把文件 artifacts 写入共享 workspace,并调用 complete({ summary, paths })(停止工具)打包交付
  5. 返回——task 提取 complete 调用,返回 { summary, artifacts[], skipped?, status, steps };artifact 携带真实 workspace 路径,编排器可直接 read_file——无翻译环节

多个子代理可以在任务独立时并行运行。由于 workspace 共享且扁平,并发子代理必须写到互不相同的输出名以免互相覆盖——委派 prompt 应指明预期文件名。文件持久存在于父任务沙箱的整个生命周期内——编排器在后续任意步骤都可以重新读取它们。

上下文隔离

子代理接收一个由 runtimeContextSchema.parse() 构建的全新 RuntimeContext,复用父级的 sandbox 与 workspace,但不带父级的任何对话或运行时状态:

继承的内容未继承的内容
taskIduserId对话历史
sandbox(父级的,1:1)todosreminders
workspace(父级真实路径)writer(仅父代理拥有通道;进度经 tool-stream 转发)
timezonesubagentRecords(不支持嵌套)

由此带来的约束:

  • 子代理无法向用户提问——子代理定义的 toolKeys 里没有 ask_user_questionconfirm
  • 子代理无法生成更多子代理——它们的 toolKeys 里也没有 task(防止递归)
  • 子代理共享 workspace 而非 chroot:隔离靠命名,而非路径限制。workspace 根仍由 NodeSandboxisUnderRoot 在 provider 层强制
  • 子代理使用 TASK_INSTRUCTIONS 系统提示词,而非 MAIN_INSTRUCTIONS——通过传给 runAgentLoopkind: "subagent" 选择

SubagentDefinition

子代理类型由业务层解析,来源于数据库代理记录和层级配置,然后传入工具的构造期依赖(ToolBuildDeps.subagentDefs)。工具在构造时读取这些定义。

每个定义包含 runAgentLoop 所需的全部信息:

字段用途
type用作枚举值的标识符(如 “general”、“research”、“coding”)
description人类可读的描述——LLM 用它来选择生成哪个子代理
model该子代理解析后的模型(来自层级配置)
maxSteps步数限制
stopConditions停止条件
toolKeys该子代理可用的工具
compactionModel该子代理上下文压缩所用的模型
instructions代理专属指令(来自数据库),作为 extraInstructions 传入

如果没有提供 subagentDefscreateTools() 返回 {}——task 工具不会被注册,LLM 永远看不到它。

动态 Schema

工具的输入 schema 根据可用定义动态构建subagent_type 是可用类型的枚举;description 是短标签, prompt 才是真正的委派内容:

const dynamicTaskInputSchema = z.object({
  subagent_type: z
    .enum(agentTypes as [string, ...string[]])
    .describe(`The type of subagent to spawn. Available types:\n${descriptions}`),
  description: z.string().max(80).describe("Short label (3-7 words) for UI display and logs. Not the task itself."),
  prompt: z
    .string()
    .describe(
      "Self-contained task delegation. Must include goal, all required context (subagent has no conversation " +
        "history), expected output / file deliverables, and scope boundaries.",
    ),
});

descriptionprompt 是刻意拆开的:短标签用于 UI 任务卡标题和执行日志,prompt 则是子代理真正接收到的、作为第一条用户消息传入的完整委派文本。

输出结构

工具返回结构化的 TaskOutput

interface TaskOutput {
  summary: string; // 来自 complete({ summary }) —— 若子代理未调 complete 则回落到 stream.text
  artifacts: Artifact[]; // 由 complete 工具校验过的文件,真实 workspace 路径(共享 workspace,无翻译)
  skipped?: SkippedPath[]; // 子代理申报但 complete 校验失败的路径
  status: "completed" | "aborted" | "error";
  steps: number;
}

interface Artifact {
  path: string; // 共享 workspace 下的绝对路径(如 /workspace/report.md)
  size: number;
  fsType: "file" | "directory" | "symlink";
  modifiedAt?: string;
}

task 工具的 compact() 实现保留 summarystatus 以及每个 artifact 的 path/size/fsType ——即便压缩之后,编排器也能继续解析并重新读取所有交付的文件。

执行流程

  1. 验证——按 subagent_type 解析子代理定义;类型未知则抛出异常
  2. 构建 RuntimeContext——runtimeContextSchema.parse({ taskId, userId, sandbox, workspace, timezone }),1:1 复用父级的 sandbox 与 workspace
  3. 生成——runAgentLoop({ context, config: { model, maxSteps, toolKeys, stopConditions, compactionModel }, input: { kind: "subagent", messages: [kickoff], extraInstructions } }) 选择 TASK_INSTRUCTIONS 与解析后的工具集;prompt 作为唯一的 kickoff 消息
  4. 转发输出——每个 UI 数据块经 DataPartEvent.TOOL_STREAM 转发到客户端,实现实时进度渲染(并配一个心跳重置父级 chunkMs 计时器)
  5. 提取 complete 调用——extractCompleteCall(steps) 找到最后一次 complete 工具的 call/result
  6. 记录追踪——将记录推入 context.subagentRecords 用于计费
  7. 返回 TaskOutput——artifact 携带真实 workspace 路径,编排器可立即对任意 artifact 调用 read_file(无路径翻译)

用量追踪

每次子代理运行都会作为一条记录推入 context.subagentRecords

字段用途
def使用了哪个子代理定义
toolCallId关联到父工具调用
descriptiontask 调用传入的短标签
steps子代理执行了多少个 LLM 步骤
usagetoken 消耗量(输入 + 输出)
status"completed" / "error" / "aborted"
startedAt / completedAt耗时测量

业务层使用这些记录进行额度计费——子代理的 token 消耗计入父任务的用量。

委派指南

工具的提示词包含了编写有效委派的结构化指南:

何时委派:复杂的多步骤任务、可从并行化中获益的独立工作、会导致编排线程膨胀的大量推理。

何时不应委派:琐碎任务(仅需几次工具调用)、不会降低复杂度的任务、或拆分只会增加延迟而无实际收益的场景。

良好的 prompt:明确的目标、完整的上下文(子代理没有对话历史)、明确的文件交付物(子代理通过 complete 返回它们)、清晰的范围边界。

不良的 prompt:模糊的目标、引用“我们之前讨论的内容”(子代理看不到)、没有指定输出格式。

为什么走文件而非内联文本

早期版本的 task 把子代理的 stream.text 作为结果直接返回,带来两个问题:

  1. 上下文膨胀——子代理的长输出(报告、代码、分析)每次 task 调用后都会注入到编排器上下文里
  2. 压缩易丢——上下文压缩在丢弃这条 inline 结果后,交付物也就没了

当前设计把 信号artifact 分开:编排器上下文里只承载 summary 和 path 指针;真正的交付物以文件形式存在于共享 workspace,跟随父任务沙箱的生命周期持久化;编排器按需通过 read_file 拉回。这与 Claude Code 的 Read/Write 工具如何让它在任意大的代码库上工作而不耗尽上下文是同一思路。

这页有帮助吗?