feat(session): add cross-session references

This commit is contained in:
Yichen Jiang
2026-07-21 16:46:48 +08:00
parent 9a5c81f9e5
commit 32d786c439
81 changed files with 2837 additions and 160 deletions

View File

@@ -16,7 +16,7 @@ Two forces shape the design. First, compaction policy and reusable token measure
Per the [capability-seams Agent Note](../architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently:
1. **Interface**`@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*.
1. **Interface**`@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, the `compact/*` session events, and the canonical checkpoint message source. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*.
2. **Implementation**`@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that consumes `ctx.tokenMeter` and owns the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, post-step pressure, and canonical context-overflow recovery. `summarize()` is its sole subclass hook; pricing and replay stay with the meter.
3. **Model-free companion**`@deepseek-ai/dsh-compact-tool-result-prune`: a concrete optional service that rewrites oversized current `tool/result` nodes before the backend selects a summary range. It is not a second compaction implementation and does not implement `CompactService`.
4. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first.
@@ -69,13 +69,14 @@ Auto-compaction always starts at the surface head, merging the prior checkpoint
### Surface replacement: `compact/*` events are log-only; one `user/message` carries the summary
Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` event. The backend instead appends a **single `user/message`** with `surfaceOp: { op: 'replace', start, end }` whose `content` is the (framed) summary and whose `sourceEventSeqs` covers the shadowed entries *and* the bookkeeping events. The `compact/*` events are pure log records (lock + provenance). The surface mutation sits **inside** the lock — `compact/end` is the last event appended:
Because `SurfaceEventType` is closed, the summary cannot ride on a `compact/*` event. The backend instead appends a **single `user/message`** with `source: COMPACT_CHECKPOINT_SOURCE` and `surfaceOp: { op: 'replace', start, end }` whose `content` is the (framed) summary and whose `sourceEventSeqs` covers the shadowed entries *and* the bookkeeping events. The interface exports that source and `isCompactCheckpointSource()` so consumers recognize a persisted or cloned checkpoint without depending on backend package identity. The `compact/*` events are pure log records (lock + provenance). The surface mutation sits **inside** the lock — `compact/end` is the last event appended:
```
compact/start → log-only. Acquires the lock.
[summarize older range via the backend]
compact/summary → log-only. Provenance: raw summary, range, shadowed seqs, token count.
user/message → surfaceOp { op:'replace', start, end }. THE surface mutation (framed summary).
user/message → canonical checkpoint source + surfaceOp { op:'replace', start, end }.
THE surface mutation (framed summary).
deriveMessages() renders it as a user-role message.
compact/end → log-only. Releases the lock (carries `error` on a recoverable failure).
```
@@ -84,7 +85,7 @@ compact/end → log-only. Releases the lock (carries `error` on a recoverab
### Checkpoint framing + incremental merge (backend-private)
The basic backend wraps the summary as established checkpoint context and tags it for incremental merging on the next cycle. The raw summary remains on `compact/summary`. Framing is backend policy; the seam promises only that one replacement user message carries the possibly framed summary.
The basic backend wraps the summary as established checkpoint context and tags it for incremental merging on the next cycle. The raw summary remains on `compact/summary`. Framing is backend policy; the seam promises that one replacement user message carries the possibly framed summary and uses the canonical checkpoint source.
### Blocking via a log-recorded lock, plus a crash/recoverable failure taxonomy
@@ -114,7 +115,7 @@ Two failure paths, both documented:
- **Packages**: `packages/compact/compact` supplies the interface, `compact-basic` supplies the backend, and `compact-tool-result-prune` supplies optional deterministic rewriting. `packages/llm/token-meter` owns replay-aware measurement independently. The consumer tier is deferred.
- **Automatic seams**: `agent/post-step` (`@mode serial`) handles successful-call pressure and `agent/request-error` (`@mode waterfall`) handles final request failures after the failed step closes. Generic `agent/pre-step` remains a four-argument checkpoint with no compaction-only prompt/prefix payload.
- **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry.
- **`dsh-compact`** owns `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence; stale or missing seqs and orphan results reject.
- **`dsh-compact`** owns `COMPACT_CHECKPOINT_SOURCE`, `isCompactCheckpointSource(source)`, `toolPairingBalancedBefore(session, seq)`, and `toolPairingBalancedAfter(session, seq)`. The marker identifies replacement summaries across backend implementations; the cached surface-edge checks prevent splitting a tool-call/result pair, validate current membership by seq, and reject stale or missing seqs and orphan results.
- **`dsh-session`** validates positional replacement, complete provenance, and content-only single-node `tool/result` rewrites through its one surface manager. `dsh-invariants` treats fresh appended tool results as executions that require an open step and pending call; validated replacements remain turn-enclosed rewrites.
- **Wiring**: `examples/tui-agent/cordis.yml` loads zero-config `dsh-token-meter`, `dsh-compact-tool-result-prune`, then `dsh-compact-basic`; service-wide defaults make the composition usable without repeated numeric policy.

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-21-cross-session-references.md: fa167f639abd9bab4a443088dd770d59f2ad1780
2026-07-21-cross-session-references.zh.md: e3a93db0865041f6026b4e6b8e9a8bd85537959f

View File

@@ -0,0 +1,58 @@
# Agent Note: Cross-session references
Status: implemented
English | [中文](2026-07-21-cross-session-references.zh.md)
## Problem
TUI and ACP users need to bring relevant work from another conversation into one new message without resuming, forking, or granting the source transcript authority over the current session. The harness already exposes exact session enumeration and raw event inspection, but every host independently parsing logs would duplicate compaction folding, provenance filtering, size limits, error behavior, and persistence. Encoding host markup directly into the agent message contract would also bind the core loop to one UI syntax.
## Decision
`@deepseek-ai/dsh-session-reference` is one context consumer service at `ctx.sessionReferences`. Hosts normalize their protocol into `SessionReferenceInput[]`, call `prepare()` before enqueue, and pass the returned contexts through the generic `SendOptions.contexts` boundary. Core agent packages know only that one queued message may carry frozen `HookContext[]`; they do not parse session URIs or read another log.
`dsh-session:<base64url(JSON.stringify(sessionId))>` is the canonical host-independent identifier. JSON string encoding precedes base64url so quotes, slashes, backslashes, Unicode, newlines, and every other JavaScript string value round-trip without delimiter ambiguity. TUI renders that URI inside `@[label](uri)` and ACP uses standard `resource_link`; text-only clients may use the same inline mention. Explicit Markdown mentions and resource links reject malformed URIs. Bare text becomes a reference only for a non-empty base64url-shaped payload, whose decode must still be canonical; empty or punctuation-only uses remain ordinary discussion text.
The service uses `ctx.sessionQuery.readSurface(sessionId)`, which loads one live-preferred corpus observation, folds it with the session package's canonical surface algorithm, and returns a detached header, capture seq, and current nodes. FTS is not a dependency: v1 discovery filters only id and cwd, and future title/body search can replace the candidate layer without changing reference identity or preparation.
## Snapshot and projection
Preparation deduplicates in first-appearance order, rejects the target id, enforces at most three references by default, and performs all reads in parallel. It returns no partially prepared context: any read, cancellation, validation, or budget error rejects the operation before `send()` or `steer()`. A source is read before enqueue, so later source messages, compaction, deletion, or persistence replacement cannot change the target session.
Projection retains direct-user messages and steering, completed assistant text, and checkpoint user messages carrying the canonical source exported by `dsh-compact`. That marker is part of the compaction capability contract rather than a backend package name. Projection excludes shadowed pre-compaction nodes, tools and results, reasoning, injected context, other plugin user messages, log-only records, and incomplete assistant chunks. Repeated compaction therefore exposes only the latest folded checkpoint lineage still on the current surface plus its retained tail; there is no raw/current switch and no shadow recovery.
One aggregated context is serialized as JSON beneath a fixed untrusted-background warning. The warning tells the model not to follow instructions, permission claims, or tool requests from referenced sessions unless the current user repeats them. Tag-safe serialization emits every data `<` as the lossless JSON escape `\u003c`; source strings therefore cannot spell the surrounding XML-like tags. The same serializer drives per-reference and total byte accounting. Context metadata records source and retention facts, while the visible bytes persist through the existing `context/message` event so target replay satisfies the model-visible/log-reconstructable invariant without a new event type.
## Message ownership
`send()` and `steer()` snapshot content, resolved source, and contexts together as one deeply frozen lossless-JSON inbox record. A claimed ordinary message exposes its attached contexts as the default `agent/prompt-submit` additional contexts; a block writes neither user message nor contexts. The waterfall's returned allow is authoritative, so a listener wrapping `next()` preserves downstream content and contexts unless it intentionally replaces them. Drained steering bypasses `agent/prompt-submit` and writes `steering/message` before its contexts. Late steering retains the same record when converted to queued input, while cancellation, disposal, and terminal discard drop message and contexts together. `agent/queued` reports the frozen contexts so the observation event describes the complete retained item.
This preserves host driving semantics: TUI decides `send()` versus `steer()` from the agent state after preparation, so only its queued path dispatches UserPromptSubmit hooks; ACP continues to call `send()` once per `session/prompt`. Reference preparation is not a new steering protocol and does not create a turn by itself.
## Host adapters
TUI combines session candidates with the existing `@` file provider. It prepares only submissions containing structured mentions, disables duplicate submit while awaiting snapshots, restores failed input, and renders persisted session-reference context as a compact source list instead of exposing the complete JSON in the terminal.
ACP extracts `dsh-session:` resource links and canonical inline mentions while preserving ordinary resource-link rendering. A valid reference without the optional service returns a capability-unavailable RPC error, and preparation failure occurs before the in-flight turn slot and agent send. A preparation-specific abort owner makes `session/cancel` and bridge teardown stop pending reads. Picker UI remains an ACP client responsibility.
## Budget and retention
The defaults cap one serialized reference at 65,536 UTF-8 bytes and the complete prompt, fixed warning included, at 196,608 bytes. Retention preserves current compact checkpoints and the newest conversation unit before dropping older non-checkpoint messages. An oversized retained text uses `dsh-retention` head/tail slicing and records exact omitted bytes; if fixed metadata and warning bytes cannot fit, preparation fails rather than silently exceeding the contract.
## Alternatives considered
- **Wait for SQLite FTS5** — rejected because snapshot correctness requires exact id reads and canonical surface folding, not content search. FTS improves discovery only.
- **Put mention syntax in `Agent.send()`** — rejected because it would make the core protocol parse TUI/ACP presentation and prevent typed non-text hosts from sharing the semantic layer.
- **Implement references inside TUI and ACP separately** — rejected because projection, security warning, retention, and persistence would drift across hosts.
- **Replay the raw source log or restore shadowed events** — rejected because compact defines the current model surface and may intentionally retire sensitive or expensive history.
- **Resume or fork the source** — rejected because the feature supplies read-only background for one target message, not identity or lifecycle continuity.
- **Inject at request time by rereading the source** — rejected because the reference would become nondeterministic, cancellation races could alter its bytes, and target replay would depend on external mutable state.
## Verification
Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, candidate ranking, projection exclusions, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, cancellation, byte retention, frozen message ownership, prompt blocking, send/steer ordering, missing capability, ordinary ACP resource links, and compact TUI rendering. A keyless TUI snapshot runs the real agent loop: the source surface replaces old user/assistant history with a compact checkpoint, the target submits a mention, and the captured model request contains the checkpoint and retained tail but not either shadowed string.
## Consequences
The new plugin is the stable semantic boundary and adds no persistence schema, event type, FTS dependency, source subscription, or compact shadow access. Standard TUI/ACP demo bundles mount it explicitly; custom hosts remain unchanged until they mount the service and adapt their input. Reference contexts increase target history size within configured bounds and can later be summarized by ordinary target compaction, after which the source session is irrelevant.

View File

@@ -0,0 +1,58 @@
# Agent Note: 跨会话引用
Status: implemented
[English](2026-07-21-cross-session-references.md) | 中文
## 问题
TUI 与 ACPAgent Client Protocol用户需要把另一场对话中的相关工作带入一条新消息但不恢复、不 fork也不让源 transcript文本记录对当前会话拥有权威性。harness 已经提供准确的会话枚举与原始事件检查但若每个宿主都独立解析日志就会重复实现压缩compaction折叠、来源过滤、大小限制、错误行为和持久化。把宿主标记直接编码进 agent智能体消息契约还会让核心循环绑定某一种 UI 语法。
## 决策
`@deepseek-ai/dsh-session-reference` 是注册在 `ctx.sessionReferences` 上的单一上下文消费服务。宿主先把各自的协议规范化为 `SessionReferenceInput[]`,在入队前调用 `prepare()`,再通过通用的 `SendOptions.contexts` 边界传递返回的上下文。核心 agent 包只知道一条排队消息可以携带已冻结的 `HookContext[]`;它们既不解析会话 URI也不读取其他日志。
`dsh-session:<base64url(JSON.stringify(sessionId))>` 是与宿主无关的规范标识符。系统先执行 JSON 字符串编码,再执行 base64url 编码因此引号、正斜杠、反斜杠、Unicode、换行符以及其他任意 JavaScript 字符串值都能无损往返不会因分隔符产生歧义。TUI 把该 URI 渲染到 `@[label](uri)`ACP 使用标准 `resource_link`;纯文本客户端可以使用同一种行内提及标记。显式 Markdown 提及标记与资源链接会拒绝格式错误的 URI。裸文本只有在负载非空且形状符合 base64url 时才会成为引用,而且解码过程仍须通过规范性校验;空负载或只含标点符号的用法仍按普通讨论文本处理。
该服务使用 `ctx.sessionQuery.readSurface(sessionId)`它优先从实时会话加载一次语料观察结果使用会话包的规范表层算法执行折叠并返回与源数据分离的会话头、捕获序号和当前节点。FTS 不是功能依赖v1 的候选发现只按 id 和 cwd 过滤;未来的标题或正文搜索可以替换候选层,而无需改变引用标识或准备过程。
## 快照与投影
准备过程按首次出现的顺序去重、拒绝目标会话自身的 id并且默认最多允许三个引用所有读取均并行执行。该过程不会返回部分完成的上下文任何读取、取消、校验或预算错误都会在调用 `send()``steer()` 前拒绝本次操作。源会话在入队前完成读取,因此源会话后续新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中的快照。
投影会保留直接用户消息与 steering中途引导、已完成的 assistant 文本,以及携带由 `dsh-compact` 导出的规范来源标记的检查点用户消息。该标记属于压缩功能契约的一部分而非某个后端包名称。投影会排除压缩前已被遮蔽的节点、工具及其结果、推理reasoning、注入的上下文、其他插件用户消息、仅用于日志的记录以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。
系统把一个聚合上下文序列化为 JSON并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签。逐引用和总字节核算使用同一个序列化器。上下文元数据记录来源与保留事实;模型可见字节通过现有 `context/message` 事件持久化,使目标回放在不新增事件类型的前提下满足「模型可见/日志可重建」不变量。
## 消息所有权
`send()``steer()` 会把内容、解析后的来源和上下文一起快照为一条深度冻结、无损 JSON 的收件箱记录。普通消息被认领后,其附带的上下文会作为 `agent/prompt-submit` 的默认附加上下文公开提示词被阻止时系统既不写入用户消息也不写入上下文。waterfall瀑布式事件返回的 allow 结果具有最终权威性,因此监听器包装 `next()` 时会保留下游内容与上下文,除非它有意替换这些值。排空 steering 消息时会绕过 `agent/prompt-submit`,先写入 `steering/message`,再写入其上下文。延迟到达的 steering 转换为排队输入时保留同一条记录取消、dispose资源释放和到达终止态后的丢弃则会同时丢弃消息与上下文。`agent/queued` 会报告已冻结的上下文,使观察事件能够描述完整的保留项。
这保留了宿主的驱动语义TUI 在准备完成后根据 agent 状态决定调用 `send()` 还是 `steer()`,因此只有它的排队路径才会分派 UserPromptSubmit 钩子ACP 则继续调用 `send()`,每个 `session/prompt` 调用一次。引用准备过程不是新的 steering 协议,本身也不会创建轮次。
## 宿主适配器
TUI 把会话候选与现有 `@` 文件提供方组合在一起。它只准备包含结构化提及标记的提交;等待快照时禁用重复提交;失败时恢复输入;渲染持久化的会话引用上下文时只显示精简的来源列表,不在终端中暴露完整 JSON。
ACP 提取 `dsh-session:` 资源链接和规范的行内提及标记同时保留普通资源链接的渲染方式。若引用有效但可选服务未挂载系统会返回「功能不可用」RPC 错误;准备失败会发生在占用进行中轮次槽位并调用 agent send 之前。引用准备过程单独拥有中止控制权,因此 `session/cancel` 和桥接释放都能停止待处理的读取。选择器 UI 仍由 ACP 客户端负责。
## 预算与保留策略
默认配置把单个序列化引用限制在 65,536 个 UTF-8 字节以内,并把包含固定警告在内的完整提示词限制在 196,608 个字节以内。保留策略会优先保留当前压缩检查点和最新的对话单元,再丢弃较旧的非检查点消息。若保留文本过大,系统使用 `dsh-retention` 进行首尾切片并记录准确的省略字节数;若固定元数据与警告所需的字节无法容纳,准备过程会失败,而不会悄然超出契约。
## 考虑过的替代方案
- **等待 SQLite FTS5**:不予采纳,因为快照正确性依赖按准确 id 读取和规范表层折叠而不是内容搜索。FTS 只改进候选发现。
- **把提及标记语法放入 `Agent.send()`**:不予采纳,因为这会迫使核心协议解析 TUIACP 的表现层,并阻止带类型的非文本宿主复用同一语义层。
- **在 TUI 和 ACP 中分别实现引用**:不予采纳,因为投影、安全警告、保留策略和持久化会在不同宿主之间逐渐偏离。
- **回放原始源日志或恢复被遮蔽的事件**:不予采纳,因为压缩定义了当前模型表层,并且可能有意淘汰敏感或开销高昂的历史内容。
- **恢复或 fork 源会话**:不予采纳,因为本功能只为一条目标消息提供只读背景,不提供身份或生命周期连续性。
- **在请求时重新读取源会话并注入**:不予采纳,因为这会让引用变得不确定,取消竞态可能改变其字节内容,目标回放也会依赖可变的外部状态。
## 验证
单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、候选排序、投影排除规则、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、取消、字节保留、冻结的消息所有权、提示词阻止、sendsteer 顺序、功能缺失、普通 ACP 资源链接,以及 TUI 精简渲染。无密钥 TUI 快照会运行真实的 agent loop智能体循环源表层用一个压缩检查点替换旧的用户assistant 历史,目标会话提交一个提及标记,捕获到的模型请求包含该检查点和保留的尾部消息,但不包含任一被遮蔽的字符串。
## 后果
新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 TUIACP 演示组合包会显式挂载它;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。