refactor(web): publish transient model request capacity (round 1)

This commit is contained in:
Hypatia May
2026-07-28 18:35:39 +08:00
parent 3f0ba77bfa
commit 35b9c454e5
54 changed files with 765 additions and 1352 deletions

View File

@@ -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 .agents/notes/implemented/architecture/2026-07-28-host-owned-web-session-metrics.md
2026-07-28-host-owned-web-session-metrics.md: 04381c7443491fd9de101a87713fa5c183800d0e
2026-07-28-host-owned-web-session-metrics.zh.md: 6ad06ea61508d3f0703c19bc7c9f14969f119334
2026-07-28-host-owned-web-session-metrics.md: e37a7635cbdae65162308bf8a3a498b5071a4910
2026-07-28-host-owned-web-session-metrics.zh.md: fd3e50c8cf8c0336a8cb2cd55f6629419bb8ad23

View File

@@ -6,17 +6,19 @@ English | [中文](2026-07-28-host-owned-web-session-metrics.zh.md)
## Problem
A Web stats line derived from the currently loaded conversation nodes is window-dependent under pagination. Compaction can replace visible content without preserving historical usage, and route changes leave the browser without an authoritative context capacity. Cache-write tokens also risk being folded into a cache-hit formula whose denominator has different semantics.
A Web stats line derived from the currently loaded conversation nodes is window-dependent under pagination. Compaction can replace visible content without preserving historical usage, while the selected model does not prove that a request used its route or capacity. Cache-write tokens also risk being folded into a cache-hit formula whose denominator has different semantics.
## Decision
The Host owns one session-level metrics projection. It incrementally folds the complete durable event log, keys settled usage by `(turn, step)`, and replaces an earlier usage record for the same key instead of double-counting chunk and message forms. Uncached input, output, cache reads, and cache writes remain four disjoint cumulative buckets. Compaction can change the current prompt surface without erasing historical usage.
Current context pressure is a separate point-in-time value from `tokenMeter.measure(session).totalTokens`. Capacity comes only from `llm.resolveModelInfo(provider, model).context.contextWindow` for the agent's selected route. A route change immediately publishes metrics with capacity absent, then publishes the resolved capacity behind a route generation fence; stale metadata cannot label the new route.
Current context pressure is the point-in-time `tokenMeter.measure(session).totalTokens`. Capacity instead belongs to the latest model request observed by the current live mux connection. `LlmService.prepareCall()` retains the context metadata obtained by the exact lookup that also validates reasoning/defaults, and the loop publishes it through one contained `agent/model-request` notification only after the final route has a successfully constructed stream handle. Failed or aborted iteration still counts as a dispatched request; preparation and synchronous construction failures do not.
The tail `session.history` response carries the projection, while older pages omit it. Live changes use `session/metrics` mux frames. Both forms carry a durable-log revision and a projection revision; the client accepts only nondecreasing revisions, preserves metrics across older-page prepend, and clears them at a new subscription baseline. Missing measurement or metadata stays absent.
The tail `session.history` response carries durable usage and pressure, while older pages omit them. Live changes use `session/metrics` mux frames. Both forms carry a durable-log revision and a projection revision; the client accepts only nondecreasing revisions and preserves metrics across older-page prepend.
The Web stats line treats the projection as its sole token source. It renders uncached input, output, and cache reads separately, computes cache hit as `cacheRead / (uncachedInput + cacheRead)`, and shows current context as a percentage of the exact route capacity. Cache writes never enter that percentage. Visible nodes continue to supply only turn and step counts.
ApiProxy forwards each notification as a distinct `session/model-request` frame only to mux connections already open when dispatch occurs. It never places the frame in `session.history` or a subscription baseline. The client retains that connection-local capacity across ordinary metrics updates, replaces or explicitly clears it on the next observed request, and clears it on `session/subscribed`; reconnect, restore, and a new subscription therefore start unknown until another request is observed.
The Web stats line treats the durable projection plus live capacity overlay as its sole token source. It renders uncached input, output, and cache reads separately, computes cache hit as `cacheRead / (uncachedInput + cacheRead)`, and shows current context as a percentage only when the current connection observed a capacity. Cache writes never enter that percentage. Visible nodes continue to supply only turn and step counts.
## Alternatives considered
@@ -26,10 +28,12 @@ The Web stats line treats the projection as its sole token source. It renders un
**Reuse one total-token field for cache hit.** Cache reads, cache writes, and uncached input represent distinct provider accounting buckets; combining them would make the displayed rate misleading.
**Keep the previous capacity until the new route resolves.** The old number would temporarily claim the wrong selected model. An explicit unknown state is honest and generation-safe.
**Query the selected route before dispatch.** Selection may never produce a request, and a second metadata lookup can race the registration-bound lookup that actually validates and dispatches the call.
**Persist or replay the latest request capacity.** That would make a former request look current on reconnect or restore even though the new connection observed no request. The denominator is deliberately live and opportunistic.
## Consequences
Token totals remain stable across pagination, replay, compaction, and browser reconnect. The client stores a small detached projection instead of scanning the conversation window, and the status row remains readable for large histories through compact number formatting.
Token totals remain stable across pagination, replay, compaction, and browser reconnect. The client stores a small detached durable projection plus one connection-local denominator instead of scanning the conversation window, and the status row remains readable for large histories through compact number formatting.
The Host performs one incremental log fold per session and schedules live projection updates only for usage, request-header, or surface-changing events; text and reasoning deltas do not publish metrics. Exact capacity resolution is asynchronous and may briefly render as unknown. Deployments without a token meter or model context metadata retain the row and label the unavailable value instead of fabricating one.
The Host performs one incremental log fold per session and schedules durable projection updates only for usage, request-header, or surface-changing events; text and reasoning deltas do not publish metrics. A new connection omits the percentage until it observes a request with context metadata. A later request without metadata clears the denominator, while deployments without a token meter still retain the durable counters and label context unavailable instead of fabricating pressure.

View File

@@ -6,17 +6,19 @@ Status: implemented
## 问题
Web 统计行若根据当前加载的会话节点推导指标其结果会随分页窗口变化。压缩compaction可以替换可见内容却无法保留历史用量路由变更会让浏览器缺少权威的上下文容量。缓存写入 token 还可能被计入缓存命中率公式,而该公式的分母具有不同语义。
Web 统计行若根据当前加载的会话节点推导指标其结果会随分页窗口变化。压缩compaction可以替换可见内容却无法保留历史用量所选模型也不能证明某次请求实际采用了该模型的路由或容量。缓存写入 token 还可能被计入缓存命中率公式,而该公式的分母具有不同语义。
## 决策
Host 拥有一项会话级指标投影。它以增量方式归并完整的持久事件日志,按 `(turn, step)` 标识已结算用量;同一标识再次出现时,会替换较早的用量记录,而不会重复统计分片和消息两种形态。未缓存输入、输出、缓存读取与缓存写入保持为四个彼此独立的累计计数项。压缩可以改变当前提示词表层,但不会抹除历史用量。
当前上下文压力是一个独立的即时值,取自 `tokenMeter.measure(session).totalTokens`。容量仅来自 `llm.resolveModelInfo(provider, model).context.contextWindow`,并对应 agent智能体所选的路由。路由变更时Host 会立即发布不带容量的指标,再通过路由代际围栏发布解析出的容量;陈旧元数据无法标记新的路由
当前上下文压力是即时的 `tokenMeter.measure(session).totalTokens`。容量则属于当前实时 mux 连接观察到的最新模型请求。`LlmService.prepareCall()` 会保留同一次精确查询取得的上下文元数据,该查询也负责校验推理设置与默认值;仅在最终路由的流句柄成功构造后,循环才会通过一条失败会被收容的 `agent/model-request` 通知发布这些元数据。后续迭代失败或中止仍算作已分派请求;准备阶段失败和同步构造失败则不算
`session.history` 尾页响应携带该投影,较早页面则省略。实时变更使用 `session/metrics` mux 帧。两种形式都携带持久日志修订号和投影修订号;客户端只接受不减小的修订号,在向前加载较早页面时保留指标,并在建立新的订阅基线时将其清除。测量值或元数据缺失时,对应字段保持缺失
`session.history` 尾页响应携带持久用量与压力,较早页面则省略这两项。实时变更使用 `session/metrics` mux 帧。两种形式都携带持久日志修订号和投影修订号;客户端只接受不减小的修订号,在向前加载较早页面时保留指标。
Web 统计行把该投影视为唯一的 token 数据来源。它分别呈现未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率,并把当前上下文显示为精确路由容量的百分比。缓存写入绝不计入缓存命中率。可见节点仍然只提供轮次和步骤计数
ApiProxy 只把每条通知作为独立的 `session/model-request` 帧转发给分派发生时已经打开的 mux 连接。它绝不会把该帧放入 `session.history` 或订阅基线。客户端会在普通指标更新期间保留这项连接本地容量,在观察到下一次请求时替换或显式清除它,并在收到 `session/subscribed` 时将其清除;因此,重连、恢复和新订阅都会从未知容量开始,直到观察到另一次请求
Web 统计行把持久投影与实时容量覆盖层视为唯一的 token 数据来源。它分别呈现未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率,并且只有当前连接观察到容量时,才把当前上下文显示为该容量的百分比。缓存写入绝不计入缓存命中率。可见节点仍然只提供轮次和步骤计数。
## 备选方案
@@ -26,10 +28,12 @@ Web 统计行把该投影视为唯一的 token 数据来源。它分别呈现未
**为缓存命中率复用单一的 token 总数字段。** 缓存读取、缓存写入与未缓存输入是提供方记账中的不同计数项;将它们合并会使显示的比率产生误导。
**在新路由解析完成前保留旧容量。** 旧数值会在短时间内错误标示所选模型。显式的「未知」状态能如实反映情况,并避免跨代串扰
**在分派前查询所选路由。** 选择操作可能永远不会产生请求;第二次元数据查询还可能与实际校验并分派调用的、绑定注册项的查询发生竞态
**持久化或回放最新请求的容量。** 即使新连接没有观察到任何请求,这也会让先前请求在重连或恢复后显得仍然有效。该分母刻意只采用实时且恰好可得的数据。
## 后果
token 总量在分页、回放、压缩和浏览器重连期间保持稳定。客户端存储一项小型脱耦投影,无需扫描会话窗口;状态行采用紧凑数字格式,因此在较长的历史记录中仍然清晰易读。
token 总量在分页、回放、压缩和浏览器重连期间保持稳定。客户端存储一项小型脱耦的持久投影与一个连接本地分母,无需扫描会话窗口;状态行采用紧凑数字格式,因此在较长的历史记录中仍然清晰易读。
Host 为每个会话执行一次增量日志归并,仅为用量事件、请求头事件或表层变更事件调度实时投影更新文本与推理reasoning增量不会发布指标。精确容量解析为异步操作,因此可能短暂显示「未知」。未部署 token 计量器或缺少模型上下文元数据时,系统仍保留该行,并标示不可用的值,而不会虚构数据
Host 为每个会话执行一次增量日志归并,仅为用量事件、请求头事件或表层变更事件调度持久投影更新文本与推理reasoning增量不会发布指标。新连接在观察到带上下文元数据的请求之前不会显示百分比。后续不带元数据的请求会清除该分母;未部署 token 计量器时,系统仍保留持久计数器,并把上下文标示不可用,而不会虚构压力值

View File

@@ -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 docs/architecture.md
architecture.md: 054985ac5ea32a44b9daca3c1abfd58dcdc5d897
architecture.zh.md: 84876faf2ae27069ba8bd026bcfbc56e32f65574
architecture.md: 52072633a0e81afce63c5e162dd1b0af7f6486ca
architecture.zh.md: f0122ece146c17366aa316cfb4ea4196a1db74bd

View File

@@ -92,7 +92,7 @@ forever:
assemble system prompt and tool schemas
snapshot the derived messages (the reconstruction boundary)
'step/start'
agent/request (config only) -> prepare reasoning/default under turn signal -> log request/header -> llm/stream (frozen, registration-bound)
agent/request (config only) -> prepare reasoning/default + context under turn signal -> log request/header -> construct llm/stream (frozen, registration-bound) -> agent/model-request (live, contained) -> iterate
'assistant/chunk'
'assistant/message'
schedule tool calls by ctx.tools.executionMode:
@@ -151,7 +151,7 @@ Log-only events may sit between turns. Owners append through `Session`, flushing
Messages use typed blocks from merge-extensible `ContentBlockMap`; the pattern also types `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason`. New blocks coordinate adapters, UI, compaction, token metering, and persistence; replay measurements live in [token-meter.md](core-data-structures/token-meter.md).
Streaming uses raw chunks and `BlockAssembler`. Each `LlmAdapter.stream()` is one provider attempt; adapters report normalized failure facts, and a handling `agent/request-error` plugin returns a retry action. The loop logs chunks, successful provenance, and replay state. Remote adapters use per-read idle watchdogs. Replay crosses routes only through a shared adapter instance ([contract](core-data-structures/llm-streaming.md)).
Streaming uses raw chunks and `BlockAssembler`. After final-stream construction, the loop emits contained, non-durable, non-replayed `agent/model-request` metadata. Adapters normalize failures; `agent/request-error` may retry. Remote adapters use per-read idle watchdogs. Replay crosses routes only through a shared adapter ([contract](core-data-structures/llm-streaming.md)).
## Extension And Composition

View File

@@ -92,7 +92,7 @@ forever:
assemble system prompt and tool schemas
snapshot the derived messages (the reconstruction boundary)
'step/start'
agent/request (config only) -> prepare reasoning/default under turn signal -> log request/header -> llm/stream (frozen, registration-bound)
agent/request (config only) -> prepare reasoning/default + context under turn signal -> log request/header -> construct llm/stream (frozen, registration-bound) -> agent/model-request (live, contained) -> iterate
'assistant/chunk'
'assistant/message'
schedule tool calls by ctx.tools.executionMode:
@@ -151,7 +151,7 @@ idle inject:
消息使用从可合并扩展的 `ContentBlockMap` 派生的类型化块;同一模式也为 `MessageSource``FinishReason``TurnTrigger``TurnEndReason` 定义类型。新增块会协调适配器、UI、压缩、token 计量和持久化;回放计量见 [token-meter.md](core-data-structures/token-meter.md)。
流式输出使用原始分片和 `BlockAssembler`每次 `LlmAdapter.stream()` 调用代表一次提供方尝试;适配器报告标准化的故障事实,负责处理的 `agent/request-error` 插件会返回重试动作。循环会记录分片、成功结果的来源信息和回放状态。远程适配器使用逐次读取空闲看门狗。回放仅通过共用适配器实例跨路由传递([契约](core-data-structures/llm-streaming.md))。
流式输出使用原始分片和 `BlockAssembler`最终流构造完成后,循环会发出 `agent/model-request` 元数据;该通知的失败会被收容,元数据不会持久化或回放。适配器会规范化故障;`agent/request-error` 可以重试。远程适配器使用逐次读取空闲看门狗。回放仅通过共用适配器跨路由传递([契约](core-data-structures/llm-streaming.md))。
## 扩展与组合

View File

@@ -32,7 +32,7 @@ Effective broad cancellation was requested, before queued/outbox work is cleared
Types: [Agent](../core-data-structures/core.md) · [AgentCancelCause](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:308`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:318`](../../packages/core/agent/src/types.ts)
### `agent/created` — emit
@@ -54,7 +54,7 @@ A fully configured agent and live session were published. Setup is composition-o
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:247`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:257`](../../packages/core/agent/src/types.ts)
### `agent/disposed` — emit
@@ -74,7 +74,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:256`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:266`](../../packages/core/agent/src/types.ts)
### `agent/error` — emit
@@ -96,7 +96,7 @@ A step or turn errored. The machine reports a failure here (plus the logger) eve
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:423`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:447`](../../packages/core/agent/src/types.ts)
### `agent/inbox/dequeue` — emit
@@ -117,7 +117,7 @@ The driver claimed one item out of the inbox: a queued item at a turn boundary,
Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:286`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:296`](../../packages/core/agent/src/types.ts)
### `agent/inbox/discard` — emit
@@ -140,7 +140,7 @@ Pending inbox items were dropped without delivering them, so every enqueued id r
Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:298`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:308`](../../packages/core/agent/src/types.ts)
### `agent/inbox/enqueue` — emit
@@ -162,7 +162,32 @@ An item entered the queued or steering inbox. `placement` is the acceptance-time
Types: [Agent](../core-data-structures/core.md) · [AgentMessage](../core-data-structures/core.md) · [InboxPlacement](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:276`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:286`](../../packages/core/agent/src/types.ts)
### `agent/model-request` — emit
One model request constructed its final stream handle and is about to iterate it. This live notification is not durable or replayed; failed or aborted iteration still has a dispatch, while preparation and synchronous stream-construction failures do not. Listener failures are contained and cannot affect the request.
```ts cordis-catalog
/**
* One model request constructed its final stream handle and is about to
* iterate it. This live notification is not durable or replayed; failed or
* aborted iteration still has a dispatch, while preparation and
* synchronous stream-construction failures do not. Listener failures are
* contained and cannot affect the request.
* @param agent - the agent dispatching the model request.
* @param turn - the open turn number.
* @param step - the request's step number.
* @param request - final route plus registration-bound context capacity.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/model-request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, request: AgentModelRequest): void
```
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:386`](../../packages/core/agent/src/types.ts)
### `agent/prompt-submit` — waterfall
@@ -186,7 +211,7 @@ Allow, rewrite, or block one claimed prompt before it becomes a user message or
Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) · [PromptDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:336`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:346`](../../packages/core/agent/src/types.ts)
### `agent/request` — waterfall
@@ -210,7 +235,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach
Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:362`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:372`](../../packages/core/agent/src/types.ts)
### `agent/request-error` — waterfall
@@ -240,7 +265,7 @@ Handle a model-request failure after its failed step has closed but before the f
Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestError](../core-data-structures/core.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:381`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:405`](../../packages/core/agent/src/types.ts)
### `agent/session-start` — emit
@@ -262,7 +287,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:321`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts)
### `agent/settled` — emit
@@ -287,7 +312,7 @@ One drain chain reached its terminal turn: that turn's `turn/end` is already com
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SettleReason](../core-data-structures/core.md)
Source: [`packages/core/agent/src/types.ts:410`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:434`](../../packages/core/agent/src/types.ts)
### `agent/status` — emit
@@ -307,7 +332,7 @@ Agent status changed (`idle` ⇄ `running`). `send()` does not enter `running` s
Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:265`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:275`](../../packages/core/agent/src/types.ts)
### `agent/step` — serial
@@ -331,7 +356,7 @@ Awaited serial checkpoint before EVERY request of a turn is built (the first as
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:349`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:359`](../../packages/core/agent/src/types.ts)
### `agent/turn-stopping` — serial
@@ -357,7 +382,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f
Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md)
Source: [`packages/core/agent/src/types.ts:396`](../../packages/core/agent/src/types.ts)
Source: [`packages/core/agent/src/types.ts:420`](../../packages/core/agent/src/types.ts)
## `agent-loop/*`
@@ -548,7 +573,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t
Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Source: [`packages/llm/llm/src/index.ts:56`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:57`](../../packages/llm/llm/src/index.ts)
## `session/*`

View File

@@ -780,14 +780,16 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<Prepared
* agent-loop request recovery; middleware and nested-call failures remain
* untagged for the outer call.
* @param options - the full request; `options.provider` selects the adapter.
* @param onDispatched - contained Agent-loop notification hook invoked after
* a stream handle is constructed and before its adapter is iterated.
* @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
*/
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable<StreamChunk>
```
Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
Source: [`packages/llm/llm/src/index.ts:189`](../../packages/llm/llm/src/index.ts)
Source: [`packages/llm/llm/src/index.ts:194`](../../packages/llm/llm/src/index.ts)
## `ctx.permission` — `PermissionService`

View File

@@ -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 docs/core-data-structures/llm-streaming.md
llm-streaming.md: db46deee28cd053d034f889eb7625c9f222b418b
llm-streaming.zh.md: fbff50bf1f3b86afd313c2d5020c15dd88e0b449
llm-streaming.md: 89628ebb96a2e8eec5209635cd92859427df4a8d
llm-streaming.zh.md: 9c8fcc1f24b970f3a7cdd7cd08d9ef3b934b4543

View File

@@ -161,21 +161,25 @@ declare class BlockAssembler {
## The seam
`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or capability, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. The service validates and materializes reasoning through `resolveCallConfig()` at the final adapter boundary, so direct calls cannot bypass unsupported-effort rejection; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm).
`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or capability, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. The service validates and materializes reasoning through `resolveCallConfig()` at the final adapter boundary, so direct calls cannot bypass unsupported-effort rejection; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch, and to retain detached context metadata from that exact lookup. Its optional observer runs after a final stream handle is constructed and before adapter iteration. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm).
```ts type-equiv
/** One model call whose config and adapter registration were resolved together. */
interface PreparedLlmCall {
/** Detached, deep-frozen config with any adapter-owned default materialized. */
readonly config: LlmCallConfig
/** Detached context metadata resolved with the registration-bound call. */
readonly context?: LlmModelContext
/**
* Dispatch this call once through the registration captured during
* preparation. The request's call-config fields must match {@link config};
* reuse or mismatch fails with `INVALID_PREPARED_CALL`.
* @param options - fully assembled request carrying the prepared config.
* @param onDispatched - contained Agent-loop notification hook invoked after
* a stream handle is constructed and before its adapter is iterated.
* @returns the chunk stream, including the `llm/stream` waterfall.
*/
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable<StreamChunk>
}
```

View File

@@ -161,21 +161,25 @@ declare class BlockAssembler {
## seam
`LlmAdapter` 是提供方 seam创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据或能力不可用,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。服务通过最终适配器边界的 `resolveCallConfig()` 校验推理强度并填入默认值因此直接调用也无法绕过对不支持推理强度的拒绝直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册。适配器查找发生在 `llm/stream` waterfall瀑布式事件的终端 continuation因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。
`LlmAdapter` 是提供方 seam创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据或能力不可用,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。服务通过最终适配器边界的 `resolveCallConfig()` 校验推理强度并填入默认值因此直接调用也无法绕过对不支持推理强度的拒绝直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册,并保留来自同一次精确查询的分离上下文元数据。其可选观察器在最终流句柄构造完成后、适配器开始迭代前运行。适配器查找发生在 `llm/stream` waterfall瀑布式事件的终端 continuation因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。
```ts type-equiv
/** One model call whose config and adapter registration were resolved together. */
interface PreparedLlmCall {
/** Detached, deep-frozen config with any adapter-owned default materialized. */
readonly config: LlmCallConfig
/** Detached context metadata resolved with the registration-bound call. */
readonly context?: LlmModelContext
/**
* Dispatch this call once through the registration captured during
* preparation. The request's call-config fields must match {@link config};
* reuse or mismatch fails with `INVALID_PREPARED_CALL`.
* @param options - fully assembled request carrying the prepared config.
* @param onDispatched - contained Agent-loop notification hook invoked after
* a stream handle is constructed and before its adapter is iterated.
* @returns the chunk stream, including the `llm/stream` waterfall.
*/
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable<StreamChunk>
}
```

View File

@@ -8,21 +8,22 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:140`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) |
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:247`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:256`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:423`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) |
| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) |
| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:298`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) |
| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:276`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:336`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:362`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:381`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:321`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) |
| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:410`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:349`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:396`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:318`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:257`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:266`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:447`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) |
| `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) |
| `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:308`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`tui`](../packages/ui/tui) |
| `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/model-request` | `emit` | [`packages/core/agent/src/types.ts:386`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `apiproxy` |
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:346`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`tui`](../packages/ui/tui) |
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:372`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) |
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:405`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) |
| `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:434`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) |
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:275`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) |
| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:359`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:420`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp) |
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
@@ -30,7 +31,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:169`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:56`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:57`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:70`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
@@ -67,7 +68,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `connection/reset` | `runtime` (`emit`) | - |
| `internal/dispatch` | - | [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/plugin` | - | `hmr`, `modules`, `webserver` |
| `internal/status` | - | [`agent`](../packages/core/agent), `apiproxy` |
| `internal/status` | - | [`agent`](../packages/core/agent) |
| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` |
| `slots/changed` | `runtime` (`emit`) | - |
| `theme/change` | `ui-theme` (`emit`) | `ui-layout`, `ui-theme` |

View File

@@ -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 packages/client/runtime/README.md
README.md: 935bb4908fc54fc629574963e28dc0ddcfb89a6c
README.zh.md: 85d444ee5d973bb989232ecaf00f2312d3195c8d
README.md: bc1149644d6112ca82c9a27912d1a58351cf84a5
README.zh.md: 993625614061e819495b25f0851156ea20c62601

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries two Host-owned full-log projections. `todos` comes from the tail history page, survives older-page prepend, and follows live `todo/write` events. `metrics` comes from tail history and live `session/metrics` frames, survives older-page prepend, and accepts only nondecreasing log and projection revisions; a subscription baseline clears it before replay so a new stream generation can restart revisions safely. Missing metrics remain `null` rather than being inferred from the visible node window.
Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list/scope/history state; WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into both managers. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. `ConversationSnapshot` carries two Host-owned full-log projections. `todos` comes from the tail history page, survives older-page prepend, and follows live `todo/write` events. Durable `metrics` comes from tail history and live `session/metrics` frames, survives older-page prepend, and accepts only nondecreasing log and projection revisions. The Session separately retains capacity from the latest `session/model-request` observed on its current mux connection and overlays it onto metrics across ordinary usage/pressure updates. A later request replaces or clears that value, while `session/subscribed` clears both metrics ordering and capacity; reconnect, restore, and a new subscription therefore show no percentage until another request is observed. Missing metrics remain `null` rather than being inferred from the visible node window.
## Workspace and Session lists

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约api-contracts v3 §4。`ConversationSnapshot` 携带两项由 Host 拥有的完整日志投影。`todos` 来自 history 尾页,在向前加载较早页面时保留,并随实时 `todo/write` 事件更新。`metrics` 来自 history 尾页和实时 `session/metrics` 帧,在向前加载较早页面时保留,并且只接受日志修订号与投影修订号均不减小的数据;订阅基线会在回放前将其清除,使新的流代次可以安全地从头开始计数修订号。缺失的 metrics 保持为 `null`,而不是根据可见节点窗口推断。
客户端 cordis 启动与不依赖 React 的对象服务SlotsService 包装 SlotCore 并提供 renderer 数据源SessionsService 拥有 Session 对象、列表scopehistory 状态WorkspacesService 依赖 SessionsService拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给两个 manager。客户端 Session 一律由 Host 出生(一次 `session.create` 同瞬产出 Session+Agent+cwd客户端不持有任何实体化之前的会话状态——Agent scopehost dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时出生,随 prune 死亡。契约api-contracts v3 §4。`ConversationSnapshot` 携带两项由 Host 拥有的完整日志投影。`todos` 来自 history 尾页,在向前加载较早页面时保留,并随实时 `todo/write` 事件更新。持久 `metrics` 来自 history 尾页和实时 `session/metrics` 帧,在向前加载较早页面时保留,并且只接受日志修订号与投影修订号均不减小的数据。Session 另行保留当前 mux 连接观察到的最新 `session/model-request` 容量,并在普通用量/压力更新期间把它覆盖到 metrics 上。后续请求会替换或清除该值,`session/subscribed` 则同时清除指标顺序状态与容量;因此,重连、恢复和新订阅都不会显示百分比,直到观察到另一次请求。缺失的 metrics 保持为 `null`,而不是根据可见节点窗口推断。
## Workspace 与 Session 列表

View File

@@ -12,6 +12,15 @@ import type { PendingInteraction } from './pending.ts'
export type { TodoItem }
/**
* Durable Host metrics with the latest capacity observed on this live mux
* connection overlaid for presentation.
*/
export interface ConversationMetrics extends SessionMetrics {
/** Latest dispatched-request capacity; absent until observed or after reset/clear. */
contextWindow?: number
}
/** Assistant content blocks sorted by what the UI cares about
* (text body / collapsible reasoning / tool-call card head / other fallback). */
export type AssistantBlock =
@@ -247,9 +256,9 @@ export interface ConversationSnapshot {
* write (last write wins); empty = the log holds no plan. */
todos: readonly TodoItem[]
/**
* Host-owned cumulative usage and current-context projection. Independent
* of `nodes` pagination; null until a tail response or live metrics frame
* supplies a current value.
* Host-owned cumulative usage/current pressure with live mux-local capacity
* overlaid. Independent of `nodes` pagination; null until a tail response or
* live metrics frame supplies a current durable value.
*/
metrics: SessionMetrics | null
metrics: ConversationMetrics | null
}

View File

@@ -12,7 +12,7 @@ import type {
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ObservableSnapshot } from '../contract/store.ts'
import type {
CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
CodeSubCall, ComposerPhase, ConversationMetrics, ConversationNode, ConversationSnapshot, OpenState,
PromptError, QueuedMessage, RunningToolCall,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
@@ -102,8 +102,10 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
/** Current whole-list todo/write projection: each tail history response replaces it (an omitted
* field is the authoritative empty list) and every live write overwrites it. */
private todos: readonly TodoItem[] = []
/** Host-owned metrics projection; ordering resets on each subscribed baseline. */
private metrics: SessionMetrics | null = null
/** Host-owned metrics with current-connection request capacity overlaid. */
private metrics: ConversationMetrics | null = null
/** Latest capacity observed on this mux connection, independent of durable metrics arrival. */
private contextWindow: number | undefined
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
* copy-on-write the per-parent array so published snapshot references never mutate. */
private codeDispatches = new Map<string, readonly CodeSubCall[]>()
@@ -367,6 +369,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.queueRev++
this.notifier.markDirty()
}
this.contextWindow = undefined
if (this.metrics !== null) {
this.metrics = null
this.notifier.markDirty()
@@ -377,6 +380,18 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
this.installMetrics(frame.metrics)
return
}
case 'session/model-request': {
if (this.contextWindow === frame.contextWindow) return
this.contextWindow = frame.contextWindow
if (this.metrics !== null) {
const { contextWindow: _previous, ...durable } = this.metrics
this.metrics = frame.contextWindow === undefined
? durable
: { ...durable, contextWindow: frame.contextWindow }
this.notifier.markDirty()
}
return
}
case 'approval/requested': {
const { type: _type, sessionId: _sid, ...payload } = frame
this.mint(new PendingWait('approval', rpcId, this.sessionId, payload, m => this.api.respond(m)))
@@ -800,7 +815,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|| metrics.projectionRevision < current.projectionRevision
)
) return
this.metrics = metrics
this.metrics = this.contextWindow === undefined
? metrics
: { ...metrics, contextWindow: this.contextWindow }
this.notifier.markDirty()
}

View File

@@ -51,7 +51,6 @@ function metrics(
cacheReadTokens: 90,
cacheWriteTokens: 3,
contextTokens: 35,
contextWindow: 100,
...over,
}
}
@@ -146,7 +145,7 @@ describe('live event path', () => {
expect(session.getSnapshot().nodes).toEqual(before.nodes)
})
it('orders live metrics, rejects stale projections, and clears the value at a reconnect baseline', async () => {
it('retains live capacity across metrics, replaces or clears it on requests, and resets at subscription', async () => {
const { session } = await opened()
const current = metrics(8, 10)
session.handleMuxEnvelope('m1' as never, {
@@ -156,6 +155,20 @@ describe('live event path', () => {
})
expect(session.getSnapshot().metrics).toBe(current)
session.handleMuxEnvelope('request-1' as never, {
type: 'session/model-request',
sessionId: SID,
turn: 1,
step: 1,
provider: 'test',
model: 'alpha',
contextWindow: 128_000,
})
expect(session.getSnapshot().metrics).toEqual({
...current,
contextWindow: 128_000,
})
session.handleMuxEnvelope('m2' as never, {
type: 'session/metrics',
sessionId: SID,
@@ -166,7 +179,31 @@ describe('live event path', () => {
sessionId: SID,
metrics: metrics(7, 11, { uncachedInputTokens: 2 }),
})
expect(session.getSnapshot().metrics).toBe(current)
expect(session.getSnapshot().metrics).toEqual({
...current,
contextWindow: 128_000,
})
const ordinaryUpdate = metrics(9, 11, { contextTokens: 40 })
session.handleMuxEnvelope('m4' as never, {
type: 'session/metrics',
sessionId: SID,
metrics: ordinaryUpdate,
})
expect(session.getSnapshot().metrics).toEqual({
...ordinaryUpdate,
contextWindow: 128_000,
})
session.handleMuxEnvelope('request-2' as never, {
type: 'session/model-request',
sessionId: SID,
turn: 2,
step: 1,
provider: 'test',
model: 'without-capacity',
})
expect(session.getSnapshot().metrics).toEqual(ordinaryUpdate)
session.handleMuxEnvelope('sub' as never, {
type: 'session/subscribed',
@@ -174,13 +211,25 @@ describe('live event path', () => {
lastSeq: 5,
})
expect(session.getSnapshot().metrics).toBeNull()
session.handleMuxEnvelope('request-3' as never, {
type: 'session/model-request',
sessionId: SID,
turn: 3,
step: 1,
provider: 'test',
model: 'beta',
contextWindow: 256_000,
})
const nextGeneration = metrics(0, 10, { contextTokens: 20 })
session.handleMuxEnvelope('m4' as never, {
session.handleMuxEnvelope('m5' as never, {
type: 'session/metrics',
sessionId: SID,
metrics: nextGeneration,
})
expect(session.getSnapshot().metrics).toBe(nextGeneration)
expect(session.getSnapshot().metrics).toEqual({
...nextGeneration,
contextWindow: 256_000,
})
})
it('accumulates chunks into partial, then finalize swaps partial out as the node lands', async () => {

View File

@@ -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 packages/client/ui-conversation/README.md
README.md: 0674a596353aac024824489e5ca25615c7426bcd
README.zh.md: 65d3eba678b7190d97244a54c629b78c7d24b8c0
README.md: 1f47260f9034f22ba560552e5a99b938bf14ed6d
README.zh.md: 7d9a9d2ab95d93668de216d64eee704bcc569547

View File

@@ -18,7 +18,7 @@ Per-session UI state for selection and the active view lives in the declared cha
The composer bar declares session-scoped single seats for `'conversation.input.plan'` and `'conversation.input.model'`, plus list slots for overlay, dock, left, and right input extensions. InputBar renders the model seat immediately before its pending indicator and send/stop button. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
The chat stats line reads durable token counters and current-context pressure only from `ConversationSnapshot.metrics`; visible nodes supply only the existing turn/step counts. It renders uncached input, output, and cache reads as separate compact values, computes cache hit as `cacheRead / (uncachedInput + cacheRead)` without cache writes, and shows context occupancy against the selected route's exact capacity. Missing host data is labeled unknown, never reconstructed from a paged window.
The chat stats line reads durable token counters/current pressure plus the runtime's connection-local capacity overlay only from `ConversationSnapshot.metrics`; visible nodes supply only the existing turn/step counts. It renders uncached input, output, and cache reads as separate compact values, computes cache hit as `cacheRead / (uncachedInput + cacheRead)` without cache writes, and shows context occupancy only after the current mux connection observes a model request with capacity. Before that request, after reconnect/restore/new subscription, or after a request without capacity, the percentage is omitted and context is labeled unknown rather than queried ahead or reconstructed from history.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).

View File

@@ -18,7 +18,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
输入栏为 `'conversation.input.plan'``'conversation.input.model'` 声明会话作用域的单实例 seat并为 overlay、dock、left 和 right 输入扩展声明列表 slot。InputBar 将模型 seat 渲染在 pending 指示器与发送停止按钮之前。各功能包拥有相应控件及其状态ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
聊天统计行只从 `ConversationSnapshot.metrics` 读取持久的 token 计数当前上下文压力;可见节点仅提供既有的轮次和步骤计数。它以相互独立的紧凑值显示未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率而不计入缓存写入,并根据所选路由的精确容量显示上下文占用率。Host 数据缺失时标为「未知」,绝不根据分页窗口重建。
聊天统计行只从 `ConversationSnapshot.metrics` 读取持久的 token 计数当前压力,以及运行时提供的连接本地容量覆盖值;可见节点仅提供既有的轮次和步骤计数。它以相互独立的紧凑值显示未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率而不计入缓存写入,并且只有当前 mux 连接观察到带容量的模型请求后才显示上下文占用率。在该请求之前、重连/恢复/新订阅之后,或在请求不带容量之后,系统都会省略百分比,并把上下文标为「未知」,而不会提前查询或根据历史记录重建。
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts``skeleton/``chat/``toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply``inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。

View File

@@ -56,7 +56,7 @@ export function cacheHitPercent(metrics: SessionMetrics): number | null {
/**
* Current context occupancy using the TUI's integer rounding and upper clamp.
* @param metrics - Host-owned current pressure and exact route capacity.
* @param metrics - Host-owned pressure plus current-connection request capacity.
* @returns occupancy percent, or null when either input is unavailable.
*/
export function contextPercent(metrics: SessionMetrics): number | null {

View File

@@ -127,6 +127,26 @@ describe('StatsLine', () => {
expect(emptyView.container.textContent).toBe('')
})
it('renders durable counters without a percentage before live capacity is observed', () => {
const { source } = makeSource({
nodes: [assistant(1, 1)],
metrics: {
logRevision: 4,
projectionRevision: 1,
uncachedInputTokens: 120,
outputTokens: 20,
cacheReadTokens: 30,
cacheWriteTokens: 10,
contextTokens: 8_000,
},
})
const view = render(<StatsLine {...props(source)} />)
expect(view.getByText(
'120 uncached input · 20 output · 30 cache read · cache hit 20% · context unknown · 1 turns · 1 steps',
)).toBeTruthy()
expect(view.container.textContent).not.toContain('% of')
})
it('renders honest unknowns when the host projection is missing', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source)} />)

View File

@@ -397,8 +397,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
jsDoc: '/**\n * Resolve one call under its current adapter registration. The returned\n * one-shot handle keeps that registration across header logging and dispatch,\n * so HMR cannot combine one adapter\'s capability result with another adapter.\n * @param config - provider/model route and optional request controls.\n * @param signal - optional cancellation for adapter-owned capability lookup.\n * @returns a prepared config and its registration-bound stream entry point.\n */',
},
{
signature: 'stream(options: GenerateOptions): AsyncIterable<StreamChunk>',
jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection remains fixed through asynchronous exact-model resolution\n * and dispatch. Selection, dispatch, and iteration failures retain their\n * original Error identity and are tagged in a call-local scope for narrow\n * agent-loop request recovery; middleware and nested-call failures remain\n * untagged for the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */',
signature: 'stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable<StreamChunk>',
jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection remains fixed through asynchronous exact-model resolution\n * and dispatch. Selection, dispatch, and iteration failures retain their\n * original Error identity and are tagged in a call-local scope for narrow\n * agent-loop request recovery; middleware and nested-call failures remain\n * untagged for the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @param onDispatched - contained Agent-loop notification hook invoked after\n * a stream handle is constructed and before its adapter is iterated.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */',
},
],
},
@@ -1048,6 +1048,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
jsDoc: '/**\n * An item entered the queued or steering inbox. `placement` is the\n * acceptance-time routing result; listeners must not reconstruct it from\n * later agent or session state.\n * @param agent - the owning agent.\n * @param message - accepted content, source, and correlation identity.\n * @param placement - resolved queued or steering placement.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'An item entered the queued or steering inbox.',
},
{
name: 'agent/model-request',
mode: 'emit',
signature: '\'agent/model-request\'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, request: AgentModelRequest): void',
jsDoc: '/**\n * One model request constructed its final stream handle and is about to\n * iterate it. This live notification is not durable or replayed; failed or\n * aborted iteration still has a dispatch, while preparation and\n * synchronous stream-construction failures do not. Listener failures are\n * contained and cannot affect the request.\n * @param agent - the agent dispatching the model request.\n * @param turn - the open turn number.\n * @param step - the request\'s step number.\n * @param request - final route plus registration-bound context capacity.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
summary: 'One model request constructed its final stream handle and is about to iterate it.',
},
{
name: 'agent/prompt-submit',
mode: 'waterfall',
@@ -1803,7 +1810,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'PreparedLlmCall',
declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n stream(options: GenerateOptions): AsyncIterable<StreamChunk>;\n}',
declaration: 'export interface PreparedLlmCall {\n readonly config: LlmCallConfig;\n readonly context?: LlmModelContext;\n stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable<StreamChunk>;\n}',
},
{
name: 'PreparedReferencedMessage',

View File

@@ -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 packages/core/agent-loop/README.md
README.md: c12140f27aed400b0f7b4246700473e877d37632
README.zh.md: 6394cd86f5f3241be07ef711c76079624bce1bfe
README.md: 39eaafd3abb2faef045c1a2f694d8dffe95c28b0
README.zh.md: f8cc972e957fe95f652d54e859f8e5411288db51

View File

@@ -62,7 +62,7 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. The anchor records the assembled content as-is, retains exact chunk provenance (`[]` for a stream with no chunks), and includes usage when available; empty content stays out of derived message history.
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort and materialize its configured default under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently.
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort, materialize its configured default, and retain available context metadata from that same exact-model lookup under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. After the final stream handle is constructed and before adapter iteration, the loop emits one contained live `agent/model-request` notification with turn, step, final provider/model, and optional registration-bound capacity. Preparation or synchronous stream-construction failures emit nothing; later failure or abortion remains an observed dispatch. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently.
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and other extension failures close directly. Recovery receives the exact live error, immutable provider facts, immutable prior failures, the immutable retry policy of the adapter registration that served the request, and the turn signal after the failed step closes; the policy is absent if no final adapter served it. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. Success clears the consecutive history, and an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract.

View File

@@ -62,7 +62,7 @@ interface Config {
每次提供方调用成功结束时,都会恰好追加一个 `assistant/message` 完成锚点,包括无内容调用和以 `max-tokens` 结束的调用。该锚点原样记录组装后的内容,保留确切的 chunk 溯源(流没有 chunk 时为 `[]`),并在用量可用时包含用量;空内容不会进入派生消息历史。
`agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`在活跃轮次信号的控制下校验由适配器持有的推理reasoning强度,并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR热模块替换不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志因此监听器可以在步骤之间更改推理强度而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID并单独解析新模型。
`agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`在活跃轮次信号的控制下校验由适配器持有的推理reasoning强度填入其配置默认值,并从同一次精确模型查询中保留可用的上下文元数据。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR热模块替换不会把某个适配器的能力解析结果与另一适配器的请求混用。最终流句柄构造完成后、适配器开始迭代前,循环会发出一条失败会被收容的实时 `agent/model-request` 通知,其中包含轮次、步骤、最终提供方/模型,以及可选的、与注册项绑定的容量。准备阶段失败或同步流构造失败不会发出通知;之后即使失败或中止,该请求仍视为已观察到的分派。生效配置会在分派前写入日志,因此监听器可以在步骤之间更改推理强度,而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID并单独解析新模型。
插件失败会结束当前轮次,而不是结束循环。只有最终适配器分发/迭代失败以及带内的终止错误或中止结束才进入 `agent/request-error`;中间件、结果处理、工具及其他扩展失败会直接关闭轮次。失败步骤关闭后,恢复逻辑会接收确切的实时错误、不可变的提供方事实、不可变的先前失败、为请求提供服务的适配器注册所对应的不可变重试策略,以及轮次信号;如果没有最终适配器为其提供服务,则该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`循环用其错误关闭失败轮次并在不插入空闲通知的情况下开启一个编号重试轮次。成功会清除连续失败历史未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end``user``parent` 记录 `aborted`dispose资源释放则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call``ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。

View File

@@ -487,7 +487,24 @@ export class ReactLoopAgent implements Agent {
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
const onDispatched = (): void => {
emitAgentEvent(
this.loopCtx,
this,
'agent/model-request',
turn,
step,
{
provider: request.provider,
model: request.model,
...preparedCall?.context === undefined
? {}
: { contextWindow: preparedCall.context.contextWindow },
},
)
}
const stream = preparedCall?.stream(request, onDispatched)
?? this.loopCtx.llm.stream(request, onDispatched)
try {
for await (const chunk of stream) {
signal.throwIfAborted()

View File

@@ -7,8 +7,10 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo } from '@deepseek-ai/dsh-llm'
import LlmService, { LlmAdapter, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk,
} from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
@@ -179,6 +181,7 @@ describe('request stability across the loop', () => {
provider,
id: model,
name: model,
context: { contextWindow: 64_000 },
reasoning: await reasoning.promise,
}
}
@@ -189,6 +192,12 @@ describe('request stability across the loop', () => {
})
const disposeFirst = ctx.llm.registerAdapter(['mock'], first)
const agent = ctx.agentLoop.create(SessionId('effort-hmr'), { provider: 'mock', model: 'mock' })
const dispatched: number[] = []
ctx.on('agent/model-request', (subject, _turn, _step, request) => {
if (subject === agent && request.contextWindow !== undefined) {
dispatched.push(request.contextWindow)
}
})
send(agent, 'go')
await started.promise
@@ -204,6 +213,7 @@ describe('request stability across the loop', () => {
ReasoningEffortId('high'),
])
expect(second.requests).toHaveLength(0)
expect(dispatched).toEqual([64_000])
const headers = agent.session.events.filter(event => event.type === 'request/header')
expect(headers.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('high'))
})
@@ -308,6 +318,94 @@ describe('request stability across the loop', () => {
})
})
it('notifies one contained live model-request edge only after successful stream construction', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: 'stable base' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
let resolutions = 0
const adapter = new class extends LlmAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
resolutions += 1
return Promise.resolve({
provider,
id: model,
name: model,
...model === 'capacity'
? { context: { contextWindow: 128_000 } }
: {},
})
}
override stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
if (options.model === 'sync-failure') throw new LlmError('construction failed', 'CONSTRUCTION')
if (options.model === 'async-failure') {
return {
[Symbol.asyncIterator]: () => ({
next: () => Promise.reject(new LlmError('iteration failed', 'ITERATION')),
}),
}
}
return (async function* () {
yield* textResponse(options.model)
})()
}
}()
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(SessionId('model-request-live'), {
provider: 'mock',
model: 'capacity',
})
const observed: {
turn: number
step: number
provider: string
model: string
contextWindow?: number
}[] = []
ctx.on('agent/model-request', (subject) => {
if (subject === agent) throw new Error('observer failed')
})
ctx.on('agent/model-request', (subject, turn, step, request) => {
if (subject === agent) observed.push({ turn, step, ...request })
})
ctx.on('agent/request', async (_subject, turn, _step, _signal, next) => ({
...await next(),
model: ['capacity', 'unknown', 'async-failure', 'sync-failure'][turn - 1]!,
}))
for (const prompt of ['one', 'two', 'three', 'four']) {
send(agent, prompt)
await waitForIdle(ctx, agent)
}
expect(observed).toEqual([
{
turn: 1,
step: 1,
provider: 'mock',
model: 'capacity',
contextWindow: 128_000,
},
{
turn: 2,
step: 1,
provider: 'mock',
model: 'unknown',
},
{
turn: 3,
step: 1,
provider: 'mock',
model: 'async-failure',
},
])
expect(resolutions).toBe(4)
})
it('a compaction replace rewrites the resend, and the log explains it', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)

View File

@@ -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 packages/core/agent/README.md
README.md: bb48fd8b227484a43af8f9f9e55f8adc8b990ce6
README.zh.md: 531db9905b3c091a5c129d31e944e4095e66123b
README.md: 304347d32df4546389ee2b45230d4e80acc60942
README.zh.md: d9adbe20015aadbad26288d43df92f80a3f550bf

View File

@@ -48,7 +48,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while the contained `agent/model-request` notification reports a final dispatched route and optional registration-bound context capacity without becoming durable state. `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
`PromptDecision.additionalContexts` is an array so every context keeps its own source. Allowed prompt content and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative.

View File

@@ -48,7 +48,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器静默后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall失败步骤关闭后它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点`agent/model-request` 是失败会被收容的通知,它会报告最终已分派路由及可选的、与注册项绑定的上下文容量,但不会成为持久状态。`agent/request-error` 是失败模型请求的恢复 waterfall失败步骤关闭后它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
`PromptDecision.additionalContexts` 是数组,因此每个上下文都保留自己的来源。获准的提示词内容与每个附加上下文都会在轮次运行前成为各自独立、面向模型的 `user/message` 事件。包装下游允许决策的监听器会保留其 `content``additionalContexts`,除非有意替换任一字段;返回的允许决策是权威来源。

View File

@@ -116,6 +116,16 @@ export type PromptDecision =
/** Model-request failure with an optional machine-routable provider code. */
export type RequestError = Error & { code?: string }
/** Live metadata for one model request that reached adapter dispatch. */
export interface AgentModelRequest {
/** Final registered provider route. */
readonly provider: string
/** Final adapter-owned model id. */
readonly model: string
/** Registration-bound context capacity when the adapter exposed one. */
readonly contextWindow?: number
}
/** Action returned by a listener that owns model-request recovery. */
export type RequestErrorAction = { kind: 'retry' } | undefined
@@ -360,6 +370,20 @@ declare module 'cordis' {
* @mode waterfall
*/
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
/**
* One model request constructed its final stream handle and is about to
* iterate it. This live notification is not durable or replayed; failed or
* aborted iteration still has a dispatch, while preparation and
* synchronous stream-construction failures do not. Listener failures are
* contained and cannot affect the request.
* @param agent - the agent dispatching the model request.
* @param turn - the open turn number.
* @param step - the request's step number.
* @param request - final route plus registration-bound context capacity.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/model-request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, request: AgentModelRequest): void
/**
* Handle a model-request failure after its failed step has closed but
* before the failed turn closes. A listener returns `{ kind: 'retry' }`

View File

@@ -15,6 +15,7 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
'agent/inbox/dequeue': args => args[0],
'agent/inbox/discard': args => args[0],
'agent/inbox/enqueue': args => args[0],
'agent/model-request': args => args[0],
'agent/prompt-submit': args => args[0],
'agent/request': args => args[0],
'agent/request-error': args => args[0],

View File

@@ -49,6 +49,7 @@ describe('scoped-dispatch invariants', () => {
'agent/step': [agent, 1, 1, signal],
'agent/prompt-submit': [agent, [], { kind: 'user' }, signal, () => Promise.resolve({ kind: 'allow' })],
'agent/request': [agent, 1, 1, signal, () => Promise.resolve(config)],
'agent/model-request': [agent, 1, 1, { provider: 'p', model: 'm', contextWindow: 128_000 }],
'agent/request-error': [
agent,
1,

View File

@@ -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 packages/host/apiproxy/README.md
README.md: e2ba8c0e5b2620d095636503f47fac5480c6c2fa
README.zh.md: 2aae0bd683e9ea1e1fb43f73000420dc2354ee0b
README.md: d3d1711242f71832f63eb570242fdf9e149cc988
README.zh.md: 4e52058c1795ea23a9ca8ed46b8890c85f129eb4

View File

@@ -20,7 +20,9 @@ Workspace and Session lists are separate reconnect baselines. `workspace.create`
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries session-level projections the page window cannot supply: the in-flight partial's chunk events; `todos`, the latest `todo/write` whole-list projection; and `metrics`, full-log usage deduplicated by `(turn, step)` plus current token-meter pressure and exact selected-route capacity when available. Older pages omit the session-level projections. Live `session/metrics` mux frames carry monotonic log/projection revisions, so clients reject stale frames and preserve the counters while prepending older pages. Cache reads and writes remain disjoint buckets; the cache-hit denominator is uncached input plus cache reads.
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries session-level projections the page window cannot supply: the in-flight partial's chunk events; `todos`, the latest `todo/write` whole-list projection; and `metrics`, full-log usage deduplicated by `(turn, step)` plus current token-meter pressure. Older pages omit the session-level projections. Live `session/metrics` mux frames carry monotonic log/projection revisions, so clients reject stale frames and preserve the counters while prepending older pages. Cache reads and writes remain disjoint buckets; the cache-hit denominator is uncached input plus cache reads.
Context capacity uses a distinct transient `session/model-request` mux frame emitted from the contained Agent notification after an actual request reaches dispatch. It carries turn, step, final provider/model, and optional capacity only to mux connections already open at that instant. `session.history`, mux subscription baselines, reconnects, and session restore never query or replay prior capacity; a frame without capacity explicitly clears the earlier connection-local value.
The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `command.execute` runs a slash-command line host-side and returns a detached result; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.

View File

@@ -20,7 +20,9 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)携带页窗口本身无法提供的会话级投影:进行中局部消息的分片事件;`todos`,即最后一次 `todo/write` 的整表投影;以及 `metrics`,即按 `(turn, step)` 去重的完整日志用量,并在可用时包含当前 token 计量压力和所选精确路由的容量。较早的页面省略会话级投影。实时 `session/metrics` mux 帧携带单调递增的日志修订号与投影修订号,因此客户端会拒绝陈旧帧,并在向前加载较早页面时保留计数器。缓存读取与缓存写入保持为彼此独立的计数项;缓存命中率的分母是未缓存输入加缓存读取。
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)携带页窗口本身无法提供的会话级投影:进行中局部消息的分片事件;`todos`,即最后一次 `todo/write` 的整表投影;以及 `metrics`,即按 `(turn, step)` 去重的完整日志用量当前 token 计量压力。较早的页面省略会话级投影。实时 `session/metrics` mux 帧携带单调递增的日志修订号与投影修订号,因此客户端会拒绝陈旧帧,并在向前加载较早页面时保留计数器。缓存读取与缓存写入保持为彼此独立的计数项;缓存命中率的分母是未缓存输入加缓存读取。
上下文容量使用独立的临时 `session/model-request` mux 帧;实际请求到达分派点后,该帧由失败会被收容的 Agent 通知发出。该帧携带轮次、步骤、最终提供方/模型与可选容量,且只发送给当时已经打开的 mux 连接。`session.history`、mux 订阅基线、重连和会话恢复绝不会查询或回放先前的容量;不带容量的帧会显式清除较早的连接本地值。
`command.*``skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent被服务的会话必有 Agent`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行并返回脱耦结果;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。

View File

@@ -6,7 +6,6 @@
import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { FiberState } from 'cordis'
import type { Context } from 'cordis'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type {
@@ -409,26 +408,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return target
}
/**
* Read the best capacity route without taking ownership of foreign routing.
* Web agents expose their live selection; other agents expose only a route
* that already crossed the durable request-header boundary.
*/
function metricsRouteFor(agent: Agent): Pick<AgentLlmTarget, 'provider' | 'model'> | undefined {
const installed = targets.get(agent)
if (installed !== undefined) return installed.current
const logged = agent.session.requestHeader()?.config
return logged === undefined
? undefined
: { provider: logged.provider, model: logged.model }
}
/** Pair a registry agent only with the exact Session lifecycle it owns. */
function metricsAgentFor(session: Session): Agent | undefined {
const agent = ctx.get('agents')?.get(session.id)
return agent?.session === session ? agent : undefined
}
/** Pre-publication setup used by both fresh and resumed Web agents. */
function installTarget(agentCtx: Context): void {
const agent = agentCtx.agent
@@ -444,39 +423,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const pendingMetricSessions = new Set<Session>()
let metricFlushScheduled = false
let metricsDisposed = false
const metricsProjector = new SessionMetricsProjector(
ctx,
metricsRouteFor,
(agent) => {
if (metricsDisposed) return
const agents = ctx.get('agents')
if (agents?.get(agent.id) !== agent) return
const sessions = ctx.get('sessions')
if (sessions?.get(agent.id) !== agent.session) return
scheduleMetrics(agent.session)
},
)
const metricsProjector = new SessionMetricsProjector(ctx)
/** Queue one full-log metrics publication after synchronous session listeners drain. */
function scheduleMetrics(session: Session): void {
if (metricsDisposed || muxQueues.size === 0) return
if (muxQueues.size === 0) return
pendingMetricSessions.add(session)
if (metricFlushScheduled) return
metricFlushScheduled = true
queueMicrotask(() => {
metricFlushScheduled = false
if (metricsDisposed) {
pendingMetricSessions.clear()
return
}
const sessions = [...pendingMetricSessions]
pendingMetricSessions.clear()
for (const current of sessions) {
broadcast({
type: 'session/metrics',
sessionId: current.id,
metrics: metricsProjector.snapshot(current, metricsAgentFor(current)),
metrics: metricsProjector.snapshot(current),
})
}
})
@@ -488,25 +451,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (affectsSessionMetrics(event)) scheduleMetrics(session)
}),
ctx.on('agent/created', (agent: Agent) => { scheduleMetrics(agent.session) }),
ctx.on('agent/model-request', (agent, turn, step, request) => {
broadcast({
type: 'session/model-request',
sessionId: agent.session.id,
turn,
step,
provider: request.provider,
model: request.model,
...request.contextWindow === undefined
? {}
: { contextWindow: request.contextWindow },
})
}),
ctx.on('session/disposed', (session: Session) => { pendingMetricSessions.delete(session) }),
ctx.on('internal/status', (fiber) => {
if (metricsDisposed) return
if (fiber.state === FiberState.UNLOADING) {
metricsProjector.invalidateCapacities()
return
}
if (fiber.state !== FiberState.ACTIVE
&& fiber.state !== FiberState.FAILED
&& fiber.state !== FiberState.DISPOSED) return
metricsProjector.invalidateCapacities()
const sessions = ctx.get('sessions')
if (sessions === undefined) return
for (const session of sessions.list()) scheduleMetrics(session)
}, { global: true }),
]
return () => {
metricsDisposed = true
metricsProjector.dispose()
pendingMetricSessions.clear()
for (const dispose of disposers) dispose()
}
@@ -819,7 +779,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// client cannot reconstruct session-level state from it).
const todos = beforeSeq === undefined ? backscanTodos(found.agent.session.events) : undefined
const metrics = beforeSeq === undefined
? metricsProjector.snapshot(found.agent.session, found.agent)
? metricsProjector.snapshot(found.agent.session)
: undefined
return ok(request, {
events: entries,
@@ -920,11 +880,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
: { reasoningEffort: resolved.reasoningEffort },
}
targetFor(found.agent).current = selected
broadcast({
type: 'session/metrics',
sessionId: found.agent.session.id,
metrics: metricsProjector.snapshot(found.agent.session, found.agent),
})
return ok(request, { selected: { ...selected } })
} catch (error: unknown) {
return err(request, {
@@ -1235,7 +1190,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
queue.push(frame({
type: 'session/metrics',
sessionId: session.id,
metrics: metricsProjector.snapshot(session, metricsAgentFor(session)),
metrics: metricsProjector.snapshot(session),
}))
}
for (const pending of pendingQuestions.values()) {
@@ -1292,7 +1247,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
queue.push(frame({
type: 'session/metrics',
sessionId: session.id,
metrics: metricsProjector.snapshot(session, metricsAgentFor(session)),
metrics: metricsProjector.snapshot(session),
}))
}),
ctx.on('session/disposed', (session: Session) => {

View File

@@ -30,6 +30,15 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
z.object({ type: z.literal('session/metrics'), sessionId: sessionIdSchema, metrics: sessionMetricsSchema }),
z.object({
type: z.literal('session/model-request'),
sessionId: sessionIdSchema,
turn: z.number().int().positive(),
step: z.number().int().positive(),
provider: z.string().min(1),
model: z.string().min(1),
contextWindow: z.number().int().positive().optional(),
}),
z.object({ type: z.literal('session/title'), sessionId: sessionIdSchema, title: z.string().min(1), eventSeq: z.number().int().nonnegative(), updatedAt: z.number() }),
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),

View File

@@ -59,6 +59,22 @@ export type MuxFrame =
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
| { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
| { type: 'session/metrics'; sessionId: SessionId; metrics: SessionMetrics }
/**
* One model request observed by this already-open mux connection after its
* final route and stream handle were resolved. This frame is transient: mux
* baselines, reconnects, and session history never replay it. An absent
* `contextWindow` explicitly clears a capacity observed from an earlier
* request on the same connection.
*/
| {
type: 'session/model-request'
sessionId: SessionId
turn: number
step: number
provider: string
model: string
contextWindow?: number
}
| { type: 'session/title'; sessionId: SessionId; title: string; eventSeq: number; updatedAt: number }
| { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }

View File

@@ -145,7 +145,7 @@ export const todoItemSchema = z.object({
status: z.union([z.literal('pending'), z.literal('in_progress'), z.literal('completed')]),
})
/** Host-owned durable usage and current-context projection. */
/** Host-owned durable usage and current-pressure projection. */
export const sessionMetricsSchema = z.object({
logRevision: z.number().int().nonnegative(),
projectionRevision: z.number().int().nonnegative(),
@@ -154,7 +154,6 @@ export const sessionMetricsSchema = z.object({
cacheReadTokens: z.number().nonnegative(),
cacheWriteTokens: z.number().nonnegative(),
contextTokens: z.number().nonnegative().optional(),
contextWindow: z.number().int().positive().optional(),
}) satisfies z.ZodType<Wire<SessionMetrics>>
/** session.history response value. */

View File

@@ -34,9 +34,9 @@ export interface HistoryEntry {
/**
* Host-owned token metrics for one durable session revision. Provider usage
* buckets are cumulative across the full log; current context fields describe
* the replayed request surface at this revision and are absent when the Host
* cannot measure pressure or resolve exact-route capacity.
* buckets are cumulative across the full log; current context pressure
* describes the replayed request surface at this revision and is absent when
* the Host cannot measure it.
*/
export interface SessionMetrics {
/** Number of durable events included in this projection. */
@@ -53,8 +53,6 @@ export interface SessionMetrics {
cacheWriteTokens: number
/** Current request pressure from `ctx.tokenMeter.measure(session).totalTokens`. */
contextTokens?: number
/** Exact selected-route capacity from `ctx.llm.resolveModelInfo()`. */
contextWindow?: number
}
/** Complete model target selected for one session. */
@@ -178,8 +176,10 @@ export interface SessionsApi {
* projection (latest `todo/write` over the FULL log, independent of the page window) —
* so a paged client restores the plan without walking history; absent when the session
* never wrote one. Older pages omit it (the projection is session-level, not per-page).
* The same tail-only rule carries `metrics`, whose cumulative usage and current context
* are Host projections over the full log rather than products of the returned page.
* The same tail-only rule carries `metrics`, whose cumulative usage and
* current pressure are Host projections over the full log rather than
* products of the returned page. Live model capacity is connection-local
* telemetry and is never reconstructed here.
*/
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; todos?: TodoItem[]; metrics?: SessionMetrics }>>

View File

@@ -5,7 +5,6 @@
*/
import type { Context } from 'cordis'
import type { Agent, AgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionMetrics } from './api/sessions.ts'
@@ -20,27 +19,10 @@ interface UsageState {
byStep: Map<string, TokenUsage>
}
interface CapacityState {
routeKey: string | undefined
generation: number
epoch: number
status: 'pending' | 'ready' | 'retryable'
contextWindow?: number
controller?: AbortController
}
type CapacityTarget = Pick<AgentLlmTarget, 'provider' | 'model'>
interface TokenMeterLike {
measure(session: Session): { totalTokens: number }
}
interface LlmLike {
resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<{
context?: { contextWindow: number }
}>
}
function usageFrom(event: SessionEvent): { turn: number; step: number; usage: TokenUsage } | undefined {
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
return { turn: event.data.turn, step: event.data.step, usage: event.data.chunk.usage }
@@ -79,51 +61,19 @@ function recordUsage(state: UsageState, turn: number, step: number, usage: Token
state.cacheWriteTokens += usage.cacheWriteTokens ?? 0
}
function routeKeyFor(target: CapacityTarget | undefined): string | undefined {
return target === undefined ? undefined : `${target.provider}\u0000${target.model}`
}
/**
* Projects durable cumulative usage and route-aware current context without
* awaiting model metadata on the session append path.
*/
/** Projects durable cumulative usage and synchronous current context pressure. */
export class SessionMetricsProjector {
private readonly usage = new WeakMap<Session, UsageState>()
private readonly capacities = new WeakMap<Agent, CapacityState>()
private readonly pendingCapacities = new Set<CapacityState>()
private capacityEpoch = 0
private disposed = false
/**
* @param ctx - Host context providing optional token-meter and LLM services.
* @param targetFor - side-effect-free selected or logged route lookup for one attached agent.
* @param onCapacityResolved - schedules a fresh live projection after exact-route metadata resolves.
*/
constructor(
private readonly ctx: Context,
private readonly targetFor: (agent: Agent) => CapacityTarget | undefined,
private readonly onCapacityResolved: (agent: Agent) => void,
) {}
/** Retire adapter-owned metadata and fence every resolution already in flight. */
invalidateCapacities(): void {
this.capacityEpoch++
for (const pending of this.pendingCapacities) this.abortCapacityResolution(pending)
}
/** Permanently retire capacity projection and cancel every adapter-owned lookup. */
dispose(): void {
this.disposed = true
this.invalidateCapacities()
}
/** @param ctx - Host context providing an optional token-meter service. */
constructor(private readonly ctx: Context) {}
/**
* Read a fresh detached projection through the session's durable tail.
* @param session - authoritative durable log owner.
* @param agent - attached route owner, when available.
* @returns cumulative usage and any currently available pressure/capacity.
* @returns cumulative usage and any currently measurable pressure.
*/
snapshot(session: Session, agent?: Agent): SessionMetrics {
snapshot(session: Session): SessionMetrics {
const state = this.syncUsage(session)
const tokenMeter = this.ctx.get('tokenMeter') as TokenMeterLike | undefined
let contextTokens: number | undefined
@@ -134,7 +84,6 @@ export class SessionMetricsProjector {
// A malformed or temporarily unmeasurable replay has no honest pressure value.
}
}
const contextWindow = agent === undefined ? undefined : this.capacityFor(agent)
return {
logRevision: state.logRevision,
projectionRevision: state.projectionRevision++,
@@ -143,7 +92,6 @@ export class SessionMetricsProjector {
cacheReadTokens: state.cacheReadTokens,
cacheWriteTokens: state.cacheWriteTokens,
...contextTokens === undefined ? {} : { contextTokens },
...contextWindow === undefined ? {} : { contextWindow },
}
}
@@ -171,87 +119,4 @@ export class SessionMetricsProjector {
}
return state
}
private capacityFor(agent: Agent): number | undefined {
if (this.disposed) return undefined
const target = this.targetFor(agent)
const routeKey = routeKeyFor(target)
let state = this.capacities.get(agent)
if (state === undefined
|| state.routeKey !== routeKey
|| state.epoch !== this.capacityEpoch
|| state.status === 'retryable') {
this.abortCapacityResolution(state)
state = {
routeKey,
generation: (state?.generation ?? 0) + 1,
epoch: this.capacityEpoch,
status: target === undefined ? 'ready' : 'pending',
}
this.capacities.set(agent, state)
if (target !== undefined) this.resolveCapacity(agent, target, state)
}
return state.status === 'ready' ? state.contextWindow : undefined
}
private resolveCapacity(
agent: Agent,
target: CapacityTarget,
pending: CapacityState,
): void {
const llm = this.ctx.get('llm') as LlmLike | undefined
if (llm === undefined) {
pending.status = 'retryable'
return
}
const controller = new AbortController()
pending.controller = controller
this.pendingCapacities.add(pending)
void Promise.resolve()
.then(() => {
controller.signal.throwIfAborted()
return llm.resolveModelInfo(target.provider, target.model, controller.signal)
})
.then(
(resolved) => {
this.finishCapacityResolution(pending, controller)
if (this.capacityResolutionIsStale(agent, pending)) return
pending.status = 'ready'
if (resolved.context !== undefined) pending.contextWindow = resolved.context.contextWindow
this.onCapacityResolved(agent)
},
() => {
this.finishCapacityResolution(pending, controller)
if (!this.capacityResolutionIsStale(agent, pending)) pending.status = 'retryable'
},
)
}
private abortCapacityResolution(pending: CapacityState | undefined): void {
if (pending === undefined || pending.controller === undefined) return
const controller = pending.controller
delete pending.controller
this.pendingCapacities.delete(pending)
controller.abort()
}
private finishCapacityResolution(pending: CapacityState, controller: AbortController): void {
this.pendingCapacities.delete(pending)
if (pending.controller === controller) delete pending.controller
}
private capacityResolutionIsStale(agent: Agent, pending: CapacityState): boolean {
if (pending.epoch !== this.capacityEpoch) return true
if (this.capacities.get(agent)?.generation !== pending.generation) return true
if (routeKeyFor(this.targetFor(agent)) === pending.routeKey) return false
// Unknown is the neutral generation; the next observed concrete route
// starts a fresh resolution even when it equals the route that disappeared.
this.capacities.set(agent, {
routeKey: undefined,
generation: pending.generation + 1,
epoch: this.capacityEpoch,
status: 'ready',
})
return true
}
}

View File

@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
import { createApiProxy } from '../src/api-proxy.ts'
async function nextFrame<K extends MuxFrame['type']>(
iterator: AsyncIterator<RpcRequest<MuxFrame>>,
type: K,
): Promise<Extract<MuxFrame, { type: K }>> {
for (;;) {
const next = await iterator.next()
if (next.done) throw new Error(`mux ended before ${type}`)
if (next.value.payload.type === type) {
return next.value.payload as Extract<MuxFrame, { type: K }>
}
}
}
describe('ApiProxy model-request telemetry', () => {
it('forwards only to open mux connections and never backfills history or reconnect baselines', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(SessionId('model-request-telemetry'))
const agent = {
id: session.id,
session,
status: 'running',
ctx,
} as Agent
ctx.agents.register(agent)
const api = createApiProxy(ctx, {
provider: 'test',
model: 'alpha',
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const primaryAbort = new AbortController()
const primary = api.events.mux(
{ rpcId: RpcId('primary'), payload: {} },
primaryAbort.signal,
)[Symbol.asyncIterator]()
expect((await nextFrame(primary, 'session/subscribed')).sessionId).toBe(session.id)
expect((await nextFrame(primary, 'session/metrics')).metrics).not.toHaveProperty('contextWindow')
agentEvents(ctx, agent).emit('agent/model-request', 1, 2, {
provider: 'test',
model: 'alpha',
contextWindow: 128_000,
})
expect(await nextFrame(primary, 'session/model-request')).toEqual({
type: 'session/model-request',
sessionId: session.id,
turn: 1,
step: 2,
provider: 'test',
model: 'alpha',
contextWindow: 128_000,
})
const history = await api.sessions.history({
rpcId: RpcId('history'),
payload: { sessionId: session.id },
})
if (!history.result.ok) throw new Error('history failed')
expect(history.result.value.metrics).not.toHaveProperty('contextWindow')
const reconnectAbort = new AbortController()
const reconnect = api.events.mux(
{ rpcId: RpcId('reconnect'), payload: {} },
reconnectAbort.signal,
)[Symbol.asyncIterator]()
expect((await nextFrame(reconnect, 'session/subscribed')).sessionId).toBe(session.id)
expect((await nextFrame(reconnect, 'session/metrics')).metrics).not.toHaveProperty('contextWindow')
agentEvents(ctx, agent).emit('agent/model-request', 2, 1, {
provider: 'test',
model: 'without-capacity',
})
for (const iterator of [primary, reconnect]) {
expect(await nextFrame(iterator, 'session/model-request')).toEqual({
type: 'session/model-request',
sessionId: session.id,
turn: 2,
step: 1,
provider: 'test',
model: 'without-capacity',
})
}
primaryAbort.abort()
reconnectAbort.abort()
await primary.return?.()
await reconnect.return?.()
await ctx.fiber.dispose()
})
})

View File

@@ -4,23 +4,20 @@
* models, and the prompt-assembly boundary for a running selection change.
*/
import { describe, expect, it, vi } from 'vitest'
import { Context, FiberState } from 'cordis'
import type { Fiber } from 'cordis'
import AgentRegistry, { agentEvents, installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {
GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo,
LlmResolvedModelInfo, StreamChunk,
} from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import { createApiProxy } from '../src/api-proxy.ts'
let nextRpc = 1
@@ -64,40 +61,6 @@ class CatalogAdapter extends LlmAdapter {
}
}
class DeferredCatalogAdapter extends CatalogAdapter {
readonly pending: {
result: PromiseWithResolvers<LlmResolvedModelInfo>
signal: AbortSignal | undefined
}[] = []
constructor() {
super('Deferred', [
{ provider: 'deferred', id: 'lifecycle-model', name: 'Lifecycle model' },
])
}
override resolveModel(
_provider: string,
_model: string,
signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
const result = Promise.withResolvers<LlmResolvedModelInfo>()
this.pending.push({ result, signal })
return result.promise
}
resolve(index: number, contextWindow: number): void {
const pending = this.pending[index]
if (pending === undefined) throw new Error(`no pending resolution at index ${String(index)}`)
pending.result.resolve({
provider: 'deferred',
id: 'lifecycle-model',
name: 'Lifecycle model',
context: { contextWindow },
})
}
}
const REASONING: LlmModelReasoningInfo = {
efforts: [
{ id: ReasoningEffortId('off'), name: 'Off' },
@@ -107,18 +70,13 @@ const REASONING: LlmModelReasoningInfo = {
defaultEffort: ReasoningEffortId('high'),
}
async function hostContext(
onSessions?: (fiber: Fiber) => void,
onAgents?: (fiber: Fiber) => void,
): Promise<Context> {
async function hostContext(): Promise<Context> {
const ctx = new Context()
const sessionsFiber = await ctx.plugin(SessionStore)
onSessions?.(sessionsFiber)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(LlmService)
await ctx.plugin(UserInteractionService)
const agentsFiber = await ctx.plugin(AgentRegistry)
onAgents?.(agentsFiber)
await ctx.plugin(AgentRegistry)
ctx.llm.registerAdapter(['deepseek'], new CatalogAdapter('DeepSeek', [
{ provider: 'deepseek', id: 'deepseek-chat', name: 'DeepSeek Chat' },
{ provider: 'deepseek', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' },
@@ -164,65 +122,6 @@ function expectValue<T>(response: { result: { ok: true; value: T } | { ok: false
return response.result.value
}
async function nextMetrics(
iterator: AsyncIterator<RpcRequest<MuxFrame>>,
): Promise<Extract<MuxFrame, { type: 'session/metrics' }>['metrics']> {
for (;;) {
const next = await iterator.next()
if (next.done) throw new Error('mux ended before a metrics frame')
if (next.value.payload.type === 'session/metrics') return next.value.payload.metrics
}
}
function attachLifecycleSession(
ctx: Context,
sessionId: SessionId,
withMarker = false,
): { session: Session; detach: () => void } {
const session = ctx.sessions.prepare(sessionId)
session.append('request/header', {
header: { config: { provider: 'deferred', model: 'lifecycle-model' } },
reason: 'initial',
})
if (withMarker) {
session.append('user/message', {
content: [{ type: 'text', text: 'replacement marker' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })
}
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
return { session, detach }
}
function attachLifecycleAgent(
ctx: Context,
session: Session,
): () => void {
const agent = {
id: session.id,
session,
status: 'running',
ctx,
} as Agent
const detach = ctx.agents.enter(agent, undefined)
ctx.agents.announce(agent)
return detach
}
function settleCapacityCompletion(): Promise<void> {
return new Promise<void>((resolve) => { setImmediate(resolve) })
}
function installDeferredAdapter(
ctx: Context,
adapter: DeferredCatalogAdapter,
): Fiber & PromiseLike<Fiber> {
return ctx.plugin(Object.assign((inner: Context) => {
inner.llm.registerAdapter(['deferred'], adapter)
}, { inject: ['llm'] }))
}
describe('Web session model selection', () => {
it('groups successful providers, isolates failures, and preserves an unlisted current model', async () => {
const { ctx, sessionId } = await harness({
@@ -230,7 +129,12 @@ describe('Web session model selection', () => {
model: 'private-preview',
reasoningEffort: ReasoningEffortId('max'),
})
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, {
provider: 'deepseek',
model: 'deepseek-chat',
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
expect(catalog.current).toEqual({
@@ -271,7 +175,12 @@ describe('Web session model selection', () => {
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
const { ctx, agent, sessionId } = await harness()
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const api = createApiProxy(ctx, {
provider: 'deepseek',
model: 'deepseek-chat',
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
@@ -336,534 +245,4 @@ describe('Web session model selection', () => {
.toEqual({ provider: 'deepseek', model: 'private-preview', reasoningEffort: 'max' })
await ctx.fiber.dispose()
})
it('publishes unknown capacity immediately on selection, then the exact selected route capacity', async () => {
const { ctx, sessionId } = await harness()
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
expectValue(await api.sessions.models(request({ sessionId })))
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
expect((await nextMetrics(iterator)).contextWindow).toBe(64_000)
expectValue(await api.sessions.selectModel(request({
sessionId,
provider: 'deepseek',
model: 'private-preview',
})))
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
controller.abort()
await iterator.return?.()
await ctx.fiber.dispose()
})
it('uses logged capacity without installing Web routing while scheduling foreign metrics', async () => {
const ctx = await hostContext()
const api = createApiProxy(ctx, {
provider: 'deepseek',
model: 'deepseek-chat',
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
const initialMetrics = nextMetrics(iterator)
const session = ctx.sessions.create()
expect((await initialMetrics).contextWindow).toBeUndefined()
session.append('request/header', {
header: { config: { provider: 'deepseek', model: 'private-preview' } },
reason: 'change',
})
const foreign = {
id: session.id,
session,
status: 'running',
ctx,
} as Agent
const foreignTarget: AgentLlmTargetRef = {
current: { provider: 'foreign', model: 'foreign-model' },
assembled: undefined,
}
const disposeForeignTarget = installAgentLlmTarget(foreign.ctx, foreignTarget)
const scheduledMetrics = nextMetrics(iterator)
ctx.agents.register(foreign)
expect((await scheduledMetrics).contextWindow).toBeUndefined()
expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
expect((await ctx.systemPrompt.assemble()).variables)
.toMatchObject({ provider: 'foreign', model: 'foreign-model' })
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
await expect(agentEvents(ctx, foreign).waterfall(
'agent/request', 1, 0, signal, () => Promise.resolve(seed),
)).resolves.toMatchObject({ provider: 'foreign', model: 'foreign-model' })
disposeForeignTarget()
expect((await ctx.systemPrompt.assemble()).variables).not.toHaveProperty('provider')
await expect(agentEvents(ctx, foreign).waterfall(
'agent/request', 1, 1, signal, () => Promise.resolve(seed),
)).resolves.toBe(seed)
controller.abort()
await iterator.return?.()
await ctx.fiber.dispose()
})
it('drops capacity completion from a replaced agent that retains the exact session', async () => {
const ctx = await hostContext()
const deferred = new DeferredCatalogAdapter()
ctx.llm.registerAdapter(['deferred'], deferred)
const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-agent-lifecycle'))
const retire = attachLifecycleAgent(ctx, lifecycle.session)
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
retire()
const detachLive = attachLifecycleAgent(ctx, lifecycle.session)
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(2) })
deferred.resolve(0, 64_000)
await settleCapacityCompletion()
deferred.resolve(1, 128_000)
await settleCapacityCompletion()
expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
controller.abort()
await iterator.return?.()
detachLive()
lifecycle.detach()
await ctx.fiber.dispose()
})
it('drops capacity completion from a replaced session while its old agent remains live', async () => {
const ctx = await hostContext()
const deferred = new DeferredCatalogAdapter()
ctx.llm.registerAdapter(['deferred'], deferred)
const sessionId = SessionId('capacity-session-lifecycle')
const retiredSession = attachLifecycleSession(ctx, sessionId)
const retireAgent = attachLifecycleAgent(ctx, retiredSession.session)
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
retiredSession.detach()
const liveSession = attachLifecycleSession(ctx, sessionId, true)
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
deferred.resolve(0, 64_000)
await settleCapacityCompletion()
retireAgent()
const detachLiveAgent = attachLifecycleAgent(ctx, liveSession.session)
const scheduled = await nextMetrics(iterator)
expect(scheduled.logRevision).toBe(2)
expect(scheduled.contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(2) })
deferred.resolve(1, 128_000)
await settleCapacityCompletion()
expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
controller.abort()
await iterator.return?.()
detachLiveAgent()
liveSession.detach()
await ctx.fiber.dispose()
})
it('does not project retired agent capacity into replacement session snapshots', async () => {
const ctx = await hostContext()
const deferred = new DeferredCatalogAdapter()
ctx.llm.registerAdapter(['deferred'], deferred)
const sessionId = SessionId('capacity-snapshot-lifecycle')
const retiredSession = attachLifecycleSession(ctx, sessionId)
const retireAgent = attachLifecycleAgent(ctx, retiredSession.session)
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const primaryController = new AbortController()
const primary = api.events.mux(request({}), primaryController.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(primary)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
deferred.resolve(0, 64_000)
await settleCapacityCompletion()
expect((await nextMetrics(primary)).contextWindow).toBe(64_000)
retiredSession.detach()
const replacement = attachLifecycleSession(ctx, sessionId)
const createdBaseline = await nextMetrics(primary)
replacement.session.append('user/message', {
content: [{ type: 'text', text: 'replacement marker' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })
const scheduledFlush = await nextMetrics(primary)
const reconnectController = new AbortController()
const reconnect = api.events.mux(request({}), reconnectController.signal)[Symbol.asyncIterator]()
const reconnectBaseline = await nextMetrics(reconnect)
expect(createdBaseline.logRevision).toBe(1)
for (const metrics of [scheduledFlush, reconnectBaseline]) {
expect(metrics.logRevision).toBe(2)
}
expect({
created: createdBaseline.contextWindow,
scheduled: scheduledFlush.contextWindow,
reconnect: reconnectBaseline.contextWindow,
}).toEqual({ created: undefined, scheduled: undefined, reconnect: undefined })
retireAgent()
const detachReplacementAgent = attachLifecycleAgent(ctx, replacement.session)
expect((await nextMetrics(primary)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(2) })
deferred.resolve(1, 128_000)
await settleCapacityCompletion()
expect((await nextMetrics(primary)).contextWindow).toBe(128_000)
primaryController.abort()
reconnectController.abort()
await primary.return?.()
await reconnect.return?.()
detachReplacementAgent()
replacement.detach()
await ctx.fiber.dispose()
})
it('refreshes same-route capacity after adapter owner replacement', async () => {
const ctx = await hostContext()
const retiredAdapter = new DeferredCatalogAdapter()
const retiredFiber = await installDeferredAdapter(ctx, retiredAdapter)
const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-adapter-lifecycle'))
const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(retiredAdapter.pending).toHaveLength(1) })
retiredAdapter.resolve(0, 64_000)
await settleCapacityCompletion()
expect((await nextMetrics(iterator)).contextWindow).toBe(64_000)
await retiredFiber.dispose()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
const replacementAdapter = new DeferredCatalogAdapter()
const replacementFiber = await installDeferredAdapter(ctx, replacementAdapter)
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(replacementAdapter.pending).toHaveLength(1) })
replacementAdapter.resolve(0, 128_000)
await settleCapacityCompletion()
expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
expect(lifecycle.session.requestHeader()?.config).toMatchObject({
provider: 'deferred',
model: 'lifecycle-model',
})
controller.abort()
await iterator.return?.()
detachAgent()
lifecycle.detach()
await replacementFiber.dispose()
await ctx.fiber.dispose()
})
it('aborts pending capacity during adapter UNLOADING and refreshes after settlement', async () => {
const ctx = await hostContext()
const retiredAdapter = new DeferredCatalogAdapter()
const releaseUnload = Promise.withResolvers<undefined>()
const releaseCancellationWait = Promise.withResolvers<undefined>()
const abortObserved = Promise.withResolvers<undefined>()
const retiredFiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.llm.registerAdapter(['deferred'], retiredAdapter)
inner.effect(
() => () => releaseUnload.promise,
'test: hold adapter unload',
)
inner.effect(() => () => {
const signal = retiredAdapter.pending[0]?.signal
if (signal === undefined) throw new Error('pending capacity signal missing')
const cancellation = new Promise<void>((resolve) => {
const finish = () => {
abortObserved.resolve(undefined)
resolve()
}
if (signal.aborted) finish()
else signal.addEventListener('abort', finish, { once: true })
})
return Promise.race([cancellation, releaseCancellationWait.promise])
}, 'test: await capacity cancellation')
}, { inject: ['llm'] }))
const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-adapter-unloading'))
const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
const api = createApiProxy(ctx, {
provider: 'deepseek',
model: 'deepseek-chat',
cwd: '/tmp',
workspaceRoot: '/tmp',
})
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
const resolveModelInfo = vi.spyOn(ctx.llm, 'resolveModelInfo')
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(retiredAdapter.pending).toHaveLength(1) })
expect(resolveModelInfo).toHaveBeenCalledOnce()
const listSessions = vi.spyOn(ctx.sessions, 'list')
listSessions.mockClear()
const pendingFrame = iterator.next()
const disposing = retiredFiber.dispose()
try {
await vi.waitFor(() => {
expect(retiredAdapter.pending[0]?.signal?.aborted).toBe(true)
})
await abortObserved.promise
expect(retiredFiber.state).toBe(FiberState.UNLOADING)
retiredAdapter.resolve(0, 64_000)
await settleCapacityCompletion()
expect(resolveModelInfo).toHaveBeenCalledOnce()
expect(listSessions).not.toHaveBeenCalled()
const outcome = await Promise.race([
pendingFrame.then(() => 'frame' as const),
new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }),
])
expect(outcome).toBe('idle')
} finally {
releaseCancellationWait.resolve(undefined)
releaseUnload.resolve(undefined)
await disposing
}
expect(retiredFiber.state).toBe(FiberState.DISPOSED)
const settledFrame = await pendingFrame
if (settledFrame.done || settledFrame.value.payload.type !== 'session/metrics') {
throw new Error('expected settled metrics refresh')
}
expect(settledFrame.value.payload.metrics.contextWindow).toBeUndefined()
await settleCapacityCompletion()
expect(resolveModelInfo).toHaveBeenCalledTimes(2)
const replacementAdapter = new DeferredCatalogAdapter()
const replacementFiber = await installDeferredAdapter(ctx, replacementAdapter)
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(replacementAdapter.pending).toHaveLength(1) })
expect(resolveModelInfo).toHaveBeenCalledTimes(3)
replacementAdapter.resolve(0, 128_000)
await settleCapacityCompletion()
expect((await nextMetrics(iterator)).contextWindow).toBe(128_000)
controller.abort()
await iterator.return?.()
detachAgent()
lifecycle.detach()
await replacementFiber.dispose()
await ctx.fiber.dispose()
})
it('aborts pending capacity when the API proxy fiber is disposed', async () => {
const ctx = await hostContext()
const deferred = new DeferredCatalogAdapter()
ctx.llm.registerAdapter(['deferred'], deferred)
const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-api-proxy-teardown'))
const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
const proxy = Promise.withResolvers<ReturnType<typeof createApiProxy>>()
const proxyFiber = await ctx.plugin(Object.assign((inner: Context) => {
proxy.resolve(createApiProxy(inner, {
provider: 'deepseek',
model: 'deepseek-chat',
cwd: '/tmp',
workspaceRoot: '/tmp',
}))
}, { inject: ['agents', 'sessions', 'userInteraction'] }))
const api = await proxy.promise
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
expect(deferred.pending[0]?.signal?.aborted).toBe(false)
await proxyFiber.dispose()
expect(deferred.pending[0]?.signal?.aborted).toBe(true)
deferred.resolve(0, 64_000)
await settleCapacityCompletion()
const pendingFrame = iterator.next()
const outcome = await Promise.race([
pendingFrame.then(() => 'frame' as const),
new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }),
])
expect(outcome).toBe('idle')
controller.abort()
await expect(pendingFrame).resolves.toMatchObject({ done: true })
await iterator.return?.()
detachAgent()
lifecycle.detach()
await ctx.fiber.dispose()
})
it('does not read the sessions service after its disposal status', async () => {
let sessionsFiber: Fiber | undefined
const ctx = await hostContext((fiber) => { sessionsFiber = fiber })
if (sessionsFiber === undefined) throw new Error('sessions fiber missing')
createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const sessions = ctx.get('sessions')
if (sessions === undefined) throw new Error('sessions service missing')
const list = vi.spyOn(sessions, 'list').mockImplementation(() => {
throw new Error('disposed sessions service read')
})
await expect(sessionsFiber.dispose()).resolves.toBeUndefined()
expect(list).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('invalidates pending capacity before SessionStore teardown can reach its callback', async () => {
let sessionsFiber: Fiber | undefined
const ctx = await hostContext((fiber) => { sessionsFiber = fiber })
if (sessionsFiber === undefined) throw new Error('sessions fiber missing')
const deferred = new DeferredCatalogAdapter()
ctx.llm.registerAdapter(['deferred'], deferred)
const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-session-store-teardown'))
const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
const agents = ctx.get('agents')
if (agents === undefined) throw new Error('agent registry missing')
expect(agents.get(lifecycle.session.id)).toBeDefined()
const getAgent = vi.spyOn(agents, 'get')
await sessionsFiber.dispose()
getAgent.mockClear()
deferred.resolve(0, 64_000)
await settleCapacityCompletion()
expect(getAgent).not.toHaveBeenCalled()
const pendingFrame = iterator.next()
const outcome = await Promise.race([
pendingFrame.then(() => 'frame' as const),
new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }),
])
expect(outcome).toBe('idle')
controller.abort()
await expect(pendingFrame).resolves.toMatchObject({ done: true })
await iterator.return?.()
detachAgent()
lifecycle.detach()
await ctx.fiber.dispose()
})
it('publishes unknown metrics after AgentRegistry terminal disposal with mux active', async () => {
let agentsFiber: Fiber | undefined
const ctx = await hostContext(undefined, (fiber) => { agentsFiber = fiber })
if (agentsFiber === undefined) throw new Error('agent registry fiber missing')
const deferred = new DeferredCatalogAdapter()
ctx.llm.registerAdapter(['deferred'], deferred)
const lifecycle = attachLifecycleSession(ctx, SessionId('capacity-agent-registry-disposed'))
const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
deferred.resolve(0, 64_000)
await settleCapacityCompletion()
expect((await nextMetrics(iterator)).contextWindow).toBe(64_000)
await agentsFiber.dispose()
const refresh = nextMetrics(iterator).then(
metrics => ({ kind: 'metrics' as const, metrics }),
() => ({ kind: 'error' as const }),
)
const outcome = await Promise.race([
refresh,
new Promise<{ kind: 'idle' }>((resolve) => {
setImmediate(() => { resolve({ kind: 'idle' }) })
}),
])
controller.abort()
await refresh
await iterator.return?.()
detachAgent()
lifecycle.detach()
await ctx.fiber.dispose()
expect(outcome.kind).toBe('metrics')
if (outcome.kind === 'metrics') {
expect(outcome.metrics.contextWindow).toBeUndefined()
expect(outcome.metrics.logRevision).toBe(1)
}
})
it.each(['agents', 'sessions'] as const)(
'drops capacity completion while %s is unavailable during unload',
async (serviceName) => {
let sessionsFiber: Fiber | undefined
let agentsFiber: Fiber | undefined
const ctx = await hostContext(
(fiber) => { sessionsFiber = fiber },
(fiber) => { agentsFiber = fiber },
)
const heldFiber = serviceName === 'sessions' ? sessionsFiber : agentsFiber
if (heldFiber === undefined) throw new Error(`${serviceName} fiber missing`)
const unloadStarted = Promise.withResolvers<undefined>()
const releaseUnload = Promise.withResolvers<undefined>()
heldFiber.ctx.effect(() => () => {
unloadStarted.resolve(undefined)
return releaseUnload.promise
}, `test: hold ${serviceName} unload`)
const deferred = new DeferredCatalogAdapter()
ctx.llm.registerAdapter(['deferred'], deferred)
const lifecycle = attachLifecycleSession(ctx, SessionId(`capacity-${serviceName}-unloading`))
const detachAgent = attachLifecycleAgent(ctx, lifecycle.session)
const api = createApiProxy(ctx, { provider: 'deepseek', model: 'deepseek-chat', cwd: '/tmp', workspaceRoot: '/tmp' })
const controller = new AbortController()
const iterator = api.events.mux(request({}), controller.signal)[Symbol.asyncIterator]()
expect((await nextMetrics(iterator)).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(deferred.pending).toHaveLength(1) })
const agents = ctx.get('agents')
if (agents === undefined) throw new Error('agent registry missing')
const sessions = ctx.get('sessions')
if (sessions === undefined) throw new Error('sessions service missing')
const getAgent = vi.spyOn(agents, 'get')
const getSession = vi.spyOn(sessions, 'get')
const disposing = heldFiber.dispose()
await unloadStarted.promise
await vi.waitFor(() => { expect(ctx.get(serviceName)).toBeUndefined() })
expect(heldFiber.state).toBe(FiberState.UNLOADING)
getAgent.mockClear()
getSession.mockClear()
deferred.resolve(0, 64_000)
await settleCapacityCompletion()
const agentReads = getAgent.mock.calls.length
const sessionReads = getSession.mock.calls.length
const pendingFrame = iterator.next()
const outcome = await Promise.race([
pendingFrame.then(() => 'frame' as const),
new Promise<'idle'>((resolve) => { setImmediate(() => { resolve('idle') }) }),
])
controller.abort()
await expect(pendingFrame).resolves.toMatchObject({ done: true })
await iterator.return?.()
releaseUnload.resolve(undefined)
await disposing
detachAgent()
lifecycle.detach()
await ctx.fiber.dispose()
expect(agentReads).toBe(0)
expect(sessionReads).toBe(0)
expect(outcome).toBe('idle')
},
)
})

View File

@@ -146,10 +146,9 @@ describe('sessions domain schemas', () => {
cacheReadTokens: 4_000,
cacheWriteTokens: 500,
contextTokens: 8_000,
contextWindow: 128_000,
},
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
}).metrics?.contextWindow).toBe(128_000)
}).metrics?.contextTokens).toBe(8_000)
expect(() => sessionMetricsSchema.parse({
logRevision: 1,
projectionRevision: 0,
@@ -158,15 +157,6 @@ describe('sessions domain schemas', () => {
cacheReadTokens: 0,
cacheWriteTokens: 0,
})).toThrow()
expect(() => sessionMetricsSchema.parse({
logRevision: 1,
projectionRevision: 0,
uncachedInputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
contextWindow: 0,
})).toThrow()
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionModelsValueSchema.parse({
current: { provider: 'deepseek', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
@@ -344,6 +334,23 @@ describe('events frame schemas', () => {
cacheWriteTokens: 40,
},
},
{
type: 'session/model-request',
sessionId: 's',
turn: 2,
step: 1,
provider: 'deepseek',
model: 'deepseek-chat',
contextWindow: 128_000,
},
{
type: 'session/model-request',
sessionId: 's',
turn: 3,
step: 1,
provider: 'deepseek',
model: 'unknown-capacity',
},
{ type: 'approval/requested', sessionId: 's', approvalId: 'a', toolName: 'bash', callId: 'c', reason: 'r' },
{ type: 'approval/resolved', sessionId: 's', approvalId: 'a', outcome: 'allowed-once' },
{ type: 'question/requested', sessionId: 's', questions: [{ id: 'q', question: 'Q?', options: [{ label: 'L' }], multiSelect: true }] },
@@ -358,6 +365,8 @@ describe('events frame schemas', () => {
{ type: 'session/title', sessionId: 's', title: '', eventSeq: 0, updatedAt: 1 },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: -1, updatedAt: 1 },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0.5, updatedAt: 1 },
{ type: 'session/model-request', sessionId: 's', turn: 0, step: 1, provider: 'p', model: 'm' },
{ type: 'session/model-request', sessionId: 's', turn: 1, step: 1, provider: 'p', model: 'm', contextWindow: 0 },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: 'now' },
{ type: 'session/title', sessionId: 's', title: 'x', eventSeq: 0, updatedAt: Number.NaN },
]) expect(() => muxFrameSchema.parse(invalid)).toThrow()

View File

@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent, AgentLlmTarget } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { affectsSessionMetrics, SessionMetricsProjector } from '../src/session-metrics.ts'
@@ -29,16 +28,8 @@ function assistant(
}, { surfaceOp: 'append' })
}
function agent(session: Session): Agent {
return { id: session.id, session } as Agent
}
function settleAsyncWork(): Promise<void> {
return new Promise<void>((resolve) => { setImmediate(resolve) })
}
describe('SessionMetricsProjector', () => {
it('filters text/reasoning stream deltas while retaining usage, headers, and surface mutations', () => {
it('filters text/reasoning deltas while retaining usage, headers, and surface mutations', () => {
const session = new Session(SessionId('metrics-filter'))
const text = session.append('assistant/chunk', {
turn: 1,
@@ -58,13 +49,16 @@ describe('SessionMetricsProjector', () => {
content: [{ type: 'text', text: 'question' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const plain = session.append('step/start', { turn: 1, step: 1 })
expect(affectsSessionMetrics(text)).toBe(false)
expect(affectsSessionMetrics(usage)).toBe(true)
expect(affectsSessionMetrics(header)).toBe(true)
expect(affectsSessionMetrics(surface)).toBe(true)
expect(affectsSessionMetrics(plain)).toBe(false)
})
it('reconciles usage by turn:step, keeps cache writes disjoint, and survives a surface replacement', () => {
it('folds usage by turn and step while synchronous pressure follows surface replacement', () => {
const ctx = new Context()
ctx.provide('tokenMeter', {
measure(session: Session) {
@@ -83,11 +77,8 @@ describe('SessionMetricsProjector', () => {
cacheWriteTokens: 8,
})
const current: AgentLlmTarget = { provider: 'test', model: 'alpha' }
const projector = new SessionMetricsProjector(ctx, () => current, () => {})
const attached = agent(session)
const before = projector.snapshot(session, attached)
expect(before).toMatchObject({
const projector = new SessionMetricsProjector(ctx)
expect(projector.snapshot(session)).toMatchObject({
uncachedInputTokens: 11,
outputTokens: 3,
cacheReadTokens: 89,
@@ -104,8 +95,7 @@ describe('SessionMetricsProjector', () => {
surfaceOp: { op: 'replace', start: first.seq, end: assistantSeq },
sourceEventSeqs: [first.seq, assistantSeq],
})
const compacted = projector.snapshot(session, attached)
expect(compacted).toMatchObject({
expect(projector.snapshot(session)).toMatchObject({
uncachedInputTokens: 11,
outputTokens: 3,
cacheReadTokens: 89,
@@ -113,357 +103,38 @@ describe('SessionMetricsProjector', () => {
contextTokens: 100,
})
// A replayed usage event for the same step replaces the settled value.
session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: {
type: 'usage',
usage: {
inputTokens: 12,
outputTokens: 4,
cacheReadTokens: 88,
cacheWriteTokens: 9,
},
usage: { inputTokens: 12, outputTokens: 4, cacheReadTokens: 88, cacheWriteTokens: 9 },
},
})
const replayed = projector.snapshot(session, attached)
expect(replayed).toMatchObject({
uncachedInputTokens: 12,
outputTokens: 4,
cacheReadTokens: 88,
cacheWriteTokens: 9,
contextTokens: 100,
})
assistant(session, 1, 2, {
inputTokens: 1_000,
outputTokens: 500,
cacheReadTokens: 2_000,
cacheWriteTokens: 3_000,
})
const after = projector.snapshot(session, attached)
expect(after).toMatchObject({
logRevision: session.events.length,
projectionRevision: 3,
uncachedInputTokens: 1_012,
outputTokens: 504,
cacheReadTokens: 2_088,
cacheWriteTokens: 3_009,
contextTokens: 200,
})
expect(after.uncachedInputTokens).not.toBe(
after.uncachedInputTokens + after.cacheReadTokens + after.cacheWriteTokens,
)
})
it('publishes only the selected route capacity when asynchronous resolutions race', async () => {
const ctx = new Context()
const resolutions = new Map<string, {
signal: AbortSignal | undefined
resolve(contextWindow: number): void
}>()
ctx.provide('tokenMeter', { measure: () => ({ totalTokens: 35_000 }) })
ctx.provide('llm', {
resolveModelInfo(_provider: string, model: string, signal?: AbortSignal) {
return new Promise<{ context: { contextWindow: number } }>((resolve) => {
resolutions.set(model, {
signal,
resolve(contextWindow) {
resolve({ context: { contextWindow } })
},
})
})
},
})
const session = new Session(SessionId('capacity-race'))
const attached = agent(session)
let current: AgentLlmTarget = { provider: 'test', model: 'alpha' }
const resolved = vi.fn()
const projector = new SessionMetricsProjector(ctx, () => current, resolved)
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(resolutions.has('alpha')).toBe(true) })
expect(resolutions.get('alpha')?.signal?.aborted).toBe(false)
current = { provider: 'test', model: 'beta' }
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
expect(resolutions.get('alpha')?.signal?.aborted).toBe(true)
await vi.waitFor(() => { expect(resolutions.has('beta')).toBe(true) })
expect(resolutions.get('beta')?.signal?.aborted).toBe(false)
resolutions.get('alpha')?.resolve(64_000)
await Promise.resolve()
expect(resolved).not.toHaveBeenCalled()
resolutions.get('beta')?.resolve(128_000)
await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() })
expect(projector.snapshot(session, attached)).toMatchObject({
contextTokens: 35_000,
contextWindow: 128_000,
})
})
it('retries a failed same-route capacity lookup only on the next snapshot', async () => {
const ctx = new Context()
const attempts: PromiseWithResolvers<{ context: { contextWindow: number } }>[] = []
ctx.provide('llm', {
resolveModelInfo() {
const attempt = Promise.withResolvers<{ context: { contextWindow: number } }>()
attempts.push(attempt)
return attempt.promise
},
})
const session = new Session(SessionId('capacity-retry'))
const attached = agent(session)
const resolved = vi.fn()
const projector = new SessionMetricsProjector(
ctx,
() => ({ provider: 'test', model: 'alpha' }),
resolved,
)
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(attempts).toHaveLength(1) })
attempts[0]?.reject(new Error('metadata temporarily unavailable'))
await settleAsyncWork()
expect(attempts).toHaveLength(1)
expect(resolved).not.toHaveBeenCalled()
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
await settleAsyncWork()
expect(attempts).toHaveLength(2)
attempts[1]?.resolve({ context: { contextWindow: 128_000 } })
await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() })
expect(projector.snapshot(session, attached).contextWindow).toBe(128_000)
})
it('aborts every active capacity on invalidation and resolves fresh generations', async () => {
const ctx = new Context()
const attempts: {
result: PromiseWithResolvers<{ context: { contextWindow: number } }>
signal: AbortSignal | undefined
}[] = []
ctx.provide('llm', {
resolveModelInfo(_provider: string, _model: string, signal?: AbortSignal) {
const result = Promise.withResolvers<{ context: { contextWindow: number } }>()
attempts.push({ result, signal })
return result.promise
},
})
const firstSession = new Session(SessionId('capacity-invalidation-first'))
const secondSession = new Session(SessionId('capacity-invalidation-second'))
const firstAgent = agent(firstSession)
const secondAgent = agent(secondSession)
const resolved = vi.fn()
const projector = new SessionMetricsProjector(
ctx,
() => ({ provider: 'test', model: 'alpha' }),
resolved,
)
expect(projector.snapshot(firstSession, firstAgent).contextWindow).toBeUndefined()
expect(projector.snapshot(secondSession, secondAgent).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(attempts).toHaveLength(2) })
expect(attempts.map(attempt => attempt.signal?.aborted)).toEqual([false, false])
projector.invalidateCapacities()
expect(attempts.map(attempt => attempt.signal?.aborted)).toEqual([true, true])
attempts[0]?.result.resolve({ context: { contextWindow: 32_000 } })
attempts[1]?.result.resolve({ context: { contextWindow: 64_000 } })
await settleAsyncWork()
expect(resolved).not.toHaveBeenCalled()
expect(projector.snapshot(firstSession, firstAgent).contextWindow).toBeUndefined()
expect(projector.snapshot(secondSession, secondAgent).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(attempts).toHaveLength(4) })
expect(attempts.slice(2).map(attempt => attempt.signal?.aborted)).toEqual([false, false])
attempts[2]?.result.resolve({ context: { contextWindow: 128_000 } })
attempts[3]?.result.resolve({ context: { contextWindow: 256_000 } })
await vi.waitFor(() => { expect(resolved).toHaveBeenCalledTimes(2) })
expect(projector.snapshot(firstSession, firstAgent).contextWindow).toBe(128_000)
expect(projector.snapshot(secondSession, secondAgent).contextWindow).toBe(256_000)
projector.dispose()
expect(projector.snapshot(firstSession, firstAgent).contextWindow).toBeUndefined()
expect(attempts).toHaveLength(4)
})
it('skips adapter work invalidated before its deferred invocation', async () => {
const ctx = new Context()
const attempts: {
result: PromiseWithResolvers<{ context: { contextWindow: number } }>
signal: AbortSignal | undefined
}[] = []
const resolveModelInfo = vi.fn((
_provider: string,
_model: string,
signal?: AbortSignal,
) => {
const result = Promise.withResolvers<{ context: { contextWindow: number } }>()
attempts.push({ result, signal })
return result.promise
})
ctx.provide('llm', { resolveModelInfo })
const session = new Session(SessionId('capacity-pre-invocation-invalidation'))
const attached = agent(session)
const resolved = vi.fn()
const projector = new SessionMetricsProjector(
ctx,
() => ({ provider: 'test', model: 'alpha' }),
resolved,
)
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
projector.invalidateCapacities()
await settleAsyncWork()
expect(resolveModelInfo).not.toHaveBeenCalled()
expect(resolved).not.toHaveBeenCalled()
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(attempts).toHaveLength(1) })
expect(attempts[0]?.signal?.aborted).toBe(false)
attempts[0]?.result.resolve({ context: { contextWindow: 128_000 } })
await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() })
expect(projector.snapshot(session, attached).contextWindow).toBe(128_000)
})
it('starts a fresh capacity generation when an unavailable route returns', async () => {
const ctx = new Context()
const resolutions: {
signal: AbortSignal | undefined
resolve(contextWindow: number): void
}[] = []
ctx.provide('llm', {
resolveModelInfo(_provider: string, _model: string, signal?: AbortSignal) {
return new Promise<{ context: { contextWindow: number } }>((resolve) => {
resolutions.push({
signal,
resolve(contextWindow) {
resolve({ context: { contextWindow } })
},
})
})
},
})
const session = new Session(SessionId('capacity-route-return'))
const attached = agent(session)
let current: AgentLlmTarget | undefined = { provider: 'test', model: 'alpha' }
const resolved = vi.fn()
const targetFor = vi.fn(() => current)
const projector = new SessionMetricsProjector(ctx, targetFor, resolved)
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(resolutions).toHaveLength(1) })
current = undefined
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
expect(resolutions[0]?.signal?.aborted).toBe(true)
resolutions[0]?.resolve(64_000)
await settleAsyncWork()
expect(resolved).not.toHaveBeenCalled()
current = { provider: 'test', model: 'alpha' }
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(resolutions).toHaveLength(2) })
expect(resolutions[1]?.signal?.aborted).toBe(false)
resolutions[1]?.resolve(128_000)
await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() })
expect(projector.snapshot(session, attached).contextWindow).toBe(128_000)
})
it('omits current context fields when measurement or model metadata is unavailable', async () => {
const ctx = new Context()
ctx.provide('tokenMeter', { measure: () => { throw new Error('unmeasurable') } })
ctx.provide('llm', { resolveModelInfo: () => Promise.reject(new Error('metadata unavailable')) })
const session = new Session(SessionId('missing-metrics'))
const attached = agent(session)
const projector = new SessionMetricsProjector(
ctx,
() => ({ provider: 'test', model: 'missing' }),
() => {},
)
const metrics = projector.snapshot(session, attached)
expect(metrics.contextTokens).toBeUndefined()
expect(metrics.contextWindow).toBeUndefined()
await vi.waitFor(() => {
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
})
})
it('keeps optional usage buckets at zero and tolerates absent host services or detached agents', async () => {
const ctx = new Context()
const session = new Session(SessionId('optional-metrics'))
assistant(session, 1, 0, { inputTokens: 7, outputTokens: 2 })
const attached = agent(session)
const selected: { current?: AgentLlmTarget } = {}
const projector = new SessionMetricsProjector(
ctx,
() => selected.current,
() => {},
)
assistant(session, 1, 2, { inputTokens: 1_000, outputTokens: 500 })
expect(projector.snapshot(session)).toMatchObject({
uncachedInputTokens: 7,
outputTokens: 2,
cacheReadTokens: 0,
cacheWriteTokens: 0,
logRevision: session.events.length,
projectionRevision: 2,
uncachedInputTokens: 1_012,
outputTokens: 504,
cacheReadTokens: 88,
cacheWriteTokens: 9,
contextTokens: 200,
})
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
selected.current = { provider: 'test', model: 'no-service' }
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
await Promise.resolve()
})
it('publishes a resolved route with no advertised capacity as unknown', async () => {
const ctx = new Context()
ctx.provide('llm', { resolveModelInfo: () => Promise.resolve({}) })
const session = new Session(SessionId('no-capacity'))
const attached = agent(session)
const resolved = vi.fn()
const projector = new SessionMetricsProjector(
ctx,
() => ({ provider: 'test', model: 'metadata-without-context' }),
resolved,
)
it('omits pressure when the token meter is absent or cannot measure the replay', () => {
const session = new Session(SessionId('metrics-pressure-unknown'))
const withoutMeter = new SessionMetricsProjector(new Context()).snapshot(session)
expect(withoutMeter.contextTokens).toBeUndefined()
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() })
expect(projector.snapshot(session, attached).contextWindow).toBeUndefined()
})
it('ignores stale resolution failures and route metadata after the target moves', async () => {
const ctx = new Context()
const resolutions = new Map<string, {
resolve(value: { context: { contextWindow: number } }): void
reject(error: Error): void
}>()
ctx.provide('llm', {
resolveModelInfo(_provider: string, model: string) {
return new Promise<{ context: { contextWindow: number } }>((resolve, reject) => {
resolutions.set(model, { resolve, reject })
})
ctx.provide('tokenMeter', {
measure() {
throw new Error('unmeasurable replay')
},
})
const session = new Session(SessionId('stale-capacity'))
const attached = agent(session)
let current: AgentLlmTarget = { provider: 'test', model: 'alpha' }
const resolved = vi.fn()
const targetFor = vi.fn(() => current)
const projector = new SessionMetricsProjector(ctx, targetFor, resolved)
projector.snapshot(session, attached)
await vi.waitFor(() => { expect(resolutions.has('alpha')).toBe(true) })
current = { provider: 'test', model: 'route-moved-before-snapshot' }
resolutions.get('alpha')?.resolve({ context: { contextWindow: 64_000 } })
await vi.waitFor(() => { expect(targetFor).toHaveBeenCalledTimes(2) })
expect(resolved).not.toHaveBeenCalled()
projector.snapshot(session, attached)
await vi.waitFor(() => { expect(resolutions.has('route-moved-before-snapshot')).toBe(true) })
current = { provider: 'test', model: 'beta' }
projector.snapshot(session, attached)
await vi.waitFor(() => { expect(resolutions.has('beta')).toBe(true) })
resolutions.get('route-moved-before-snapshot')?.reject(new Error('stale failure'))
await Promise.resolve()
resolutions.get('beta')?.resolve({ context: { contextWindow: 128_000 } })
await vi.waitFor(() => { expect(resolved).toHaveBeenCalledOnce() })
expect(projector.snapshot(session, attached).contextWindow).toBe(128_000)
expect(new SessionMetricsProjector(ctx).snapshot(session).contextTokens).toBeUndefined()
})
})

View File

@@ -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 packages/llm/llm/README.md
README.md: 2328188e420df6de60f024982a31d37a858a303e
README.zh.md: 586767a9e790fa68d27673836700fd789ce6a180
README.md: d28a5632a3fbdbf11c7dba2ee0c57a704f2ba6f4
README.zh.md: fd93fa43d5bfabd6e8751d4efbad229096ced08d

View File

@@ -16,7 +16,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises.
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` Resolve validated exact-model identity plus available context and reasoning metadata from the owning adapter, with optional cancellation for asynchronous adapters.
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` Validate an explicit effort and materialize an adapter-configured default without clamping.
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config and capture its current adapter registration as one cancellable, one-shot call.
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` Resolve a config plus available context metadata in one exact-model lookup and capture its current adapter registration as one cancellable, one-shot call.
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`.
`LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`.
@@ -25,7 +25,7 @@ Provider and model metadata is a discovery surface, not a routing whitelist. `re
Exact-model metadata is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelInfo()` asks the adapter that owns the exact provider/model route once; an adapter can describe an unlisted dynamic model, and absent `context` or `reasoning` fields mean only that those capabilities are unavailable. Invalid identity, context, or reasoning metadata fails with `INVALID_MODEL_INFO`, `INVALID_MODEL_CONTEXT`, or `INVALID_MODEL_REASONING`.
Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O.
Reasoning identifiers are opaque adapter-owned strings rather than a core enum. An adapter publishes its ordered selectable list, including an `off` id when that model's capability API exposes one. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Asynchronous model resolvers receive the caller's signal and must settle promptly after cancellation. `prepareCall()` additionally exposes the detached context metadata from that same lookup and retains the exact adapter registration through header logging and terminal dispatch, so HMR cannot combine one adapter's capability result with another adapter's request; reusing its one-shot handle or changing its call-config fields fails with `INVALID_PREPARED_CALL`. Its dispatch observer runs after a final stream handle is constructed and before adapter iteration. An unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O.
### Events

View File

@@ -16,7 +16,7 @@
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` 发现某个已注册提供方当前公布的模型。
- `ctx.llm.resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>` 从拥有精确路由的适配器解析经校验的确切模型身份、可用上下文和推理reasoning元数据异步适配器可选地支持取消。
- `ctx.llm.resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>` 校验显式推理强度,并填入适配器配置的默认值,但不自动调整。
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 解析配置并将其当前适配器注册捕获为一次可取消、一次性调用。
- `ctx.llm.prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>` 在一次精确模型查询中解析配置与可用上下文元数据,并将其当前适配器注册捕获为一次可取消、一次性调用。
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` 将一次模型调用流式输出为原始 chunktoken 级 delta。消费方使用 `BlockAssembler` 将 chunk 组装为块/消息。
`LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回相邻的不可变 `LlmFailure``llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器的原始编码 `Error`
@@ -25,7 +25,7 @@
确切模型元数据是独立的正确性查询,不是 catalog 装饰或全局 LLM 设置。`resolveModelInfo()` 会向拥有精确提供方/模型路由的适配器查询一次;适配器可以描述未列出的动态模型,缺少 `context``reasoning` 字段只表示相应能力不可用。无效的身份、上下文或推理元数据会以 `INVALID_MODEL_INFO``INVALID_MODEL_CONTEXT``INVALID_MODEL_REASONING` 失败。
推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal并且必须在取消后迅速完成结算。`prepareCall()` 还会让精确适配器注册跨越请求头记录和最终分派,因此 HMR热模块替换不会将一个适配器的能力结果与另一个适配器的请求混用复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。
推理标识符是由适配器持有的不透明字符串,而非核心枚举。适配器会公布有序可选列表;模型能力 API 提供 `off` id 时,列表也会包含它。`resolveCallConfig()` 只接受与已公布标识符完全一致的值,在存在 `defaultEffort` 时填入它,否则保留提供方默认值。异步模型解析器会接收调用方的 signal并且必须在取消后迅速完成结算。`prepareCall()` 还会公开同一次查询得到的脱耦上下文元数据,并让精确适配器注册跨越请求头记录和最终分派,因此 HMR热模块替换不会将一个适配器的能力结果与另一个适配器的请求混用复用其一次性句柄或更改调用配置字段会以 `INVALID_PREPARED_CALL` 失败。其分派观察器在最终流句柄构造完成后、适配器开始迭代前运行。不支持的显式或配置推理强度会在提供方 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败。
### 事件

View File

@@ -10,6 +10,7 @@ import { Context, Service } from 'cordis'
import type {
GenerateOptions,
LlmFailure,
LlmModelContext,
LlmModelInfo,
LlmResolvedModelInfo,
LlmProviderInfo,
@@ -111,14 +112,18 @@ export class LlmError extends HarnessError {
export interface PreparedLlmCall {
/** Detached, deep-frozen config with any adapter-owned default materialized. */
readonly config: LlmCallConfig
/** Detached context metadata resolved with the registration-bound call. */
readonly context?: LlmModelContext
/**
* Dispatch this call once through the registration captured during
* preparation. The request's call-config fields must match {@link config};
* reuse or mismatch fails with `INVALID_PREPARED_CALL`.
* @param options - fully assembled request carrying the prepared config.
* @param onDispatched - contained Agent-loop notification hook invoked after
* a stream handle is constructed and before its adapter is iterated.
* @returns the chunk stream, including the `llm/stream` waterfall.
*/
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable<StreamChunk>
}
/**
@@ -392,15 +397,16 @@ export class LlmService extends Service {
* @returns a detached config only when a default must be materialized.
*/
async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig> {
return this.resolveCallConfigFor(this.registration(config.provider), config, signal)
return (await this.resolveCallFor(this.registration(config.provider), config, signal)).config
}
private async resolveCallConfigFor(
private async resolveCallFor(
registration: AdapterRegistration,
config: LlmCallConfig,
signal?: AbortSignal,
): Promise<LlmCallConfig> {
const reasoning = (await this.resolveModelInfoFor(registration, config.model, signal)).reasoning
): Promise<{ config: LlmCallConfig; context?: LlmModelContext }> {
const resolved = await this.resolveModelInfoFor(registration, config.model, signal)
const reasoning = resolved.reasoning
const requested = config.reasoningEffort
if (reasoning === undefined) {
if (requested !== undefined) {
@@ -409,17 +415,28 @@ export class LlmService extends Service {
'UNSUPPORTED_REASONING_EFFORT',
)
}
return config
return {
config,
...resolved.context === undefined ? {} : { context: resolved.context },
}
}
const effective = requested ?? reasoning.defaultEffort
if (effective === undefined) return config
if (effective === undefined) {
return {
config,
...resolved.context === undefined ? {} : { context: resolved.context },
}
}
if (!reasoning.efforts.some(effort => effort.id === effective)) {
throw new LlmError(
`provider "${config.provider}" model "${config.model}" does not support reasoning effort "${effective}"`,
'UNSUPPORTED_REASONING_EFFORT',
)
}
return requested === effective ? config : { ...config, reasoningEffort: effective }
return {
config: requested === effective ? config : { ...config, reasoningEffort: effective },
...resolved.context === undefined ? {} : { context: resolved.context },
}
}
/**
@@ -432,18 +449,25 @@ export class LlmService extends Service {
*/
async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall> {
const registration = this.registration(config.provider)
const resolvedConfig = deepFreeze(structuredClone(
await this.resolveCallConfigFor(registration, config, signal),
))
const resolved = await this.resolveCallFor(registration, config, signal)
const resolvedConfig = deepFreeze(structuredClone(resolved.config))
const context = resolved.context === undefined
? undefined
: Object.freeze(structuredClone(resolved.context))
let dispatched = false
return Object.freeze({
config: resolvedConfig,
stream: (options: GenerateOptions): AsyncIterable<StreamChunk> => {
...context === undefined ? {} : { context },
stream: (options: GenerateOptions, onDispatched?: () => void): AsyncIterable<StreamChunk> => {
if (dispatched) {
throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL')
}
dispatched = true
return this.streamWithRegistration(options, { registration, config: resolvedConfig })
return this.streamWithRegistration(
options,
{ registration, config: resolvedConfig },
onDispatched,
)
},
})
}
@@ -478,25 +502,51 @@ export class LlmService extends Service {
* so it cannot suppress the primary provider error. A downstream close awaits
* adapter cleanup, whose failures remain ordinary untagged work.
*/
private async * adapterStream(
private adapterStream(
options: GenerateOptions,
failures: AdapterFailureScope,
prepared?: { registration: AdapterRegistration; config: LlmCallConfig },
): AsyncGenerator<StreamChunk> {
onDispatched?: () => void,
): AsyncIterable<StreamChunk> {
if (prepared === undefined) {
return this.resolveAndStream(options, failures, onDispatched)
}
let iterator: AsyncIterator<StreamChunk>
try {
const registration = prepared?.registration ?? this.registration(options.provider)
const registration = prepared.registration
failures.retryPolicy = registration.retryPolicy
const resolvedConfig = prepared === undefined
? await this.resolveCallConfigFor(registration, options, options.signal)
: prepared.config
if (prepared !== undefined && !callConfigEquals(options, resolvedConfig)) {
const resolvedConfig = prepared.config
if (!callConfigEquals(options, resolvedConfig)) {
throw new LlmError(
'prepared LLM call config changed before adapter dispatch',
'INVALID_PREPARED_CALL',
)
}
const resolvedOptions = prepared !== undefined || callConfigEquals(options, resolvedConfig)
const adapter = registration.adapter
const stream = adapter.stream(this.forAdapter(options, adapter))
iterator = stream[Symbol.asyncIterator]()
} catch (error: unknown) {
return this.failedAdapterStream(markLlmAdapterFailure(failures, error))
}
this.notifyDispatched(onDispatched)
return this.iterateAdapter(iterator, failures)
}
private async * resolveAndStream(
options: GenerateOptions,
failures: AdapterFailureScope,
onDispatched?: () => void,
): AsyncGenerator<StreamChunk> {
let iterator: AsyncIterator<StreamChunk>
try {
const registration = this.registration(options.provider)
failures.retryPolicy = registration.retryPolicy
const resolvedConfig = (await this.resolveCallFor(
registration,
options,
options.signal,
)).config
const resolvedOptions = callConfigEquals(options, resolvedConfig)
? options
: Object.isFrozen(options)
? deepFreeze({ ...options, ...resolvedConfig })
@@ -507,7 +557,19 @@ export class LlmService extends Service {
} catch (error: unknown) {
throw markLlmAdapterFailure(failures, error)
}
this.notifyDispatched(onDispatched)
yield* this.iterateAdapter(iterator, failures)
}
private async * failedAdapterStream(error: Error): AsyncGenerator<StreamChunk> {
await Promise.resolve()
throw error
}
private async * iterateAdapter(
iterator: AsyncIterator<StreamChunk>,
failures: AdapterFailureScope,
): AsyncGenerator<StreamChunk> {
let completed = false
let iterationFailed = false
try {
@@ -537,6 +599,15 @@ export class LlmService extends Service {
}
}
private notifyDispatched(onDispatched: (() => void) | undefined): void {
if (onDispatched === undefined) return
try {
onDispatched()
} catch (error: unknown) {
this.ctx.logger.warn(`llm dispatch observer threw: ${String(error)}`)
}
}
/**
* Stream one model call as raw chunks (token-level deltas). Throws
* `LlmError` with code `NO_ADAPTER` if no adapter is registered for
@@ -548,23 +619,32 @@ export class LlmService extends Service {
* agent-loop request recovery; middleware and nested-call failures remain
* untagged for the outer call.
* @param options - the full request; `options.provider` selects the adapter.
* @param onDispatched - contained Agent-loop notification hook invoked after
* a stream handle is constructed and before its adapter is iterated.
* @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
*/
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
return this.streamWithRegistration(options)
stream(options: GenerateOptions, onDispatched?: () => void): AsyncIterable<StreamChunk> {
return this.streamWithRegistration(options, undefined, onDispatched)
}
private streamWithRegistration(
options: GenerateOptions,
prepared?: { registration: AdapterRegistration; config: LlmCallConfig },
onDispatched?: () => void,
): AsyncIterable<StreamChunk> {
const failures: AdapterFailureScope = { failures: new WeakMap<Error, LlmFailure>() }
let terminalEntered = false
const stream = this.ctx.waterfall(
this,
'llm/stream',
options,
() => this.adapterStream(options, failures, prepared),
() => {
terminalEntered = true
return this.adapterStream(options, failures, prepared, onDispatched)
},
)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- waterfall mutates this latch.
if (!terminalEntered) this.notifyDispatched(onDispatched)
return bindAdapterFailureScope(stream, failures)
}
}

View File

@@ -1058,6 +1058,40 @@ describe('LlmService', () => {
})).toThrow(expect.objectContaining({ code: 'INVALID_PREPARED_CALL' }))
})
it('reuses one exact-model lookup for prepared config and context metadata', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
let resolutions = 0
const source = { contextWindow: 128_000 }
const adapter = new class extends ScriptedAdapter {
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
resolutions += 1
return Promise.resolve({
provider,
id: model,
name: model,
context: source,
reasoning: {
efforts: [{ id: ReasoningEffortId('high'), name: 'High' }],
defaultEffort: ReasoningEffortId('high'),
},
})
}
}(SCRIPT)
ctx.llm.registerAdapter(['route'], adapter)
const prepared = await ctx.llm.prepareCall({ provider: 'route', model: 'model' })
source.contextWindow = 64_000
expect(prepared.config.reasoningEffort).toBe(ReasoningEffortId('high'))
expect(prepared.context).toEqual({ contextWindow: 128_000 })
expect(Object.isFrozen(prepared.context)).toBe(true)
for await (const _chunk of prepared.stream({
...prepared.config,
messages: [],
})) { /* drain */ }
expect(resolutions).toBe(1)
})
it('passes cancellation through exact-model resolution', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)

View File

@@ -217,6 +217,7 @@ const FOUNDATION_TYPE_NAMES = new Set([
/** Project types deliberately documented outside the core-data catalog. */
const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
AgentFactory: 'agent creation seam is owned by packages/core/agent/README.md',
AgentModelRequest: 'event-local live request metadata is owned by packages/core/agent/README.md',
BeginCommandRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
InsertReferenceRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',
ConsumeTokenRequest: 'event-local request contract is owned by packages/client/ui-slash/src/types.ts',