refactor(token-meter): make context occupancy durable projection state

Replace the transient `session/model-request` mux frame with ordinary durable
session state. Occupancy now rides two last-wins projection fields instead of a
non-replayable frame that needed removal tombstones and cross-stream fencing.

The frame was the only non-replayable class on the mux stream. Because host and
mux are independent SSE streams with no cross-stream order, a request emitted
before a removal could arrive after `host/session-removed`, and a legitimate
request for a new lifecycle reusing the same id could be fenced by a late
removal. Fixing that needed a lifecycle generation on every frame; the frame
itself was the problem.

Removed: the `session/model-request` frame and schema, the `agent/model-request`
core event, the ApiProxy measurement point, the client-side telemetry map and
removal tombstone, and the synthetic `cancelled` open error used to signal
reconnect through the error channel.

Added: `request/context`, a log-only session event recording the
registration-bound capacity of the route a request resolved to, appended beside
`request/header` from the lookup that already prepared the call and skipped when
the route is unchanged. Capacity stays out of `EpochHeader` because it is
adapter metadata about a route, not an input the request was built from, so it
must not join request reconstruction or header equality.

The `contextPressure` projection pairs the newest provider-reported prompt size
with the newest recorded capacity. The two are deliberately not one atomic
request observation: switching models can pair a fresh capacity with the prior
route's pressure until the next request reports usage. The figure is a
user-facing reference, and this matches how the TUI status line has always
computed occupancy.
This commit is contained in:
Hypatia May
2026-07-30 13:53:08 +08:00
parent fdccc58cef
commit 4819210142
62 changed files with 382 additions and 1362 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 packages/host/apiproxy/README.md
README.md: 872d653a194bc08ec7a4a2125137af51d0105a52
README.zh.md: 5470b7680b1fea42cc3383ebcc121f05015f5226
README.md: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74
README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9

View File

@@ -22,10 +22,6 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the
`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. Its tail page (no `beforeSeq`) carries only the generic `projections` baseline for registered units; older pages omit it. When token-meter is composed with the projection registry, full-log provider billing rides the ordinary `tokenUsage` key. Its usage chunks and final messages are deduplicated by `(turn, step)`, while cache reads and writes remain disjoint buckets. ApiProxy owns no token-specific history field, mux frame, projector, revision counter, or refresh queue.
Request context uses a distinct transient `session/model-request` mux frame emitted from the contained Agent notification after an observed request attempt returns its outer stream handle. This boundary does not prove provider I/O began. In the same synchronous event boundary, ApiProxy optionally reads `tokenMeter.measure(session).totalTokens` once and combines it with capacity from that exact prepared call. The atomic frame carries turn, step, final provider/model, and optional `contextTokens`/`contextWindow` only to mux connections already open at that instant. Measurement failure omits only the numerator. `session.history`, mux subscription baselines, reconnects, and session restore never query or replay an earlier snapshot, and missing fields in a later frame replace rather than inherit prior values.
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 with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing.
## Carrier layer (`/client` + root)

View File

@@ -22,10 +22,6 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
`session.history` 按消息边界分页。其尾页(不带 `beforeSeq`)只携带已注册单元的通用 `projections` 基线;较早页面则省略该基线。当 token-meter 与投影注册表组合时,完整日志中的提供方计费用量会通过普通 `tokenUsage` 键承载。系统按 `(turn, step)` 对其用量分片和最终消息去重,缓存读取与写入则仍是相互独立的计数项。ApiProxy 不拥有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或刷新队列。
请求上下文使用独立的临时 `session/model-request` mux 帧。外层流调用为一次已观测的请求尝试返回句柄后,系统会根据 Agent 通知发出该帧,并收容通知失败。这个边界不能证明提供方 I/O 已开始。在同一同步事件边界内,ApiProxy 会可选地读取一次 `tokenMeter.measure(session).totalTokens`,并将结果与该次准备完成调用的容量合并。这个原子帧携带轮次、步骤、最终提供方/模型与可选的 `contextTokens`/`contextWindow`,且只发送给当时已经打开的 mux 连接。测量失败时只省略分子。`session.history`、mux 订阅基线、重连和会话恢复绝不会查询或回放更早的快照;后续帧中缺失的字段会清除对应的先前值,而不是继承它。
`command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。
## 载体层(`/client` + 根路径)

View File

@@ -60,19 +60,12 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@deepseek-ai/dsh-token-meter": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-invariants": "^0.0.1"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-token-meter": {
"optional": true
}
},
"devDependencies": {
"@deepseek-ai/dsh-storage": "workspace:^",
"@deepseek-ai/dsh-storage-domain": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"cordis": "^4.0.0-rc.7",
"@deepseek-ai/dsh-invariants": "workspace:^"
}

View File

@@ -32,8 +32,6 @@ import type {
import type {} from '@deepseek-ai/dsh-session-projection'
// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
import type {} from '@deepseek-ai/dsh-session-projection-cache'
// Type-only: resolves the optional `ctx.get('tokenMeter')` service seam.
import type {} from '@deepseek-ai/dsh-token-meter'
// GoalError narrows domain rejections to their stable codes at the wire boundary.
import { GoalError } from '@deepseek-ai/dsh-goal'
import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
@@ -497,31 +495,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
for (const queue of muxQueues) queue.push(envelope)
}
ctx.on('agent/model-request', (agent, turn, step, request) => {
const tokenMeter = ctx.get('tokenMeter')
let contextTokens: number | undefined
if (tokenMeter !== undefined) {
try {
contextTokens = tokenMeter.measure(agent.session).totalTokens
} catch {
// A malformed or temporarily unmeasurable replay omits only the
// numerator; this request still replaces stale telemetry.
}
}
broadcast({
type: 'session/model-request',
sessionId: agent.session.id,
turn,
step,
provider: request.provider,
model: request.model,
...contextTokens === undefined ? {} : { contextTokens },
...request.contextWindow === undefined
? {}
: { contextWindow: request.contextWindow },
})
})
// Projection change feed → session/projection push frames. The carrier
// mints the wire frame (the seam package holds no wire vocabulary); the
// child activates only when a projection registry is composed, and the
@@ -966,7 +939,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return { event, ...view === undefined ? {} : { view } }
})
// Baseline rider: tail page only — loadOlder (beforeSeq present) is
// the one path that never needs fresh projection state.
// the one path that never needs a fresh projection baseline.
const projections = beforeSeq === undefined ? projectionsFor(ctx, found.agent) : undefined
return ok(request, {
events: entries,

View File

@@ -10,9 +10,7 @@ import type { HostFrame, MuxFrame } from './events.ts'
import type { Wire } from './rpc.schema.ts'
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
import { approvalRequestIdSchema } from './approvals.schema.ts'
import {
contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
} from './sessions.schema.ts'
import { contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
/** Question shape validated strictly against core dsh-user-interaction. */
@@ -37,16 +35,6 @@ const messageSchema = z.object({
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/model-request'),
sessionId: sessionIdSchema,
turn: z.number().int().positive(),
step: z.number().int().positive(),
provider: z.string().min(1),
model: z.string().min(1),
contextTokens: z.number().int().nonnegative().optional(),
contextWindow: z.number().int().positive().optional(),
}),
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')]) }),
// Non-empty by wire contract: the user-interaction service rejects empty

View File

@@ -31,24 +31,6 @@ export type ToolEventView =
| { for: 'call'; view: ToolCallView }
| { for: 'result'; view: ToolResultView }
/** Atomic telemetry captured at one observed model-request boundary. */
export interface ModelRequestTelemetry {
/**
* Agent request identity and resolved route. The current StatsLine consumes
* only occupancy, while the complete snapshot preserves provenance for
* diagnostics and later request-bound consumers without consulting mutable
* selected-model state.
*/
turn: number
step: number
provider: string
model: string
/** Token-meter pressure measured synchronously for this exact request. */
contextTokens?: number
/** Registration-bound capacity from this exact prepared call. */
contextWindow?: number
}
/** Streaming face of the contract: the two SSE stream openers (mux + host). */
export interface EventsApi {
/**
@@ -75,18 +57,6 @@ export interface EventsApi {
export type MuxFrame =
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
| { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
/**
* One request attempt observed by this already-open mux connection after its
* final route and outer `llm/stream` handle were obtained. This does not prove
* provider I/O began. The frame is transient: mux baselines, reconnects, and
* session history never replay it. The optional numerator and capacity are
* one atomic request snapshot; absent fields explicitly replace, rather
* than inherit from, the preceding request.
*/
| ({
type: 'session/model-request'
sessionId: SessionId
} & ModelRequestTelemetry)
| { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }

View File

@@ -29,16 +29,13 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock,
SessionsApi, SessionSummary,
ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
export type {
EventsApi, HostFrame, ModelRequestTelemetry, MuxFrame, ToolCallView, ToolEventView, ToolResultView,
} from './events.ts'
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
export type { ApprovalResponsePayload } from './approvals.ts'
export type { QuestionResponsePayload } from './questions.ts'

View File

@@ -151,7 +151,7 @@ export const sessionProjectionsBlockSchema = z.object({
values: z.record(z.string(), z.unknown()),
}) as unknown as z.ZodType<SessionProjectionsBlock>
/** session.history response value (projections ride the tail page only). */
/** session.history response value (projections rides the tail page only). */
export const sessionHistoryValueSchema = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),

View File

@@ -188,15 +188,9 @@ export interface SessionsApi {
* the client needs a fresh baseline already pulls the tail page, and
* loadOlder (the only beforeSeq path) is the only path that never needs one.
* A deployment without the registry serves histories without the block.
* Model-request telemetry is connection-local and is never reconstructed
* from history.
*/
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
Promise<RpcResponse<{
events: HistoryEntry[]
hasMore: boolean
projections?: SessionProjectionsBlock
}>>
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; projections?: SessionProjectionsBlock }>>
/** Reads a fresh advisory model directory for this session. Provider lookups run independently. */
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>

View File

@@ -1,124 +0,0 @@
import { describe, expect, it, vi } 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('atomically measures the observed request, forwards only live, and degrades per field', 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 measure = vi.fn(() => ({ totalTokens: 321 }))
const removeTokenMeter = ctx.provide('tokenMeter', { measure })
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)
agentEvents(ctx, agent).emit('agent/model-request', 1, 2, {
provider: 'test',
model: 'alpha',
contextWindow: 128_000,
})
expect(measure).toHaveBeenCalledWith(session)
expect(await nextFrame(primary, 'session/model-request')).toEqual({
type: 'session/model-request',
sessionId: session.id,
turn: 1,
step: 2,
provider: 'test',
model: 'alpha',
contextTokens: 321,
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).not.toHaveProperty('metrics')
expect(history.result.value).not.toHaveProperty('modelRequest')
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)
measure.mockImplementation(() => { throw new Error('unmeasurable replay') })
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',
})
}
removeTokenMeter()
agentEvents(ctx, agent).emit('agent/model-request', 3, 1, {
provider: 'test',
model: 'without-meter',
contextWindow: 64_000,
})
expect(await nextFrame(primary, 'session/model-request')).toEqual({
type: 'session/model-request',
sessionId: session.id,
turn: 3,
step: 1,
provider: 'test',
model: 'without-meter',
contextWindow: 64_000,
})
primaryAbort.abort()
reconnectAbort.abort()
await primary.return?.()
await reconnect.return?.()
await ctx.fiber.dispose()
})
})

View File

@@ -153,13 +153,11 @@ describe('sessions domain schemas', () => {
expect(sessionCreateValueSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionHistoryRequestSchema.parse({ sessionId: 's1', beforeSeq: 3, maxMessages: 5 }).beforeSeq).toBe(3)
expect(() => sessionHistoryRequestSchema.parse({ sessionId: 's1', maxMessages: 0 })).toThrow()
const history = sessionHistoryValueSchema.parse({
expect(sessionHistoryValueSchema.parse({
events: [],
hasMore: false,
projections: { asOfSeq: 11, values: { todos: [] } },
modelTarget: { provider: 'deepseek', model: 'deepseek-v4-flash' },
})
expect(history.projections).toEqual({ asOfSeq: 11, values: { todos: [] } })
}).hasMore).toBe(false)
expect(sessionModelsRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1')
expect(sessionModelsValueSchema.parse({
current: { provider: 'deepseek', model: 'deepseek-v4-flash', reasoningEffort: 'max' },
@@ -361,24 +359,6 @@ describe('events frame schemas', () => {
const frames = [
{ type: 'session/event', sessionId: 's', event: { type: 't', seq: 0, time: 1, data: null } },
{ type: 'session/subscribed', sessionId: 's', lastSeq: -1 },
{
type: 'session/model-request',
sessionId: 's',
turn: 2,
step: 1,
provider: 'deepseek',
model: 'deepseek-chat',
contextTokens: 8_000,
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 }] },
@@ -391,10 +371,6 @@ describe('events frame schemas', () => {
for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type })
expect(() => muxFrameSchema.parse({ type: 'unknown/frame' })).toThrow()
for (const invalid of [
{ type: 'session/model-request', sessionId: 's', turn: 0, step: 1, provider: 'p', model: 'm' },
{ type: 'session/model-request', sessionId: 's', turn: 1, step: 0, provider: 'p', model: 'm' },
{ type: 'session/model-request', sessionId: 's', turn: 1, step: 1, provider: 'p', model: 'm', contextTokens: -1 },
{ type: 'session/model-request', sessionId: 's', turn: 1, step: 1, provider: 'p', model: 'm', contextWindow: 0 },
{ type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 },
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: -1 },
{ type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: 0.5 },

View File

@@ -23,9 +23,6 @@
{
"path": "../../llm/llm"
},
{
"path": "../../llm/token-meter"
},
{
"path": "../../core/agent"
},