// runAgent.ts:15
import { query } from '../../query.js'子 agent 的全部"魔法"就在这个 import 里。它导入了主循环的 query() 函数,然后递归调用它:
// runAgent.ts 核心逻辑(极度简化)
export async function* runAgent({
agentDefinition,
promptMessages,
toolUseContext,
canUseTool,
availableTools,
...
}): AsyncGenerator<Message, void> {
// 创建子 agent 的上下文
const childContext = createSubagentContext(toolUseContext)
const childTools = filterToolsForAgent(agentDefinition, availableTools)
// 递归调用主循环
for await (const event of query({
messages: promptMessages,
tools: childTools,
toolUseContext: childContext,
canUseTool,
})) {
yield event // 流式传递子 agent 的输出
}
}这意味着子 agent 自动继承了主循环的所有能力:
- 四层上下文压缩
- 七种错误恢复
- 流式工具执行
- 权限检查
- 模型降级
不需要为子 agent 单独实现任何基础设施。
| 特性 | 递归(Claude Code) | 编排(CrewAI/AutoGen) |
|---|---|---|
| 基础设施代码 | 写一次,自动继承 | 每层独立实现 |
| 新功能传播 | 自动(加在 query 里就行) | 手动(每层更新) |
| 维护成本 | O(1) | O(n),n = agent 层数 |
| 嵌套深度 | 受 token 预算限制 | 受架构限制 |
| 调试复杂度 | 只看一个函数 | 需要跨多个框架概念 |
子 agent 的上下文是从父 agent 派生的,但有选择性的隔离:
// utils/forkedAgent.ts(简化)
function createSubagentContext(parentContext: ToolUseContext): ToolUseContext {
return {
// 隔离的
abortController: new AbortController(), // 独立取消
fileStateCache: parentContext.fileStateCache.clone(), // 克隆文件缓存
agentId: generateId(), // 独立 ID
// 共享的
setAppState: parentContext.setAppState, // 全局状态路由到根
getAppState: parentContext.getAppState, // 全局状态
// 受限的
options: {
tools: filteredTools, // 可能是工具子集
...parentContext.options,
},
}
}如果子 agent 的 abort 和父 agent 共享,那么:
- 子 agent 被取消 → 父 agent 也被取消 → 整个会话终止
这不是想要的行为。子 agent 可能因为超时或用户取消被终止,但父 agent 应该能继续工作(比如告诉用户"子 agent 超时了,我换个方法")。
文件缓存是一个性能优化——避免重复读取同一个文件。克隆的原因:
- 父→子:子 agent 继承父 agent 已经读过的文件缓存,避免重复 I/O
- 子→父:子 agent 读的新文件不污染父 agent 的缓存(子 agent 可能在不同的 worktree 中)
- 子→子:多个子 agent 之间互不影响
// 克隆时设置大小限制
const childCache = parentCache.clone()
childCache.maxSize = READ_FILE_STATE_CACHE_SIZE // 防止内存泄漏AppState 包含了全局状态,最重要的是权限更新。
场景:用户在子 agent 的弹框中点了 "Always Allow for Read"。如果 AppState 不共享,这个决策只对当前子 agent 生效——父 agent 和其他子 agent 还会继续弹框。
通过路由到根 store,权限更新对所有 agent 立即全局生效。
每个 agent 类型由一个定义文件描述:
type AgentDefinition = {
name: string // "Explore", "Plan", "general-purpose"
prompt: string // agent 的系统提示
tools: string[] // 可用工具列表(或 ['*'])
model?: string // 模型覆盖
permissionMode?: string // 权限模式覆盖
mcpServers?: McpServer[] // agent 专属的 MCP 服务器
}Claude Code 内置了几种 agent 类型:
| Agent | 工具限制 | 典型用途 |
|---|---|---|
general-purpose |
所有工具 | 复杂多步骤任务 |
Explore |
只读工具(无 Edit/Write) | 代码搜索和探索 |
Plan |
只读工具(无 Edit/Write) | 设计实现方案 |
filterToolsForAgent() 根据 agent 定义过滤可用工具:
// agentToolUtils.ts:70-116
function filterToolsForAgent(
agentDefinition: AgentDefinition,
allTools: Tools,
): Tools {
// 1. MCP 工具(mcp__*)始终允许
// 2. ALL_AGENT_DISALLOWED_TOOLS 对所有 agent 禁用
// 3. CUSTOM_AGENT_DISALLOWED_TOOLS 对非内置 agent 禁用
// 4. 异步 agent 有额外的允许工具列表限制
}MCP 工具始终允许的设计很有意思——这意味着自定义 MCP 服务器提供的工具对所有 agent 类型可用,不受工具过滤影响。
当 run_in_background: true 时:
父 agent: [处理工具1] [处理工具2] [继续对话...]
↑
子 agent: [独立运行中...] ──完成通知──→ ┘
父 agent 不等待子 agent 完成。子 agent 在后台独立运行,完成后通过通知机制告知父 agent。
// agentToolUtils.ts:508-686
async function runAsyncAgentLifecycle({
agentGenerator,
taskId,
progressTracker,
...
}) {
try {
// 1. 驱动 query generator
for await (const message of agentGenerator) {
// 更新进度
updateAsyncAgentProgress(taskId, message)
}
// 2. 成功完成
transitionTaskState(taskId, 'completed')
enqueueNotification(taskId, 'completed')
} catch (error) {
// 3. 失败处理
transitionTaskState(taskId, 'failed')
enqueueNotification(taskId, 'failed')
}
}后台 agent 有一个棘手的问题:它需要权限确认,但 UI 焦点在父 agent 上。
解决方案取决于配置:
if (isAsync) {
// 默认:跳过权限弹框,使用 shouldAvoidPermissionPrompts
shouldAvoidPermissionPrompts: true
// 但如果显式配置了 canShowPermissionPrompts:
if (canShowPermissionPrompts) {
// 可以弹框,但先等自动化检查完成
awaitAutomatedChecksBeforeDialog: true
}
}awaitAutomatedChecksBeforeDialog: true 确保在弹框之前先运行所有自动化检查(如分类器)——减少不必要的弹框。
Git worktree 允许你在同一个仓库中同时检出多个分支:
git worktree add /tmp/my-experiment feature-branch
# 现在 /tmp/my-experiment 是一个完整的仓库副本
# 在 feature-branch 上工作当 isolation: "worktree" 时:
1. 创建临时 worktree(新分支)
2. 子 agent 在 worktree 目录中工作
3. 所有文件操作都在 worktree 中(不影响主仓库)
4. 子 agent 完成后:
├─ 有变更 → 返回 worktree 路径和分支名
└─ 无变更 → 自动清理 worktree
用户: "试试用 Redis 替代 Memcached,看看性能怎么样"
Claude Code:
├─ 主分支: 保持不动
└─ Worktree(子 agent):
├─ 替换 Memcached → Redis
├─ 运行 benchmark
├─ 报告结果
└─ 如果用户满意 → 合并分支
如果不满意 → 丢弃 worktree
这解决了一个真实痛点:让 agent 大胆实验,不怕搞砸。
Worktree 的创建和清理由 EnterWorktreeTool 和 ExitWorktreeTool 管理:
- 创建时:
git worktree add+ 设置工作目录 - 清理时:检查是否有未提交的变更
- 有变更:保留 worktree,返回路径信息
- 无变更:
git worktree remove自动清理
Agent 定义可以声明自己需要的 MCP 服务器:
# agent 定义
name: database-explorer
mcpServers:
- name: postgres
command: npx
args: ["@modelcontextprotocol/server-postgres"]// runAgent.ts:95-218
async function initializeAgentMcpServers(
agentDefinition: AgentDefinition,
parentClients: McpClients,
): Promise<{ clients: McpClients; cleanup: () => Promise<void> }> {
// 1. 继承父 agent 的 MCP 连接
const mergedClients = { ...parentClients }
// 2. 启动 agent 专属的 MCP 服务器
for (const server of agentDefinition.mcpServers) {
mergedClients[server.name] = await startMcpServer(server)
}
// 3. 返回清理函数(只清理新创建的,不关闭继承的)
return {
clients: mergedClients,
cleanup: async () => {
for (const server of agentDefinition.mcpServers) {
await mergedClients[server.name].close()
}
}
}
}只清理新创建的——继承的 MCP 连接由父 agent 管理,子 agent 不应该关闭它们。
父 agent 可以传递部分对话历史给子 agent:
// runAgent.ts:369-378
if (forkContextMessages) {
// 过滤不完整的工具调用
const filtered = filterIncompleteToolCalls(forkContextMessages)
promptMessages = [...filtered, ...promptMessages]
}过滤不完整的工具调用是关键——如果父 agent 的历史中有一个 tool_use 但没有对应的 tool_result(比如工具正在执行中),把它传给子 agent 会导致 API 400 错误。
forkContextMessages 还有一个缓存优化的作用:如果子 agent 使用相同的上下文前缀,prompt caching 可以利用这些共享前缀。
当 auto 模式启用时,子 agent 的输出在返回给用户之前会经过分类检查:
// agentToolUtils.ts:404-460
async function classifyHandoffIfNeeded(
agentOutput: Message[],
autoModeEnabled: boolean,
) {
if (!autoModeEnabled) return
const decision = await classifyYoloAction(agentOutput)
if (decision === 'block') {
// 阻止子 agent 的输出直接展示给用户
// 需要人工确认
}
}这是一层额外的安全网——即使子 agent 的权限检查通过了,它的最终输出仍然会被分类器审查。
Claude Code 的子 agent 系统证明了一个反直觉的设计原则:
最强大的抽象不是创建新概念,而是复用已有概念。
- 没有新的"Crew"抽象——子 agent 就是递归的 query()
- 没有新的通信协议——子 agent 通过 yield 传递消息
- 没有新的状态管理——隔离通过 context 克隆实现
- 没有新的权限模型——子 agent 继承父 agent 的权限
结果是一个零额外概念的子 agent 系统——你理解了 query(),就理解了子 agent。