Producer-declared context forms
由生产方声明的上下文形态
Every logged non-user `user/message` rendered through one body: the whole message serialized as inline JSON. A reader opening a row met `{ "content": [ { "type": "text", "text": "…\n\n…" } ], "source": { … } }`, where the escaping had collapsed the only thing worth reading — the model-facing prose — into a single line, and the producer fields sat inside the same blob. Naming the producer in the header (the [source an
English
Problem
Every logged non-user user/message rendered through one body: the whole message serialized as inline JSON. A reader opening a row met { "content": [ { "type": "text", "text": "…\n\n…" } ], "source": { … } }, where the escaping had collapsed the only thing worth reading — the model-facing prose — into a single line, and the producer fields sat inside the same blob.
Naming the producer in the header (the source and steer marks decision) fixed who added this. It could not fix what kind of thing was added, because nothing in the log said so. Injected context is not one shape: a reconciled AGENTS.md, a catalog of available skills, a runtime policy snapshot, and a subagent's report are as different from each other as a terminal card is from a diff card, yet all four presented as the same wall of escaped JSON.
The tool surface already solved this shape. ToolCallView has three cards, not one per tool, and a tool declares which card its call is. Context had no equivalent: no vocabulary of shapes, and no way for a producer to say which one it emits.
Decision
MessageSource gains an optional producer-declared form: ContextForm — a small tagged vocabulary of information shapes, independent of kind:
kindanswers who produced this and carries no presentation choice.formanswers what shape of information it is. Several producers may share one form, and one producer may emit more than one over a session.
The vocabulary is semantic, never visual. A value states that the content is a file's instructions or a catalog of available items; colors, icons, ordering, and collapse defaults are the consumer's business and must not enter the union. It grows one value at a time, as producers gain the structured fields their form needs. The declared forms:
instructions — instructions read out of workspace files. agent-instructions declares it on both the startup baseline and later deltas; its existing changes[] already carried the paths, actions, and digests the presentation needs, so no field was added. The body lists the reconciled files above the text, and keeps the <system-reminder> framing verbatim: the framing is part of what the model read, so hiding it would misreport the request.
catalog — a catalog of items available this session, republished as it changes. dsh-tool-skill moves off the shared plugin kind to its own skill-catalog source carrying entries (the exact name/description pairs published) and update on a replacement, which the body renders as a replacement notice. The body lists those entries instead of re-parsing the <available_skills> block out of the prose.
Entries record the published fact unescaped. The pseudo-XML escaping belongs to the <available_skills> frame, which exists for the model, so it is applied when rendering that frame and never stored; otherwise a consumer would have to know the frame's encoding to display a description containing <, and the same frame knowledge this decision removes would leak back in another shape. escapeText is deterministic and injective, so digesting the unescaped entries preserves republish semantics exactly, and the model-facing text stays byte-identical.
That move also relocates catalog identity: the republish digest now covers the durable entries rather than the rendered text, so the model-facing framing can no longer decide whether a republish is needed, and the text-slicing that recovered entries from a logged message is gone. A resumed session whose newest catalog predates this change republishes once, which the pre-release stance permits. One case does not self-heal: if that old-format catalog is the only one and the current view has no skills, the plugin sees no published catalog and emits no tombstone, so the model keeps a stale catalog nothing replaces. The pre-release stance ("backends reject old on-disk formats") permits it; it is recorded here rather than left to the optimistic path.
snapshot — current state that a later snapshot from the same producer supersedes. The runtime-context snapshot, time-context, and tmux-context declare it. renderContextSections() exposes the assembly's named contributions, which renderContextSnapshot() already joined for the model, so the body attributes each part to the subsystem that produced it without re-splitting joined prose. The two single-contribution producers record one section each. The cleared runtime-context marker has no contributions left and declares no form.
notice — a one-off account of something that just happened. tool-jobs, tool-goal wrap-up, plan-mode switches, and repeat-tool-reminder reminders declare it with a summary, which rides the collapsed row: a notice is meant to be read without expanding at all. The summary is bounded where its inputs are caller text (a task's label and status detail have no length of their own). Goal state changes remain domain-owned goal/change events rather than model context, so they declare no form.
relay — a message another agent addressed to this one. Both subagent-addressed sources declare it; the sender is shown as the opaque session id the source already records, because this client cannot resolve it to a title.
recall — material lifted out of another session's log. session-reference declares it and needed no new field: its references already record the label, retained and omitted counts, and truncation flag, which the body shows first, because recalled context is bounded on the way in and a card that hid the omitted count would overstate what the model received.
Both readers are all-or-nothing: one unreadable entry disqualifies the record rather than being dropped, because a body that replaces the model-facing text must not present a confident but incomplete account of what the model read. The row's form marker reports what actually rendered, not what was declared.
The producer side validates the same durable data with the same posture. catalogHistory reads source.entries out of agent.session.events, which on resume or fork is a JSONL/SQLite seed whose validation only guarantees a source object with a non-empty kind — no per-kind field is checked. An unreadable catalog is therefore skipped as "not this plugin's record", the posture the replaced content digest had; throwing there would fail every later step of that session at the latest, least diagnosable point.
Everything else — including a form this UI version does not present, a form absent from the source, and a catalog whose entries are unusable — renders the opaque body: the model-facing text with its real line breaks, then the remaining source data as fields. Opaque is the documented default; the contract assigns these unsupported cases to it. A resumed, forked, or foreign log must render whether or not its producer is mounted here, which is also why the classification lives in the durable source rather than in a client-side table keyed by producer.
Why not a presenter registry
The tool presentation contract pairs its vocabulary with presentCall(args), a host-side pure function each tool implements. Context deliberately has no equivalent, because the input differs in ownership: a tool's args are generated by the model against a model-facing schema, so a translation step is unavoidable; a context source is constructed by the producing plugin itself, under no external constraint, and can simply record the facts a presentation needs. Adding a registry would have bought a translation nobody needs, at the cost of a host computation point, a wire field per context message, and a browser bundle for every producing package (the client purity gate forbids host packages from contributing components).
Alternatives considered
Map source kinds to renderers in the client. Cheapest to write and requires no format change, but it puts producer knowledge back in the client: every new kind then needs a client release to render as anything but opaque, and a foreign log cannot be classified at all. It also reintroduces exactly the coupling the source and steer marks decision removed for labels.
Reuse kind as the form. One discriminant is simpler, and agent-instructions is already 1:1 with its form. This design loses information when several producers share one form: three producers emit runtime snapshots today, and combining them into one kind would make it impossible to tell which producer supplied each message. Separate kind and form fields record the producer while allowing several producers to share one presentation.
Let the client parse the model-facing prose. The entries and file sections are visibly structured in the text. Parsing them couples the presentation to prompt wording, so every reword silently breaks a card — the same reason catalog identity moved off the text.
Render instructions as Markdown. The body is a Markdown file and would read better rendered. The text also carries <system-reminder> framing, which the Markdown renderer drops as raw HTML, so a Markdown body would silently hide part of what the model read. Deferred until the producer records per-file content structurally.
Testing
packages/client/runtimepins the form projection, including the unknown, empty, wrongly-typed, and absent values that must degrade to opaque.packages/client/ui-conversationpins each body: the opaque body's preserved line breaks and source fields, the instructions body's file list and verbatim framing, the catalog body's entry list, and a catalog with unusable entries falling back to opaque.packages/skill/tool-skillpins the new source on first publication and replacement, republish behavior driven by the durable entries, and a malformed durable catalog leaving step observation intact.- The keyless assembled-Web seeded-history scenario expands a real
instructionscontext in Chromium and asserts its file list, verbatim framing, and the unchanged disclosure geometry.cataloghas no assembled coverage: the hermetic scaffold publishes no skills, so no catalog reaches a browser scenario.
Consequences
- A reader can tell what was added without expanding, and reading it no longer means reading escaped JSON.
- The durable
MessageSourcenow records content shape beside the producer kind and its fields. The boundary is load-bearing: facts and shape only, never presentation. A producer that wants a better card records better facts. - Catalog identity no longer depends on the model-facing prose, deleting the text-slicing path that could mistake a reworded catalog for a changed one.
- Every shipped producer except the two hook bridges now declares a form. The bridges stay opaque by design: their content is whatever an external program printed, so no shape can be promised for it. Unknown kinds and unreadable records land there too.
ContextFormedis discriminated byform, so a producer cannot declare a shape without the facts that shape is presented from — anoticewithout its summary, or asnapshotwithout its sections, fails to compile.
中文
问题
每一条已记录的非用户 user/message 都通过同一个内容区渲染:把整条消息序列化成内联 JSON。读者展开一行,看到的是 { "content": [ { "type": "text", "text": "…\n\n…" } ], "source": { … } }——转义把唯一值得读的东西(面向模型的散文)压成了一行,生产者字段也放在同一个 JSON 对象中。
在标题栏写出生产者(来源与 steer 标识决策)解决了「这是谁加的」。它解决不了「加进来的是什么东西」,因为日志里根本没有这项信息。注入上下文不是一种形状:对账后的 AGENTS.md、可用 skill(技能)的目录、运行时策略快照、subagent 的汇报,彼此之间的差别不亚于终端卡片与 diff 卡片,然而这四者呈现出来是同一堵转义 JSON 的墙。
工具呈现层早就解决过同一个形状问题。ToolCallView 只有三种卡片,而不是每个工具一种,由工具自己声明本次调用属于哪一种。上下文没有对应物:既没有形状词汇表,生产方也无从声明自己发出的是哪一种。
决策
MessageSource 新增一个可选、由生产方声明的 form: ContextForm——一份关于信息形状的小型带标签词汇表,与 kind 相互独立:
kind回答由谁产生,不携带呈现选择。form回答这是何种形态的信息。多个生产方可以共用一种形态,一个生产方在一次会话中也可以发出多种。
该词汇表是语义的,绝不涉及视觉。取值只陈述「内容是某个文件的指令」或「是一份可用项的目录」;颜色、图标、排序、默认折叠状态归消费方管,不得进入这个联合类型。它随生产方补齐各自形态所需的结构化字段而逐个增长。已声明的形态:
instructions——从工作区文件中读出的指令。agent-instructions 在启动基线与后续增量上都声明它;其既有的 changes[] 已经携带了呈现所需的路径、动作与 digest,因此没有新增字段。内容区在正文之上列出对账过的文件,并原样保留 <system-reminder> 包装:那层包装本就是模型读到的一部分,隐藏它会歪曲这次请求。
catalog——本会话可用项的目录,随变化重新发布。dsh-tool-skill 从共享的 plugin kind 迁到自有的 skill-catalog 来源,携带 entries(本次发布的 name/description 对)以及替换时的 update,后者由内容区渲染成替换提示。内容区直接列出这些条目,不再从散文里反解 <available_skills> 块。
条目记录的是未转义的发布事实。伪 XML 转义属于 <available_skills> 这层为模型而设的框架,因此只在渲染该框架时施加、从不存储;否则消费方要正确展示含 < 的描述就得知道框架的编码方式,本决策刚移除的框架知识会换一种形式泄漏回来。escapeText 确定且单射,故对未转义条目取 digest 与此前完全等价,重新发布语义不变,面向模型的文本逐字节不变。
这次迁移同时挪动了目录的身份:重新发布用的 digest 现在覆盖持久条目而非渲染文本,于是面向模型的包装再也无法左右是否需要重新发布,那段从已记录消息里切出条目的文本切分逻辑也随之删除。若恢复的会话中最新目录早于本次改动,会重新发布一次——预发布立场允许这样做。有一种情形不会自愈:当那份旧格式目录是唯一的一份、且当前视图没有任何 skill 时,插件看不到已发布目录,也就不会发出 tombstone,模型手里会留着一份无人替换的陈旧目录。预发布立场(「后端拒绝旧的磁盘格式」)允许这一点;此处如实记录,而不是只写乐观路径。
snapshot——会被同一生产方后续快照取代的当前状态。运行时快照、time-context、tmux-context 声明它。renderContextSections() 暴露出装配时的具名贡献——renderContextSnapshot() 本来就是把它们拼给模型的——因此内容区能把每一段归属到产生它的子系统,而不必去切分已经拼好的散文。两个单贡献生产方各记录一段。运行时快照的「已清空」标记没有任何贡献可归属,因此不声明形态。
notice——刚刚发生了什么的一次性说明。tool-jobs、tool-goal 收尾、plan-mode 切换与 repeat-tool-reminder 提醒都带 summary 声明它,而该摘要出现在折叠态行上:notice 的全部意义就是不展开也能读完。摘要在其输入是调用方文本时自行封顶(任务的 label 与状态 detail 本身没有长度约束)。Goal 状态变更仍是由领域层持有的 goal/change 事件,而非模型上下文,因此不声明 form。
relay——另一个 agent(智能体)发给本 agent 的消息。两个 subagent 定向来源都声明它;发送方以来源已记录的不透明会话 id 呈现,因为本客户端无法把它解析成标题。
recall——从另一个会话日志搬来的材料。session-reference 声明它,且不需要新增字段:其 references 已经记录了标签、保留与省略条数、截断标记,内容区把这些放在最前面——召回上下文在进入时是有界的,隐藏省略条数的卡片会夸大模型实际收到的内容。
两个读取器都是全有或全无:一条不可读的条目即判定整条记录不可用,而不是把它丢掉——会替换掉面向模型文本的内容区,不得给出自信但残缺的「模型读到了什么」。行上的形态标记报告的是实际渲染出的形态,而非声明的形态。
生产方一侧对同一份持久数据采取同样的姿态。catalogHistory 从 agent.session.events 读 source.entries,而恢复或 fork 时它来自 JSONL/SQLite 种子,种子验证只保证来源是带非空 kind 的对象,不校验任何 kind 特有字段。因此不可读的目录被当作「不是本插件的记录」跳过——正是被替换掉的内容 digest 原有的姿态;在那里抛错会让该会话此后每一步都在最晚、最难定位的点失败。
其余一切——包括本 UI 版本不呈现的形态、来源未声明形态、以及条目不可用的 catalog——一律渲染 opaque 内容区:按真实换行展示面向模型的文本,其后把剩余来源数据列成字段。opaque 是文档规定的默认;约定要求这些不支持的情况使用它。恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处都必须渲染得出来——这同样是分类信息必须落在持久来源里、而不是落在客户端以生产方为键的表里的原因。
为什么不做 presenter 注册表
工具呈现约定把它的词汇表与 presentCall(args) 配对,那是每个工具在 host 侧实现的纯函数。上下文刻意不设对应物,因为输入的归属不同:工具的 args 由模型按面向模型的 schema 生成,翻译步骤无法回避;而上下文的 source 由生产方插件自己构造,不受任何外部约束,完全可以直接记录呈现所需的事实。加一层注册表买到的是一次没人需要的翻译,代价却是一个 host 计算点、每条上下文消息一个 wire 字段、以及每个生产方包都要出浏览器 bundle(客户端纯度门禁禁止 host 包贡献组件)。
考虑过的替代方案
在客户端把来源 kind 映射到渲染器。 写起来最省,也不用改格式,但它把生产方知识放回了客户端:此后每新增一个 kind 都要客户端发版才能渲染成 opaque 以外的东西,而外部日志根本无法分类。它还会重新引入来源与 steer 标识决策刚为名称去掉的那种耦合。
复用 kind 充当形态。 单一判别字段更简单,agent-instructions 本来也与它的形态一一对应。但多个生产方共享同一形态时,这种设计无法保留完整信息:今天有三个生产方发出运行时快照,把它们并成一个 kind 后,将无法分辨每条消息由哪个生产方提供。独立的 kind 和 form 字段会记录生产方,同时允许多个生产方共用一种呈现方式。
让客户端解析面向模型的散文。 条目与文件分节在文本里确实有可见结构。解析它们会把呈现耦合到提示词措辞上,于是每改一次文案就会悄悄破坏一张卡片——这也正是目录身份从文本上迁走的原因。
把 instructions 渲染成 Markdown。 正文本来就是 Markdown 文件,渲染出来更好读。但文本同时携带 <system-reminder> 包装,Markdown 渲染器会把它当原始 HTML 丢弃,于是 Markdown 内容区会悄悄隐藏模型读到的一部分。推迟到生产方按文件结构化记录内容之后再做。
测试
packages/client/runtime钉住形态投影,包括必须降级为 opaque 的未知值、空值、类型不符与缺失。packages/client/ui-conversation逐个钉住内容区:opaque 的换行留存与来源字段、instructions 的文件列表与原样包装、catalog 的条目列表,以及条目不可用的 catalog 回落到 opaque。packages/skill/tool-skill钉住首次发布与替换时的新来源、由持久条目驱动的重新发布行为,以及畸形持久目录不打断步骤观察。- 无密钥的组装 Web seeded-history 场景在 Chromium 中展开一条真实的
instructions上下文,断言其文件列表、原样包装与未改动的展开项几何。catalog没有组装态覆盖:隔离脚手架不发布任何 skill,因此没有目录能进入浏览器场景。
后果
- 读者不展开就能知道加进来的是什么,展开之后读到的也不再是转义 JSON。
- 持久
MessageSource现在会在生产者 kind 及其字段之外记录内容形状。这条边界是承重的:只放事实与形状,绝不放呈现。生产方若想获得更好的卡片,就应记录更好的事实。 - 目录身份不再依赖面向模型的散文,删掉了那条可能把「改了措辞」误判为「改了内容」的文本切分路径。
- 除两个钩子桥接外,每个已发布的生产方现在都声明了形态。桥接按设计保持 opaque:其内容是外部程序打印出来的任意文本,无法承诺任何形状。未知 kind 与不可读记录同样落在这里。
ContextFormed按form判别,因此生产方无法在缺少该形态所需事实的情况下声明它——没有 summary 的notice、没有 sections 的snapshot,都会编译失败。