新增了一个工具:
task

原因
- 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 读过的所有文件和中间输出继续思考。
一句话:
task 不增加新的执行能力,它让局部探索拥有干净上下文;父 Agent 只拿子 Agent 的最终结论继续推理。流程图
父子 Agent 总流程
HTTP 一来一回
这里要注意一个边界:
- 父 Agent 和子 Agent 都在同一个
Node.js 进程里。 子 Agent不是新的终端命令,也不是后台任务。- 它只是
task工具背后启动的一段同步局部 Agent Loop。 - 隔离的是
messages,不是工作目录。
入口
第 6 课没有新增子命令。
hx agent "先用 todo_write 写计划,然后使用 task 子代理读取 README.md 和 src/*.js,判断这个项目的 CLI 入口和 Agent 循环分别在哪里。父 agent 最终只输出中文结论。执行过程中更新 todo 状态。"入口仍然在 src/index.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 的初始上下文还是两条消息:
const messages = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: text },
];第 6 课的变化在父 system prompt 和工具列表。
const SYSTEM_PROMPT = [
`你是运行在 ${process.cwd()} 的编程 Agent。`,
"请使用工具完成用户任务。",
"遇到局部探索、跨文件阅读、资料收集、可独立收口的子任务,可以调用 task 启动一个全新上下文的子代理。",
"task 只返回子代理最终结论;父代理不会继承子代理的中间 messages。",
].join("\n");这几句是在告诉模型:
- 如果任务适合局部探索,可以用
task。 task返回的是结论,不是完整过程。- 父 Agent 的主线继续留在父
messages里。
父 Agent 发给 DeepSeek 的工具列表现在有 7 个:
bash
read_file
write_file
edit_file
glob
todo_write
tasktask 的工具定义只需要一个字段:
{
"type": "function",
"function": {
"name": "task",
"description": "启动一个全新上下文的同步子代理来处理局部复杂任务。",
"parameters": {
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "交给子代理完成的具体局部任务。"
}
},
"required": ["description"]
}
}
}父 Agent 收到的是什么
当模型决定委派局部任务时,会返回一条普通的工具调用:
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_xxx",
"type": "function",
"function": {
"name": "task",
"arguments": "{\"description\":\"读取 README.md 和 src/*.js,判断 CLI 入口和 Agent 循环分别在哪里。\"}"
}
}
]
}父 Agent 的本地分发器并不特殊对待它。
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 先校验入参:
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 里启动。
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 只拿基础工具:
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 的任务面板。
权限没有被跳过。
async function runSubagentToolCall(toolCall, stats) {
return executeToolCall(toolCall, stats, {
handlers: SUBAGENT_TOOL_HANDLERS,
renderPrefix: "subagent:",
});
}executeToolCall 里仍然会触发 hooks。
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。
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 里只会多一条类似这样的工具结果:
CLI 入口在 src/index.js;Agent 循环在 src/agent-loop.js。
src/deepseek.js 负责 DeepSeek 请求封装。这就是本课说的“局部探索不污染主上下文”。
怎么跑

这里只跑第 6 课的最小完整示例。
hx agent "先用 todo_write 写计划,然后使用 task 子代理读取 README.md 和 src/*.js,判断这个项目的 CLI 入口和 Agent 循环分别在哪里。父 agent 最终只输出中文结论。执行过程中更新 todo 状态。"这个 prompt 有三个目的:
- 明确要求先写计划,方便观察第 5 课的
todo_write仍然存在。 - 明确要求使用
task,方便稳定触发第 6 课的子代理机制。 - 让子 Agent 做局部读文件任务,父 Agent 只输出结论。
跑起来后,关键输出应该能看到:
[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 和权限规则。
- 上下文隔离不等于安全策略隔离。
一句话:
父 Agent 负责主线,子 Agent 负责局部探索;task 的工具结果只把子 Agent 的最终结论回填给父 Agent。源码
这里保留当前版本的主流程缩略代码,方便以后代码继续往后迭代时,还能回来看第 6 课到底加了什么。
代码概览
src/index.js 仍然只负责入口。
if (command === "agent") {
// 第 6 课没有新增命令,只是 agent 内部多了 task 工具。
const task = rest.join(" ").trim();
await runAgent(task);
return;
}父 Agent 工具列表新增 task。
这一课新增的
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 的工具列表从父工具里筛出来。
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 接到本地函数。
const TOOL_HANDLERS = {
bash: runBashTool,
read_file: runReadFile,
write_file: runWriteFile,
edit_file: runEditFile,
glob: runGlob,
todo_write: runTodoWrite,
task: runTaskTool,
};代码细分
父 Agent Loop 不需要为 task 写特殊分支。
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 进入通用执行器。
async function runToolCall(toolCall, stats) {
return executeToolCall(toolCall, stats, {
handlers: TOOL_HANDLERS,
renderPrefix: "",
});
}子 Agent 工具调用也进入同一个执行器,只是换了 handler 表和日志前缀。
async function runSubagentToolCall(toolCall, stats) {
return executeToolCall(toolCall, stats, {
handlers: SUBAGENT_TOOL_HANDLERS,
renderPrefix: "subagent:",
});
}通用执行器负责解析参数、打印日志、触发 hooks、执行 handler。
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 只负责启动子代理。
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。
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 最终文本。
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();
}整条链路可以压成这一行:
TOOL_DEFINITIONS -> runAgent -> runToolCall -> runTaskTool -> spawnSubagent -> role=tool 回填父 Agent