DSH / Atlas
2026-07-05implementedarchitecture

Every LLM request is reconstructable from the session log

每个 LLM 请求都可从会话日志重建

The request pipeline did not guarantee prefix stability for provider caching, and the session log could not reconstruct what the model saw. It omitted model, system prompt, and tool schemas while allowing per-call request rewrites. Cache behavior and replay equivalence therefore depended on whichever plugins happened to be loaded. The reference shape for the happy path is MiniCode's `LLMClient`: a stateful conversati

English

Problem

The request pipeline did not guarantee prefix stability for provider caching, and the session log could not reconstruct what the model saw. It omitted model, system prompt, and tool schemas while allowing per-call request rewrites. Cache behavior and replay equivalence therefore depended on whichever plugins happened to be loaded.

The reference shape for the happy path is MiniCode's LLMClient: a stateful conversation client, appended to — never rebuilt — as the conversation advances, resetting only when the system prompt, tool set, or compaction genuinely changes what the model must see. The design question this Agent Note answers is how to get that discipline without giving up event-sourcing.

Decision

The principle

Model-visible ⟺ durably referenced. Anything that reaches a model request must be reconstructable from the session log and the immutable content-addressed objects it references. The checkable consequence: anyone holding the log, its referenced attachment objects, and the pinned code version reconstructs every loop request byte-for-byte. Text-only GenerateOptions remain a pure function of the log; image-bearing requests additionally resolve ImageAttachmentRef bytes through ctx.attachments during adapter serialization, where digest and recorded metadata verification make the object lookup deterministic and fail loud on missing or corrupt data. Direct one-shots (compaction's summarize call) log their envelope scalars (compaction/summary.{provider, model, maxTokens}), and their input is deterministic code over the logged region plus those referenced objects — outside the invariant because only the loop marks request ownership.

Prefix-cache stability is corollary #1, not the headline: an append-only log projected by a per-node pure function yields requests that are append-extensions of their predecessors whenever the header is unchanged — stability is emergent, not managed. Byte-exact audit/replay is corollary #2; resume and fork with attributable drift is corollary #3.

The mechanism

Messages. Session.deriveMessages() is cached: each surface entry is projected exactly once, when first seen, through the public per-event function deriveEventMessage(event); a surface rewrite (a compaction replaceSurfaceManager.replaceGeneration) rebuilds. Callers get a fresh array per call over shared, deep-frozen messages: mutating logged history through a projection is unrepresentable (it throws), replacing the old clone-per-call isolation. External reconstructors fold the same public function over a log prefix, so no two paths can disagree.

EpochHeader records the request's non-history state: call config, rendered system prompt, and tool schemas, with empty values canonicalized to absence. request/header always writes a full snapshot: the first loop instance uses reason initial, later instances use resume, and an in-instance change uses change. foldRequestHeader selects the latest snapshot. Legacy request/header-delta events and the removed fallback reason are rejected when appended or loaded.

Each proposed step first claims its inbox batch and runs agent/pre-step. Rejection opens no step; enter opens step/start and records the final message batch as user/message events. The step then assembles the system prompt and tools, while agent/request may replace only the frozen call-config seed. The loop records the owed full header snapshot, builds GenerateOptions from derived messages and that header, and deep-freezes it while leaving AbortSignal live. The first call config starts from explicit AgentOptions, preserving fork overrides and resume reconfiguration; later calls start from the folded header.

The open step is the reconstruction boundary. Its entered user/message batch and any newly written request/header precede request dispatch. Injection after the atomic claim joins a later request, while a listener that must affect this request returns messages through agent/pre-step. Header reconstruction selects the step's request/header, or carries the prior snapshot when no new header is written.

Enforcement. The dsh-agent-loop/invariant companion registers with ctx.invariants and, when selected, independently rebuilds each loop request through a fresh Session, so the live cache cannot vouch for itself, then compares messages and folded header fields at llm/stream. The loop records the exact frozen request through markAgentLoopRequest() in dsh-llm; the process-local identity lets the companion and other request observers recognize conversation work, while direct one-shots remain excluded regardless of their frozen shape or session id. Correctness depends on sequence-bounded reconstruction rather than listener order. A with-key e2e requires positive cache-read tokens after the first request; per-step usage is the production signal, and a header change or compaction appears as a cache-read drop on the next step.

The MiniCode shape: adopted, with the event log as the source

Like MiniCode, the conversation advances append-only and resets only when model-visible state changes. Unlike MiniCode, the event log remains the source of truth because it also owns persistence, recovery, boundaries, tool pairing, and links from derived events to their inputs. Session caches message and header folds derived from that log, making every request independently checkable.

Alternatives considered

  • Client as source of truth (literal MiniCode): a second operative truth beside the log — the two drift and nothing notices; see the section above.
  • A stateful transmission client mirroring the log — duplicates conversation state, needs rollback around listeners, leaves an unlogged edit path, and still cannot reconstruct request headers. Session-owned caches plus logged headers avoid those split truths.
  • Per-call request scalars (a freely mutable config handed to each agent/request dispatch): a listener flips the model per call with zero accounting, silently abandoning the provider cache this design exists to protect. Config is per-conversation logged state; the waterfall proposes, the log records.
  • Detect-and-report (compare consecutive requests, warn on divergence): catches violations after the fact; a violating request is still constructible and ships. Rejected for interface-level unrepresentability.
  • Event-driven assembly (re-render only on change signals): a missed-signal bug class — a tool registered mid-session emits tools/change, not system-prompt/change, and a third-party provider may emit nothing. Per-step render + value compare is robust with zero signal discipline.
  • A custom header-delta codec (system line edits, name-keyed tool edits, whole config/prefix replacements): reduced repeated bytes but duplicated the representation and its diff/apply/fallback machinery. Full snapshots retain one replay representation.
  • Narrative changed-field lists on header snapshots: derivable by comparing consecutive snapshots. The reason remains because an instance boundary is not derivable from the snapshot values.

Consequences

  • A request that is not explained by the log cannot be constructed by accident — not by the loop, not by a listener; mutating a built request throws; every header change is a durable, diffable log event.
  • Model-visible context uses logged message channels. agent.inject() and tool additionalContexts enter the inbox for a later claim, while agent/pre-step returns context that must settle with the current claimed batch. Each entered value is a durable sourced user/message, paid once and prefix-cached thereafter at the price of accumulating in history until compaction.
  • What still costs full price at the provider is inherent and logged: compaction (its compaction/* events and replacement entry), a real prompt, tool, or config change (request/header with reason change), or a process boundary with drift (a differing resume snapshot). The provider's own reasoning-content exclusion is managed server-side.
  • agent/pre-step is the current-request message channel; direct inbox mutation is the eventual later-request channel.
  • Tool-result trimming needs no new mechanism: a logged single-entry surface replace (start === end) carrying a trimmed tool/result under the same callId — compaction-family, replay-correct, cache-bust batched by the same pressure logic.
  • Unreadable referenced attachment objects still fail model requests; automatic attachment quarantine records the proposed recovery without weakening byte-exact reconstruction.
  • Session logs grow one request/header snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. SESSION_FORMAT_VERSION stays 0; legacy delta events are rejected rather than migrated.
  • Snapshot expected outputs changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths.

中文

问题

请求流水线未能保证前缀稳定性以利用提供方缓存,会话日志也无法重建模型实际看到的内容。日志遗漏了模型、系统提示词和工具 schema,同时允许逐次调用的请求改写。因此缓存行为和回放等价性取决于碰巧加载了哪些插件。

快乐路径的参考形态是 MiniCode 的 LLMClient:一个有状态的对话客户端,随对话推进只做追加而不重建,仅在系统提示词、工具集或压缩(compaction)真正改变了模型需要看到的内容时才重置。本 Agent Note 回答的设计问题是:如何在不放弃事件溯源的前提下获得这种纪律。

决策

原则

模型可见 ⟺ 已持久引用。 凡到达模型请求的内容都必须能从会话日志及其引用的不可变内容寻址对象中重建。可检查的推论:任何人持有日志、日志引用的附件对象和固定代码版本,即可逐字节重建循环的每个请求。纯文本 GenerateOptions 仍是日志的纯函数;含图片请求还会在适配器序列化期间通过 ctx.attachments 解析 ImageAttachmentRef 字节,其中对内容摘要及已记录元数据的校验使对象查找具有确定性,并在数据缺失或损坏时明确失败。直接的一次性调用(压缩的 summarize 调用)记录其信封标量(compaction/summary.{provider, model, maxTokens}),其输入是对日志区域及这些引用对象的确定性代码运算——由于只有循环会标记请求归属,因此它们不在不变式内。

前缀缓存稳定性是推论 #1,而非标题:一个仅追加的日志经逐节点纯函数投影,在 header 不变时自然产出前一请求的追加扩展——稳定性是涌现的,不是管理出来的。字节精确的审计/回放是推论 #2;带可归因漂移的恢复与 fork 是推论 #3。

机制

消息。 Session.deriveMessages() 带缓存:每个 surface 条目在首次出现时通过公开的逐事件函数 deriveEventMessage(event) 精确投影一次;surface 重写(压缩的 replace,即 SurfaceManager.replaceGeneration)触发重建。调用方每次获得一个新数组,底层是共享的深度冻结消息:通过投影变异已记录的历史是不可表达的(会抛异常),取代了旧的逐次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。

EpochHeader 记录请求的非历史状态:调用配置、渲染后的系统提示词和工具 schema,空值规范化为缺失。request/header 始终写入完整快照:首个循环实例使用 reason initial,后续实例使用 resume,实例内变更使用 changefoldRequestHeader 选择最新快照。旧的 request/header-delta 事件和已移除的 fallback reason 在追加或加载时都会被拒绝。

每个拟议步骤先领取其 inbox 批次,再运行 agent/pre-step。reject 不打开步骤;enter 打开 step/start,并把最终消息批次记录为 user/message 事件。随后步骤组装系统提示词与工具,agent/request 只能替换冻结的调用配置种子。循环记录所需的完整 header 快照,从派生消息与该 header 构建 GenerateOptions,对其深度冻结但保持 AbortSignal 活跃。首次调用配置从显式的 AgentOptions 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。

已打开步骤是重建边界。 进入步骤的 user/message 批次与任何新写入的 request/header 都位于请求分派之前。原子领取后发生的注入加入后续请求;必须影响本次请求的监听器则通过 agent/pre-step 返回消息。header 重建选择该步骤的 request/header,或在无新 header 写入时沿用前一个快照。

强制执行。 dsh-agent-loop/invariant 配套插件向 ctx.invariants 注册,并在被选用时通过一个全新的 Session 独立重建每个循环请求,使活跃缓存无法为自身背书,然后在 llm/stream 处比较消息和折叠后的 header 字段。循环通过 dsh-llmmarkAgentLoopRequest() 记录精确的冻结请求;这一进程内标识让配套插件和其他请求观察者识别对话工作,而直接的一次性调用无论其冻结形状或会话 id 如何都保持排除。正确性依赖于序列有界的重建,而非监听器顺序。带密钥的 e2e 要求首次请求之后有正值的 cache-read token;逐步骤用量是生产信号,header 变更或压缩表现为下一步骤的 cache-read 下降。

MiniCode 形态:采纳,以事件日志为真源

与 MiniCode 相同,对话仅追加推进,仅在模型可见状态变更时重置。与 MiniCode 不同,事件日志仍是真源,因为它同时拥有持久化、恢复、边界、工具配对,并记录派生事件到其输入事件的关联。Session 缓存从日志推导的消息和 header 折叠结果,使每个请求都可独立检查。

曾考虑的替代方案

  • 客户端作为真源(照搬 MiniCode):在日志之外多出一个运行时真相——两者漂移而无人察觉;见上节。
  • 镜像日志的有状态传输客户端:重复对话状态,需要围绕监听器做回滚,留下未记录的编辑路径,且仍无法重建请求 header。Session 拥有的缓存加已记录的 header 避免了这些分裂的真相。
  • 逐次调用的请求标量(一个可自由变异的配置传给每次 agent/request 分发):监听器可以零记账地逐次切换模型,悄然放弃本设计旨在保护的提供方缓存。配置是逐对话的已记录状态;waterfall(瀑布式事件)提议,日志记录。
  • 检测并报告(比较连续请求,发散时告警):事后捕获违规;违规请求仍可构造并发出。因违规必须在接口层面不可表达而否决。
  • 事件驱动组装(仅在变更信号时重新渲染):存在漏信号的 bug 类别——会话中途注册的工具发出 tools/change 而非 system-prompt/change,第三方提供方可能什么都不发。逐步骤渲染加值比较在零信号纪律下即可稳健工作。
  • 自定义 header-delta 编解码器(系统行编辑、按名称键控的工具编辑、完整配置/前缀替换):减少了重复字节,却复制了表示及其 diff/apply/fallback 机制。完整快照只保留一种回放表示。
  • Header 快照上的叙事性变更字段列表:可以通过比较连续快照推导。reason 仍保留,因为实例边界无法从快照值推导。

后果

  • 一个日志无法解释的请求不可能被意外构造——无论是循环还是监听器;变异已构建的请求会抛异常;每个 header 变更都是持久的、可 diff 的日志事件。
  • 模型可见上下文使用已记录消息通道。agent.inject() 与工具 additionalContexts 进入 inbox,等待后续领取;必须与当前已领取批次一起结算的上下文由 agent/pre-step 返回。每个进入步骤的值都是带来源的持久 user/message,只付出一次代价并在后续成为可缓存前缀,代价是会在历史中累积直至压缩。
  • 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 compaction/* 事件和替换条目)、真正的提示词、工具或配置变更(reason 为 changerequest/header),或带漂移的进程边界(不同的 resume 快照)。提供方自身的 reasoning-content 排除由服务端管理。
  • agent/pre-step 是当前请求的消息通道;直接修改 inbox 则是最终进入后续请求的通道。
  • 工具结果裁剪无需新机制:一个已记录的单条目 surface replace(start === end),携带同一 callId 下裁剪后的 tool/result——属压缩家族,回放正确,缓存失效由相同的压力逻辑批量处理。
  • 无法读取的被引用附件对象仍会让模型请求失败;附件自动隔离记录了不削弱字节精确重建的拟议恢复方案。
  • 会话日志每个循环实例增长一个 request/header 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。SESSION_FORMAT_VERSION 保持 0;旧的 delta 事件被拒绝而非迁移。
  • 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。