数据截至 (上 游 commit cdaa80b77807)
Agent 主机层:拼提示 · 管上下文 · 控权限 · 派子agent
30 秒导读: 上一章 讲的 loop 是一台无状态的"发一次请求、跑一批工具、判断要不要再来一轮"的机器。它自己不记历史、不知道系统提示、不管权限。本章讲的
Agent类,就是把这台机器装配成一个能真正用起来的编码 agent 的主机:它准备好 loop 每一步要的原料(系统提示、消息历史、工具表),在 loop 的每个钩子上插入自己的逻辑(压缩、权限、去重、记账),并把结果记进可回放的日志。
本章范围锁定 packages/agent-core/src/agent/。不深入具体工具怎么实现(留给 03-tools),不讲 provider 协议细节(留给 04-providers)。
1. 这是什么(零基础也能懂)
一句话定义: Agent 是一个长期存活的对象,它握着一次会话的全部可变状态(消息历史、配置、权限模式、计划/目标状态),并在每次该请求模型时,把这些状态渲染成无状态 loop 需要的输入。
loop 和 Agent 的分工,用一个类比:
- loop = 一台榨汁机。 你塞进去水果(消息 + 工具表),它吐出果汁(模型回复 + 工具结果),它不关心水果哪来的、果汁往哪去。
- Agent = 厨房。 它负责买菜、洗菜、切好(拼系统提示、投影历史)、决定这次榨什么、榨完把渣清理掉(压缩)、把成品装盘记账(records)。榨汁机可以换,厨房的流程不变。
为什么需要这一层? 因为一个无状态循环缺了三样东西才能变成 agent:
| 缺的东西 | Agent 怎么补 | 在哪 |
|---|---|---|
| 它是谁、能干什么 | 系统提示 + 画像(profile)装配 | profile/、services/prompt/ |
| 它记得什么 | 上下文记忆 + 投影 + 压缩 | agent/context/、agent/compaction/ |
| 它被允许做什么 | 工具执行前的权限门控 | agent/permission/ |
一个关键设计约束(全书都要记住): Agent 必须能独立使用——它的构造函数不强迫调用方先造一个 Session,不要求 agentId 或 session。它可以接一个可选的 sessionId 作为请求配置的提示(比如映射到 provider 的 prompt_cache_key),但实例本身不持有 sessionId,也不依赖 Session 的生命周期、元数据或父子关系(依据:仓库根 CLAUDE.md "General Coding Rules";以 agent/index.ts 构造函数为准 packages/agent-core/src/agent/index.ts:192-251)。
这条约束贯穿本章:凡是需要"多个 agent 协作/父子关系"的东西(比如子 agent 编排),都不在 agent/ 里,而在 session/ 里。agent/ 只知道"我是一个 agent",不知道"我是谁的孩子"。
2. 顶层全景(它大概怎么转)
2.1 Agent 类是一堆子系统的装配台
Agent 的构造函数本身几乎不含逻辑——它就是一条装配线:接住选项,然后把十几个子系统 new 出来,每个都把 this(Agent 自己)传进去,于是子系统之间能通过 this.agent.xxx 互相拿到对方(packages/agent-core/src/agent/index.ts:220-250)。
┌─────────────────────────────┐
AgentOptions ───────▶ │ Agent (主机) │
(kaos, config, │ 一次会话的全部可变状态 │
modelProvider, └───────────────┬─────────────┘
subagentHost, ...) │ 构造时装配(每个子系统持有 this)
▼
┌──────────┬───────────┬───────────┬───────────┬───────────┬──────────┐
│ config │ context │ compaction│ permission│ turn │ tools │
│ 模型/画像 │ 记忆+投影 │ 压缩 │ 权限门控 │ 回合驱动 │ 工具表 │
├──────────┼───────────┼───────────┼───────────┼───────────┼──────────┤
│ planMode │ goal │ swarmMode │ injection │ background│ cron │
│ 计划模式 │ 目标模式 │ 群体模式 │ 动态注入 │ 背景任务 │ 定时 │
├──────────┴───────────┴───────────┴───────────┴───────────┴──────────┤
│ records(写 wire 日志) replayBuilder(攒回放) usage(记账) │
└─────────────────────────────────────────────────────────────────────┘
2.2 各部件一句话职责
| 部件(字段) | 干什么 | 起点文件 |
|---|---|---|
config | 存当前模型别名、画像名、思考档位、系统提示;算出 provider 与能力 | agent/config/index.ts ConfigState |
context | 存消息历史;把历史投影成 provider 能收的合法请求 | agent/context/index.ts ContextMemory |
fullCompaction / microCompaction | 上下文快满时压缩历史 | agent/compaction/full.ts、micro.ts |
permission | 工具执行前跑一串策略,决定放行/拒绝/问用户 | agent/permission/index.ts PermissionManager |
turn | 把 kosong 适配成 loop 的 LLM,驱动一个个回合,并把上面这些挂进 loop 钩子 | agent/turn/index.ts TurnFlow |
tools | 维护当前可用工具表(内建 + 用户 + MCP + 动态) | agent/tool/index.ts ToolManager |
planMode / goal / swarmMode | 三种"跑法":只读计划、自主追目标、群体协作 | agent/plan/、goal/、swarm/ |
injection | 每步/边界处往历史尾部追加系统提醒(待办、权限模式、目标) | agent/injection/manager.ts |
background / cron | 背景 bash / 背景子 agent;定时任务 | agent/background/、agent/cron/ |
records / replayBuilder | 把每个状态变更写成 wire 记录,支持 resume 精确重建 | agent/records/、agent/replay/ |
2.3 主线走一遍(高层,不进代码)
从"用户敲一句话"到"模型开始生成",Agent 内部大致这样流转:
用户输入
│
▼
turn.prompt() ──写 turn.prompt 记录──▶ records
│
▼
context.appendUserMessage() 把话进历史
│
▼
turn 启动一个回合,调 runTurn(loop) ←── 把 Agent 的子系统挂成 loop 的钩子:
│ beforeStep → 压缩 + 注入
│ buildMessages → context.messages(投影)
│ authorizeToolExecution → permission
│ finalizeToolResult → 去重 + 预算 + 钩子
▼
loop 向 KosongLLM.chat() 要一次生成
│
▼
context.messages 把历史投影成合法 wire ──▶ kosong.generate() ──▶ provider
关键洞察:loop 不主动去 Agent 里拿东西,是 Agent 把自己"喂"给 loop。 turn 在调 runTurn 时,把 buildMessages、buildTools、以及一堆 hooks 作为回调传进去(packages/agent-core/src/agent/turn/index.ts:836-1021)。loop 需要原料时回调这些函数,Agent 就在回调里现算。这样 loop 保持无状态,Agent 保持"活"的状态。
3. 系统提示与画像装配
它要解决的小问题: 模型不知道自己是谁、在哪、有哪些工具、项目有什么约定。系统提示就是每次请求最前面那段"人设 + 环境说明",得在运行时拼出来。
3.1 画像(profile)= 模板 + 变量 + 工具集
一个"画像"就是一种 agent 人设。默认有四个:agent(主 agent)、coder、explore、plan,用 YAML 定义,靠 extends 继承(packages/agent-core/src/profile/default/)。例如 coder.yaml 继承 agent,只覆盖 roleAdditional(告诉它"你现在是子 agent")和工具白名单。
画像加载后不是一段死文本,而是一个渲染器函数:它闭包住合并后的模板和变量,等到真要生成提示时,才用运行时上下文把 模板变量填进去(packages/agent-core/src/profile/resolve.ts:133-182 createSystemPromptRenderer)。为什么要延迟?因为 cwd 目录列表、AGENTS.md、技能清单这些只有运行时才知道。
3.2 运行时上下文哪来的
prepareSystemPromptContext 并行采集三样运行时信息(packages/agent-core/src/profile/context.ts:29-46):
| 变量 | 内容 | 采集方式 |
|---|---|---|
KIMI_WORK_DIR_LS | 当前目录列表 | listDirectory(kaos) |
KIMI_AGENTS_MD | 项目/用户级 AGENTS.md 拼接 | 从用户目录到项目叶子逐级收集 |
KIMI_ADDITIONAL_DIRS_INFO | 额外目录的列表 | 逐个列目录 |
AGENTS.md 的收集顺序是"用户级在前、项目级在后",于是项目级能覆盖用户级(packages/agent-core/src/profile/context.ts:76-104)。超过 32 KB 不截断,只发一条 warning 提醒用户瘦身(AGENTS_MD_RECOMMENDED_MAX_BYTES context.ts:15)。
注意:这些
AGENTS.md对 Agent 而言是被采集进提示的数据,不是给写文档的人的指令——本章按开源仓库的真实源码事实来写。
3.3 装配的三步
useProfile 是把画像挂上 Agent 的入口,做三件事(packages/agent-core/src/agent/index.ts:445-462):
useProfile(profile, context)
│
├─▶ setActiveProfile 记住当前画像(供压缩后重渲染用)
├─▶ updateSystemPromptFromProfile 用 context 渲染出系统提示 → config.update()
└─▶ tools.setActiveTools(profile.tools) 按画像白名单裁工具
压缩之后为什么要重来一遍?因为压缩会把老历史折叠掉,但系统提示里的 cwd 列表、技能清单是会话启动时的旧快照。refreshSystemPrompt 在压缩后重新采集运行时上下文再渲染一次,让压缩后的回合看到新鲜环境(代价是让 prompt cache 的前缀失效,这是有意的 packages/agent-core/src/agent/index.ts:475-483)。
4. 上下文记忆与投影
这是本章工程含量最高的一支。ContextMemory 存的历史和最终发给 provider 的消息,几乎从不一字不差——中间隔着一层叫"投影(projection)"的翻译。
4.1 为什么"存的"和"发的"要分开
ContextMemory._history 存的是事实:每一步的助手内容、工具调用、工具结果原文、以及结构化的 isError / note 字段(packages/agent-core/src/agent/context/index.ts:52-63)。它不为任何 provider 而优化,它只忠实记录发生了什么。
但严格 provider(如 Anthropic)对请求体有一堆硬规矩:每个 tool_use 后面必须紧跟对应的 tool_result;第一条消息必须是 user;不能有空文本块;不能有连续同角色。真实历史因为背景任务通知、steer 插入、中断等原因,经常违反这些规矩。
投影就是那层"临时修复":读侧变换,把历史整成合法 wire,但不动底层历史(packages/agent-core/src/agent/context/projector.ts:111-127 project)。
4.2 一次投影都修了啥
project() 是一条流水线,按顺序跑若干修复,每个修复都会通过 onAnomaly 汇报,好让"被悄悄修过的历史"留下痕迹而不是被掩盖:
history(事实)
│
├─ 合并相邻 user 消息 / 丢空白文本块
├─ (严格重发才开) 去重重复的 tool_use id
├─ 修复工具交换的相邻性 ◀── 核心:把错位的 tool_result 挪回它的 tool_use 后面;
│ 中途缺结果的调用合成一个占位结果关闭它
├─ (严格重发才开) 合并连续 assistant
├─ 丢掉没有对应调用的孤儿 tool_result
└─ (严格重发才开) 丢掉开头的非 user 消息
▼
Message[](合法 wire)
"修复工具交换相邻性"最关键(packages/agent-core/src/agent/context/projector.ts:159-219)。它的巧处在于区分"尾部"和"中段"的缺失结果:
- 中段某个
tool_use没结果 → 后面已经有新回合了,证明它不可能还在飞,合成占位结果关闭它。 - 尾部那个
tool_use没结果 → 可能真的还在执行中,默认不动它(留给别的机制处理)。
于是历史保持忠实,模型永远收到合法的工具交换。
4.3 三种投影视图
ContextMemory 暴露几个只读 getter,对应不同场景(packages/agent-core/src/agent/context/index.ts:546-592):
| 视图 | 用途 | 额外做的事 |
|---|---|---|
messages | 正常每回合请求 | 丢孤儿结果 |
strictMessages | provider 报了 400 之后的兜底重发 | 全套严格修复(去重、丢开头非 user、合并 assistant…) |
mediaDegradedMessages | provider 报 413(体积过大)后重发 | 除最近 2 个外的媒体换成文字标记 |
这三个视图直接被 turn 当作 buildMessages / buildMessagesStrict / buildMessagesMediaDegraded 传给 loop——loop 报错时按需切换到更狠的投影重发。
4.4 历史是怎么长出来的:appendLoopEvent
历史不是直接 push 的,而是由 loop 事件驱动的 状态机(packages/agent-core/src/agent/context/index.ts:651-762)。loop 每产出一个事件,appendLoopEvent 就先写进 records,再改内存:
step.begin → 新建一条空 assistant 消息,登记为 openStep
content.part → 往当前 openStep 追加内容块
tool.call → 往 openStep 追加工具调用,把 id 记入"待结果集合"
tool.result → 建 tool 消息,从"待结果集合"移除该 id
step.end → 结算 token,若工具交换已闭合则冲刷被延迟的消息
一个不变量保证正确:历史里除了尾部,不能有未闭合的工具交换。所以当有工具调用还没结果时,后来的用户消息(比如背景通知)会先被塞进 deferredMessages 暂存,等交换闭合了再冲刷进历史(appendMessage + flushDeferredMessagesIfToolExchangeClosed)。这避免了"用户消息插在 tool_use 和 tool_result 中间"这种非法结构。
4.5 工具结果的渲染只发生一次
历史里 tool 消息存的是原始输出 + isError + note。模型看到的那份"带 <system>ERROR: 前缀的、空输出补占位符的、note 拼在后面的"文本,是在投影边界恰好渲染一次的(packages/agent-core/src/agent/context/tool-result-render.ts:53-91 renderToolResultForModel)。好处:每一段系统生成的文字都带同样的 <system> 标记,模型永远能分清"这是工具产出"还是"这是主机在说话";而 UI 拿原始输出、靠 isError 自己上色,看不到这些标记。
4.6 动态工具与通知的投影
两件与投影相关的收尾:
- 动态工具上下文:开了
select_tools渐进披露时,历史里会有"工具 schema 消息"和"可加载工具公告"。给一个没有该能力的模型发请求时,stripDynamicToolContext会在投影之前把这些剥掉(因为投影会抹掉origin锚点packages/agent-core/src/agent/context/dynamic-tools.ts:52-71)。 - 通知 XML:背景任务/子 agent 完成的通知,渲染成
<notification …>结构块注入历史,子 agent 类型的通知还会带agent_id属性,方便模型直接拿去Agent(resume=…)(packages/agent-core/src/agent/context/notification-xml.ts:26-49)。
5. 上下文压缩(解决上下文窗口溢出)
它要解决的小问题: 会话越来越长,迟早撑爆模型的上下文窗口。压缩就是"在快满时,把老对话换成一段摘要,腾出空间接着聊"。
5.1 何时触发:strategy
策略层很薄:默认在上下文用量达到窗口的 85% 时触发压缩,并且 blockRatio 也是 85%,意味着压缩是同步阻塞的(没有后台压缩)。另外还预留 50000 token 的输出空间,快踩到预留线时也提前压(packages/agent-core/src/agent/compaction/strategy.ts:25-31 DEFAULT_COMPACTION_CONFIG)。
turn 在每步之前调 fullCompaction.beforeStep:先看要不要压,该阻塞就等压缩跑完再继续(packages/agent-core/src/agent/compaction/full.ts:270-275)。
5.2 全量压缩(full.ts):把整段历史换成一封"给自己的信"
FullCompaction.compactionRound 是主流程(packages/agent-core/src/agent/compaction/full.ts:400-673):
- 快照当前历史,估算压缩前 token。
- 把历史(剥掉动态工具协议上下文)投影成合法请求,末尾接一条压缩指令,请模型写摘要。
- 拿到摘要后做一次竞态检查:如果压缩期间历史的前缀变了(比如用户撤销),或者尾部长出了会被压缩丢掉的非用户消息,就放弃这次压缩(避免悄悄吞掉内容)。
- 调
context.applyCompaction,把历史重建成[保留的用户消息, 摘要]。
这封"摘要"很讲究:压缩指令让模型用第一人称、现在时给未来的自己写一封续写笔记,而不是写第三方报告,并且要用对话本来的语言写(packages/agent-core/src/agent/compaction/compaction-instruction.md:1-25)。目的是让压缩后的下一回合能无缝接上。
applyCompaction 保留用户消息时不是简单留尾巴,而是头 + 尾都留、中间用省略标记:在 token 预算内保留最老的头几条用户输入和最近的尾部输入,中间放一个 elision 标记说"这里省略了 N token"(packages/agent-core/src/agent/context/index.ts:314-430)。头也留,是因为最初的任务描述往往最重要。
5.3 溢出时的多级降级
如果连"发压缩请求"本身都塞不下,compactionRound 有一串兜底(同一个 while 循环里):
压缩请求被拒
├─ 413/图片格式错 且还没试过 → 把媒体换成文字标记,重试
├─ 上下文溢出 / 请求过大 → 按 0.7/0.5/0.35 比例砍掉最老消息,重试(最多 3 次)
├─ 空响应 / 被截断 → 丢最老一条消息重试(受重试预算封顶)
└─ 其它可重试错误 → 退避重试
被砍掉的老消息不在摘要覆盖范围内,droppedCount 会如实报告这个盲区(full.ts:453-582)。
5.4 微压缩(micro.ts):当前已禁用
诚实说明:micro.ts 里的微压缩(把老的大工具结果就地截断成一个标记、保留最近若干条)在本 commit 是关闭的——它依赖的 micro_compaction 实验开关已从注册表移除,detect() 和 compact() 都直接返回、原实现整段注释掉(packages/agent-core/src/agent/compaction/micro.ts:50-138)。context.project 里仍会调 microCompaction.compact(shaped),但当前是恒等变换。所以现在实际生效的只有 §5.2 的全量压缩。
6. 权限门控(工具执行前 gate)
它要解决的小问题: 模型想写文件、跑命令、删东西——哪些直接放行、哪些得先问用户、哪些直接拒绝?这个判断必须在工具真正执行之前做。
6.1 挂载点:loop 的 authorizeToolExecution 钩子
turn 把权限管理器挂在 loop 的授权钩子上(packages/agent-core/src/agent/turn/index.ts:977-979):
loop 准备执行工具
│
▼
authorizeToolExecution(ctx) ──▶ agent.permission.beforeToolCall(ctx)
│ 跑一串策略,第一个出结果的赢
▼
返回 undefined = 放行 / {block:true} = 拒绝 / 触发审批请求 = 问用户
6.2 策略链:一串"看情况说话"的判官
PermissionManager.beforeToolCall 按顺序问每个策略,第一个给出非 undefined 结果的胜出(packages/agent-core/src/agent/permission/index.ts:96-114 + evaluatePolicies)。策略的顺序本身就是优先级,读一遍这个顺序基本就懂了整套权限模型(packages/agent-core/src/agent/permission/policies/index.ts:28-71)。挑关键几条:
| 顺序(靠前=优先) | 策略 | 决定 |
|---|---|---|
| 1 | PreToolUse 钩子返回 block | 拒绝 |
| 2 | AgentSwarm 必须独占一批 | 拒绝(与模式无关) |
| 4 | plan 模式下写计划文件外的文件 | 拒绝 |
| 5 | 用户配置的 deny 规则命中 | 拒绝 |
| 6 | auto 模式 | 放行(能拦的 deny 都在它上面) |
| 7 | 本会话已"批准整场"的记忆命中 | 放行 |
| 8 / 9 | 用户配置的 ask / allow 规则 | 问 / 放行 |
| 倒数几条 | 碰到敏感文件(.env、SSH key) | 问 |
| 倒数第 4 | yolo 模式 | 放行 |
| 倒数第 3 | 默认放行表里的只读工具 | 放行 |
| 最后 | 什么都没命中 | 问用户(fallback) |
这个"deny 全在 approve 前面、fallback 是 ask"的排布,保证了默认安全:没被任何规则明确放行的操作,兜底都会去问人。
6.3 规则怎么匹配:一个小 DSL
用户配的权限规则是形如 Read(/etc/**)、Bash(!rm *)、mcp__github__* 的字符串。parsePattern 把它拆成"工具名 + 可选的参数模式",工具名用 picomatch 做 glob 匹配,参数模式则交给工具自带的 matchesRule 匹配器判断(packages/agent-core/src/agent/permission/matches-rule.ts:47-98)。工具名与参数分离,是因为二者语义不同——工具名是主机认识的,参数怎么算命中只有工具自己知道。
6.4 审批请求与"记住这次"
若策略判为 ask,requestToolApproval 通过 RPC 向前端要审批,顺便触发 PermissionRequest/PermissionResult 钩子。如果用户选了"本会话都批准",就把该规则模式记进 localSessionApprovalRulePatterns,下次同类调用直接被 §6.2 第 7 条放行(packages/agent-core/src/agent/permission/index.ts:116-250、72-94)。
子 agent 被拒时,拒绝话术会不一样:告诉它"别重试同一个调用、别想绕过限制"(因为子 agent 不能直接问最终用户 permission/index.ts:304-311)。
7. 三种模式:plan · goal · swarm
三种模式改变的是"这个 agent 这一段怎么跑",都是挂在 Agent 上的独立子系统。
7.1 计划模式(plan):只读地想清楚再动手
PlanMode 维护一个"是否在计划中 + 计划文件路径"的状态(packages/agent-core/src/agent/plan/index.ts:14-140)。进入后,权限策略里的 PlanModeGuardDeny 会拒绝对计划文件之外的写/编辑,PlanModeToolApprove 只放行读、进计划、写计划文件本身——于是模型被逼着"只读地想、把方案写进计划文件",直到用户 ExitPlanMode 审核通过。计划内容存成一个 .md 文件(homedir/plans/<id>.md 或 cwd 下)。
7.2 目标模式(goal):自主追一个目标直到完成/受阻
GoalMode 是三者里最厚的(packages/agent-core/src/agent/goal/index.ts:227-718),因为它管一个持久的、有生命周期的目标:
active ──pause──▶ paused ──resume──▶ active
│ \ ▲
│ \──markBlocked──▶ blocked ───────┘
│
└──markComplete──▶ complete(瞬态:宣告成功后立刻清空,从不落盘)
状态被刻意压到最少:落盘的只有 active/paused/blocked,complete 是"宣告即清除"的瞬态。paused 和 blocked 本质相同(都是"驱动不再跑、但目标完好可 /goal resume"),只差在谁停的(用户 vs 系统)和 terminalReason。没有单独的 impossible/error/cancelled 状态——不可达就是 blocked、运行失败就是 paused、取消就直接 删记录。
目标还带预算(token / 回合数 / 墙钟时间),turn 每步把 token 记进目标,超预算就在回合边界把目标标为 blocked(packages/agent-core/src/agent/turn/index.ts:852-859、843-845)。每个目标回合其实是一个普通回合,由 turn 里的 goal 驱动决定要不要再跑一轮,续跑用的是一段合成的 "continue working toward the goal" 提示(turn/index.ts:94-123)。
7.3 群体模式(swarm):一批子 agent 并行
SwarmMode 只是个开关 + 提醒(packages/agent-core/src/agent/swarm/index.ts:13-60):进入时往历史注入一段"群体模式"提醒,退出时撤掉或补一段退出提醒。真正的并行编排在别处(见 §8 的 AgentSwarm 与 subagent-batch)——这符合本章的分层:agent/ 只管"我处于群体模式",多 agent 的调度归 session/。
8. TurnFlow:把 kosong 适配成 loop、并把子系统挂进 loop
TurnFlow 是 Agent 和 loop 之间的变速箱。它做两件事:提供一个 loop 能用的 LLM;在 loop 的每个钩子上插入 Agent 的逻辑。
8.1 把 kosong 适配成 loop 的 LLM:KosongLLM
loop 只认一个抽象的 LLM.chat() 接口,不认 kosong。KosongLLM 就是把 kosong 的流式 generate() 桥接成这个接口(packages/agent-core/src/agent/turn/kosong-llm.ts:69-170):
- kosong 的逐块
onMessagePart回调 → 转成 loop 的逐 delta 回调(文本/思考/工具调用)。 - 流结束后再把合并好的完整内容块回放给 loop 的逐块回调,保证顺序、避免半截内容落地。
- 按需给每次请求算并套上"补全预算"(留多少输出空间),还会把当前模型不支持的媒体(图/音/视频)降级成文字占位(
downgradeUnsupportedMedia)。
Agent.llm getter 每次都新建一个 KosongLLM,喂进当前 provider、系统提示、能力(packages/agent-core/src/agent/index.ts:426-443)。
8.2 把子系统挂进 loop 钩子
这是整章的枢纽。turn 调 runTurn 时,传进一组 hooks,把 Agent 的各个子系统精确地插到 loop 的时序里(packages/agent-core/src/agent/turn/index.ts:836-1021):
| loop 钩子 | Agent 插进去的逻辑 |
|---|---|
buildMessages | context.messages(投影) |
beforeStep | 全量压缩检查 + 冲刷 steer 缓冲 + 动态注入(待办/权限模式/计划) |
afterStep | 记 usage + 压缩后处理 + 去重器结算本步 |
prepareToolExecution | 同步去重:同一步里重复的 (工具,参数) 复用首个结果 |
authorizeToolExecution | 权限门控(§6) |
finalizeToolResult | 跨步去重收尾 + 触发 PostToolUse 钩子 + 大结果落盘 |
shouldContinueAfterStop | 决定回合要不要因 steer/goal/Stop 钩子而续跑 |
8.3 工具去重:别让模型原地打转
ToolCallDeduplicator 处理两种重复(packages/agent-core/src/agent/turn/tool-dedup.ts:117-292):
- 同步内去重:同一个 LLM 步里发了两个一模一样的调用,第二个不真跑,直接复用第一个的结果。
- 跨步去重:连续多步发同一个调用,给结果追加升级式的系统提醒——连了 3 次给温和提醒,5 次给"三选一决策菜单",8 次给"现在就写最终答复",到 12 次直接
stopTurn强制结束这一回合。这套阶梯专治模型卡死循环。
8.4 大工具结果落盘:别撑爆上下文
budgetToolResultForModel:工具输出超过 5 万字符就写到 homedir/tool-results/*.txt,只给模型留一段 2000 字的预览 + 文件路径 + "用 Read 分页看全文"的提示(packages/agent-core/src/agent/turn/tool-result-budget.ts:19-83)。一次巨大的 grep 结果不会一口气塞满上下文。
9. 子 agent 编排(为什么在 session/ 而不在 agent/)
关键分层: 派子 agent 需要"父子关系、共享一个会话、并行调度"——这些恰恰是 §1 那条约束禁止 Agent 依赖的东西。所以子 agent 编排住在 session/,而不是 agent/。Agent 只提供"能被当子 agent 用"的能力,不知道自己是谁的孩子。
9.1 一个子 agent 就是另一个 Agent 实例
SessionSubagentHost.spawn 的流程(packages/agent-core/src/session/subagent-host.ts:166-191):
父 agent 想派活
│
├─ session.createAgent({type:'sub'}) 造一个全新的 Agent 实例(子)
├─ configureChild() 子继承父的模型/cwd/思考档;按子画像装配系统提示与工具
├─ child.turn.prompt(任务) 子自己跑一个完整回合(有自己的 context/permission)
└─ waitForChildCompletion() 等子跑完,取它最后一条 assistant 文本当交接
子 agent 是独立的 Agent:自己的历史、自己的权限管理器(带 parent 指针继承规则)、自己的压缩。父 agent 看不到子的上下文,只看到子的最后一条消息——所以子画像(coder.yaml)的系统提示反复强调"你的最终消息就是全部交接,要技术上完整"(packages/agent-core/src/profile/default/coder.yaml:4-7)。太短(<200 字符)还会被要求扩写一轮(subagent-host.ts:418-426)。