全文目录Contents
- 0 · The Project at a Glance, and a Map of the Code
- 1 · The Entry Layer and Startup
- 2 · The Session Layer: QueryEngine
- 2.1 The problem it solves
- 2.2 What state it holds
- 2.3 The full flow of one submitMessage
- 2.4 Why the user message must hit disk first
- 2.5 Consuming the main loop’s output: one big switch
- 2.6 The compact boundary: proactively releasing memory
- 2.7 Three kinds of exit result
- 2.8 ask(): a convenience wrapper for one-shot calls
- 3 · The Agent Main Loop ★
- 3.1 The skeleton of the loop
- 3.2 State: centralizing cross-iteration state
- 3.3 transition: a field that exists purely for testability
- 3.4 The seven transition edges, one by one
- 3.5 The error-withholding mechanism
- 3.6 Interrupt handling
- 3.7 Model fallback: three actions
- 3.8 The three laws of thinking blocks
- 3.9 Other mechanisms in the loop
- 3.10 Every exit point of the loop
- 4 · The Tool Model
- 4.1 The Tool interface: seven orthogonal capability groups
- 4.2 Why the “safety predicates” deserve their own group
- 4.3 Fail-safe defaults
- 4.4 The 40 built-in tools, by category
- 4.5 Progressive tool loading
- 4.6 Tool list assembly: a hidden constraint about caching
- 4.7 backfillObservableInput: an extreme example of cache protection
- 5 · Tool Execution
- 5.1 The execution pipeline at a glance
- 5.2 Concurrency partitioning: a greedy algorithm
- 5.3 Context modifications are queued until the batch ends
- 5.4 A single execution: the full flow of runToolUse
- 5.5 The streaming tool executor
- 5.6 The sibling abort controller: the most elegant design in the file
- 5.7 The discard mechanism
- 5.8 “Tombstone” messages
- 5.9 Final processing of results
- 6 · Context Management ★
- 7 · The Permission System
- 8 · Subagents
- 9 · The Extension System
- 10 · The Terminal UI Layer
- 10.1 Writing a Terminal UI in React
- 10.2 The Four Biggest Components
- 10.3 Why the Input Box Is 347 KB
- 10.4 The Virtualized Message List
- 10.5 Six Rendering States for Tool Results
- 10.6 Collapsing: Avoiding Screen Flood
- 10.7 87 State-Management Units
- 10.8 The Interface Between UI and Kernel: Callbacks in ToolUseContext
- 10.9 A Fun Detail: ANSI to PNG
- 11 · Persistence and Resume
- 12 · The Observability System
- 13 · Build and Distribution
8 · 子智能体
tools/AgentTool/ 目录,主文件 228 KB。这是所有工具里最复杂的一个 —— 因为它的作用是递归地启动另一个完整的智能体。
8.1 首要动机是上下文隔离,不是并行
场景:主智能体要找到某个函数定义在哪,可能需要读 20 个文件才能确定。
自己读:20 个文件的完整内容进入主上下文,此后每一轮都要重发一遍。每个文件 2,000 token,就是 40,000 token 永久占用,一直付费到会话结束。
派子智能体读:子智能体读完 20 个文件、得出结论、返回「在 foo.ts 第 42 行」,然后它的整个上下文被丢弃。主智能体只收到那一句,约 15 token。
并行只是副产品。子智能体的第一性原理是「用一次性的上下文,换一个结论」。
8.2 三种形态
| 形态 | 上下文 | 用途 |
|---|---|---|
命名子智能体subagent_type: 'Explore' |
全新,只带任务描述 | 内建的探索型智能体、通用型智能体,或用户自定义的(写在 .claude/agents/*.md) |
| 分叉子智能体 省略 subagent_type |
完整继承父的对话历史和系统提示词 | 并行探索同一问题的多个方向 |
| 协调者工人 COORDINATOR_MODE |
受限工具集,上下文独立 | 协调者模式下的执行单元 |
8.3 分叉:把提示词缓存用到极致
分叉的典型用法是「同时派 5 个子智能体,从不同角度探索同一个问题」。这 5 个的上下文几乎完全一样 —— 唯一区别是最后那句「你负责方向 A / B / C / D / E」。
提示词缓存是前缀匹配的。所以:
如果能让这 5 个子智能体发出的请求前缀达到字节级一致,第 1 个建立缓存,后面 4 个全部命中。输入成本从 5 份降到约 1.4 份(1 份全价 + 4 份 10% 折扣价),省下 70% 以上。
为此做的四件事
① 系统提示词传「已渲染好的字节」
/**
* The getSystemPrompt here is unused: the fork path passes
* `override.systemPrompt` with the parent's already-rendered system prompt
* bytes, threaded via `toolUseContext.renderedSystemPrompt`. Reconstructing
* by re-calling getSystemPrompt() can diverge (GrowthBook cold→warm)
* and bust the prompt cache; threading the rendered bytes is byte-exact.
*/
claude-code/src/tools/AgentTool/forkSubagent.ts
译:这里的 getSystemPrompt 是没用到的:分叉路径传递的是父已经渲染好的系统提示词字节,通过 renderedSystemPrompt 字段串下来。重新调用生成函数可能产生分歧(因为特性开关配置可能从冷缓存变成热缓存),从而毁掉提示词缓存;传递已渲染的字节是字节级精确的。
展开解释这个坑:系统提示词的内容并不完全固定,它可能包含 A/B 实验的变体。而实验配置本身有缓存 —— 父智能体生成提示词的那一刻,某个实验配置可能还是「冷缓存」状态(用默认值);几秒后子智能体重新生成时,配置已经变成「热缓存」(用真实值)。两次生成的字节不同,缓存全废。
对应的字段在 Tool.ts 里有定义:
/**
* Parent's rendered system prompt bytes, frozen at turn start.
* Used by fork subagents to share the parent's prompt cache — re-calling
* getSystemPrompt() at fork-spawn time can diverge (GrowthBook cold→warm)
* and bust the cache. See forkSubagent.ts.
*/
renderedSystemPrompt?: SystemPrompt
「frozen at turn start」(在轮次开始时冻结) —— 这是关键。
② 工具清单原样继承
/**
* Synthetic agent definition for the fork path.
*
* Not registered in builtInAgents — used only when `!subagent_type` and the
* experiment is active. `tools: ['*']` with `useExactTools` means the fork
* child receives the parent's exact tool pool (for cache-identical API
* prefixes). `permissionMode: 'bubble'` surfaces permission prompts to the
* parent terminal. `model: 'inherit'` keeps the parent's model for context
* length parity.
*/
export const FORK_AGENT = {
tools: ['*'],
permissionMode: 'bubble',
model: 'inherit',
...
}
三个字段各有理由:
tools: ['*']配合useExactTools—— 子智能体拿到父的精确工具池。它用不到那么多工具,但工具定义是请求前缀的一部分,改了就没缓存。permissionMode: 'bubble'—— 权限确认冒泡到父智能体所在的终端。子智能体自己没有界面。model: 'inherit'—— 继承父的模型,保证上下文窗口大小一致。如果子用了窗口更小的模型,继承来的历史可能直接放不下。
③ 消息构造:只让最后一个文本块不同
/**
* Build the forked conversation messages for the child agent.
*
* For prompt cache sharing, all fork children must produce byte-identical
* API request prefixes. This function:
* 1. Keeps the full parent assistant message (all tool_use blocks, thinking, text)
* 2. Builds a single user message with tool_results for every tool_use block
* using an identical placeholder, then appends a per-child directive text block
*
* Result: [...history, assistant(all_tool_uses), user(placeholder_results..., directive)]
* Only the final text block differs per child, maximizing cache hits.
*/
export function buildForkedMessages(...)
那个「占位工具结果」的巧妙之处:父那条消息里有 5 个工具调用(每个对应一个分叉)。按接口规则,每个工具调用必须有配对的结果。但这 5 个分叉还没跑完,真实结果不存在。于是给每一个都填完全相同的占位内容 —— 既满足配对要求,又保证 5 个子智能体看到的这段一模一样。
源码里还有一个专门的常量:
/** Must be identical across all fork children for prompt cache sharing. */
④ 递归分叉守卫:从接口层下移到调用层
/**
* Guard against recursive forking. Fork children keep the Agent tool in their
* tool pool for cache-identical tool definitions, so we reject fork attempts
* at call time by detecting the fork boilerplate tag in conversation history.
*/
export function isInForkChild(messages: MessageType[]): boolean { ... }
常规做法是「把 Agent 工具从子智能体的工具池里去掉」。但那样就改变了工具定义,破坏缓存。所以改为:工具留着,但在真正调用的那一刻检查对话历史里有没有分叉标记:
if (isInForkChild(toolUseContext.messages)) {
throw new Error('Fork is not available inside a forked worker. '
+ 'Complete your task directly using your tools.')
}
逐条看,每一个在常规代码评审里都会被挑刺:
- 给用不到的工具 → 「为什么不做最小权限?」
- 传字节而不是重新生成 → 「为什么不复用现成的生成函数?」
- 用无意义的占位符填充结果 → 「这不是在造假数据吗?」
- 守卫从接口层下移到调用层 → 「为什么不在类型系统里禁掉?」
但对于要做扇出的智能体,这些代价值得付:5 个子智能体里 4 个走缓存,输入成本降到 1/3 以下。
这体现的能力是「知道什么时候该为性能牺牲整洁度」 —— 比单纯背诵设计原则有价值得多。
工作树隔离的额外提示
/**
* Notice injected into fork children running in an isolated worktree.
* Tells the child to translate paths from the inherited context, re-read
* potentially stale files, and that its changes are isolated.
*/
export function buildWorktreeNotice(...)
如果分叉子智能体跑在隔离的 git 工作树里,它继承的历史里那些文件路径指向的是父的目录。所以要显式告诉它:路径要翻译、文件可能已经不是你看到的那个版本、你的修改是隔离的。
8.4 子智能体的工具限制
export const ALL_AGENT_DISALLOWED_TOOLS = new Set([
TASK_OUTPUT_TOOL_NAME, // 不能查看其他任务的输出
EXIT_PLAN_MODE_V2_TOOL_NAME, // 不能退出计划模式(会话级全局状态)
ENTER_PLAN_MODE_TOOL_NAME, // 不能进入计划模式
// 内部用户允许嵌套子智能体,外部用户不允许
...(process.env.USER_TYPE === 'ant' ? [] : [AGENT_TOOL_NAME]),
ASK_USER_QUESTION_TOOL_NAME, // ★ 不能向用户提问
TASK_STOP_TOOL_NAME, // 不能停止其他任务
// 防止在子智能体内递归执行工作流
...(feature('WORKFLOW_SCRIPTS') ? [WORKFLOW_TOOL_NAME] : []),
])
export const CUSTOM_AGENT_DISALLOWED_TOOLS = new Set([...ALL_AGENT_DISALLOWED_TOOLS])
claude-code/src/constants/tools.ts
| 原则 | 为什么 |
|---|---|
| 不能修改全局状态 | 计划模式是整场会话级的开关。子智能体改了会影响父和所有兄弟,而它们完全不知情 |
| 不能直接和用户对话 | 子智能体跑在后台,没有界面通道,弹不出确认框。它传递信息只能通过「返回结果」 |
| 不能操作兄弟任务 | 没有横向权限。避免子智能体互相干扰或形成意料之外的协作 |
后台异步智能体的白名单更严格
/*
* Async Agent Tool Availability Status (Source of Truth)
*/
export const ASYNC_AGENT_ALLOWED_TOOLS = new Set([
FILE_READ_TOOL_NAME, // 读文件
WEB_SEARCH_TOOL_NAME, // 网络搜索
TODO_WRITE_TOOL_NAME, // 待办清单
GREP_TOOL_NAME, // 内容搜索
WEB_FETCH_TOOL_NAME, // 抓网页
GLOB_TOOL_NAME, // 文件名搜索
...
])
注意这里从「黑名单」变成了「白名单」 —— 而且几乎全是只读工具。
能力面必须随「交互能力」和「信任级别」同步收缩:
· 有人在场、能弹确认框 → 黑名单模式,给全量工具减去几个
· 后台跑、弹不出确认框 → 白名单模式,只给明确安全的
从黑名单切到白名单,是安全等级的一次质变:黑名单漏掉一个就是漏洞,白名单漏掉一个只是功能缺失。
8.5 子智能体的上下文构造
创建子智能体时会构造一个新的 ToolUseContext。有几个字段的处理很讲究:
/**
* Always-shared setAppState for session-scoped infrastructure (background
* tasks, session hooks). Unlike setAppState, which is no-op for async agents
* (see createSubagentContext), this always reaches the root store so agents
* at any nesting depth can register/clean up infrastructure that outlives
* a single turn. Only set by createSubagentContext; main-thread contexts
* fall back to setAppState.
*/
setAppStateForTasks?: (f: (prev: AppState) => AppState) => void
译:供会话级基础设施(后台任务、会话钩子)使用的「永远共享」的状态写入函数。不同于普通的 setAppState(对异步智能体是空操作),这个函数总能抵达根存储 —— 这样任意嵌套深度的智能体都能注册或清理那些生命周期超过单轮的基础设施。只有创建子智能体上下文时才设置它;主线程上下文回退到普通的 setAppState。
| 需求 | 通道 |
|---|---|
| 状态隔离 子智能体不该污染主线程的界面状态 |
setAppState 对子智能体是空操作 |
| 基础设施注册 子智能体启动的后台进程必须能被清理 |
setAppStateForTasks 总是抵达根存储 |
如果只有一个通道,就得在「隔离」和「可清理」之间二选一。子智能体启动了一个后台进程但注册不进根存储 → 它结束后那个进程变成孤儿,永远不会被清理。
另外两个相关字段:
agentId?: AgentId // 只有子智能体才设置;钩子用它来区分是不是子智能体调用
agentType?: string // 子智能体的类型名
/** When true, preserve toolUseResult on messages even for subagents.
* Used by in-process teammates whose transcripts are viewable by the user. */
preserveToolUseResults?: boolean
最后那个字段说明:默认情况下子智能体的工具结果会被丢弃(省内存,反正用户看不到)。但「进程内队友」这种形态的子智能体,它的对话记录是用户可见的,所以要保留。
8.6 内建智能体类型
tools/AgentTool/builtInAgents.ts 定义了几个内建类型,其中最常用的是:
| 类型 | 特征 |
|---|---|
| Explore 探索 | 只读工具集。用于「扫一遍代码库找答案」这类任务。它读片段而不是整个文件,所以能定位代码,但不适合做审查 |
| general-purpose 通用 | 全量工具。用于多步骤的复杂任务 |
| Plan 架构 | 只读 + 规划。返回分步计划,识别关键文件,考虑架构权衡 |
用户还可以自定义 —— 在 .claude/agents/*.md 里写一个 Markdown 文件,头部元数据声明名字、描述、可用工具、模型。loadAgentsDir.ts 负责加载。
8.7 后台任务的几种形态
tasks/ 目录下有多种任务形态,它们的差别在于「跑在哪里」和「怎么通信」:
| 形态 | 说明 |
|---|---|
LocalAgentTask | 本地进程内的子智能体 |
LocalShellTask | 本地后台 shell 命令(比如启动一个开发服务器) |
InProcessTeammateTask | 进程内「队友」—— 多智能体群模式下的伙伴,对话记录用户可见 |
RemoteAgentTask | 远程执行的智能体(123 KB,最复杂) |
LocalMainSessionTask | 主会话自身作为一个任务被追踪 |
DreamTask | 内部实验特性(KAIROS_DREAM 开关) |
任务的完成通过消息队列通知回主循环 —— 就是第 3.9 节讲的那个「进程级全局队列」,每个智能体只取走发给自己的通知。
8.8 中断的级联
中止控制器构成一棵树:
这是自建智能体最容易漏的地方:派生容易,回收难。
具体的暴雷场景:派出 5 个子智能体之后用户按了 Ctrl+C。
· 没有级联中止 → 那 5 个继续跑完,继续烧钱,而且没人在看结果
· 没有补齐合成结果 → 下一轮接口调用直接报格式错误,会话再也恢复不了
两个问题都不会在开发阶段暴露(开发时你不会去按 Ctrl+C),但在生产环境每天都会发生。
8 · Subagents
The tools/AgentTool/ directory; its main file is 228 KB. This is the most complex of all the tools — because its job is to recursively launch another complete agent.
8.1 The Primary Motivation Is Context Isolation, Not Parallelism
Scenario: the main agent needs to find where some function is defined, and might have to read 20 files to be sure.
Read them itself: the full contents of 20 files enter the main context, and from then on get re-sent every single turn. At 2,000 tokens per file, that's 40,000 tokens permanently occupied, paid for until the session ends.
Delegate to a subagent: the subagent reads the 20 files, reaches a conclusion, returns “it's in foo.ts at line 42,” and then its entire context is thrown away. The main agent receives only that one sentence, about 15 tokens.
Parallelism is just a byproduct. The first principle of subagents is “trade a disposable context for a conclusion.”
8.2 Three Forms
| Form | Context | Use |
|---|---|---|
Named subagentsubagent_type: 'Explore' |
Fresh, carrying only the task description | The built-in Explore or general-purpose agents, or user-defined ones (written in .claude/agents/*.md) |
| Forked subagent omit subagent_type |
Fully inherits the parent's conversation history and system prompt | Exploring several directions of the same problem in parallel |
| Coordinator worker COORDINATOR_MODE |
Restricted tool set, independent context | The execution unit in coordinator mode |
8.3 Forking: Pushing Prompt Caching to the Limit
The typical use of forking is “spawn 5 subagents at once to explore the same problem from different angles.” Their five contexts are almost identical — the only difference is the final line: “you take direction A / B / C / D / E.”
Prompt caching is prefix-matched. So:
If the requests these 5 subagents send can be made byte-identical in their prefix, the first one populates the cache and the other four all hit it. Input cost drops from 5 copies to about 1.4 (1 at full price + 4 at the 10% discounted price), saving over 70%.
Four things done to make that happen
① The system prompt is passed as “already-rendered bytes”
/**
* The getSystemPrompt here is unused: the fork path passes
* `override.systemPrompt` with the parent's already-rendered system prompt
* bytes, threaded via `toolUseContext.renderedSystemPrompt`. Reconstructing
* by re-calling getSystemPrompt() can diverge (GrowthBook cold→warm)
* and bust the prompt cache; threading the rendered bytes is byte-exact.
*/
claude-code/src/tools/AgentTool/forkSubagent.ts
Put plainly: the getSystemPrompt here goes unused: the fork path passes the parent's already-rendered system prompt bytes, threaded through the renderedSystemPrompt field. Regenerating by calling the generator again can diverge (because the feature-flag config may have gone from cold cache to warm cache), which busts the prompt cache; passing the rendered bytes is byte-exact.
To unpack the trap: the system prompt's content isn't entirely fixed; it can include A/B experiment variants. And the experiment config is itself cached — at the moment the parent generates its prompt, some experiment config may still be in a “cold cache” state (using the default value); a few seconds later, when the child regenerates it, the config has become “warm cache” (using the real value). The two generations produce different bytes, and the cache is wasted entirely.
The corresponding field is defined in Tool.ts:
/**
* Parent's rendered system prompt bytes, frozen at turn start.
* Used by fork subagents to share the parent's prompt cache — re-calling
* getSystemPrompt() at fork-spawn time can diverge (GrowthBook cold→warm)
* and bust the cache. See forkSubagent.ts.
*/
renderedSystemPrompt?: SystemPrompt
“frozen at turn start” — that's the key.
② The tool list is inherited as-is
/**
* Synthetic agent definition for the fork path.
*
* Not registered in builtInAgents — used only when `!subagent_type` and the
* experiment is active. `tools: ['*']` with `useExactTools` means the fork
* child receives the parent's exact tool pool (for cache-identical API
* prefixes). `permissionMode: 'bubble'` surfaces permission prompts to the
* parent terminal. `model: 'inherit'` keeps the parent's model for context
* length parity.
*/
export const FORK_AGENT = {
tools: ['*'],
permissionMode: 'bubble',
model: 'inherit',
...
}
Each of the three fields has its reason:
tools: ['*']together withuseExactTools— the child gets the parent's exact tool pool. It won't use that many tools, but tool definitions are part of the request prefix; change them and the cache is gone.permissionMode: 'bubble'— permission confirmations bubble up to the terminal where the parent lives. The subagent has no UI of its own.model: 'inherit'— inherits the parent's model, guaranteeing the same context window size. If the child used a model with a smaller window, the inherited history might simply not fit.
③ Message construction: only the last text block differs
/**
* Build the forked conversation messages for the child agent.
*
* For prompt cache sharing, all fork children must produce byte-identical
* API request prefixes. This function:
* 1. Keeps the full parent assistant message (all tool_use blocks, thinking, text)
* 2. Builds a single user message with tool_results for every tool_use block
* using an identical placeholder, then appends a per-child directive text block
*
* Result: [...history, assistant(all_tool_uses), user(placeholder_results..., directive)]
* Only the final text block differs per child, maximizing cache hits.
*/
export function buildForkedMessages(...)
What's clever about the “placeholder tool results”: the parent's message contains 5 tool calls (one per fork). By the API's rules, every tool call must have a paired result. But the 5 forks haven't finished yet, so real results don't exist. So each one gets filled with exactly the same placeholder content — satisfying the pairing requirement while guaranteeing all 5 children see an identical stretch.
The source even has a dedicated constant for it:
/** Must be identical across all fork children for prompt cache sharing. */
④ The recursive-fork guard: moved down from the interface layer to the call layer
/**
* Guard against recursive forking. Fork children keep the Agent tool in their
* tool pool for cache-identical tool definitions, so we reject fork attempts
* at call time by detecting the fork boilerplate tag in conversation history.
*/
export function isInForkChild(messages: MessageType[]): boolean { ... }
The conventional approach is “remove the Agent tool from the subagent's tool pool.” But that changes the tool definitions and breaks the cache. So instead: keep the tool, but at the moment it's actually called, check the conversation history for a fork marker:
if (isInForkChild(toolUseContext.messages)) {
throw new Error('Fork is not available inside a forked worker. '
+ 'Complete your task directly using your tools.')
}
Taken one by one, every one of these would get flagged in an ordinary code review:
- Handing over tools that won't be used → “Why not least privilege?”
- Passing bytes instead of regenerating → “Why not reuse the existing generator?”
- Filling results with meaningless placeholders → “Isn't that fabricating data?”
- Moving the guard from the interface layer down to the call layer → “Why not forbid it in the type system?”
But for an agent that needs to fan out, these costs are worth paying: 4 of 5 subagents ride the cache, and input cost drops below one third.
The skill on display is “knowing when to trade cleanliness for performance” — far more valuable than reciting design principles.
An extra notice for worktree isolation
/**
* Notice injected into fork children running in an isolated worktree.
* Tells the child to translate paths from the inherited context, re-read
* potentially stale files, and that its changes are isolated.
*/
export function buildWorktreeNotice(...)
If a forked child runs in an isolated git worktree, the file paths in the history it inherited point at the parent's directory. So it has to be told explicitly: translate paths, files may no longer be the version you saw, and your changes are isolated.
8.4 Tool Restrictions on Subagents
export const ALL_AGENT_DISALLOWED_TOOLS = new Set([
TASK_OUTPUT_TOOL_NAME, // can't view other tasks' output
EXIT_PLAN_MODE_V2_TOOL_NAME, // can't exit plan mode (session-wide global state)
ENTER_PLAN_MODE_TOOL_NAME, // can't enter plan mode
// internal users may nest subagents; external users may not
...(process.env.USER_TYPE === 'ant' ? [] : [AGENT_TOOL_NAME]),
ASK_USER_QUESTION_TOOL_NAME, // ★ can't ask the user questions
TASK_STOP_TOOL_NAME, // can't stop other tasks
// prevent recursive workflow execution inside a subagent
...(feature('WORKFLOW_SCRIPTS') ? [WORKFLOW_TOOL_NAME] : []),
])
export const CUSTOM_AGENT_DISALLOWED_TOOLS = new Set([...ALL_AGENT_DISALLOWED_TOOLS])
claude-code/src/constants/tools.ts
| Principle | Why |
|---|---|
| Can't modify global state | Plan mode is a session-wide switch. If a subagent flips it, the parent and every sibling are affected without knowing it |
| Can't talk to the user directly | Subagents run in the background with no UI channel; they can't pop a confirmation dialog. The only way they pass information is by “returning a result” |
| Can't operate on sibling tasks | No lateral permissions. Prevents subagents from interfering with each other or forming unexpected collaborations |
The allowlist for background async agents is stricter
/*
* Async Agent Tool Availability Status (Source of Truth)
*/
export const ASYNC_AGENT_ALLOWED_TOOLS = new Set([
FILE_READ_TOOL_NAME, // read files
WEB_SEARCH_TOOL_NAME, // web search
TODO_WRITE_TOOL_NAME, // todo list
GREP_TOOL_NAME, // content search
WEB_FETCH_TOOL_NAME, // fetch web pages
GLOB_TOOL_NAME, // filename search
...
])
Note the shift here from a “denylist” to an “allowlist” — and nearly everything on it is a read-only tool.
The capability surface has to shrink in step with “interactivity” and “trust level”:
· A human is present and dialogs can be shown → denylist mode: the full tool set minus a few
· Running in the background, no dialogs possible → allowlist mode: only what's explicitly safe
Switching from denylist to allowlist is a qualitative change in security level: miss one on a denylist and you have a vulnerability; miss one on an allowlist and you have a missing feature.
8.5 Constructing a Subagent's Context
Creating a subagent constructs a new ToolUseContext. Several fields are handled with care:
/**
* Always-shared setAppState for session-scoped infrastructure (background
* tasks, session hooks). Unlike setAppState, which is no-op for async agents
* (see createSubagentContext), this always reaches the root store so agents
* at any nesting depth can register/clean up infrastructure that outlives
* a single turn. Only set by createSubagentContext; main-thread contexts
* fall back to setAppState.
*/
setAppStateForTasks?: (f: (prev: AppState) => AppState) => void
Put plainly: an “always-shared” state setter for session-scoped infrastructure (background tasks, session hooks). Unlike the regular setAppState (a no-op for async agents), this one always reaches the root store — so an agent at any nesting depth can register or clean up infrastructure that outlives a single turn. Only set when a subagent context is created; main-thread contexts fall back to the regular setAppState.
| Need | Channel |
|---|---|
| State isolation Subagents shouldn't pollute the main thread's UI state |
setAppState is a no-op for subagents |
| Infrastructure registration Background processes a subagent starts must be cleanable |
setAppStateForTasks always reaches the root store |
With only one channel, you'd have to choose between “isolated” and “cleanable.” A subagent starts a background process but can't register it in the root store → after the subagent finishes, that process becomes an orphan and is never cleaned up.
Two more related fields:
agentId?: AgentId // set only for subagents; hooks use it to tell whether a call came from a subagent
agentType?: string // the subagent's type name
/** When true, preserve toolUseResult on messages even for subagents.
* Used by in-process teammates whose transcripts are viewable by the user. */
preserveToolUseResults?: boolean
That last field tells you: by default, a subagent's tool results are discarded (saves memory; the user can't see them anyway). But for the “in-process teammate” kind of subagent, whose transcript is visible to the user, they're kept.
8.6 Built-in Agent Types
tools/AgentTool/builtInAgents.ts defines several built-in types; the most commonly used:
| Type | Traits |
|---|---|
| Explore Exploration | Read-only tool set. For tasks like “sweep the codebase for an answer.” It reads excerpts rather than whole files, so it can locate code but isn't suited to reviewing it |
| general-purpose General purpose | Full tool set. For complex multi-step tasks |
| Plan Architect | Read-only + planning. Returns step-by-step plans, identifies key files, weighs architectural trade-offs |
Users can also define their own — write a Markdown file under .claude/agents/*.md, with front matter declaring the name, description, available tools, and model. loadAgentsDir.ts handles loading them.
8.7 The Forms Background Tasks Take
The tasks/ directory holds several task forms; they differ in “where it runs” and “how it communicates”:
| Form | Description |
|---|---|
LocalAgentTask | A subagent inside the local process |
LocalShellTask | A local background shell command (say, starting a dev server) |
InProcessTeammateTask | An in-process “teammate” — a partner in multi-agent swarm mode, with a transcript visible to the user |
RemoteAgentTask | A remotely executed agent (123 KB, the most complex) |
LocalMainSessionTask | The main session itself, tracked as a task |
DreamTask | Internal experimental feature (the KAIROS_DREAM flag) |
Task completion is reported back to the main loop via the message queue — the “process-level global queue” from section 3.9, where each agent takes only the notifications addressed to it.
8.8 Cascading Interruption
The abort controllers form a tree:
This is the spot home-built agents most often miss: spawning is easy, reclaiming is hard.
The concrete blow-up scenario: you've spawned 5 subagents and the user presses Ctrl+C.
· No cascading abort → those 5 keep running to completion, keep burning money, and nobody is looking at the results
· No synthetic results filled in → the next API call fails with a format error, and the session can never be resumed
Neither problem shows up during development (you don't press Ctrl+C while developing), but both happen every day in production.