From 498b0f16a2a727d8ecf17b1a3f4bc6b65f4bf78f Mon Sep 17 00:00:00 2001 From: Leo Date: Sun, 23 Aug 2026 01:06:04 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20perf(sdk):=20getContex?= =?UTF-8?q?tUsage=20=E6=83=B0=E6=80=A7=E5=8C=96=E2=80=94=E2=80=94run=20?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=E4=B8=8D=E5=86=8D=E5=85=A8=E9=87=8F=E4=BC=B0?= =?UTF-8?q?=E7=AE=97=20token=20(#386)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit finally 只保留引擎引用,全量 messages JSON.stringify 估算改到宿主 实际调用 getContextUsage 时执行。sidecar 对该 API 零调用,此前每轮 run 收尾都在白算 ~150 行估算。 Co-Authored-By: Claude Fable 5 --- packages/sdk/src/agent.test.ts | 33 ++++++++++++++++++++++++++++++++- packages/sdk/src/agent.ts | 10 ++++++---- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/sdk/src/agent.test.ts b/packages/sdk/src/agent.test.ts index f9c314bb0..a945dd354 100644 --- a/packages/sdk/src/agent.test.ts +++ b/packages/sdk/src/agent.test.ts @@ -1,8 +1,9 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect, spyOn, test } from "bun:test" import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { createAgent, sessionMessagesFromHistory } from "./agent.js" +import { QueryEngine } from "./engine.js" import { SkillTool } from "./tools/skill-tool.js" import type { SDKMessage, ToolDefinition } from "./types.js" import type { CreateMessageParams, CreateMessageResponse, LLMProvider } from "./providers/types.js" @@ -1507,3 +1508,33 @@ describe("Agent session message uuid realignment (#363)", () => { expect(rebuilt[2]!.uuid).toBe("u-6") }) }) + +describe("Agent lazy context usage estimation (#386)", () => { + test("run completion does not estimate tokens; getContextUsage computes on demand", async () => { + const provider = new StaticProvider() + const agent = createAgent({ persistSession: false, tools: [], provider }) + const spy = spyOn(QueryEngine.prototype, "getContextUsage") + + try { + for await (const _event of agent.query("hello")) { + // drain query + } + + // The per-run finally must not run the full-message token estimation + // when no host ever reads usage. + expect(spy).toHaveBeenCalledTimes(0) + + const usage = await agent.getContextUsage() + expect(spy).toHaveBeenCalledTimes(1) + expect(usage.totalTokens).toBeGreaterThan(0) + expect(usage.messageBreakdown.assistantMessageTokens).toBeGreaterThan(0) + + // Each on-demand read recomputes against the retained engine. + await agent.getContextUsage() + expect(spy).toHaveBeenCalledTimes(2) + } finally { + spy.mockRestore() + await agent.close() + } + }) +}) diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts index 7d3957ae3..3ae6fe491 100644 --- a/packages/sdk/src/agent.ts +++ b/packages/sdk/src/agent.ts @@ -291,7 +291,7 @@ export class Agent { private loadedCommands: CommandDefinition[] = [] private fileCheckpointState: FileCheckpointState = {} private latestUserMessageId: string | undefined - private lastContextUsage: ContextUsageResult | null = null + private lastUsageEngine: QueryEngine | null = null private queuedSdkEvents: SDKMessage[] = [] private readonly skillRegistry: SkillRegistry @@ -1053,7 +1053,9 @@ export class Agent { await persistScheduler.cancel() opts.abortSignal?.removeEventListener('abort', forwardAbort) this.history = engine.getMessages() - this.lastContextUsage = engine.getContextUsage() + // Keep only the engine reference: the full token estimation runs + // lazily when a host actually calls getContextUsage (#386). + this.lastUsageEngine = engine this.currentEngine = null if (compactionBoundarySeen) { this.sessionMessages = sessionMessagesFromHistory(this.history, this.sessionMessages) @@ -1345,8 +1347,8 @@ export class Agent { if (this.currentEngine) { return this.currentEngine.getContextUsage() } - if (this.lastContextUsage) { - return this.lastContextUsage + if (this.lastUsageEngine) { + return this.lastUsageEngine.getContextUsage() } const init = await this.getInitializationResult() return { From 2c66124546ce6e21c47cf047d341a814f4347885 Mon Sep 17 00:00:00 2001 From: Leo Date: Sun, 23 Aug 2026 01:10:29 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(shared):=20?= =?UTF-8?q?=E6=8A=BD=E5=85=AC=E5=85=B1=20stableSerialize=EF=BC=8C=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E4=B8=A4=E4=BB=BD=E8=A7=84=E8=8C=83=E5=8C=96=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E5=8C=96=E5=89=AF=E6=9C=AC=20(#391)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 递归排序键 + 过滤 undefined + 保序数组的 JSON 规范化序列化此前在 sdk engine(repeat guard 签名)与 sidecar submission store(payload hash)各有一份手写实现。抽到 @lume/shared 单源,黄金值测试钉死 字节输出。 submission store 副本原用 localeCompare 排序,依赖运行环境默认 locale,跨机器不确定;统一为码点序后对实际 payload 键集(camelCase 标识符)输出逐字节一致,已对照实证。 Co-Authored-By: Claude Fable 5 --- .../services/agent/agent-submission-store.ts | 15 +------ packages/sdk/src/engine.ts | 15 +------ packages/shared/src/index.ts | 1 + packages/shared/src/stable-serialize.test.ts | 40 +++++++++++++++++++ packages/shared/src/stable-serialize.ts | 22 ++++++++++ 5 files changed, 66 insertions(+), 27 deletions(-) create mode 100644 packages/shared/src/stable-serialize.test.ts create mode 100644 packages/shared/src/stable-serialize.ts diff --git a/apps/sidecar/src/services/agent/agent-submission-store.ts b/apps/sidecar/src/services/agent/agent-submission-store.ts index e422e554a..8b1e801a4 100644 --- a/apps/sidecar/src/services/agent/agent-submission-store.ts +++ b/apps/sidecar/src/services/agent/agent-submission-store.ts @@ -3,6 +3,7 @@ import { existsSync, rmSync } from "node:fs"; import { createRequire } from "node:module"; import { join } from "node:path"; import type { AgentSavedFile, AgentSendInput, AgentSubmissionReceipt, AgentThreadMessageDispatchResult } from "@lume/shared"; +import { stableSerialize } from "@lume/shared"; import { getConfigDir } from "../infra/config-paths"; import { writeLogRecord } from "../infra/logger"; @@ -440,19 +441,7 @@ export function hashAgentSubmission(input: AgentSendInput): string { workspaceId: input.workspaceId, messageMetadata: input.messageMetadata, }; - return createHash("sha256").update(stableStringify(payload)).digest("hex"); -} - -function stableStringify(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; - if (value && typeof value === "object") { - return `{${Object.entries(value as Record) - .filter(([, item]) => item !== undefined) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) - .join(",")}}`; - } - return JSON.stringify(value) ?? "null"; + return createHash("sha256").update(stableSerialize(payload)).digest("hex"); } function rowToReceipt(row: SubmissionRow): AgentSubmissionReceipt { diff --git a/packages/sdk/src/engine.ts b/packages/sdk/src/engine.ts index b57507cbb..5fd3c6409 100644 --- a/packages/sdk/src/engine.ts +++ b/packages/sdk/src/engine.ts @@ -72,6 +72,7 @@ import { } from './utils/messages.js' import type { HookRegistry, HookInput, HookExecutionResult } from './hooks.js' import { readRepeatGuardState } from './repeat-guard.js' +import { stableSerialize } from '@lume/shared' import { buildStructuredOutputInstruction, parseStructuredOutput } from './utils/structured-output.js' import { captureFileSnapshots, captureWorkspaceFileSnapshots, collectCheckpointPaths, requiresWorkspaceCheckpoint } from './utils/file-checkpoints.js' import { generatePromptSuggestion } from './utils/prompt-suggestions.js' @@ -115,20 +116,6 @@ interface RepeatedToolCallState { const MAX_EQUIVALENT_MUTATION_RESULTS = 2 const MAX_BLOCKED_REPEAT_ATTEMPTS = 2 -function stableSerialize(value: unknown): string { - if (value === null || typeof value !== 'object') { - return JSON.stringify(value) ?? String(value) - } - if (Array.isArray(value)) { - return `[${value.map((item) => stableSerialize(item)).join(',')}]` - } - return `{${Object.keys(value as Record) - .sort() - .filter((key) => (value as Record)[key] !== undefined) - .map((key) => `${JSON.stringify(key)}:${stableSerialize((value as Record)[key])}`) - .join(',')}}` -} - function toolCallSignature(block: ToolUseBlock): string { return `${block.name}\0${stableSerialize(block.input)}` } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dc339bc1a..336f9be57 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -11,6 +11,7 @@ export * from "./data/model-meta"; export * from "./data/catalog-mapping"; export * from "./browser-api-registry"; export * from "./agent-island-projections"; +export * from "./stable-serialize"; // Bootstrap-level compatibility types used by MIG-001 scaffold. export type AppMode = "chat" | "agent"; diff --git a/packages/shared/src/stable-serialize.test.ts b/packages/shared/src/stable-serialize.test.ts new file mode 100644 index 000000000..a3b1b9816 --- /dev/null +++ b/packages/shared/src/stable-serialize.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { stableSerialize } from "./stable-serialize"; + +// The exact byte output is load-bearing (repeat-guard signatures in the SDK, +// persisted payload hashes in the sidecar submission store) — these golden +// values pin it. Any change here is a breaking change for existing hashes. +describe("stableSerialize", () => { + test("sorts object keys by code-unit order, independent of locale", () => { + expect(stableSerialize({ b: 1, a: 2, C: 3 })).toBe('{"C":3,"a":2,"b":1}'); + }); + + test("drops undefined-valued properties but keeps null", () => { + expect(stableSerialize({ a: undefined, b: null, c: 0, d: "" })).toBe( + '{"b":null,"c":0,"d":""}', + ); + }); + + test("preserves array order", () => { + expect(stableSerialize([3, 1, 2])).toBe("[3,1,2]"); + expect(stableSerialize({ list: ["z", "a"] })).toBe('{"list":["z","a"]}'); + }); + + test("recurses into nested structures deterministically", () => { + const input = { + outer: { z: 1, a: { y: [2, { k: undefined, j: 1 }] } }, + arr: [{ q: 1, b: 2 }], + }; + expect(stableSerialize(input)).toBe( + '{"arr":[{"b":2,"q":1}],"outer":{"a":{"y":[2,{"j":1}]},"z":1}}', + ); + }); + + test("serializes primitives exactly like JSON.stringify", () => { + expect(stableSerialize("x")).toBe('"x"'); + expect(stableSerialize(42)).toBe("42"); + expect(stableSerialize(true)).toBe("true"); + expect(stableSerialize(null)).toBe("null"); + expect(stableSerialize(undefined)).toBe("undefined"); + }); +}); diff --git a/packages/shared/src/stable-serialize.ts b/packages/shared/src/stable-serialize.ts new file mode 100644 index 000000000..2ca03f6aa --- /dev/null +++ b/packages/shared/src/stable-serialize.ts @@ -0,0 +1,22 @@ +/** + * Deterministic JSON serialization used for equality signatures and content + * hashes: object keys are sorted ascending (code-unit order, independent of + * the runtime locale), `undefined` valued properties are dropped, and arrays + * keep their order (position-sensitive sequences must hash differently). + * + * Consumers must treat the exact byte output as load-bearing (persisted + * hashes, repeat-guard signatures); changes here are breaking by definition. + */ +export function stableSerialize(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value) ?? String(value) + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableSerialize(item)).join(',')}]` + } + return `{${Object.keys(value as Record) + .sort() + .filter((key) => (value as Record)[key] !== undefined) + .map((key) => `${JSON.stringify(key)}:${stableSerialize((value as Record)[key])}`) + .join(',')}}` +} From 6d8502f4b81eb80684a0d92f28a96a501c7cc921 Mon Sep 17 00:00:00 2001 From: Leo Date: Sun, 23 Aug 2026 01:12:49 +0800 Subject: [PATCH 3/7] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(sdk):=20execu?= =?UTF-8?q?teTools=20=E5=8F=8C=E5=BE=AA=E7=8E=AF=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E4=B8=BA=E5=85=B1=E4=BA=AB=E9=A1=BA=E5=BA=8F=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E9=97=AD=E5=8C=85=20(#393)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skill 混批与 serial 两个循环各自维护的软中断 break / guard-stop 占位 / 中断兜底 / events+toolsUsed 扇出提取为同函数作用域的局部 闭包 runSequentialItem,repeatGuardStop 提升为跨阶段单一声明。 顺带消除"preCheck 仅在不执行时返回空 toolsUsed"的隐式不变量: guard 拒绝路径的空扇出改在 executeToolWithRepeatGuard 唯一出口 显式构造,preCheck 返回类型收窄,concurrent 批删除恒空 push。 纯重构,行为等价,靠现有 engine/abort 测试验证。 Co-Authored-By: Claude Fable 5 --- packages/sdk/src/engine.ts | 112 +++++++++++++++++++------------------ 1 file changed, 58 insertions(+), 54 deletions(-) diff --git a/packages/sdk/src/engine.ts b/packages/sdk/src/engine.ts index 5fd3c6409..1465b1396 100644 --- a/packages/sdk/src/engine.ts +++ b/packages/sdk/src/engine.ts @@ -1739,34 +1739,57 @@ export class QueryEngine { // NaN/0/negative would silently skip every concurrent batch or spin forever const MAX_CONCURRENCY = Number.isInteger(parsedConcurrency) && parsedConcurrency > 0 ? parsedConcurrency : 10 + // Sticky across both execution phases: once the guard decides to stop, + // later successful tools must not clear it and nothing else may execute. + let repeatGuardStop: { message: string; errorCode: string } | undefined + + // Shared pipeline for the two sequential phases (skill activations and + // serial mutations): soft-abort break, guard-stop placeholders, + // interrupted-call fallback, and the events/toolsUsed fan-out. + // Returns true when the caller must break out (soft abort). + const runSequentialItem = async ( + block: ToolUseBlock, + tool: ToolDefinition | undefined, + storeResult: (result: ToolResult & { tool_name?: string }) => void, + ): Promise => { + if (this.config.abortSignal?.aborted) return true // soft abort: skip remaining tools + if (repeatGuardStop) { + // The guard already decided to stop this run: pair the remaining + // tool_use blocks with skipped placeholders instead of executing. + storeResult(createRepeatGuardSkippedToolResult(block)) + return false + } + let outcome + try { + outcome = await this.executeToolWithRepeatGuard(block, tool, context) + } catch (error) { + if (!this.config.abortSignal?.aborted) throw error + this.abortedPendingToolCalls.push({ id: block.id, name: block.name, input: block.input }) + outcome = { + result: createInterruptedToolResult(block), + events: [] as SDKMessage[], + toolsUsed: [] as string[], + repeatGuardStop: undefined, + } + } + storeResult(outcome.result) + events.push(...outcome.events) + toolsUsed.push(...outcome.toolsUsed) + if (outcome.repeatGuardStop) repeatGuardStop ??= outcome.repeatGuardStop + return false + } + const hasSkillActivation = toolUseBlocks.some((block) => block.name === 'Skill') if (hasSkillActivation && toolUseBlocks.length > 1) { const resultsById = new Map() - let repeatGuardStop: { message: string; errorCode: string } | undefined for (const block of toolUseBlocks.filter((item) => item.name === 'Skill')) { - if (this.config.abortSignal?.aborted) break // soft abort: skip remaining activations - if (repeatGuardStop) { - // The guard already decided to stop this run: pair the remaining - // tool_use blocks with skipped placeholders instead of executing. - resultsById.set(block.id, createRepeatGuardSkippedToolResult(block)) - continue - } - const tool = this.config.tools.find((t) => t.name === block.name) - let result - try { - result = await this.executeToolWithRepeatGuard(block, tool, context) - } catch (error) { - if (!this.config.abortSignal?.aborted) throw error - this.abortedPendingToolCalls.push({ id: block.id, name: block.name, input: block.input }) - result = { result: createInterruptedToolResult(block), events: [] as SDKMessage[], toolsUsed: [] as string[] } - } - resultsById.set(block.id, result.result) - events.push(...result.events) - toolsUsed.push(...result.toolsUsed) - // Sticky on purpose: once the guard decides to stop, later successful - // tools in the same batch must not clear that decision. - if (result.repeatGuardStop) repeatGuardStop ??= result.repeatGuardStop + const aborted = await runSequentialItem( + block, + this.config.tools.find((t) => t.name === block.name), + (result) => resultsById.set(block.id, result), + ) + if (aborted) break } for (const block of toolUseBlocks.filter((item) => item.name !== 'Skill')) { @@ -1805,10 +1828,6 @@ export class QueryEngine { const results: Array<(ToolResult & { tool_name?: string }) | undefined> = new Array(toolUseBlocks.length) - // Sticky across both phases: once the guard decides to stop, later - // successful tools must not clear it and nothing else may execute. - let repeatGuardStop: { message: string; errorCode: string } | undefined - // Execute concurrent tools (batched by MAX_CONCURRENCY). Read-only calls go // through the same repeat guard as mutations so repeated equivalent results // — including failures — are refused without burning real executions. @@ -1831,7 +1850,6 @@ export class QueryEngine { continue } results[item.index] = blocked.result - events.push(...blocked.events) if (blocked.repeatGuardStop) { repeatGuardStop ??= blocked.repeatGuardStop guardStoppedHere = true @@ -1865,27 +1883,12 @@ export class QueryEngine { // Execute serial tools sequentially for (const item of serial) { - if (this.config.abortSignal?.aborted) break // soft abort: skip remaining serial tools - if (repeatGuardStop) { - // The guard already decided to stop this run: pair the remaining - // tool_use blocks with skipped placeholders instead of executing. - results[item.index] = createRepeatGuardSkippedToolResult(item.block) - continue - } - let result - try { - result = await this.executeToolWithRepeatGuard(item.block, item.tool, context) - } catch (error) { - if (!this.config.abortSignal?.aborted) throw error - this.abortedPendingToolCalls.push({ id: item.block.id, name: item.block.name, input: item.block.input }) - result = { result: createInterruptedToolResult(item.block), events: [] as SDKMessage[], toolsUsed: [] as string[] } - } - results[item.index] = result.result - events.push(...result.events) - toolsUsed.push(...result.toolsUsed) - // Sticky on purpose: once the guard decides to stop, later successful - // tools in the same batch must not clear that decision. - if (result.repeatGuardStop) repeatGuardStop ??= result.repeatGuardStop + const aborted = await runSequentialItem( + item.block, + item.tool, + (result) => { results[item.index] = result }, + ) + if (aborted) break } return { @@ -1922,8 +1925,6 @@ export class QueryEngine { block: ToolUseBlock, ): { result: ToolResult & { tool_name?: string } - events: SDKMessage[] - toolsUsed: string[] repeatGuardStop?: { message: string; errorCode: string } } | undefined { const signature = toolCallSignature(block) @@ -1955,8 +1956,6 @@ export class QueryEngine { }, }, }, - events: [], - toolsUsed: [], ...(shouldStop ? { repeatGuardStop: { errorCode: 'repeated_tool_call', @@ -2002,7 +2001,12 @@ export class QueryEngine { repeatGuardStop?: { message: string; errorCode: string } }> { const blocked = this.repeatGuardPreCheck(block) - if (blocked) return blocked + if (blocked) { + // A guard refusal executes nothing: no side effects, so no events and + // no toolsUsed — constructed here so callers can fan out uniformly + // instead of relying on an empty-array invariant of the pre-check. + return { ...blocked, events: [], toolsUsed: [] } + } const execution = await this.executeSingleTool(block, tool, context) this.repeatGuardPostRecord(block, execution.result) return execution From 3959b42002bbde71f696819396b7981f9aa03520 Mon Sep 17 00:00:00 2001 From: Leo Date: Sun, 23 Aug 2026 01:20:28 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=A7=B9=20fix(sdk):=20=E4=BD=8E?= =?UTF-8?q?=E5=8D=B1=E7=A7=AF=E5=8E=8B=E5=85=AD=E9=A1=B9=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=AD=BB=E4=BA=8B=E4=BB=B6=E4=B8=8B=E7=BA=BF/subtype=20?= =?UTF-8?q?=E6=94=B6=E7=B4=A7/=E9=98=9F=E5=88=97=E6=B3=84=E6=BC=8F/natives?= =?UTF-8?q?=20=E4=B8=8E=20model-meta=20=E5=AE=88=E5=8D=AB=20(#413)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. session_state_changed 全域零消费:删四处发射点、类型与导出,不再每轮持久化进 sessionMessages jsonl。 2. SDKResultMessage.subtype 补 'error_completion_guard' 并删 '| string';sidecar 测试 mock 的非法 "error" 改为真实发射值 error_during_execution。 3. 宿主中途放弃生成器时 finally 清空 queuedSdkEvents,迟到异步事件不再混入下一 run;尾部 drain 前置堵住最后窗口。 4. natives countTokens 降级路径补 catch→null,对齐同文件契约。 5. nativeSearch 公开签名补已支持的 multiline 参数。 6. findModelMeta("") 空串入口守卫,不再命中第一条注册表记录。 Co-Authored-By: Claude Fable 5 --- .../src/services/agent/agent-service.test.ts | 2 +- packages/natives/index.ts | 8 +++- packages/sdk/src/agent.test.ts | 41 +++++++++++++++++++ packages/sdk/src/agent.ts | 15 ++++++- packages/sdk/src/engine.test.ts | 5 +-- packages/sdk/src/engine.ts | 26 ------------ packages/sdk/src/index.ts | 1 - packages/sdk/src/types.ts | 11 +---- packages/shared/src/data/model-meta.test.ts | 4 ++ packages/shared/src/data/model-meta.ts | 3 ++ 10 files changed, 70 insertions(+), 46 deletions(-) diff --git a/apps/sidecar/src/services/agent/agent-service.test.ts b/apps/sidecar/src/services/agent/agent-service.test.ts index a007cfba2..cc95eea2a 100644 --- a/apps/sidecar/src/services/agent/agent-service.test.ts +++ b/apps/sidecar/src/services/agent/agent-service.test.ts @@ -328,7 +328,7 @@ mock.module("../agent-runtime/runtime-core/attempt", () => ({ } as SDKMessage); emit.onSdkMessage({ type: "result", - subtype: "error", + subtype: "error_during_execution", error: "network failed", } as SDKMessage); emit.onError("network failed"); diff --git a/packages/natives/index.ts b/packages/natives/index.ts index a1eef5df0..9d79ea7a6 100644 --- a/packages/natives/index.ts +++ b/packages/natives/index.ts @@ -424,7 +424,11 @@ export function assertNativeAvailable(): NativeDiagnostics { export function countTokens(input: TokenCountInput): TokenCountResult | null { const native = loadNative(); if (!native) return null; - return native.countTokens(input); + try { + return native.countTokens(input); + } catch { + return null; + } } export function countStringTokens(text: string, model?: string): number { @@ -457,7 +461,7 @@ export async function nativeGrep( export function nativeSearch( content: string, pattern: string, - options?: { ignore_case?: boolean; context?: number; max_count?: number }, + options?: { ignore_case?: boolean; multiline?: boolean; context?: number; max_count?: number }, ): NativeGrepMatch[] | null { const native = loadNative(); if (!native) return null; diff --git a/packages/sdk/src/agent.test.ts b/packages/sdk/src/agent.test.ts index a945dd354..b5a5904e2 100644 --- a/packages/sdk/src/agent.test.ts +++ b/packages/sdk/src/agent.test.ts @@ -1538,3 +1538,44 @@ describe("Agent lazy context usage estimation (#386)", () => { } }) }) + +describe("Agent queued async events across runs (#413)", () => { + const fakeAsyncEvent = () => + ({ type: "system", subtype: "task_notification", session_id: "s" }) as SDKMessage + + test("abandoning a run mid-stream drops pending async events", async () => { + const provider = new StaticProvider() + const agent = createAgent({ persistSession: false, tools: [], provider }) + + let abandoned = false + for await (const _event of agent.query("hello")) { + if (abandoned) break + // An async event lands in the queue while the consumer is still + // iterating, then the consumer abandons the generator. + ;(agent as any).queuedSdkEvents.push(fakeAsyncEvent()) + abandoned = true + } + + expect((agent as any).queuedSdkEvents).toHaveLength(0) + await agent.close() + }) + + test("completed runs still deliver queued async events", async () => { + const provider = new StaticProvider() + const agent = createAgent({ persistSession: false, tools: [], provider }) + + const seen: SDKMessage[] = [] + let injected = false + for await (const event of agent.query("hello")) { + seen.push(event) + if (!injected) { + ;(agent as any).queuedSdkEvents.push(fakeAsyncEvent()) + injected = true + } + } + + expect(seen.some((event) => event.type === "system" && event.subtype === "task_notification")).toBe(true) + expect((agent as any).queuedSdkEvents).toHaveLength(0) + await agent.close() + }) +}) diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts index 3ae6fe491..d7f1fad8c 100644 --- a/packages/sdk/src/agent.ts +++ b/packages/sdk/src/agent.ts @@ -1010,6 +1010,7 @@ export class Agent { let persistedSessionEvent: SDKMessage | null = null let compactionBoundarySeen = false + let runCompleted = false try { for await (const event of engine.submitMessage(modelFacingPrompt)) { if (event.type === 'assistant') { @@ -1044,7 +1045,14 @@ export class Agent { yield queued } } + runCompleted = true } finally { + if (!runCompleted) { + // Consumer abandoned the generator mid-run (break / close): pending + // async events can no longer be delivered and must not leak into the + // next run's event stream. + this.queuedSdkEvents.length = 0 + } // Drop any pending debounced write and wait out one already in flight: // the awaited persistCurrentSession below writes the same (or fresher) // state. Flushing here instead would launch a concurrent fire-and-forget @@ -1066,11 +1074,14 @@ export class Agent { persistedSessionEvent = await this.persistCurrentSession(cwd, opts) } + // Drain before the final yields: once the consumer stops iterating, the + // queue must be empty either way — leftover async events belong to this + // dead run, not the next one. + const tailQueued = this.drainQueuedSdkEvents() if (persistedSessionEvent) { yield persistedSessionEvent } - - for (const queued of this.drainQueuedSdkEvents()) { + for (const queued of tailQueued) { yield queued } } diff --git a/packages/sdk/src/engine.test.ts b/packages/sdk/src/engine.test.ts index 072538a5b..a6dc8683d 100644 --- a/packages/sdk/src/engine.test.ts +++ b/packages/sdk/src/engine.test.ts @@ -1312,10 +1312,7 @@ describe("QueryEngine context controller", () => { }); const iterator = engine.submitMessage("run"); - expect((await iterator.next()).value).toMatchObject({ - type: "system", - subtype: "session_state_changed" - }); + // session_state_changed was retired (#413): init is now the first event. expect((await iterator.next()).value).toMatchObject({ type: "system", subtype: "init" diff --git a/packages/sdk/src/engine.ts b/packages/sdk/src/engine.ts index 1465b1396..9d1d4e12f 100644 --- a/packages/sdk/src/engine.ts +++ b/packages/sdk/src/engine.ts @@ -963,13 +963,6 @@ export class QueryEngine { return } - yield { - type: 'system', - subtype: 'session_state_changed', - state: 'running', - session_id: this.sessionId, - } - if (this.isManualCompactPrompt(prompt)) { const compacted = yield* this.runCompaction('manual') yield { @@ -984,12 +977,6 @@ export class QueryEngine { cost: this.totalCost, ...(!compacted ? { errors: ['Context compaction failed; the original context was preserved.'] } : {}), } as SDKMessage - yield { - type: 'system', - subtype: 'session_state_changed', - state: 'idle', - session_id: this.sessionId, - } return } @@ -1379,12 +1366,6 @@ export class QueryEngine { cost: this.totalCost, errors: [err?.message || 'Unknown provider error'], } - yield { - type: 'system', - subtype: 'session_state_changed', - state: 'idle', - session_id: this.sessionId, - } return } @@ -1680,13 +1661,6 @@ export class QueryEngine { } } } - - yield { - type: 'system', - subtype: 'session_state_changed', - state: 'idle', - session_id: this.sessionId, - } } /** diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 5279bb815..ecc38a710 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -524,7 +524,6 @@ export type { SDKStreamlinedTextMessage, SDKStreamlinedToolUseSummaryMessage, SDKToolUseSummaryMessage, - SDKSessionStateChangedMessage, SDKLocalCommandOutputMessage, SDKElicitationCompleteMessage, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index c38ac9475..495e81b30 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -85,7 +85,6 @@ export type SDKMessage = | SDKStreamlinedTextMessage | SDKStreamlinedToolUseSummaryMessage | SDKToolUseSummaryMessage - | SDKSessionStateChangedMessage | SDKLocalCommandOutputMessage | SDKElicitationCompleteMessage | SDKRunAbortedMessage @@ -139,7 +138,7 @@ export interface SDKResultMessage { | 'error_max_budget_usd' | 'error_max_output_tokens' | 'error_max_structured_output_retries' - | string + | 'error_completion_guard' uuid?: string session_id?: string is_error?: boolean @@ -655,14 +654,6 @@ export interface SDKToolUseSummaryMessage { session_id: string } -export interface SDKSessionStateChangedMessage { - type: 'system' - subtype: 'session_state_changed' - state: 'idle' | 'running' | 'requires_action' - uuid?: string - session_id: string -} - export interface SDKLocalCommandOutputMessage { type: 'system' subtype: 'local_command_output' diff --git a/packages/shared/src/data/model-meta.test.ts b/packages/shared/src/data/model-meta.test.ts index 3d0679d13..3319247a0 100644 --- a/packages/shared/src/data/model-meta.test.ts +++ b/packages/shared/src/data/model-meta.test.ts @@ -32,6 +32,10 @@ describe('findModelMeta', () => { expect(findModelMeta('unknown-model-xyz')).toBeUndefined() }) + test('returns undefined for empty input instead of the first registry entry', () => { + expect(findModelMeta('')).toBeUndefined() + }) + test('returns pricing when available', () => { const meta = findModelMeta('claude-sonnet-4-20250514') expect(meta!.pricing).toEqual({ input: 3, output: 15 }) diff --git a/packages/shared/src/data/model-meta.ts b/packages/shared/src/data/model-meta.ts index 688f0455d..018307f2e 100644 --- a/packages/shared/src/data/model-meta.ts +++ b/packages/shared/src/data/model-meta.ts @@ -109,6 +109,9 @@ function stripConnectionPrefix(id: string): string { * Returns undefined for unknown models. */ export function findModelMeta(modelId: string): ModelMeta | undefined { + // Empty input would hit the first registry entry via the prefix branch + // (`meta.id.startsWith("")` is always true). + if (!modelId) return undefined const connectionModelId = stripConnectionPrefix(modelId) const candidates = [...new Set([ modelId, From 969a4ea1a04cf913649fbe2a14d5b9f85c8f7592 Mon Sep 17 00:00:00 2001 From: Leo Date: Sun, 23 Aug 2026 09:09:14 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=90=9B=20fix(sdk):=20close=20?= =?UTF-8?q?=E9=87=8A=E6=94=BE=20lastUsageEngine=E2=80=94=E2=80=94run=20?= =?UTF-8?q?=E9=97=B4=E7=A9=BA=E9=97=B2=E6=9C=9F=E4=B8=8D=E5=86=8D=E9=A9=BB?= =?UTF-8?q?=E7=95=99=20engine=20=E4=B8=8E=E6=B6=88=E6=81=AF=E5=8E=86?= =?UTF-8?q?=E5=8F=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #386 为惰性估算保留了 QueryEngine 引用,但 Agent 的关闭路径未清它, engine 连同内部 messages 历史双份驻留到 Agent 生命周期结束。close() 现在置空该引用,getContextUsage 落回零值安全路径,不再触发估算。 --- packages/sdk/src/agent.test.ts | 26 ++++++++++++++++++++++++++ packages/sdk/src/agent.ts | 5 +++++ 2 files changed, 31 insertions(+) diff --git a/packages/sdk/src/agent.test.ts b/packages/sdk/src/agent.test.ts index b5a5904e2..f5d06eb63 100644 --- a/packages/sdk/src/agent.test.ts +++ b/packages/sdk/src/agent.test.ts @@ -1537,6 +1537,32 @@ describe("Agent lazy context usage estimation (#386)", () => { await agent.close() } }) + + test("close releases the retained usage engine; later reads stay safe", async () => { + const provider = new StaticProvider() + const agent = createAgent({ persistSession: false, tools: [], provider }) + const spy = spyOn(QueryEngine.prototype, "getContextUsage") + + try { + for await (const _event of agent.query("hello")) { + // drain query + } + expect((agent as any).lastUsageEngine).not.toBeNull() + + await agent.close() + // The engine (holding the run's full message history) must not stay + // reachable through the Agent past close. + expect((agent as any).lastUsageEngine).toBeNull() + + // Post-close reads fall back to the safe zero-value shape without + // triggering the retained engine's estimation. + const usage = await agent.getContextUsage() + expect(spy).toHaveBeenCalledTimes(0) + expect(usage.totalTokens).toBe(0) + } finally { + spy.mockRestore() + } + }) }) describe("Agent queued async events across runs (#413)", () => { diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts index d7f1fad8c..60479e55a 100644 --- a/packages/sdk/src/agent.ts +++ b/packages/sdk/src/agent.ts @@ -1496,6 +1496,11 @@ export class Agent { this.unregisterFileSkills() this.unregisterExplicitSkills() this.unregisterPluginSkills() + // Release the engine retained for lazy usage estimation (#386): past + // close, it would otherwise keep the full message history reachable for + // the lifetime of the Agent. getContextUsage falls back to the safe + // zero-value shape. + this.lastUsageEngine = null } } From 22e81340185d19c87fc45f0b3cd42940517e7f05 Mon Sep 17 00:00:00 2001 From: Leo Date: Sun, 23 Aug 2026 09:09:36 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=90=9B=20fix(sdk):=20run=20=E4=BB=A3?= =?UTF-8?q?=E9=99=85=E6=A0=87=E8=AE=B0=E5=A0=B5=E8=BF=9F=E5=88=B0=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=AE=8B=E7=AA=97=E2=80=94=E2=80=94=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=20onAsyncEvent=20=E7=9B=B4=E6=8E=A5=E5=85=A5=E9=98=9F=E5=8D=B3?= =?UTF-8?q?=E5=BC=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit executeSingleTool 的后台 task_notification 闭包可在宿主放弃迭代之后 迟到触发,#413③ 的 finally 清空与 drain 前置都堵不住这个窗口。引入 asyncEventEpoch:run 的 finally 递增,各 run 的 onAsyncEvent 闭包捕获 创建时的代际,入队前比对,过期代际直接丢弃——run 结束后任何迟到 事件都不再存活到下一 run 的 drain 窗口。 --- packages/sdk/src/agent.test.ts | 27 +++++++++++++++++++++++++++ packages/sdk/src/agent.ts | 15 +++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/sdk/src/agent.test.ts b/packages/sdk/src/agent.test.ts index f5d06eb63..6413dab57 100644 --- a/packages/sdk/src/agent.test.ts +++ b/packages/sdk/src/agent.test.ts @@ -1604,4 +1604,31 @@ describe("Agent queued async events across runs (#413)", () => { expect((agent as any).queuedSdkEvents).toHaveLength(0) await agent.close() }) + + test("late background notification after abandoned iteration never enters the queue", async () => { + const provider = new StaticProvider() + const agent = createAgent({ persistSession: false, tools: [], provider }) + + for await (const _event of agent.query("hello")) { + break // host abandons mid-stream + } + + // Simulate executeSingleTool's post-tool notification path firing after + // the host gave up: a background task_notification delivered through the + // dead run's captured onAsyncEvent closure. + const engine = (agent as any).lastUsageEngine as QueryEngine + ;(engine.config.onAsyncEvent as (event: SDKMessage) => void)(fakeAsyncEvent()) + + // The closure belongs to a finished generation: dropped, not enqueued... + expect((agent as any).queuedSdkEvents).toHaveLength(0) + + // ...and nothing leaks into the next run's stream either. + const seen: SDKMessage[] = [] + for await (const event of agent.query("hello")) { + seen.push(event) + } + expect(seen.some((event) => event.type === "system" && event.subtype === "task_notification")).toBe(false) + expect((agent as any).queuedSdkEvents).toHaveLength(0) + await agent.close() + }) }) diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts index 60479e55a..da43bdb73 100644 --- a/packages/sdk/src/agent.ts +++ b/packages/sdk/src/agent.ts @@ -293,6 +293,12 @@ export class Agent { private latestUserMessageId: string | undefined private lastUsageEngine: QueryEngine | null = null private queuedSdkEvents: SDKMessage[] = [] + // Generation marker for the async-event queue: advanced when a run's + // finally completes. Each run's onAsyncEvent closure captures the value + // from before its run, so a late background task_notification firing after + // the host abandoned iteration (or between runs) is recognized as stale and + // dropped instead of leaking into the next run's event stream. + private asyncEventEpoch = 0 private readonly skillRegistry: SkillRegistry constructor(options: AgentOptions = {}) { @@ -924,6 +930,9 @@ export class Agent { await this.persistCurrentSession(cwd, opts) } + // Captured before the engine exists: once this run's finally advances the + // epoch, closures holding the stale value are recognized as dead runs'. + const sdkEventEpoch = this.asyncEventEpoch const engine = new QueryEngine({ cwd, model: opts.model || this.modelId, @@ -980,6 +989,7 @@ export class Agent { opts.onAsyncEvent(event) return } + if (sdkEventEpoch !== this.asyncEventEpoch) return this.queuedSdkEvents.push(event) }, onLiveEvent: opts.onLiveEvent, @@ -1065,6 +1075,11 @@ export class Agent { // lazily when a host actually calls getContextUsage (#386). this.lastUsageEngine = engine this.currentEngine = null + // Invalidate this run's async-event closures: anything they enqueue from + // here on is post-run residue (late background task_notification after + // the host stopped iterating) and must not survive into the next run's + // drain windows. + this.asyncEventEpoch++ if (compactionBoundarySeen) { this.sessionMessages = sessionMessagesFromHistory(this.history, this.sessionMessages) } From 467756348a12d20a19adc232cf3cacf1e3b69a4c Mon Sep 17 00:00:00 2001 From: Leo Date: Sun, 23 Aug 2026 09:09:52 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=A7=AA=20test(shared):=20stableSerial?= =?UTF-8?q?ize=20=E9=BB=84=E9=87=91=E5=80=BC=E8=A1=A5=E9=9D=9E=20ASCII=20?= =?UTF-8?q?=E9=94=AE=E2=80=94=E2=80=94=E9=92=89=E4=BD=8F=E7=A0=81=E7=82=B9?= =?UTF-8?q?=E5=BA=8F=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文键/emoji 键/大小写混合键 {aZ,Az,az,AZ} 正是 localeCompare 与 码点序的分叉点;黄金值钉死排序字节输出,防止未来误改为 locale 排序。 --- packages/shared/src/stable-serialize.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/shared/src/stable-serialize.test.ts b/packages/shared/src/stable-serialize.test.ts index a3b1b9816..c33f4bbe7 100644 --- a/packages/shared/src/stable-serialize.test.ts +++ b/packages/shared/src/stable-serialize.test.ts @@ -9,6 +9,15 @@ describe("stableSerialize", () => { expect(stableSerialize({ b: 1, a: 2, C: 3 })).toBe('{"C":3,"a":2,"b":1}'); }); + test("sorts non-ASCII and mixed-case keys by code points, not locale collation", () => { + // The localeCompare fork point: locale collation groups case-insensitively + // and reorders CJK/symbol blocks; code-unit sort keeps uppercase < lowercase + // and BMP < astral. This exact byte order is load-bearing. + expect( + stableSerialize({ az: 1, 中文: 2, AZ: 3, "🎉": 4, Az: 5, 键: 6, aZ: 7 }), + ).toBe('{"AZ":3,"Az":5,"aZ":7,"az":1,"中文":2,"键":6,"🎉":4}'); + }); + test("drops undefined-valued properties but keeps null", () => { expect(stableSerialize({ a: undefined, b: null, c: 0, d: "" })).toBe( '{"b":null,"c":0,"d":""}',