From 8c6e28cef8c301d5a7c0fe8b49bffad27cc80a79 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 21 Jul 2026 20:16:18 +0800 Subject: [PATCH] fix(session): simplify reference byte limits --- ...6-07-21-cross-session-references.i18n.yaml | 4 +- .../2026-07-21-cross-session-references.md | 10 ++-- .../2026-07-21-cross-session-references.zh.md | 10 ++-- docs/config-catalog.md | 6 +- docs/cordis-catalog/services.md | 2 +- packages/context/session-reference/README.md | 7 +-- .../context/session-reference/src/config.ts | 10 +--- .../context/session-reference/src/index.ts | 55 ++++++++----------- .../tests/session-reference.spec.ts | 46 ++++++++++++++-- .../examples/tui-demo/tests/tui-agent.spec.ts | 2 - 10 files changed, 85 insertions(+), 67 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml index 665d29bcb0..33b770c103 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml @@ -2,5 +2,5 @@ # 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: d640bce919af159329320415c45c395961ea4ddf -2026-07-21-cross-session-references.zh.md: b1b3ed021f808b64a6d403ce1049a4e019dc4a53 +2026-07-21-cross-session-references.md: b8ecae9f1f453ea377de1a59c96e388bdb6f859b +2026-07-21-cross-session-references.zh.md: 61d294f53c4355cb2d0c0eb616f5eeb46542a1fe diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md index d640bce919..b8ecae9f1f 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.md @@ -18,11 +18,11 @@ The service uses `ctx.sessionQuery.readSurface(sessionId)`, which loads one live ## 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()`. Cancellation races in-flight discovery and exact reads, so a host settles promptly even when a persistence backend cannot interrupt its pending operation; any late backend settlement is observed but cannot enqueue the message. A source is read before enqueue, so later source messages, compaction, deletion, or persistence replacement cannot change the target session. +Preparation deduplicates in first-appearance order, rejects the target id, enforces a configurable limit with a hard maximum of three references, 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()`. Cancellation races in-flight discovery and exact reads, so a host settles promptly even when a persistence backend cannot interrupt its pending operation; any late backend settlement is observed but cannot enqueue the message. 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. +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 each source's independent 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 @@ -38,7 +38,7 @@ ACP detects direct slash commands from ordinary prompt flattening before extract ## 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. +Each of at most three references is independently capped at 65,536 UTF-8 bytes by default. 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 one source's fixed serialized fields cannot fit its cap, the whole preparation fails rather than emitting a partial context. ## Alternatives considered @@ -51,8 +51,8 @@ The defaults cap one serialized reference at 65,536 UTF-8 bytes and the complete ## Verification -Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, candidate ranking, terminal-control escaping, projection exclusions, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, byte retention, frozen message ownership, prompt blocking, send/steer ordering, missing capability, ordinary ACP resource links, opaque ACP command arguments, 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. +Unit and integration coverage pins URI round-trips and text-boundary punctuation, explicit malformed references, candidate ranking, terminal-control escaping, projection exclusions, backend-independent compact checkpoints, tag-safe framing, deduplication, self-reference, count limits, all-or-nothing reads, prompt cancellation against a non-settling storage read, independent per-source byte retention, frozen message ownership, prompt blocking, send/steer ordering, missing capability, ordinary ACP resource links, opaque ACP command arguments, 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 and expose its count and byte budgets in their own config; 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. +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 and expose its count and per-source byte limits in their own config; 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. diff --git a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md index b1b3ed021f..61d294f53c 100644 --- a/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md @@ -18,11 +18,11 @@ TUI 与 ACP(Agent Client Protocol)用户需要把另一场对话中的相关 ## 快照与投影 -准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且默认最多允许三个引用,所有读取均并行执行。该过程不会返回部分完成的上下文:任何读取、取消、校验或预算错误都会在调用 `send()` 或 `steer()` 前拒绝本次操作。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,宿主也能及时结束等待;后端迟到的完成结果仍会被观察,但不能让消息入队。源会话在入队前完成读取,因此源会话后续新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中的快照。 +准备过程按首次出现的顺序去重、拒绝目标会话自身的 id,并且执行可配置的数量限制,但引用硬上限为三个,所有读取均并行执行。该过程不会返回部分完成的上下文:任何读取、取消、校验或预算错误都会在调用 `send()` 或 `steer()` 前拒绝本次操作。取消会与进行中的候选发现和精确读取竞速,因此即使持久化后端无法中断待处理操作,宿主也能及时结束等待;后端迟到的完成结果仍会被观察,但不能让消息入队。源会话在入队前完成读取,因此源会话后续新增消息、执行压缩、被删除或替换持久化内容,都无法改变目标会话中的快照。 投影会保留直接用户消息与 steering(中途引导)、已完成的 assistant 文本,以及携带由 `dsh-compact` 导出的规范来源标记的检查点用户消息。该标记属于压缩功能契约的一部分,而非某个后端包名称。投影会排除压缩前已被遮蔽的节点、工具及其结果、推理(reasoning)、注入的上下文、其他插件用户消息、仅用于日志的记录,以及尚未完成的 assistant 分片。因此,重复压缩只会暴露当前表层仍保留的最新折叠检查点谱系及其尾部消息;系统不提供 raw/current 开关,也不恢复被遮蔽的内容。 -系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签。逐引用和总字节核算使用同一个序列化器。上下文元数据记录来源与保留事实;模型可见字节通过现有 `context/message` 事件持久化,使目标回放在不新增事件类型的前提下满足「模型可见/日志可重建」不变量。 +系统把一个聚合上下文序列化为 JSON,并置于固定的不可信背景警告之后。该警告要求模型不要遵循被引用会话中的指令、权限声明或工具请求,除非当前用户再次提出这些内容。标签安全序列化会把数据中的每个 `<` 无损转义为 JSON `\u003c`;因此源字符串无法拼出外围类似 XML 的标签。同一个序列化器会独立核算每个源的字节数。上下文元数据记录来源与保留事实;模型可见字节通过现有 `context/message` 事件持久化,使目标回放在不新增事件类型的前提下满足「模型可见/日志可重建」不变量。 ## 消息所有权 @@ -38,7 +38,7 @@ ACP 先从普通提示词扁平化结果中检测直接斜杠命令,再提取 ## 预算与保留策略 -默认配置把单个序列化引用限制在 65,536 个 UTF-8 字节以内,并把包含固定警告在内的完整提示词限制在 196,608 个字节以内。保留策略会优先保留当前压缩检查点和最新的对话单元,再丢弃较旧的非检查点消息。若保留文本过大,系统使用 `dsh-retention` 进行首尾切片并记录准确的省略字节数;若固定元数据与警告所需的字节无法容纳,准备过程会失败,而不会悄然超出契约。 +最多三个引用中的每一个默认独立限制在 65,536 个 UTF-8 字节以内,不设置完整提示词的总预算。保留策略会优先保留当前压缩检查点和最新的对话单元,再丢弃较旧的非检查点消息。若保留文本过大,系统使用 `dsh-retention` 进行首尾切片并记录准确的省略字节数;若某个源的固定序列化字段无法装入其上限,整个准备过程会失败,不会输出部分上下文。 ## 考虑过的替代方案 @@ -51,8 +51,8 @@ ACP 先从普通提示词扁平化结果中检测直接斜杠命令,再提取 ## 验证 -单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、候选排序、终端控制字符转义、投影排除规则、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、字节保留、冻结的消息所有权、提示词阻止、send/steer 顺序、功能缺失、普通 ACP 资源链接、不透明的 ACP 命令参数,以及 TUI 精简渲染。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求包含该检查点和保留的尾部消息,但不包含任一被遮蔽的字符串。 +单元与集成测试覆盖 URI 无损往返与文本边界标点、显式格式错误的引用、候选排序、终端控制字符转义、投影排除规则、与后端无关的压缩检查点、标签安全封套、去重、自引用、数量限制、读取的全有或全无、存储读取不结束时取消提示词、逐源独立字节保留、冻结的消息所有权、提示词阻止、send/steer 顺序、功能缺失、普通 ACP 资源链接、不透明的 ACP 命令参数,以及 TUI 精简渲染。无密钥 TUI 快照会运行真实的 agent loop(智能体循环):源表层用一个压缩检查点替换旧的用户/assistant 历史,目标会话提交一个提及标记,捕获到的模型请求包含该检查点和保留的尾部消息,但不包含任一被遮蔽的字符串。 ## 后果 -新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 TUI/ACP 演示组合包会显式挂载它,并在各自的配置中暴露引用数量和字节预算;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。 +新插件构成稳定的语义边界,不会新增持久化 schema、事件类型、FTS 依赖、源会话订阅或对压缩所遮蔽内容的访问。标准 TUI/ACP 演示组合包会显式挂载它,并在各自的配置中暴露引用数量和逐源字节上限;自定义宿主在挂载该服务并适配输入前保持不变。引用上下文会在配置的界限内增大目标历史,随后可由目标会话的普通压缩进行摘要;完成压缩后,源会话便不再相关。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dd195db478..a02dfd7401 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -870,18 +870,16 @@ Requires: `sessionQuery` ```ts config-catalog /** Session-reference service configuration. */ export interface Config { - /** Maximum distinct source sessions referenced by one message. */ + /** Maximum distinct source sessions referenced by one message, from one to three. */ maxReferences?: number /** Default host candidate-list limit. */ candidateLimit?: number /** Maximum rendered UTF-8 bytes for one source snapshot. */ maxReferenceBytes?: number - /** Maximum rendered UTF-8 bytes for the complete injected prompt. */ - maxTotalBytes?: number } ``` -Source: [`packages/context/session-reference/src/config.ts:13`](../packages/context/session-reference/src/config.ts) +Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts) ## `@deepseek-ai/dsh-skill` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 97946316c3..8fe36ffb31 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -872,7 +872,7 @@ async prepare( agent: Agent, content: ContentBlock[], references: SessionReferen Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [PreparedReferencedMessage](../core-data-structures/session-reference.md) · [SessionReferenceCandidate](../core-data-structures/session-reference.md) · [SessionReferenceInput](../core-data-structures/session-reference.md) -Source: [`packages/context/session-reference/src/index.ts:71`](../../packages/context/session-reference/src/index.ts) +Source: [`packages/context/session-reference/src/index.ts:69`](../../packages/context/session-reference/src/index.ts) ## `ctx.sessions` — `SessionStore` diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index 26ab5d58d5..7c257a2559 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -18,12 +18,11 @@ The context source is `{ kind: 'plugin', plugin: 'session-reference' }`. Its met | Key | Default | Contract | |---|---:|---| -| `maxReferences` | `3` | Maximum distinct source sessions in one prepared message. | +| `maxReferences` | `3` | Maximum distinct source sessions in one prepared message; must be at most `3`. | | `candidateLimit` | `50` | Default metadata candidate count returned to a host. | | `maxReferenceBytes` | `65536` | Maximum serialized JSON bytes for one reference object. | -| `maxTotalBytes` | `196608` | Maximum complete prompt bytes, including fixed warning and tags. | -Retention keeps compact checkpoints and the newest message before dropping older non-checkpoint units. Oversized retained text uses `dsh-retention` head/tail truncation with an exact UTF-8 omission notice. The total budget is applied to the complete rendered prompt, including escaped JSON and fixed warning text; a snapshot whose fixed data cannot fit fails with `SESSION_REFERENCE_BUDGET_EXCEEDED`. +Retention applies `maxReferenceBytes` independently to each source, keeps compact checkpoints and the newest message before dropping older non-checkpoint units, and uses `dsh-retention` head/tail truncation with an exact UTF-8 omission notice. If one source's fixed serialized fields cannot fit, preparation fails with `SESSION_REFERENCE_BUDGET_EXCEEDED` instead of returning a partial context. ## Model Experience @@ -35,7 +34,7 @@ The model sees the current message's readable `@label` plus one same-level user- #### Token effect -Each referenced message adds the fixed warning plus the retained serialized snapshots, bounded by `maxReferenceBytes` and `maxTotalBytes`. The exact snapshot remains in target history until target compaction shadows or summarizes it; source-session changes add no further tokens. +Each referenced message adds the fixed warning plus up to three serialized snapshots, each independently bounded by `maxReferenceBytes`. The exact snapshot remains in target history until target compaction shadows or summarizes it; source-session changes add no further tokens. #### KV Cache effect diff --git a/packages/context/session-reference/src/config.ts b/packages/context/session-reference/src/config.ts index d9e0d69ae5..9ed156686e 100644 --- a/packages/context/session-reference/src/config.ts +++ b/packages/context/session-reference/src/config.ts @@ -1,24 +1,20 @@ /** Configuration and stable diagnostics for session references. */ -/** Default maximum references accepted by one message. */ -export const DEFAULT_MAX_REFERENCES = 3 +/** Hard maximum references accepted by one message. */ +export const MAX_REFERENCES = 3 /** Default number of discovery candidates returned to a host. */ export const DEFAULT_CANDIDATE_LIMIT = 50 /** Default UTF-8 budget for one rendered reference JSON object. */ export const DEFAULT_MAX_REFERENCE_BYTES = 65_536 -/** Default UTF-8 budget for the complete injected reference prompt. */ -export const DEFAULT_MAX_TOTAL_BYTES = 196_608 /** Session-reference service configuration. */ export interface Config { - /** Maximum distinct source sessions referenced by one message. */ + /** Maximum distinct source sessions referenced by one message, from one to three. */ maxReferences?: number /** Default host candidate-list limit. */ candidateLimit?: number /** Maximum rendered UTF-8 bytes for one source snapshot. */ maxReferenceBytes?: number - /** Maximum rendered UTF-8 bytes for the complete injected prompt. */ - maxTotalBytes?: number } /** Stable failure codes exposed to host adapters. */ diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index f0c453fe00..5b87966916 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -13,9 +13,8 @@ import type { JsonValue, SessionId } from '@deepseek-ai/dsh-session' import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query' import { DEFAULT_CANDIDATE_LIMIT, - DEFAULT_MAX_REFERENCES, DEFAULT_MAX_REFERENCE_BYTES, - DEFAULT_MAX_TOTAL_BYTES, + MAX_REFERENCES, SessionReferenceError, type Config, } from './config.ts' @@ -27,9 +26,8 @@ export type * from './types.ts' export type { Config, SessionReferenceErrorCode } from './config.ts' export { DEFAULT_CANDIDATE_LIMIT, - DEFAULT_MAX_REFERENCES, DEFAULT_MAX_REFERENCE_BYTES, - DEFAULT_MAX_TOTAL_BYTES, + MAX_REFERENCES, SessionReferenceError, } from './config.ts' export { @@ -71,10 +69,9 @@ interface RenderedSource { export class SessionReferenceService extends Service { static inject = ['sessionQuery'] static Config: z = z.object({ - maxReferences: z.number().step(1).min(1).default(DEFAULT_MAX_REFERENCES), + maxReferences: z.number().step(1).min(1).max(MAX_REFERENCES).default(MAX_REFERENCES), candidateLimit: z.number().step(1).min(1).default(DEFAULT_CANDIDATE_LIMIT), maxReferenceBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REFERENCE_BYTES), - maxTotalBytes: z.number().step(1).min(1).default(DEFAULT_MAX_TOTAL_BYTES), }) private readonly config: Required @@ -82,10 +79,9 @@ export class SessionReferenceService extends Service { constructor(ctx: Context, config: Config = {}) { super(ctx, 'sessionReferences') this.config = { - maxReferences: config.maxReferences ?? DEFAULT_MAX_REFERENCES, + maxReferences: config.maxReferences ?? MAX_REFERENCES, candidateLimit: config.candidateLimit ?? DEFAULT_CANDIDATE_LIMIT, maxReferenceBytes: config.maxReferenceBytes ?? DEFAULT_MAX_REFERENCE_BYTES, - maxTotalBytes: config.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES, } for (const [name, value] of Object.entries(this.config)) { if (!Number.isSafeInteger(value) || value <= 0) { @@ -95,6 +91,12 @@ export class SessionReferenceService extends Service { ) } } + if (this.config.maxReferences > MAX_REFERENCES) { + throw new SessionReferenceError( + `session-reference: maxReferences must not exceed ${MAX_REFERENCES}`, + 'SESSION_REFERENCE_INVALID_CONFIG', + ) + } } /** @@ -173,7 +175,7 @@ export class SessionReferenceService extends Service { } assertNotCancelled(signal) - const rendered = this.fitTotalBudget(prepared) + const rendered = this.renderSources(prepared) const prompt = renderPrompt(rendered.map(source => source.data)) const meta = { kind: 'session-reference', @@ -194,32 +196,19 @@ export class SessionReferenceService extends Service { return { content: acceptedContent, contexts: [context] } } - private fitTotalBudget(sources: readonly PreparedSource[]): RenderedSource[] { - let low = 1 - let high = this.config.maxReferenceBytes - let best: RenderedSource[] | undefined - while (low <= high) { - const cap = Math.floor((low + high) / 2) - const candidate = sources.map(source => retainReferencedSession(source.snapshot, source.input.label, cap)) - if (candidate.some(source => source === undefined)) { - low = cap + 1 - continue - } - const rendered = candidate as RenderedSource[] - if (Buffer.byteLength(renderPrompt(rendered.map(source => source.data)), 'utf8') <= this.config.maxTotalBytes) { - best = rendered - low = cap + 1 - } else { - high = cap - 1 + private renderSources(sources: readonly PreparedSource[]): RenderedSource[] { + const rendered: RenderedSource[] = [] + for (const source of sources) { + const retained = retainReferencedSession(source.snapshot, source.input.label, this.config.maxReferenceBytes) + if (retained === undefined) { + throw new SessionReferenceError( + 'referenced session snapshot cannot fit the configured byte budget', + 'SESSION_REFERENCE_BUDGET_EXCEEDED', + ) } + rendered.push(retained) } - if (best === undefined) { - throw new SessionReferenceError( - 'referenced session snapshot cannot fit the configured byte budgets', - 'SESSION_REFERENCE_BUDGET_EXCEEDED', - ) - } - return best + return rendered } } diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index ed8d4ecac2..17a147bdce 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -358,8 +358,8 @@ describe('session reference discovery and preparation', () => { .rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED')) }) - it('retains compact checkpoints and latest messages within exact UTF-8 budgets', async () => { - const ctx = await harness({ maxReferenceBytes: 360, maxTotalBytes: 650 }) + it('retains compact checkpoints and latest messages within an exact per-reference UTF-8 budget', async () => { + const ctx = await harness({ maxReferenceBytes: 360 }) const target = ctx.sessions.create(SessionId('target')) const source = ctx.sessions.create(SessionId('source')) appendConversation(source) @@ -377,7 +377,6 @@ describe('session reference discovery and preparation', () => { const prepared = await ctx.sessionReferences.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }]) const context = prepared.contexts[0] if (context?.content[0]?.type !== 'text') throw new Error('expected text context') - expect(Buffer.byteLength(context.content[0].text, 'utf8')).toBeLessThanOrEqual(650) const data = promptData(context.content[0].text) as unknown[] expect(Buffer.byteLength(stringifyTagSafeJson(data[0]), 'utf8')).toBeLessThanOrEqual(360) expect(context.content[0].text).toContain('checkpoint') @@ -386,8 +385,41 @@ describe('session reference discovery and preparation', () => { expect(context.meta).toMatchObject({ references: [{ truncated: true, compacted: true }] }) }) + it('applies the full byte limit independently to each of three references', async () => { + const maxReferenceBytes = 360 + const ctx = await harness({ maxReferenceBytes }) + const target = ctx.sessions.create(SessionId('target')) + const sources = ['one', 'two', 'three'].map((id) => { + const source = ctx.sessions.create(SessionId(id)) + source.append( + 'user/message', + { content: [{ type: 'text', text: `${id}-${'界'.repeat(400)}` }], source: COMPACT_CHECKPOINT_SOURCE }, + { surfaceOp: 'append' }, + ) + source.append( + 'user/message', + { content: [{ type: 'text', text: `${id}-tail` }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + return source + }) + + const prepared = await ctx.sessionReferences.prepare( + fakeAgent(target), + [{ type: 'text', text: 'go' }], + sources.map(source => ({ sessionId: source.id })), + ) + const context = prepared.contexts[0] + if (context?.content[0]?.type !== 'text') throw new Error('expected text context') + const data = promptData(context.content[0].text) as unknown[] + const sizes = data.map(source => Buffer.byteLength(stringifyTagSafeJson(source), 'utf8')) + expect(sizes).toHaveLength(3) + expect(sizes.every(size => size <= maxReferenceBytes)).toBe(true) + expect(sizes.reduce((sum, size) => sum + size, 0)).toBeGreaterThan(maxReferenceBytes * 2) + }) + it('fails without producing a partial context when fixed prompt data cannot fit', async () => { - const ctx = await harness({ maxReferenceBytes: 16, maxTotalBytes: 32 }) + const ctx = await harness({ maxReferenceBytes: 16 }) const target = ctx.sessions.create(SessionId('target')) const source = ctx.sessions.create(SessionId('source')) await expect(ctx.sessionReferences.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }])) @@ -454,6 +486,12 @@ describe('session reference discovery and preparation', () => { expect(() => new SessionReferenceService(ctx, { maxReferences: 0 })) .toThrow(expectCode('SESSION_REFERENCE_INVALID_CONFIG')) + const oversizedCtx = new Context() + await oversizedCtx.plugin(SessionStore) + await oversizedCtx.plugin(SessionQueryService) + expect(() => new SessionReferenceService(oversizedCtx, { maxReferences: 4 })) + .toThrow(expectCode('SESSION_REFERENCE_INVALID_CONFIG')) + const defaultCtx = new Context() await defaultCtx.plugin(SessionStore) await defaultCtx.plugin(SessionQueryService) diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index 231bd443cc..213ff5b8d5 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -36,7 +36,6 @@ describe('dsh-tui-demo app', () => { maxReferences: 2, candidateLimit: 7, maxReferenceBytes: 1234, - maxTotalBytes: 2345, }, welcome: 'TUI ready', ui: { color: false, maxToolOutputLines: 3 }, @@ -63,7 +62,6 @@ describe('dsh-tui-demo app', () => { maxReferences: 2, candidateLimit: 7, maxReferenceBytes: 1234, - maxTotalBytes: 2345, }) const tuiConfig = calls[6]?.config as { sessionId: string } expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 })