Coding Agent Harness 拆解 · Session 编排层:内核如何缝进产品(九)
本文是《Coding Agent Harness 拆解》系列的一篇。整个系列逐个拆开一个真实在用的终端 AI 编程 agent codebot,以及它底下的执行内核 agentcore。前面几篇立过一条边界:内核负责"怎么转",harness 负责"转什么、给谁看"。内核 agentcore.Agent 只暴露一条事件流,向下只依赖注入进来的模型与工具。本篇正面拆那个把内核缝进产品的接缝,即 internal/agent.Session:用户输入怎样在送进内核前被组装、内核事件回来后怎样落盘与收尾、以及一个长期复用的 Session 对象如何切换、重置、侧问。
一个结构体缝住两个世界
Session 是整套 harness 的缝合层:向下是一个几乎纯粹的内核 agentcore.Agent,向上是 TUI / print / ACP 三种前端和一整套产品能力(持久化、reminder、压缩、遥测、hooks、技能、目标)。这些东西都不各自去戳内核,全部挂在 Session 这一根缝线上。
Session(internal/agent/session.go)字段很多,但按"它在缝谁"分三类就清楚了:
| 面向内核 | 面向产品状态 | 面向持久化 / 观测 |
| --- | --- | --- |
| agent *agentcore.Agent | reminders reminderState(静态 + 一次性队列) | store *storage.Store(当前会话 JSONL) |
| contextManager | skillRuntime / 目标 / plan 信号回调 | mgr *storage.Manager(切换 / 新建) |
| chatModel | activeTools / overlays / dynamicText | cacheSnap(上一轮缓存指纹) |
| unsub func()(退订句柄) | pendingToolCalls / recentToolCalls | pendingUserMsg(LazyPersist 缓冲) |
构造时(NewSession)它一次装好四个内部协作器,然后是最关键的一行,把自己挂到内核事件流上:
s.prompts = newSessionPromptManager(s) // system blocks / reminders / tool-set rebuild
s.persistence = newSessionPersistence(s) // persist + llm_call + auto-name
s.context = newSessionContextController(s)
s.runtime = newSessionRuntimePolicy(s) // duplicate-call detect / goal continue / plan reminder
// ...
s.unsub = cfg.Agent.Subscribe(s.handleAgentEvent) // attach self to the kernel event stream
Subscribe 返回的 unsub 存进 s.unsub,Close 时调用它退订,这是缝线两端能被干净拆开的前提。
Prompt 的 preflight:真正"送进内核前"做的事
Session.Prompt(text) 不是简单地把文本转发给内核。它在 agent.PromptMessages 之前跑一段严格有序的 preflight,把"这一轮用户输入"组装成内核能吃、又带齐产品上下文的消息。顺序不能乱:hook 产出的上下文要先入队,才能被随后组装的用户消息带上。
func (s *Session) Prompt(text string) error {
var hookContext string
if s.hookRunner != nil {
dec, err := s.hookRunner.RunUserPromptSubmit(context.Background(), text) // 1. UserPromptSubmit hook
if err != nil {
return err
}
hookContext = strings.TrimSpace(dec.AdditionalContext)
}
s.beginTurn() // 2. reset per-turn state + workspace snapshot
if s.beforePrompt != nil {
s.beforePrompt()
}
if hookContext != "" { // 3. wrap hook context as a one-shot reminder
s.queueRuntimeReminder("hook_context", ReminderHookContext, wrapHookContext(hookContext))
}
s.runtime.beforeUserPrompt([]agentcore.ContentBlock{agentcore.TextBlock(text)}) // 4. queue task/plan reminders
var msgs []agentcore.AgentMessage
if !s.preambleInjected && s.deferredToolsPreamble != "" { // 5. inject deferred-tools list once
msgs = append(msgs, injectedUserMsg(s.deferredToolsPreamble))
s.preambleInjected = true
}
msgs = append(msgs, s.buildUserMessage(agentcore.TextBlock(text))) // 6. assemble user msg with reminders
return s.startPromptMessages(msgs...) // 7. open telemetry span -> agent.PromptMessages
}
七步逐一对上真实符号:
| 步骤 | 调用 | 作用 |
| --- | --- | --- |
| 1 | hookRunner.RunUserPromptSubmit | 跑 UserPromptSubmit 钩子,产出的 AdditionalContext 作为上下文注入 |
| 2 | beginTurn | 清空 currentTurn、复位 per-turn reminder 去重、snapshotTurnStart 做工作区 checkpoint(供 /undo) |
| 3 | queueRuntimeReminder("hook_context", …) | hook 输出被 wrapHookContext 裹成 <system-reminder>,进一次性队列 |
| 4 | runtime.beforeUserPrompt | queueTaskManagementPromptReminder + queuePlanModePromptReminder,按状态排队 |
| 5 | deferredToolsPreamble | <available-deferred-tools> 作为首条用户消息注入,preambleInjected 保证只此一次 |
| 6 | buildUserMessage | 把排好队的 reminder 前置成文本块 |
| 7 | startPromptMessages | 包一层遥测 run,再调 s.agent.PromptMessages(ctx, msgs...) |
第 6 步的 buildUserMessage 是 reminder 注入的落点。它把两类提醒前置到用户文本之前:一次性队列(reminders.runtime,读完即清空并重置去重键)和静态提醒(reminders.static,跨轮常驻,由 rebuildPrompt 从上下文文件 + 技能经 config.BuildReminders 生成):
// runtime reminders are one-shot: drained here and the dedup keys reset
runtimeReminders := append([]string(nil), s.reminders.runtime...)
s.reminders.runtime = nil
s.reminders.runtimeKeys = make(map[string]struct{})
staticReminders := append([]string(nil), s.reminders.static...)
// ... blocks = [runtime...] + [static...] + userBlocks
顺序是刻意的:一次性提醒在最前(时效性最强),静态提醒居中,用户原文永远是最后一个文本块。这个约定被 tryAutoName 里的 lastTextBlock 复用:取最后一个文本块就能拿到用户输入的那句话,而不会拿到某条 reminder。
::: tip preflight 只组织"入参",不驱动循环
preflight 做的全是准备:跑钩子、排提醒、拼消息。实际“转起来”在第 7 步交给 agent.PromptMessages 之后,才由内核循环(agentcore/loop.go)逐点 emit 事件。Session 对循环内部一无所知,它只在两头出现:入口塞消息,事件流上做副作用。
:::
一次 Prompt 的完整时序
把 preflight、内核循环、事件回调三段接起来,一次 Prompt 的骨架是这样:
sequenceDiagram
participant UI as TUI/print
participant Sess as Session.Prompt
participant Agent as agentcore.Agent
participant Runner as AgentLoop
participant Cb as handleAgentEvent
participant Store as storage.Store
UI->>Sess: Prompt(text)
Sess->>Sess: RunUserPromptSubmit / beginTurn / 排 reminder
Sess->>Sess: buildUserMessage(前置 reminder)
Sess->>Agent: PromptMessages(ctx, msgs...)
Agent->>Runner: 起循环
loop 每个内核 Event(同步回调)
Runner-->>Agent: Event
Agent-->>Cb: handleAgentEvent(ev)
Cb->>Cb: 按 ev.Type 做副作用
Cb->>Store: EventMessageEnd → 落盘 / llm_call / 自动命名
Cb-->>UI: emit SEAgentEvent
end
Note over Cb: EventAgentEnd → flush / hooks / idleHook
handleAgentEvent(internal/agent/session_state.go)就是缝线上的回调。它对每个内核事件先驱动产品副作用,再 s.emit(SessionEvent{Type: SEAgentEvent, AgentEvent: &ev}) 透传给前端。事件分发本身属于消息与事件流那一层,这里只盯它上面挂的两块重活:持久化和 EventAgentEnd 收尾。
持久化:每条消息落一次盘,每次调用留一笔账
助手消息成型(EventMessageEnd 且 Role==Assistant)时,persistence.handleMessageEnd 依次做三件事:
p.persistMessage(msg) // append to the session JSONL
if msg.Role == agentcore.RoleAssistant {
p.persistLLMCall(msg) // write one llm_call observability entry
p.tryAutoName() // first user input -> session name (once)
p.maybeExtractSessionMemory()
}
persistMessage 把消息 AppendMessage 到 store。失败不致命,它把错误(含非法工具入参的诊断)以 SEError 抛给 UI,绝不阻断会话。
persistLLMCall 为刚结束的这次模型调用写一条 LLMCallEntry:token 用量、StopReason、以及由 lastAssistantStart(在 EventMessageStart 时打点)算出的 latency_ms。Usage 为空(如恢复出来的消息)时直接跳过。
tryAutoName 用 autoNamed 标志保证一个会话只命名一次:扫第一条非注入用户消息,取前 50 个 rune 作会话名,SetName 丢进 goroutine 异步落盘。
::: tip cache_read 掉了是谁的锅?detectCacheBreak 当场归因
persistLLMCall 每次都会用 detectCacheBreak(prevSnap, currSnap) 比对这一轮和上一轮的缓存指纹(cacheSnapshot:frozen 系统块 hash、dynamic 块 hash、工具集 hash、观测到的 CacheRead)。当 cache_read 相对上一轮跌超 5% 且绝对值超 2000 token,它就写一条带归因的 CacheBreakInfo:frozen 前缀变了是"异常,要查",dynamic 块变了是"plan 切换 / MCP 刷新"这类日常,都没变则记为"TTL 过期或 provider 侧未命中"。这是一套自我诊断缓存效率的观测,指纹只是廉价 hash,从不存请求本身(internal/agent/cache_monitor.go)。
:::
LazyPersist 是一个可选的安全裕度:开启后用户消息先缓冲进 pendingUserMsg,直到助手回复到达才 flushPendingMessages 一起落盘,避免"用户发了话、模型还没回、进程就崩了"留下半截会话。默认关闭。
EventAgentEnd:一轮真正落幕时的收尾
EventAgentEnd 是整个 run 的终点,handleAgentEvent 在这里跑一段带闸门的有序收尾,每一步都可能因为"还有后续在飞"而提前 return,把 idle 推迟:
if ev.Type == agentcore.EventAgentEnd {
s.endTelemetryRun(ev.Err) // close telemetry span, record lastRunSummary
s.persistence.flushPendingMessages() // flush the LazyPersist buffer
if s.context.handleAgentEnd() { // compaction/retry still in flight? -> defer teardown
return
}
s.finalizeTurnOutcome() // currentTurn -> lastTurn
if s.runtime.continuePendingReminder() { // a steered reminder needs Continue()?
return // -> resume in a goroutine, do not go idle
}
s.runtime.afterAgentEnd() // post-stop checks / goal auto-continue
if s.hookRunner != nil {
s.hookRunner.RunNotification(context.Background(), "agent response complete")
}
s.clearSkillDelta() // drop this turn's ephemeral skill overrides
if s.idleHook != nil {
s.idleHook() // truly idle -- notify UI (e.g. suggestion gen)
}
}
idleHook 由 SetIdleHook 注册,语义很硬:只有当这一轮彻底安定(没有压缩要续、没有被 steer 的 reminder 要 Continue)才触发。它跑在事件分发 goroutine 上,所以必须廉价,常见用途是让 TUI 知道"可以去生成下一句输入建议了"。两道闸门(context.handleAgentEnd 与 continuePendingReminder)保证 idle 不会在一轮还会自动续跑时误触发。
生命周期:切换、重置、关闭
同一个 Session 对象在整个进程里复用,切换会话不重建对象,只把内核清空后重新灌。三个方法共享同一套"清干净再重建"的纪律:
| 方法 | 关键动作 | 不变式 |
| --- | --- | --- |
| SwitchSession(id) | mgr.Open → BuildSnapshot → agent.ClearMessages/ClearAllQueues → SetMessages(snapshot) → ReactivateDeferred → 恢复 model/thinking | 换会话即换缓存血缘,SetPromptCacheKey 重指向目标 SessionID |
| Reset() | mgr.Create 建新 JSONL → 清空内核 → autoNamed=false | 全新会话,缓存路由键指向新 SessionID |
| Close() | unsub() 退订 → flushPendingMessages → snapshotter.Close → store.Close | 缝线两端干净拆开,不落半截消息 |
三者都会走 resetHarnessStateLocked:它 generation++(让在飞的异步 goroutine 通过 continueIfCurrentGeneration 之类的检查自行退出,避免跨会话回调)、清空 reminders / pendingToolCalls / recentToolCalls / 各类快照,并 fileReadState.Reset()。因为换了对话,LLM 就丢了读文件的历史,旧的"先读后写"时间戳必须一并作废,否则下一次 write/edit 会基于模型已经不记得的读操作放行。SwitchSession 与 Reset 都会先 bump generation 再动内核,原因就在这里。
ephemeralQuery:复用对话前缀的一次性侧问
有些功能想"借着当前对话问一句",但绝不能污染会话历史,比如输入建议、/btw 侧问。ephemeralQuery 就是这条一次性通道,SideQuestion 和 GenerateSuggestion 共用它:
raw, _ := s.agent.BuildLLMMessages() // the loop's full message set (system + conversation)
msgs := make([]agentcore.Message, 0, len(raw))
for _, m := range raw {
// keep system/user as-is; for assistant keep only its ContentText blocks
// (drops tool_call + thinking); tool_result messages are skipped entirely --
// litellm serializes assistant via TextContent(), so a thinking-only
// message would arrive empty and get rejected by OpenAI.
// ...
}
msgs = append(msgs, agentcore.Message{ // append this side question
Role: agentcore.RoleUser, Content: []agentcore.ContentBlock{agentcore.TextBlock(userText)},
})
toolSpecs := s.agent.BuildLLMTools() // forward tools verbatim (see below)
return model.Generate(ctx, msgs, toolSpecs,
agentcore.WithThinking(agentcore.ThinkingOff),
agentcore.WithCallPromptCacheKey(sessionID)) // result is never persisted
两个反直觉的细节都是为了蹭上主循环的 prompt cache:
- tools 原样转发,哪怕根本不指望模型调用它们。因为 Anthropic 的缓存前缀是
tools → system → messages,缓存断点就设在最后一个 system 块上;这里若省掉 tools,前缀断裂,即使 system 块逐字节相同也会白付整块 system 缓存(约 10K token)。模型不调工具靠 prompt 引导,没有用 tool_choice:后者在 OpenAI 与 Anthropic 之间线格式不同、litellm 不归一。万一模型真调了,就把响应里的 tool_call 块丢弃了事。
WithCallPromptCacheKey(sessionID) 用与主循环相同的路由键,让 key-routed provider(如 OpenAI 的 prompt_cache_key)把侧问落到同一分片,才能命中主对话的缓存。
GenerateSuggestion 在 ephemeralQuery 之上加了一层清洗:喂 config.SuggestionPrompt、限 500 token,然后把返回值去引号、判 NONE、超过 12 个词就丢弃,保证吐出来的是一句干净的下一步输入猜测,拿不到就返回空串。
三问回顾
- 这个机制解决了什么痛点? 它给内核
Agent 和全部产品能力之间提供了唯一的接缝:所有 harness 逻辑(钩子、reminder、持久化、遥测、生命周期)不各自去戳内核,集中挂在 Prompt 入口和一条事件回调上,产品迭代因此动不到内核。
- 如果没有它,会在哪里崩? reminder 注入、
llm_call 观测、自动命名、缓存诊断、会话切换的 generation 防护会散落到各个前端里;切换会话时残留的时间戳与在飞 goroutine 会造成跨会话串扰,/btw 与建议也会污染真实对话历史。
- 它和前后怎么接上? 向下它
Subscribe 内核 Agent,把 Event 包成 SessionEvent;持久化落进会话 JSONL 树;系统块与 reminder 的重建接的是上下文工程;而 EventAgentEnd 上那道 context.handleAgentEnd 闸门背后,就是压缩衔接的入口。
延伸阅读