From 06556237f4d3b5dd98ccdb4ee01129c8c16b464d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:24:34 +0800 Subject: [PATCH 1/2] fix(tools): close scheduler coverage gaps; regen persistence catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage gate flagged three untaken paths in the bridge scheduler: - the exclusive-head inFlight re-check was dead (the shared guard above already returns for an exclusive head with any in-flight sibling) — removed; - the commit-cursor undefined-dispatched break was structurally unreachable once entries join commitQueue only after start() ran synchronously — reordered the pump so the invariant holds by construction, annotated; - the finish (final-result) commit arm and the pump re-entry guard gain a covering test (throwing tools/pre-execute listener) and a defensive annotation respectively; mid-run unregistration test renamed to match its actual post-result settlement path. Also covers the direct-construction maxParallelSubCalls default (index.ts) and commits the regenerated persistence catalog for the new dispatch pair. --- docs/persistence-catalog.md | 4 +- packages/core/tools/src/code-mode.ts | 18 ++++-- packages/core/tools/tests/code-mode.spec.ts | 70 +++++++++++++++++++++ 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 14b323704c..e7790fa76f 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -474,7 +474,7 @@ Source: [`packages/core/session/src/types.ts:283`](../packages/core/session/src/ Types: [CallId](core-data-structures/core.md) · [ContentBlock](core-data-structures/core.md) -Source: [`packages/core/tools/src/code-mode.ts:48`](../packages/core/tools/src/code-mode.ts) +Source: [`packages/core/tools/src/code-mode.ts:49`](../packages/core/tools/src/code-mode.ts) #### `tool/code-dispatch-start` — log-only @@ -497,7 +497,7 @@ Source: [`packages/core/tools/src/code-mode.ts:48`](../packages/core/tools/src/c Types: [CallId](core-data-structures/core.md) -Source: [`packages/core/tools/src/code-mode.ts:32`](../packages/core/tools/src/code-mode.ts) +Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/code-mode.ts) #### `tool/result` — surface diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/code-mode.ts index 54b851232a..c97338518d 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/code-mode.ts @@ -285,6 +285,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => const head = commitQueue[0] /* v8 ignore next -- the loop condition bounds the index. */ if (head === undefined) break + /* v8 ignore next -- entries join commitQueue only after start() set dispatched (see pump). */ if (head.dispatched === undefined) break await head.dispatched commitQueue.shift() @@ -295,7 +296,11 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => } } const pump = (): void => { - // The finally-driven re-entry below would otherwise recurse. + // Defensive re-entry guard: today every caller (binding submission, + // flight.finally, drain) runs off promise callbacks, never while pump + // is on the stack, so this cannot fire — kept against a future + // synchronous caller. + /* v8 ignore next -- see the re-entry note above. */ if (pumping) return pumping = true try { @@ -310,12 +315,10 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => // Reclassify at start time (fail-closed on registry changes). const mode = head.classify() if (exclusiveActive || inFlight.size >= (mode === 'exclusive' ? 1 : maxParallel)) return - if (mode === 'exclusive') { - if (inFlight.size > 0) return - exclusiveActive = true - } + // The guard above already returned for an exclusive head with any + // in-flight sibling, so claiming the barrier here is race-free. + if (mode === 'exclusive') exclusiveActive = true pendingQueue.shift() - commitQueue.push(head) const flight = head.start().finally(() => { inFlight.delete(flight) if (mode === 'exclusive') exclusiveActive = false @@ -325,6 +328,9 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => void commitReady() pump() }) + // Joined AFTER start() ran synchronously, so every commitQueue + // entry already carries its `dispatched` promise. + commitQueue.push(head) inFlight.add(flight) } } finally { diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 973660a5ce..e227f07544 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -478,6 +478,38 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { expect(gated.peakLive()).toBe(2) }) + it('a tool unregistered between binding enumeration and dispatch fails as unknown tool', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + const calls: unknown[] = [] + const dispose = ctx.tools.register(defineTool({ + name: 'ephemeral', + description: 'Unregistered between binding enumeration and dispatch.', + parameters: {}, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value }], + }, + execute() { + calls.push('ran') + return Promise.resolve('ok') + }, + })) + runtime.behavior = async (request) => { + // The binding exists (enumerated at run start); the registry mutation + // makes prepare resolve UNKNOWN_TOOL as a final-result, which commits + // through scheduler.finish (no post-execute). + dispose() + const message = await request.bindings[0]!.functions.ephemeral!({}) + .then(() => 'resolved', (error: unknown) => error instanceof Error ? error.message : String(error)) + return { logs: [], value: message } + } + const result = await runCode(ctx, 'program') + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected success') + expect(result.value).toMatchObject({ result: 'unknown tool "ephemeral"' }) + expect(calls).toEqual([]) + }) + it('post-execute and context commitment stay in submission order under out-of-order completion', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) const gated = registerGated(ctx, 'safe_read', true) @@ -662,6 +694,37 @@ describe('the run_code dispatch bridge', () => { expect(result.content[0]).toEqual({ type: 'text', text: 'caught: deliberate failure' }) }) + it('a throwing tools/pre-execute listener settles the sub-call without post-execute', async () => { + const { ctx, runtime } = await setup({ mode: 'code' }) + const calls = registerEcho(ctx) + const postExecuted: string[] = [] + ctx.on('tools/pre-execute', (exec, next) => { + if (exec.name === 'echo') throw new Error('gate exploded') + return next() + }) + ctx.on('tools/post-execute', (exec, _result, next): Promise => { + if (exec.name === 'echo') postExecuted.push(exec.name) + return next() + }) + const { agent, events } = fakeAgent() + runtime.behavior = async (request) => { + const message = await request.bindings[0]!.functions.echo!({ value: 'x' }) + .then(() => 'resolved', (error: unknown) => error instanceof Error ? error.message : String(error)) + return { logs: [], value: message } + } + const result = await runCode(ctx, 'program', { agent }) + expect(result.isError).toBe(false) + if (result.isError) throw new Error('expected success') + expect(result.value).toMatchObject({ result: 'gate exploded' }) + // The pipeline failure is final: the body never ran and post-execute was + // skipped, yet the settle event still carries the error outcome. + expect(calls).toEqual([]) + expect(postExecuted).toEqual([]) + const settles = events.filter(event => event.type === 'tool/code-dispatch') + expect(settles).toHaveLength(1) + expect(settles[0]?.data).toMatchObject({ name: 'echo', isError: true }) + }) + it('a tools/pre-execute deny reaches the program as a binding rejection', async () => { const { ctx, runtime } = await setup({ mode: 'code' }) registerEcho(ctx) @@ -1230,6 +1293,13 @@ describe('the run_code dispatch bridge', () => { expect(derived[0]?.role).toBe('user') }) + it('direct construction in code mode defaults the parallel sub-call cap', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt, {}) + const registry = new ToolRegistry(ctx, { mode: 'code' }) + expect(registry.get(RUN_CODE_NAME)).toBeDefined() + }) + it('defaults to native mode under direct construction with no config', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt, {}) From ded5d01c6ceb6eca7be38fdf2b512bd84e38c79e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 13:40:02 +0800 Subject: [PATCH 2/2] docs: regenerate catalogs and register CodeDispatchLog type-equiv on this tree The static CI gates run per-branch on the merged tree: the cordis catalog/api, config-catalog, and type-equiv manifest updates for the tools/code-dispatch-log waterfall and CodeDispatchLog payload previously landed only on the shiki branch (09734f23b); this branch's own tree needs the same regenerated outputs and manifest entries. --- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 28 +++++++++++++++++-- docs/cordis-catalog/services.md | 14 ++++++++-- .../cordis/tool-cordis/src/api-catalog.ts | 15 ++++++++++ scripts/type-equiv.manifest.json | 10 +++++++ 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index eeaed0302a..058eb65eeb 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1718,7 +1718,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:562`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:564`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-tui` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 1948c17f97..4c97fdc72d 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -842,7 +842,31 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:143`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:156`](../../packages/core/tools/src/index.ts) + +### `tools/code-dispatch-log` — waterfall + +Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the unshaped content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + +```ts cordis-catalog +/** + * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before + * the bridge appends its `tool/code-dispatch` event. `next()` keeps the + * content unchanged; a listener may return replacement blocks (e.g. the + * spill policy's preview + locator for an oversized text result). Only the + * logged copy is affected — the program already received the complete + * value, and the model sees neither. A throwing listener is contained: + * the bridge falls back to logging the unshaped content. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + * @param dispatch - the parent execution, sub-call identity, and the settled content to log. + * @mode waterfall + */ +'tools/code-dispatch-log'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise +``` + +Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md) + +Source: [`packages/core/tools/src/index.ts:138`](../../packages/core/tools/src/index.ts) ### `tools/execute` — waterfall @@ -927,7 +951,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:133`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:146`](../../packages/core/tools/src/index.ts) ## `workflow/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0903f35b1c..6facbbb9b3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1830,6 +1830,16 @@ schemas(scope?: ScopeKey): ToolSchema[] */ executionMode(exec: ToolExecutionInput): ToolExecutionMode +/** + * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch + * and return the content the bridge should log on `tool/code-dispatch`. + * Contained: a throwing listener falls back to the unshaped content — log + * shaping must never fail the dispatch or lose the settle event. + * @param dispatch - the sub-dispatch identity and its default logged content. + * @returns the (possibly reshaped) content for the durable event. + */ +async shapeDispatchLog(dispatch: CodeDispatchLog): Promise + /** * Execute through pre-policy, guards, around-dispatch, post-policy, * definition-owned content finalization, and final notification. Tool and @@ -1847,9 +1857,9 @@ executionMode(exec: ToolExecutionInput): ToolExecutionMode async execute(exec: ToolExecutionInput): Promise ``` -Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) +Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:642`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:677`](../../packages/core/tools/src/index.ts) ## `ctx.tui` — `TuiExtensionService` (abstract seam) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d834d3ee21..37df1a65d3 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -864,6 +864,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'executionMode(exec: ToolExecutionInput): ToolExecutionMode', jsDoc: '/**\n * Classify a pending call through the caller\'s visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */', }, + { + signature: 'async shapeDispatchLog(dispatch: CodeDispatchLog): Promise', + jsDoc: '/**\n * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch\n * and return the content the bridge should log on `tool/code-dispatch`.\n * Contained: a throwing listener falls back to the unshaped content — log\n * shaping must never fail the dispatch or lose the settle event.\n * @param dispatch - the sub-dispatch identity and its default logged content.\n * @returns the (possibly reshaped) content for the durable event.\n */', + }, { signature: 'async execute(exec: ToolExecutionInput): Promise', jsDoc: '/**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */', @@ -1222,6 +1226,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * A tool was registered or unregistered, or a scoped restriction changed\n * (the available tool set changed — possibly for one scope only). An\n * UNFILTERED registry-subject notification, deliberately not scope-filtered\n * dispatch: a global change concerns every agent\'s next assembly, so a\n * scoped listener subscribing here sees every change, not just its own\n * scope\'s.\n * @mode emit\n */', summary: 'A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only).', }, + { + name: 'tools/code-dispatch-log', + mode: 'waterfall', + signature: '\'tools/code-dispatch-log\'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise', + jsDoc: '/**\n * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before\n * the bridge appends its `tool/code-dispatch` event. `next()` keeps the\n * content unchanged; a listener may return replacement blocks (e.g. the\n * spill policy\'s preview + locator for an oversized text result). Only the\n * logged copy is affected — the program already received the complete\n * value, and the model sees neither. A throwing listener is contained:\n * the bridge falls back to logging the unshaped content.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s dispatches.\n * @param dispatch - the parent execution, sub-call identity, and the settled content to log.\n * @mode waterfall\n */', + summary: 'Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event.', + }, { name: 'tools/execute', mode: 'waterfall', @@ -1432,6 +1443,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CodeBindingNamespace', declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record;\n errorClass?: CodeBindingErrorClass;\n}', }, + { + name: 'CodeDispatchLog', + declaration: 'export interface CodeDispatchLog {\n readonly exec: ToolExecution;\n readonly agent?: Agent;\n readonly subCallId: CallId;\n readonly name: string;\n readonly isError: boolean;\n readonly content: ContentBlock[];\n}', + }, { name: 'CodeJsonValue', declaration: 'export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | {\n [key: string]: CodeJsonValue;\n};', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 0310306bfa..506b01f5ec 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -609,6 +609,11 @@ "symbol": "ToolExecutionMode", "source": "packages/core/tools/src/index.ts" }, + { + "doc": "docs/core-data-structures/tools.md", + "symbol": "CodeDispatchLog", + "source": "packages/core/tools/src/index.ts" + }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRunContext", @@ -1747,6 +1752,11 @@ "symbol": "ToolExecutionMode", "source": "packages/core/tools/src/index.ts" }, + { + "doc": "docs/core-data-structures/tools.zh.md", + "symbol": "CodeDispatchLog", + "source": "packages/core/tools/src/index.ts" + }, { "doc": "docs/core-data-structures/tools.zh.md", "symbol": "ToolRunContext",