Create every message as an identified immutable value
将每条消息创建为带标识的不可变值
The harness had several message-shaped representations with different identity rules. Agent input acquired an inbox correlation id only when the loop accepted it, while durable user messages, assistant messages, tool results, and model-request messages could have no identity. Prompt admission therefore sat between creation and identity, and equivalent content was copied across live events, durable events, and model r
English
Problem
The harness had several message-shaped representations with different identity rules. Agent input acquired an inbox correlation id only when the loop accepted it, while durable user messages, assistant messages, tool results, and model-request messages could have no identity. Prompt admission therefore sat between creation and identity, and equivalent content was copied across live events, durable events, and model requests without one value that named the message throughout its lifetime.
This made identity a routing side effect rather than a message invariant. Producers could not refer to a message before calling the agent, prompt hooks received content and source separately, and later projections had to reconstruct a message while deciding whether an id existed. Immutability also began at different boundaries: some inputs were frozen by the loop, some only by session append, and provider-produced assistant output used a separate shape carrying provider, model, and replay state.
Decision
@deepseek-ai/dsh-llm owns one Message value with required id, role, content, and source. MessageId is opaque and shared by user, assistant, and tool-result messages. A message receives its id at creation, before inbox routing, claim, pre-step rewriting, durable append, or request projection. The same id survives every representation boundary.
createMessage(input) is the canonical role-generic creation boundary. It mints a MessageId, detaches the supplied role, content, and source, and deep-freezes the complete value before returning it. createUserMessage({ content, source }) fixes the user role for prompt and context producers. createAssistantMessage({ content, source }) fixes both the assistant role and the model source kind, so model-output producers supply only content plus provider, model, and optional replay state. All creation helpers exclude an input id so callers cannot accidentally present creation as import. freezeMessage(message) is the separate import or transformation boundary: it detaches and deep-freezes a message whose identity already exists, without minting a replacement.
The helpers live in dsh-llm beside the base message vocabulary because their complete contracts depend only on that vocabulary. createToolResultMessage() belongs with the other creation helpers: it couples a tool call id to the exact user-role tool-result block and source without depending on session state or events. dsh-session consumes complete messages rather than owning their construction.
The Agent interface accepts a complete UserMessage through followup, steer, and inject. These operations never allocate or return identity; they freeze an imported value whose id the caller already holds. Inbox claims and agent/pre-step receive that message directly. A content rewrite creates a frozen replacement with the same id, while an additional context is a separately created UserMessage with its own id.
Durable message-producing events store complete messages. user/message stores its UserMessage directly; assistant/message and tool/result wrap their role-specialized message beside event-local position, usage, failure, or presentation facts. Session derivation returns those frozen values instead of reconstructing anonymous messages. Assistant assembly creates a model-sourced message when a response completes, and tool execution creates a tool-sourced message when a result is committed.
Any operation that changes only the representation of an existing semantic message preserves its id and returns another frozen value. An operation that creates a new semantic message mints a new id. Compaction content rewrites therefore preserve the rewritten tool-result identity, while a summary checkpoint is a new message.
Alternatives considered
Keep ids optional on the base message. This would minimize fixture migration and allow provider or persistence shapes to remain anonymous. It would also preserve the original ambiguity: every consumer would need to branch on whether identity exists, and no type would prove that admission, logging, or projection retained it.
Let agent delivery allocate the id. This keeps identity scoped to inbox correlation but makes the agent call the earliest point at which a producer can name its own message. Prompt construction, UI attachments, and synchronous enqueue/discard coordination then need content matching or an out-of-band token before delivery returns.
Let each durable event allocate a new id. This gives persisted messages identities but deliberately breaks correlation with the live input and makes replayed requests appear to contain different messages. Identity belongs to the semantic value, not to each envelope that carries it.
Freeze only at agent or session admission. This avoids a creation helper but leaves an identified mutable interval in which caller code can change the meaning associated with an id. The decision makes “has an id” and “is an immutable snapshot” coincide.
Consequences
Every message producer must choose creation or import explicitly, and tests construct complete values rather than partial content/source records. UUID generation moves outward to the first semantic creation point, so deterministic fixtures that provide an existing id use freezeMessage() instead of createMessage().
Live inbox events, durable events, derived history, and model requests can correlate one message without content equality or envelope-specific ids. Pending-input policy and UI attachment cleanup can compare MessageId before a turn exists, while claims retain that identity inside the open turn. Deep freezing prevents a producer, hook, or observer from changing the value after identity is established.
The shared representation removes the old UserMessageData/AgentMessage split and puts provider, model, and optional replay state into typed message sources. Event envelopes still own facts that are not message semantics, such as turn and step position, token usage, internal tool failure identity, and presentation metadata.
The message and helper unit tests pin immediate identity, detachment, deep immutability, and preservation of an imported id. Agent-loop tests pin identity across admission, inbox lifecycle, durable append, content rewriting, and cancellation; session tests pin frozen derivation and identity-preserving replacement.
Related
- Unified agent delivery routing and coalesced injected context — this note supersedes its input-representation and agent-assigned-id details while retaining its routing decision.
- Reconstructable requests — the session log remains the authority for every model-visible input.
中文
问题
harness 曾存在多种形似消息的表示,各自采用不同的标识规则。agent(智能体)输入只有在 agent loop 接受后才会取得 inbox 关联 id,而持久用户消息、assistant 消息、工具结果和模型请求消息都可能没有标识。因此,提示词准入介于创建消息与建立标识之间;等价内容会在实时事件、持久事件和模型请求之间复制,却没有一个值能在消息的整个生命周期中标识它。
这使标识成为路由的副作用,而不是消息不变量。生产方无法在调用 agent 前引用一条消息,提示词钩子会分别接收内容和来源,后续投影则必须一边重建消息,一边决定 id 是否存在。不可变性也从不同边界开始:部分输入由 agent loop 冻结,部分直到会话追加时才冻结,由提供方生成的 assistant 输出则使用另一种携带提供方、模型和回放状态的形状。
决策
@deepseek-ai/dsh-llm 持有唯一一种 Message 值,其 id、role、content 和 source 均为必填。MessageId 是不透明标识,由用户消息、assistant 消息和工具结果消息共享。消息在创建时就会获得 id,早于 inbox 路由、领取、pre-step 改写、持久追加或请求投影。同一个 id 会跨越每个表示边界。
createMessage(input) 是角色通用的规范创建边界。它会生成 MessageId,将传入的角色、内容和来源与调用方对象解除引用关系,并在返回完整值前将其深度冻结。createUserMessage({ content, source }) 为提示词和上下文生产方固定 user 角色。createAssistantMessage({ content, source }) 同时固定 assistant 角色与模型来源类别,因此模型输出生产方只需提供内容,以及提供方、模型和可选的回放状态。所有创建辅助函数的输入都不包含 id,因此调用方不会意外地把新消息的创建伪装成已有消息的导入。freezeMessage(message) 是独立的导入或转换边界:它会将已有标识的消息与调用方对象解除引用关系并深度冻结,不会生成替代标识。
这些辅助函数位于基础消息词汇旁的 dsh-llm 中,因为它们的完整约定只依赖该词汇。createToolResultMessage() 与其他创建辅助函数同属此处:它将工具调用 id 与确切的 user-role 工具结果块及来源耦合起来,不依赖会话状态或事件。dsh-session 只消费完整消息,不负责构造它们。
Agent 接口通过 followup、steer 和 inject 接收完整的 UserMessage。这些操作绝不会分配或返回标识;它们会冻结导入的值,而调用方已经持有该值的 id。inbox 领取和 agent/pre-step 会直接接收该消息。改写内容时会创建具有相同 id 的冻结替代值,而每个附加上下文都是单独创建的 UserMessage,拥有自己的 id。
产生持久消息的事件会存储完整消息。user/message 直接存储其 UserMessage;assistant/message 和 tool/result 则将各自角色专用的消息与事件本地的位置、用量、失败或呈现事实包装在一起。会话派生会返回这些冻结值,而不是重建匿名消息。assistant 组装会在响应完成时创建模型来源的消息,工具执行会在提交结果时创建工具来源的消息。
仅改变已有语义消息表示的操作会保留其 id,并返回另一个冻结值。创建新语义消息的操作则会生成新 id。因此,压缩(compaction)中的内容改写会保留被改写工具结果的标识,而摘要检查点是一条新消息。
考虑过的替代方案
让基础消息的 id 保持可选。 这能减少 fixture(测试前置数据)迁移,并允许提供方或持久化形状继续保持匿名,但也会保留原有歧义:每个消费方都必须根据标识是否存在执行分支,且没有任何类型能证明准入、记录或投影保留了标识。
让 agent 交付分配 id。 这会将标识限定在 inbox 关联范围内,却也会让 agent 调用成为生产方可以标识自身消息的最早时机。这样一来,在交付返回前,提示词构造、UI 附件和同步入队/丢弃协调都需要进行内容匹配,或使用带外 token。
让每个持久事件分配新 id。 这能为持久消息提供标识,却会有意切断它与实时输入的关联,并让回放请求表现得像包含了不同消息。标识属于语义值,而不是承载它的每个封装。
只在 agent 或会话准入时冻结。 这能省去创建辅助函数,却会留下一个带标识但可变的时间区间,调用方代码可以在这段时间内改变该 id 所关联的含义。本决策让「拥有 id」与「是不可变快照」同时成立。
后果
每个消息生产方都必须显式选择创建或导入,测试也会构造完整值,而不是不完整的内容/来源记录。UUID 的生成会前移至最初的语义创建点,因此提供已有 id 的确定性 fixture 会使用 freezeMessage(),而不是 createMessage()。
实时 inbox 事件、持久事件、派生历史和模型请求可以关联同一条消息,无需比较内容或使用封装专用 id。待处理输入策略和 UI 附件清理可以在轮次存在之前比较 MessageId,领取后则会在已打开的轮次内保留该标识。深度冻结可以防止生产方、钩子或观察方在标识建立后更改消息值。
共享表示移除了旧的 UserMessageData/AgentMessage 划分,并将提供方、模型和可选的回放状态放入带类型的消息来源。事件封装仍持有不属于消息语义的事实,例如轮次与步骤位置、token 用量、内部工具失败标识和呈现元数据。
消息和辅助函数的单元测试会锁定即时标识、解除输入引用、深度不可变性,以及导入 id 的保留。agent loop 测试会锁定标识跨越准入、inbox 生命周期、持久追加、内容改写和取消的行为;会话测试会锁定冻结派生和保留标识的替换行为。
相关
- 统一 agent 交付路由,并合并注入上下文——本记录取代其中的输入表示和由 agent 分配 id 的细节,同时保留其路由决策。
- 可重建的请求——会话日志仍是每项模型可见输入的权威来源。