# Agent Note: 由生产方声明的上下文形态

Status: implemented

[English](2026-08-05-context-form-vocabulary.md) | 中文

## 问题

每一条已记录的非用户 `user/message` 都通过同一个内容区渲染：把整条消息序列化成内联 JSON。读者展开一行，看到的是 `{ "content": [ { "type": "text", "text": "…\n\n…" } ], "source": { … } }`——转义把唯一值得读的东西（面向模型的散文）压成了一行，生产者字段也放在同一个 JSON 对象中。

在标题栏写出生产者（[来源与 steer 标识决策](2026-08-04-web-context-source-and-steer-marks.zh.md)）解决了「这是谁加的」。它解决不了「加进来的是什么东西」，因为日志里根本没有这项信息。注入上下文不是一种形状：对账后的 `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 标识决策](2026-08-04-web-context-source-and-steer-marks.zh.md)刚为名称去掉的那种耦合。

**复用 `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`，都会编译失败。
