附件与溢写
会话里有两类"太大,不该住进事件日志"的字节:用户附上的图片,和模型工具产出的超长结果。
它们各有一个内容寻址的旁路存储。对应实现:src/Tether.Attachment/ + .Local 与 src/Tether.Spill/ + .Local。
附件:内容寻址的图片存储
public interface IAttachmentStore
{
ImageAttachmentLimits ImageLimits { get; }
ValueTask<ImmutableArray<AttachmentRef>> AdmitAsync(
IReadOnlyList<AttachmentInput> inputs,
CancellationToken ct = default);
ValueTask<AttachmentContent> OpenAsync(AttachmentRef reference, CancellationToken ct = default);
}
- 内容寻址。
AttachmentRef以 SHA-256 摘要为身份;同一批字节重复提交返回同一个引用——天然去重。 - 准入即校验。
AdmitAsync对整批 PNG / JPEG / WebP / GIF 做声明类型、解码尺寸、像素、单图字节、图片数与总字节校验;任何一项失败都不返回半批引用。 - 原子对象发布。 每个对象写盘是原子的:要么完整对象出现,要么不存在,崩溃不会留下半截文件。
- 读回重校验。
OpenAsync重新核对摘要,盘上损坏以ATTACHMENT_DIGEST_MISMATCH暴露为错误,而不是把坏字节交给模型。
目标边界是事件日志只记 AttachmentRef(id、媒体类型、字节数、摘要与已验证尺寸),正文留在对象存储里;请求派发时才按引用取回。
command 与普通 Agent 发送共用统一准入
direct command 的 wire 可以携带有序 EncodedImageAttachment[]。只有 descriptor 明确声明
Input.Images=true 的命令才能消费;Host executor 会在 handler 前把整批图片解码,并只调用
一次 IAttachmentStore.AdmitAsync。缺 provider、声明不允许、任一格式/策略超限或取消都会写
确定的 command/done error,handler 不会看到半批 durable refs。
准入成功后,CommandInvocation.Attachments 只含冻结的 CommandImageBlock(AttachmentRef);
command/run / command/done、CLI transcript 和 projection 不保存原始 path、bytes 或 base64。
这条边界只负责把完整提交信封安全交给命令 handler,registry 不会自行把引用发送给模型。
核心 user/message 携带 refs-only 图片块(MessageAttachment)。
Order 是 text/image 合并序列里的 0-based slot:例如两个 durable text 与一张
Order=1 图片,模型看到 text-before, image, text-after。重复或越界 order
会在任何 inbox/spliced / user/message append 前失败,不会留下 partial event。
durable user payload 无论有没有 refs 都只接受无 metadata 的 TextContent:message/content 的
AdditionalProperties、author/id/time、annotations 与 raw JSON unknown fields 均在 snapshot/
replay 边界 fail closed;attachment Name
只能是经准入清理、最长 255 字符的叶显示名,path、URL、控制字符和未 trim 文本在构造与冷重放时
一律 fail closed。因此 bytes/base64 甚至不会先进入 process-local durable snapshot 再等待落盘拒绝。
Host 先用 AgentImageAdmission.AdmitAsync 原子准入整批,再调用
IAgent.SendAsync(InboxMessage) 提交多个 text block 与 refs。LLM dispatch 使用同一
exact-client prepared capability fence,随后经 IAttachmentStore.OpenAsync 读取并复核
digest,把图片放进精确 slot,生成只存在于内存的 transient DataContent。
attachment/attached 保持 log-only 审计用途,不是第二份消息事实;
DeriveMessages()、JSONL/SQLite、CLI/Headless snapshot 与 JSON-RPC 都保持 text + refs,
不保存原始 path、base64 或 bytes。
每次 request 从同一个稳定 event-log cut 折叠 current surface,并从 exact current
user/message node 读取其 refs;SurfaceOp.Replace shadow 掉旧图片消息后,旧 refs
不会按历史 user ordinal 错绑到后续纯文本请求。
Release-published Headless 的 —action agent-image 通过默认 Bundles 的真实
attachment-local、prepared client 和普通 AgentLoop 验证这条路径;它不是 command
图片准入的别名,也不让通用 ReplayLlm 假装所有模型都支持图片。
溢写:超长结果的旁路
工具结果有时会远超适合塞进会话历史的尺寸(整份构建日志、几千条搜索结果)。ISpillStore 把它整体落到盘上:
public interface ISpillStore
{
ValueTask<SpillRef> SaveAsync(string ns, string text, CancellationToken ct = default);
ValueTask<string> ReadAsync(SpillRef reference, CancellationToken ct = default);
}
SpillRef 的可见形式是一个 spill://<ns>/<id> 定位符:
- 按产出会话分区。 artifact 创建时写入 producing session 的
ns。fork seed 中已有的 ancestor locator 保持原值和原 owner,不复制也不重新归属,并可由 child 继续读取;fork 后新产出的 artifact 才写入 child SessionId 对应的命名空间。无继承关系的会话仍各自隔离。 - 模型看到定位符。 工具管线把超阈值的正文替换成定位符加头部摘要;模型需要完整内容时通过读工具按定位符取回。
SPILL_BAD_LOCATOR/SPILL_NOT_FOUND稳定码区分”格式不对”与”对象不在”。
为什么分两条 seam
附件与溢写都是”字节旁路”,但方向相反:附件是会话的输入(用户给模型的),溢写是工具的输出(模型给自己的)。输入需要媒体类型准入与摘要重校验,输出需要命名空间隔离与定位符语法——各自单独成 seam,谁也不必为对方的需求背包袱。