# Agent Note: 将每条消息创建为带标识的不可变值

Status: implemented

[English](2026-07-28-identified-immutable-message-values.md) | 中文

## 问题

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` 中，因为它们的完整约定只依赖该词汇。它们使用 `dsh-brand` 的无状态 `brandString()` 构造函数生成 `MessageId`，并在通过 `structuredClone()` 分离输入后使用 `dsh-util-values` 的共享 `deepFreeze()` 实现。`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 交付路由，并合并注入上下文](2026-07-22-unified-send-and-coalesced-user-messages.zh.md)——本记录取代其中的输入表示和由 agent 分配 id 的细节，同时保留其路由决策。
- [可重建的请求](2026-07-05-reconstructable-requests.zh.md)——会话日志仍是每项模型可见输入的权威来源。
