227. 技术:HarnessX 第 6 课:给 Agent 加 task 子代理,让局部探索不污染主上下文

2026.06.25

·技术harnessx

新增了一个工具:task

20260625_7.webp

原因

  • Agent 做复杂任务时,经常会先读很多文件、跑很多命令,只是为了查清一个局部问题。
  • 这些中间过程会一直塞在 messages 里。东西越塞越多,模型后面就更容易抓不住主线。
  • task 的作用就是给局部问题开一个临时小窗口:
    • 子 Agent 自己查,查完只把结论交回父 Agent,中间过程不带回来。

重点

  • 第 6 课当前要做的是给 Agent 加一个子代理工具:
    • 用户还是敲 hx agent "..."
    • 父 Agent 还是用自己的 messages 和完整工具列表请求 DeepSeek。
    • 新增的 task 工具可以启动一个同步子 Agent。
    • 子 Agent 用全新的 messages,只做父 Agent 交给它的局部任务。
    • 子 Agent 可以读文件、查文件、跑命令、写文件、编辑文件。
    • 子 Agent 没有 task,不能继续委派。
    • 子 Agent 没有 todo_write,不会污染父 Agent 的待办计划。
    • 子 Agent 最后只返回一段结论给父 Agent
  • 这一课真正新增的能力不是“多了一个能读文件的工具”。
    • 读文件、查文件这些能力第 2 课已经有了。
    • task 的价值是把一段局部探索放进干净上下文里完成。
    • 父 Agent 不需要背着子 Agent 读过的所有文件和中间输出继续思考。

一句话:

text
task 不增加新的执行能力,它让局部探索拥有干净上下文;父 Agent 只拿子 Agent 的最终结论继续推理。

流程图

20260625_1.svg|888

父子 Agent 总流程

227. 技术:HarnessX 第 6 课:给 Agent 加 task 子代理,让局部探索不污染主上下文 图表 1

HTTP 一来一回

227. 技术:HarnessX 第 6 课:给 Agent 加 task 子代理,让局部探索不污染主上下文 图表 2

这里要注意一个边界:

  • 父 Agent 和子 Agent 都在同一个 Node.js 进程里。
  • 子 Agent 不是新的终端命令,也不是后台任务。
  • 它只是 task 工具背后启动的一段同步局部 Agent Loop。
  • 隔离的是 messages,不是工作目录。

入口

第 6 课没有新增子命令。

bash
hx agent "先用 todo_write 写计划,然后使用 task 子代理读取 README.md 和 src/*.js,判断这个项目的 CLI 入口和 Agent 循环分别在哪里。父 agent 最终只输出中文结论。执行过程中更新 todo 状态。"

入口仍然在 src/index.js

js
if (command === "agent") {
  // 第 6 课把 task 子代理加进 Agent Loop。
  // 命令入口不需要知道子代理怎么跑。
  const task = rest.join(" ").trim();
  await runAgent(task);
  return;
}
  • hx agent 只负责把用户任务交给 runAgent
  • task 是 Agent Loop 里的一个工具。
  • 父 Agent 是否调用 task,仍然由模型根据提示词和工具定义决定。

父 Agent 发出去的是什么

父 Agent 的初始上下文还是两条消息:

js
const messages = [
  { role: "system", content: SYSTEM_PROMPT },
  { role: "user", content: text },
];

第 6 课的变化在父 system prompt 和工具列表。

js
const SYSTEM_PROMPT = [
  `你是运行在 ${process.cwd()} 的编程 Agent。`,
  "请使用工具完成用户任务。",
  "遇到局部探索、跨文件阅读、资料收集、可独立收口的子任务,可以调用 task 启动一个全新上下文的子代理。",
  "task 只返回子代理最终结论;父代理不会继承子代理的中间 messages。",
].join("\n");

这几句是在告诉模型:

  • 如果任务适合局部探索,可以用 task
  • task 返回的是结论,不是完整过程。
  • 父 Agent 的主线继续留在父 messages 里。

父 Agent 发给 DeepSeek 的工具列表现在有 7 个:

text
bash
read_file
write_file
edit_file
glob
todo_write
task

task 的工具定义只需要一个字段:

json
{
  "type": "function",
  "function": {
    "name": "task",
    "description": "启动一个全新上下文的同步子代理来处理局部复杂任务。",
    "parameters": {
      "type": "object",
      "properties": {
        "description": {
          "type": "string",
          "description": "交给子代理完成的具体局部任务。"
        }
      },
      "required": ["description"]
    }
  }
}

父 Agent 收到的是什么

当模型决定委派局部任务时,会返回一条普通的工具调用:

json
{
  "role": "assistant",
  "content": null,
  "tool_calls": [
    {
      "id": "call_xxx",
      "type": "function",
      "function": {
        "name": "task",
        "arguments": "{\"description\":\"读取 README.md 和 src/*.js,判断 CLI 入口和 Agent 循环分别在哪里。\"}"
      }
    }
  ]
}

父 Agent 的本地分发器并不特殊对待它。

js
const TOOL_HANDLERS = {
  bash: runBashTool,
  read_file: runReadFile,
  write_file: runWriteFile,
  edit_file: runEditFile,
  glob: runGlob,
  todo_write: runTodoWrite,
  task: runTaskTool,
};

这就是 Agent 工具系统最重要的稳定点:

  • 模型只返回工具名和 JSON 参数。
  • 本地用 TOOL_HANDLERS[toolName] 查到函数。
  • task 只是这张表里新增的一项。

子 Agent 怎么跑

runTaskTool 先校验入参:

js
async function runTaskTool(input, context = {}) {
  // description 是父 Agent 交给子 Agent 的局部任务。
  const description = typeof input.description === "string"
    ? input.description.trim()
    : "";

  if (!description) {
    return "错误:task.description 必须是非空字符串";
  }

  return spawnSubagent(description, context.stats);
}

真正的子代理在 spawnSubagent 里启动。

js
async function spawnSubagent(description, stats) {
  // 这里是本课的核心:子 Agent 使用全新的 messages。
  // 父 Agent 之前读过什么、聊过什么,不会塞进来。
  const messages = [
    { role: "system", content: SUBAGENT_SYSTEM_PROMPT },
    { role: "user", content: description },
  ];

  for (let turn = 1; turn <= SUBAGENT_MAX_TURNS; turn += 1) {
    const message = await callDeepSeekMessage(messages, {
      tools: SUBAGENT_TOOL_DEFINITIONS,
      tool_choice: "auto",
    });

    messages.push(normalizeAssistantMessage(message));

    const toolCalls = message.tool_calls || [];
    if (toolCalls.length === 0) {
      return extractText(message.content) || "子代理没有返回结论。";
    }

    for (const toolCall of toolCalls) {
      const output = await runSubagentToolCall(toolCall, stats);
      messages.push({
        role: "tool",
        tool_call_id: toolCall.id,
        content: output,
      });
    }
  }

  return `错误:子代理超过 ${SUBAGENT_MAX_TURNS} 轮仍未完成`;
}

这里有四个设计点:

  • 子 Agent 有自己的 messages
  • 子 Agent 也调用 DeepSeek。
  • 子 Agent 工具结果只回填给子 messages
  • 子 Agent 结束后,整段子 messages 都丢掉,只返回最终文本。

子 Agent 能用哪些工具

子 Agent 只拿基础工具:

js
const SUBAGENT_TOOL_NAMES = new Set([
  "bash",
  "read_file",
  "write_file",
  "edit_file",
  "glob",
]);

const SUBAGENT_TOOL_DEFINITIONS = TOOL_DEFINITIONS.filter((tool) =>
  SUBAGENT_TOOL_NAMES.has(tool.function?.name),
);

它没有两个工具:

  • 没有 task
    • 防止子 Agent 继续开子 Agent。
    • 这一课只讲一层同步子代理。
  • 没有 todo_write
    • 父 Agent 的待办计划属于父会话。
    • 子 Agent 是局部探索,不维护父 Agent 的任务面板。

权限没有被跳过。

js
async function runSubagentToolCall(toolCall, stats) {
  return executeToolCall(toolCall, stats, {
    handlers: SUBAGENT_TOOL_HANDLERS,
    renderPrefix: "subagent:",
  });
}

executeToolCall 里仍然会触发 hooks。

js
const blocked = await triggerHooks("PreToolUse", toolName, input, toolCall);
if (blocked) {
  stats.blockedToolCalls += 1;
  return "Permission denied.";
}

const output = await handler(input, { stats });
await triggerHooks("PostToolUse", toolName, input, output, toolCall);
return output;

所以子 Agent 如果尝试危险命令,也会走同一套权限判断。

父 Agent 怎么拿到子结论

父 Agent 执行 task 时,拿到的是一个字符串。

这个字符串会像其他工具结果一样回填到父 messages

js
const output = await runToolCall(toolCall, stats);

messages.push({
  role: "tool",
  tool_call_id: toolCall.id,
  content: output,
});

这一步非常关键:

  • 父 Agent 看不到子 Agent 的完整中间消息
  • 父 Agent 只看到 task 工具结果。
  • 这个工具结果就是子 Agent 最后的中文结论。

如果子 Agent 为了判断项目结构读了 5 个文件,父 messages 里不会追加那 5 个文件的全文。

messages 里只会多一条类似这样的工具结果:

text
CLI 入口在 src/index.js;Agent 循环在 src/agent-loop.js。
src/deepseek.js 负责 DeepSeek 请求封装。

这就是本课说的“局部探索不污染主上下文”。

怎么跑

20260625_8.webp

这里只跑第 6 课的最小完整示例。

bash
hx agent "先用 todo_write 写计划,然后使用 task 子代理读取 README.md 和 src/*.js,判断这个项目的 CLI 入口和 Agent 循环分别在哪里。父 agent 最终只输出中文结论。执行过程中更新 todo 状态。"

这个 prompt 有三个目的:

  • 明确要求先写计划,方便观察第 5 课的 todo_write 仍然存在。
  • 明确要求使用 task,方便稳定触发第 6 课的子代理机制。
  • 让子 Agent 做局部读文件任务,父 Agent 只输出结论。

跑起来后,关键输出应该能看到:

bash
[HOOK] UserPromptSubmit cwd=/Users/liguwe/832/832X
> todo_write items=...
[HOOK] PreToolUse todo_write(...)

> task description=读取 README.md src/*.js...
[HOOK] PreToolUse task(...)

> subagent:read_file path=README.md limit=all
[HOOK] PreToolUse read_file(...)
[HOOK] PostToolUse read_file output_chars=...

> subagent:glob pattern=src/*.js
[HOOK] PreToolUse glob(...)
[HOOK] PostToolUse glob output_chars=...

[HOOK] PostToolUse task output_chars=...

验收标准:

  • 能看到 > task description=...
  • 能看到 > subagent:read_file ...> subagent:glob ...
  • 子 Agent 的基础工具仍然有 hooks 日志。
  • task 本身不出现 Allow? [y/N]
  • 最终回答只给父 Agent 的结论,不把子 Agent 读到的所有源码全文贴出来。

这一课真正要记住

  • task
    • 父 Agent 用来启动子 Agent 的工具。
    • 输入是一个局部任务描述。
    • 输出是子 Agent 的最终结论。
  • 子 Agent
    • 一个同步的小 Agent Loop。
    • 有全新的 messages
    • 共享当前工作目录和文件副作用。
  • 上下文隔离
    • 隔离的是消息历史,不是文件系统。
    • 子 Agent 的中间消息不会进入父 Agent。
  • 工具收缩
    • 子 Agent 只有基础工具。
    • 没有 task,避免递归。
    • 没有 todo_write,避免污染父计划。
  • 权限复用
    • 子 Agent 工具调用仍走同一套 hooks 和权限规则。
    • 上下文隔离不等于安全策略隔离。

一句话:

text
父 Agent 负责主线,子 Agent 负责局部探索;task 的工具结果只把子 Agent 的最终结论回填给父 Agent。

源码

这里保留当前版本的主流程缩略代码,方便以后代码继续往后迭代时,还能回来看第 6 课到底加了什么。

代码概览

src/index.js 仍然只负责入口。

js
if (command === "agent") {
  // 第 6 课没有新增命令,只是 agent 内部多了 task 工具。
  const task = rest.join(" ").trim();
  await runAgent(task);
  return;
}

父 Agent 工具列表新增 task

这一课新增的

js
const TOOL_DEFINITIONS = [
  // bash / read_file / write_file / edit_file / glob / todo_write
  {
    type: "function",
    function: {
      name: "task",
      description: "启动一个全新上下文的同步子代理...",
      parameters: {
        type: "object",
        properties: {
          description: { type: "string" },
        },
        required: ["description"],
      },
    },
  },
];

子 Agent 的工具列表从父工具里筛出来。

js
const SUBAGENT_TOOL_NAMES = new Set([
  "bash",
  "read_file",
  "write_file",
  "edit_file",
  "glob",
]);

const SUBAGENT_TOOL_DEFINITIONS = TOOL_DEFINITIONS.filter((tool) =>
  SUBAGENT_TOOL_NAMES.has(tool.function?.name),
);

工具分发表把 task 接到本地函数。

js
const TOOL_HANDLERS = {
  bash: runBashTool,
  read_file: runReadFile,
  write_file: runWriteFile,
  edit_file: runEditFile,
  glob: runGlob,
  todo_write: runTodoWrite,
  task: runTaskTool,
};

代码细分

父 Agent Loop 不需要为 task 写特殊分支。

js
for (const toolCall of toolCalls) {
  const output = await runToolCall(toolCall, stats);

  // task 的结果也走同一条 role=tool 回填通道。
  // 父 Agent 下一轮只看到子 Agent 的最终结论。
  messages.push({
    role: "tool",
    tool_call_id: toolCall.id,
    content: output,
  });
}

runToolCall 进入通用执行器

js
async function runToolCall(toolCall, stats) {
  return executeToolCall(toolCall, stats, {
    handlers: TOOL_HANDLERS,
    renderPrefix: "",
  });
}

子 Agent 工具调用也进入同一个执行器,只是换了 handler 表和日志前缀。

js
async function runSubagentToolCall(toolCall, stats) {
  return executeToolCall(toolCall, stats, {
    handlers: SUBAGENT_TOOL_HANDLERS,
    renderPrefix: "subagent:",
  });
}

通用执行器负责解析参数、打印日志、触发 hooks、执行 handler。

js
async function executeToolCall(toolCall, stats, options) {
  const toolName = toolCall.function?.name;
  const handler = options.handlers[toolName];

  // 模型返回的是 JSON 字符串,本地要先解析。
  const input = JSON.parse(toolCall.function.arguments || "{}");

  // 父工具显示 read_file,子工具显示 subagent:read_file。
  console.log(renderToolCall(toolName, input, options.renderPrefix));
  stats.toolCalls += 1;

  // 权限和日志 hook 在父子工具调用里复用。
  const blocked = await triggerHooks("PreToolUse", toolName, input, toolCall);
  if (blocked) {
    stats.blockedToolCalls += 1;
    return "Permission denied.";
  }

  const output = await handler(input, { stats });
  await triggerHooks("PostToolUse", toolName, input, output, toolCall);
  return output;
}

task handler 只负责启动子代理。

js
async function runTaskTool(input, context = {}) {
  const description = typeof input.description === "string"
    ? input.description.trim()
    : "";

  if (!description) {
    return "错误:task.description 必须是非空字符串";
  }

  return spawnSubagent(description, context.stats);
}

子代理启动时,最关键的是全新的 messages

js
async function spawnSubagent(description, stats) {
  const messages = [
    { role: "system", content: SUBAGENT_SYSTEM_PROMPT },
    { role: "user", content: description },
  ];

  for (let turn = 1; turn <= SUBAGENT_MAX_TURNS; turn += 1) {
    const message = await callDeepSeekMessage(messages, {
      tools: SUBAGENT_TOOL_DEFINITIONS,
      tool_choice: "auto",
    });

    messages.push(normalizeAssistantMessage(message));

    const toolCalls = message.tool_calls || [];
    if (toolCalls.length === 0) {
      // 子 Agent 没有继续调用工具,说明它给出了最终结论。
      // 这个结论会作为 task 的工具结果回到父 Agent。
      return extractText(message.content) || "子代理没有返回结论。";
    }

    for (const toolCall of toolCalls) {
      const output = await runSubagentToolCall(toolCall, stats);
      messages.push({
        role: "tool",
        tool_call_id: toolCall.id,
        content: output,
      });
    }
  }

  return `错误:子代理超过 ${SUBAGENT_MAX_TURNS} 轮仍未完成`;
}

extractText 只取子 Agent 最终文本。

js
function extractText(content) {
  if (typeof content === "string") {
    return content.trim();
  }

  if (Array.isArray(content)) {
    return content
      .map((block) => typeof block?.text === "string" ? block.text : "")
      .filter(Boolean)
      .join("\n")
      .trim();
  }

  return content == null ? "" : String(content).trim();
}

整条链路可以压成这一行:

text
TOOL_DEFINITIONS -> runAgent -> runToolCall -> runTaskTool -> spawnSubagent -> role=tool 回填父 Agent