feat(tools): live dispatch lifecycle + native-contract parallel sub-calls in Code Mode
The bridge replaces its serialization queue with a pool that reuses the native concurrency contract: submissions classify through registry.executionMode (fail-closed isConcurrencySafe), start strictly in submission order, overlap up to the validated maxParallelSubCalls config (default 10; 1 restores serial), and exclusive calls drain the pool, run alone, and bar later calls. Each started sub-call logs a tool/code-dispatch-start event at pool entry; the existing tool/code-dispatch settles the pair (started ⇔ settles exactly once; abandoned queued calls log neither). SDK prompt guidance now states the true Promise.all contract — re-recorded across every code/both-mode snapshot (plus the stale cordis-dynamic-toolchain fixture gaining the required description arg). Client: CodeSubCall widens to RunningToolCall | ToolResultNode — starts land the running shape (rows wear the native running ring), settles replace in place preserving start order, callTime pairs to the start time. Fixture emits start/settle pairs; jsdom pins the running sub-row; runtime specs pin in-place settlement and out-of-order completion.
This commit is contained in:
@@ -144,30 +144,22 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
data: { turn, step: 0, content: [{ type: 'tool-call', id: callId, name: 'run_code', arguments: args } as ContentBlock], provenance: { provider: 'fixture', model: 'fx-1' } },
|
||||
})
|
||||
push({ type: 'tool/call', data: { turn, step: 0, callId, name: 'run_code', arguments: args } })
|
||||
push({
|
||||
type: 'tool/code-dispatch',
|
||||
data: {
|
||||
parentCallId: callId, subCallId: `${callId}:code:1`, name: 'bash',
|
||||
arguments: { command: 'ls notes', description: 'List notes' },
|
||||
isError: false, content: [{ type: 'text', text: 'demo.txt\nnew-demo.txt' }],
|
||||
},
|
||||
})
|
||||
push({
|
||||
type: 'tool/code-dispatch',
|
||||
data: {
|
||||
parentCallId: callId, subCallId: `${callId}:code:2`, name: 'read',
|
||||
arguments: { path: 'notes/demo.txt' },
|
||||
isError: false, content: [{ type: 'text', text: 'hello fixture\n' }],
|
||||
},
|
||||
})
|
||||
push({
|
||||
type: 'tool/code-dispatch',
|
||||
data: {
|
||||
parentCallId: callId, subCallId: `${callId}:code:3`, name: 'read',
|
||||
arguments: { path: 'notes/missing.txt' },
|
||||
isError: true, content: [{ type: 'text', text: 'Error: ENOENT: notes/missing.txt not found' }],
|
||||
},
|
||||
})
|
||||
const dispatchPair = (n: number, name: string, dispatchArgs: Record<string, unknown>, resultText: string, isError = false): void => {
|
||||
push({
|
||||
type: 'tool/code-dispatch-start',
|
||||
data: { parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
|
||||
})
|
||||
push({
|
||||
type: 'tool/code-dispatch',
|
||||
data: {
|
||||
parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
|
||||
arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }],
|
||||
},
|
||||
})
|
||||
}
|
||||
dispatchPair(1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt\nnew-demo.txt')
|
||||
dispatchPair(2, 'read', { path: 'notes/demo.txt' }, 'hello fixture\n')
|
||||
dispatchPair(3, 'read', { path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true)
|
||||
push({
|
||||
type: 'tool/result', surfaceOp: 'append',
|
||||
data: { turn, step: 0, callId, content: text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), isError: false },
|
||||
|
||||
@@ -128,15 +128,19 @@ export type ConversationNode =
|
||||
| UnknownSurfaceNode
|
||||
|
||||
/**
|
||||
* One `run_code` sub-dispatch materialized as a {@link ToolResultNode} so every
|
||||
* consumer (tool rows, details panel) renders it through the exact components
|
||||
* that render a native settled call. Never part of the surface `nodes` flow —
|
||||
* sub-calls live under their parent via {@link ConversationSnapshot.codeDispatches}.
|
||||
* `callId` is the deterministic sub-call id (`<parent>:code:<n>`); `call`
|
||||
* carries the sub-tool name and its JSON-stringified logged arguments;
|
||||
* `content`/`isError` are the sub-call's complete logged outcome.
|
||||
* One `run_code` sub-dispatch materialized in the native call-block shapes so
|
||||
* every consumer (tool rows, details panel) renders it through the exact
|
||||
* components that render a native call: a started-but-unsettled sub-call is a
|
||||
* {@link RunningToolCall} (rows derive the running state from the shape,
|
||||
* exactly as for native calls) and its `tool/code-dispatch` settlement
|
||||
* replaces it in place with the {@link ToolResultNode} form. Never part of
|
||||
* the surface `nodes` flow — sub-calls live under their parent via
|
||||
* {@link ConversationSnapshot.codeDispatches}. `callId` is the deterministic
|
||||
* sub-call id (`<parent>:code:<n>`); the call side carries the sub-tool name
|
||||
* and its JSON-stringified logged arguments; `content`/`isError` are the
|
||||
* settled sub-call's complete logged outcome.
|
||||
*/
|
||||
export type CodeSubCall = ToolResultNode
|
||||
export type CodeSubCall = RunningToolCall | ToolResultNode
|
||||
|
||||
/** In-flight tool card material: tool/call seen, tool/result not yet. */
|
||||
export interface RunningToolCall {
|
||||
|
||||
@@ -616,14 +616,36 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
/** Per-event side effects (right column of the §A.9 dispatch table):
|
||||
* chunk accumulation / partial clear on finalize / openCalls add-remove. */
|
||||
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
|
||||
// `tool/code-dispatch` is declared by the host-side dsh-tools plugin whose
|
||||
// types cannot enter the client program (its host Context merges collide
|
||||
// with the client's), so this wire consumer narrows it structurally —
|
||||
// the same posture as every other cross-wire event payload.
|
||||
// The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by
|
||||
// the host-side dsh-tools plugin whose types cannot enter the client
|
||||
// program (its host Context merges collide with the client's), so this
|
||||
// wire consumer narrows them structurally — the same posture as every
|
||||
// other cross-wire event payload.
|
||||
if ((event.type as string) === 'tool/code-dispatch-start') {
|
||||
// A started sub-dispatch enters the index as a RunningToolCall — the
|
||||
// exact shape a native in-flight call renders from — under its parent
|
||||
// run_code callId; it never joins the surface flow.
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
name: string
|
||||
arguments: unknown
|
||||
}
|
||||
const running: CodeSubCall = {
|
||||
callId: data.subCallId, name: data.name,
|
||||
argsRaw: JSON.stringify(data.arguments),
|
||||
turn: 0, step: 0, time: event.time, callView: null,
|
||||
}
|
||||
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
|
||||
this.codeDispatches.set(data.parentCallId, [...siblings, running])
|
||||
this.dispatchesRev++
|
||||
return
|
||||
}
|
||||
if ((event.type as string) === 'tool/code-dispatch') {
|
||||
// A sub-dispatch becomes a ToolResultNode so rows and the details
|
||||
// panel reuse the native rendering path verbatim; it indexes under its
|
||||
// parent run_code callId and never joins the surface flow.
|
||||
// Settlement replaces the running entry in place (same array position,
|
||||
// so parallel sub-calls keep their start order) with the
|
||||
// ToolResultNode form; a settle with no observed start (history window
|
||||
// cut mid-pair, or a pre-start-event log) appends directly.
|
||||
const data = event.data as unknown as {
|
||||
parentCallId: string
|
||||
subCallId: string
|
||||
@@ -632,17 +654,22 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
isError: boolean
|
||||
content: ContentBlock[]
|
||||
}
|
||||
const parent = data.parentCallId
|
||||
const siblings = this.codeDispatches.get(parent) ?? []
|
||||
const sub: CodeSubCall = {
|
||||
const siblings = this.codeDispatches.get(data.parentCallId) ?? []
|
||||
const at = siblings.findIndex(sub => sub.callId === data.subCallId)
|
||||
const started = at === -1 ? undefined : siblings[at]
|
||||
const settled: CodeSubCall = {
|
||||
kind: 'tool-result', seq: event.seq, time: event.time,
|
||||
callId: data.subCallId,
|
||||
call: { name: data.name, argsRaw: JSON.stringify(data.arguments) },
|
||||
callTime: event.time,
|
||||
// Duration source: the paired start's time when observed.
|
||||
callTime: started === undefined ? event.time : started.time,
|
||||
content: data.content, isError: data.isError,
|
||||
callView: null, resultView: null,
|
||||
}
|
||||
this.codeDispatches.set(parent, [...siblings, sub])
|
||||
this.codeDispatches.set(
|
||||
data.parentCallId,
|
||||
at === -1 ? [...siblings, settled] : siblings.map((sub, index) => (index === at ? settled : sub)),
|
||||
)
|
||||
this.dispatchesRev++
|
||||
return
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@ export const ev = {
|
||||
at(seq, { type: 'tool/call', data: { turn, step, callId, name, arguments: args } }),
|
||||
toolResult: (seq: number, turn: number, callId: string, body: string, step = 0): SessionEvent =>
|
||||
at(seq, { type: 'tool/result', surfaceOp: 'append', data: { turn, step, callId, content: text(body), isError: false } }),
|
||||
codeDispatchStart: (seq: number, parentCallId: string, n: number, name: string, args: unknown): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'tool/code-dispatch-start',
|
||||
data: { parentCallId, subCallId: `${parentCallId}:code:${n}`, name, arguments: args },
|
||||
}),
|
||||
codeDispatch: (seq: number, parentCallId: string, n: number, name: string, args: unknown, body: string, isError = false): SessionEvent =>
|
||||
at(seq, {
|
||||
type: 'tool/code-dispatch',
|
||||
|
||||
@@ -645,6 +645,32 @@ describe('resync', () => {
|
||||
})
|
||||
|
||||
describe('run_code sub-dispatch indexing', () => {
|
||||
it('a start event lands as a running-shaped sub-call and its settle replaces it in place', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
await session.open()
|
||||
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
|
||||
feed(ev.turnStart(6, 1))
|
||||
feed(ev.toolCall(7, 1, 'p1', 'run_code', '{"code":"1","description":"d"}'))
|
||||
feed(ev.codeDispatchStart(8, 'p1', 1, 'bash', { command: 'sleep' }))
|
||||
feed(ev.codeDispatchStart(9, 'p1', 2, 'read', { path: 'a.txt' }))
|
||||
const live = session.getSnapshot().codeDispatches.get('p1')
|
||||
expect(live).toHaveLength(2)
|
||||
// Running shape (no 'kind'): the exact RunningToolCall form native rows use.
|
||||
expect(live?.[0]).toMatchObject({ callId: 'p1:code:1', name: 'bash', argsRaw: '{"command":"sleep"}' })
|
||||
expect(live?.[0] !== undefined && 'kind' in live[0]).toBe(false)
|
||||
// Settle out of order (parallel run): #2 first — replaces in place, keeping start order.
|
||||
feed(ev.codeDispatch(10, 'p1', 2, 'read', { path: 'a.txt' }, 'alpha'))
|
||||
const mixed = session.getSnapshot().codeDispatches.get('p1')
|
||||
expect(mixed?.map(sub => 'kind' in sub)).toEqual([false, true])
|
||||
expect(mixed?.[1]).toMatchObject({ callId: 'p1:code:2', content: [{ type: 'text', text: 'alpha' }] })
|
||||
// The settle carries the paired start's time as callTime (duration source).
|
||||
feed(ev.codeDispatch(11, 'p1', 1, 'bash', { command: 'sleep' }, 'done'))
|
||||
const settled = session.getSnapshot().codeDispatches.get('p1')
|
||||
expect(settled?.map(sub => 'kind' in sub)).toEqual([true, true])
|
||||
expect(settled?.[0]).toMatchObject({ callId: 'p1:code:1', callTime: 1_700_000_000_008 })
|
||||
})
|
||||
|
||||
it('indexes live tool/code-dispatch events under their parent as native-shaped result nodes', async () => {
|
||||
const { api, session } = makeSession()
|
||||
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
memo, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
|
||||
} from 'react'
|
||||
import type {
|
||||
ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
|
||||
CodeSubCall, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -46,18 +46,22 @@ type RenderToolRow = ChatViewSlotProps['renderSlot']
|
||||
type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
|
||||
|
||||
/** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a
|
||||
* top-level call (same registrations, same fallback), nested by the parent. */
|
||||
* top-level call (same registrations, same fallback), nested by the parent.
|
||||
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
|
||||
* renders the running state exactly as a native in-flight row. */
|
||||
const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected }: {
|
||||
renderSlot: RenderToolRow
|
||||
node: ToolResultNode
|
||||
node: CodeSubCall
|
||||
onOpenDetails: OpenDetails
|
||||
selected: boolean
|
||||
}) {
|
||||
const toolName = node.call?.name ?? ''
|
||||
const settled = 'kind' in node
|
||||
const toolName = settled ? node.call?.name ?? '' : node.name
|
||||
const seq = settled ? node.seq : node.time
|
||||
const owner = useMemo(() => ({
|
||||
callId: node.callId, toolName, block: node,
|
||||
openDetails: () => { onOpenDetails({ turnSeq: node.seq, callId: node.callId, toolName }) },
|
||||
}), [node, toolName, onOpenDetails])
|
||||
openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) },
|
||||
}), [node, toolName, seq, onOpenDetails])
|
||||
return (
|
||||
<div className={css.callRow} data-selected={selected || undefined}>
|
||||
{renderSlot('conversation.chat.toolview', owner, {
|
||||
@@ -82,8 +86,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
|
||||
seq: number
|
||||
onOpenDetails: OpenDetails
|
||||
selected: boolean
|
||||
/** `run_code` sub-dispatches in dispatch order (reference-stable per parent); undefined for ordinary calls. */
|
||||
subCalls?: readonly ToolResultNode[] | undefined
|
||||
/** `run_code` sub-dispatches in dispatch order (reference-stable per parent; running entries settle in place); undefined for ordinary calls. */
|
||||
subCalls?: readonly CodeSubCall[] | undefined
|
||||
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
|
||||
selectedCallId?: string | undefined
|
||||
}) {
|
||||
@@ -122,7 +126,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
|
||||
/** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */
|
||||
selectedCallId: string | undefined
|
||||
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
|
||||
codeDispatches: ReadonlyMap<string, readonly ToolResultNode[]>
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}) {
|
||||
return (
|
||||
<div className={css.toolGroup}>
|
||||
|
||||
@@ -31,13 +31,16 @@ function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | nu
|
||||
if (open !== undefined) {
|
||||
return { name: open.name, argsRaw: open.argsRaw, result: null, running: true }
|
||||
}
|
||||
// run_code sub-dispatches: already ToolResultNode-shaped, so a selected
|
||||
// sub-row resolves through the same material as a native settled call.
|
||||
// run_code sub-dispatches: the native call-block shapes, so a selected
|
||||
// sub-row resolves through the same material as a native call — the
|
||||
// settled ToolResultNode form, or the RunningToolCall form mid-flight.
|
||||
for (const subs of s.codeDispatches.values()) {
|
||||
for (const sub of subs) {
|
||||
if (sub.callId === callId) {
|
||||
if (sub.callId !== callId) continue
|
||||
if ('kind' in sub) {
|
||||
return { name: sub.call?.name ?? callId, argsRaw: sub.call?.argsRaw ?? null, result: sub, running: false }
|
||||
}
|
||||
return { name: sub.name, argsRaw: sub.argsRaw, result: null, running: true }
|
||||
}
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -199,6 +199,21 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
expect(nest!.querySelector('[data-sample="bash-global"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => {
|
||||
const parent = 'call-live'
|
||||
const runningSub: CodeSubCall = {
|
||||
callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}',
|
||||
turn: 0, step: 0, time: 21_000, callView: null,
|
||||
}
|
||||
const dispatches = new Map([[parent, [runningSub]]])
|
||||
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
|
||||
const view = mountApp(b.slots)
|
||||
// The nested row derives 'running' from the RunningToolCall shape — the
|
||||
// same StateDot ring a native in-flight row wears.
|
||||
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]')
|
||||
expect(nested).not.toBeNull()
|
||||
})
|
||||
|
||||
it('an ordinary tool row renders no sub-call nest', async () => {
|
||||
const parent = 'call-64'
|
||||
const plain: ToolResultNode = {
|
||||
|
||||
@@ -112,16 +112,16 @@ Returning `undefined` selects generic fallback. Presenters depend only on their
|
||||
|
||||
### Code Mode
|
||||
|
||||
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only the program's outer logs and return value re-enter model context. The SDK declares exact `ToolArgsMap` and `ToolOutputMap` entries for every visible tool, and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline sequentially with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
|
||||
Under `code` or `both`, the registry exposes the reserved `run_code` transport and a deterministic TypeScript SDK for the current scope; only the program's outer logs and return value re-enter model context. The SDK declares exact `ToolArgsMap` and `ToolOutputMap` entries for every visible tool, and each binding resolves to the tool's canonical JSON value. Each lossless-JSON binding call re-enters the complete tool pipeline under the native scheduling contract (concurrency-safe calls may overlap up to `maxParallelSubCalls`; exclusive calls run alone as ordering barriers) with logged correlation to the outer call. Denials and other failed results reject with the real program-visible `ToolCallError` carrying only `toolName` and `message`; Native content and internal error codes stay outside the Code contract. Ordinary side effects are not rolled back, and sub-call `additionalContexts` are deferred through the parent result to preserve call/result adjacency. Run settlement aborts and drains outstanding bindings; runtime failures surface as `CodeRunFailedError`. See the [Code Mode foundation](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md), [typed-return contract](../../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md), and [code-runtime seam](../../code-runtime/README.md). Try `pnpm run demo:code-mode`.
|
||||
|
||||
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, `JsonValue`, exact `ToolArgsMap` / `ToolOutputMap`, `ToolName`, the `ToolCallError` declaration, and a mapped `tools` namespace for the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) handles every unified schema construct and degrades unsupported raw constructs to `unknown`, never throwing during prompt assembly.
|
||||
- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>` and the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path); `deriveMessages()` does not surface that event or persist the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
|
||||
- **The dispatch bridge** (`run_code`'s execute): every binding call is snapshotted as lossless JSON before dispatch (`undefined`, `BigInt`, cycles, sparse arrays, `-0`, and exotic objects reject that one call), scheduled through a per-run pool that reuses the native concurrency contract — calls start strictly in submission order, consecutive `isConcurrencySafe` calls overlap up to the validated `maxParallelSubCalls` config (default 10; `1` restores serial dispatch), and an exclusive-classified call drains the pool, runs alone, and bars later calls — given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A success returns the final canonical value after policy; a failure reaches the worker as one message and becomes `ToolCallError(toolName, message)`. Each started sub-call logs a `tool/code-dispatch-start` event (deterministic id `<parent>:code:<n>`, numbered by submission) at pipeline entry and settles with one `tool/code-dispatch` event carrying the complete model-facing `content`/`isError` outcome (the `tool/result` vocabulary, so UIs render sub-calls through the native path — the pair's `time` fields carry per-sub-call timing); a queued call abandoned by run settlement logs neither. `deriveMessages()` surfaces neither event nor persists the canonical value. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. Every sub-call `additionalContexts` entry is deferred through the outer `ToolRunContext` in dispatch order; the loop appends those contexts only after the parent `run_code` result, preserving adjacency and retaining each source/meta even when the program later fails.
|
||||
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
|
||||
- **Result boundary**: intermediate binding values cross the worker boundary whole and have no per-binding byte cap. `run_code` returns canonical `{ logs: string[], result?: JsonValue }`; strings render raw, every other present JSON root renders through a stack-safe pretty JSON traversal whose total indentation is capped at ten characters (deeper subtrees stay compact), `null` remains explicit, and absent `result` means the program returned `undefined`. The worker's configurable `maxOutputBytes` (default 64 MiB) applies only to the combined serialized outer log-array, completion-value, or failure-message payloads; fixed result-envelope syntax and presentation whitespace are outside that ledger. Invalid and over-limit completions fail explicitly, and only this outer result is eligible for ordinary spill.
|
||||
|
||||
### Parallel execution
|
||||
|
||||
The agent loop groups consecutive `parallel` calls into a bounded rolling pool and treats each `exclusive` call as an ordering barrier. Only dispatch/body overlaps; policy, durable results, and context retain model order. Code Mode bindings remain serial. The [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the shipped declarations and rationale.
|
||||
The agent loop groups consecutive `parallel` calls into a bounded rolling pool and treats each `exclusive` call as an ordering barrier. Only dispatch/body overlaps; policy, durable results, and context retain model order. Code Mode bindings reuse the same classification through the bridge's own pool. The [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the shipped declarations and rationale.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -154,7 +154,7 @@ Pass `run_code` the body of an async TypeScript function (erasable syntax only
|
||||
|
||||
- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.
|
||||
- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.
|
||||
- Calls execute sequentially, even under `Promise.all`.
|
||||
- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.
|
||||
- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
|
||||
|
||||
The available tools:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Code Mode `run_code` transport. Programs call the registry's agent-visible
|
||||
* tools through nested, sequential executions; each sub-dispatch is logged for
|
||||
* reconstruction, while only the outer curated result enters model history.
|
||||
* tools through nested executions scheduled under the native concurrency
|
||||
* contract; each sub-dispatch is logged for reconstruction, while only the
|
||||
* outer curated result enters model history.
|
||||
* @module @deepseek-ai/dsh-tools/src/code-mode
|
||||
*/
|
||||
|
||||
@@ -16,18 +17,33 @@ import type { ToolDefinition, ToolRegistry } from './index.ts'
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* One bridged sub-dispatch from a `run_code` program: the parent
|
||||
* `run_code` call id, the deterministic sub-call id
|
||||
* (`<parent>:code:<n>`), the tool `name` with its JSON-normalized
|
||||
* `arguments` — the exact value dispatched, normalized BEFORE dispatch,
|
||||
* so this append can never fail on payload shape — and the sub-call's
|
||||
* complete model-facing outcome in `tool/result`'s own vocabulary
|
||||
* One sub-dispatch STARTING inside a `run_code` program: the parent
|
||||
* `run_code` call id, the deterministic sub-call id (`<parent>:code:<n>`,
|
||||
* numbered in submission order), and the tool `name` with its
|
||||
* JSON-normalized `arguments` — the exact value dispatched, normalized
|
||||
* BEFORE dispatch, so this append can never fail on payload shape.
|
||||
* Appended when the scheduler actually starts the call (not at
|
||||
* submission), so a start means the tool body pipeline was entered; a
|
||||
* call abandoned in the queue logs nothing. Log-only: `deriveMessages()`
|
||||
* ignores it; UIs use it for live per-sub-call running state and pair it
|
||||
* with `tool/code-dispatch` by `subCallId` (timing = the two events'
|
||||
* `time` fields).
|
||||
*/
|
||||
'tool/code-dispatch-start': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown }
|
||||
/**
|
||||
* One bridged sub-dispatch SETTLING: the pairing ids (matching the
|
||||
* `tool/code-dispatch-start` with the same `subCallId`), the tool `name`
|
||||
* with the same JSON-normalized `arguments`, and the sub-call's complete
|
||||
* model-facing outcome in `tool/result`'s own vocabulary
|
||||
* (`content` + `isError`), so UIs render a sub-call through the exact
|
||||
* code path that renders a native call.
|
||||
* code path that renders a native call. Every started sub-call settles
|
||||
* with exactly one of these (abort included: the aborted pipeline result
|
||||
* is an `isError` outcome).
|
||||
* Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter
|
||||
* model context; persistence and UIs get every call. Appended inside the
|
||||
* parent `run_code`'s execution (the bridge drains its queue before
|
||||
* returning), so the turn-enclosure invariant holds by construction.
|
||||
* parent `run_code`'s execution (the bridge drains in-flight dispatches
|
||||
* before returning), so the turn-enclosure invariant holds by
|
||||
* construction.
|
||||
*/
|
||||
'tool/code-dispatch': { parentCallId: CallId; subCallId: CallId; name: string; arguments: unknown; isError: boolean; content: ContentBlock[] }
|
||||
}
|
||||
@@ -178,9 +194,11 @@ type RunCodeOutput = { logs: string[]; result?: JsonValue }
|
||||
* bindings cover its registered tools).
|
||||
* @param requireRuntime - resolves `ctx.codeRuntime` or throws the loud
|
||||
* misconfiguration error (shared with the registry's assembly-time checks).
|
||||
* @param maxParallel - the run's overlap cap for parallel-classified
|
||||
* sub-calls (the registry passes its validated `maxParallelSubCalls`).
|
||||
* @returns the registry-ready definition.
|
||||
*/
|
||||
export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime): ToolDefinition {
|
||||
export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () => CodeRuntime, maxParallel: number): ToolDefinition {
|
||||
return defineTool({
|
||||
name: RUN_CODE_NAME,
|
||||
description:
|
||||
@@ -228,19 +246,49 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
exec.signal.addEventListener('abort', onOuterAbort, { once: true })
|
||||
|
||||
let dispatches = 0
|
||||
// The per-run serialization queue: every binding call chains onto the tail, so even
|
||||
// `Promise.all` executes the underlying tool calls one at a time in submission order (the
|
||||
// tool contract carries no concurrency-safety metadata yet).
|
||||
let queue: Promise<void> = Promise.resolve()
|
||||
const enqueue = <T>(task: () => Promise<T>): Promise<T> => {
|
||||
const turn = queue.then(() => {
|
||||
// The per-run scheduler, reusing the NATIVE concurrency contract
|
||||
// (isConcurrencySafe classification through registry.executionMode):
|
||||
// submitted calls start strictly in submission order; consecutive
|
||||
// parallel-classified calls overlap up to maxParallel; an
|
||||
// exclusive-classified call waits for the pool to drain, runs alone,
|
||||
// and bars later calls until it settles — exactly the loop scheduler's
|
||||
// group semantics, adapted to calls that arrive over time.
|
||||
interface PendingDispatch {
|
||||
run(): Promise<void>
|
||||
mode: 'parallel' | 'exclusive'
|
||||
abandon(): void
|
||||
}
|
||||
const pendingQueue: PendingDispatch[] = []
|
||||
const inFlight = new Set<Promise<void>>()
|
||||
let exclusiveActive = false
|
||||
const pump = (): void => {
|
||||
for (;;) {
|
||||
const head = pendingQueue[0]
|
||||
if (head === undefined) return
|
||||
if (runController.signal.aborted) {
|
||||
throw new Error(`run_code run is over (${String(runController.signal.reason)}); tool call abandoned`)
|
||||
pendingQueue.shift()
|
||||
head.abandon()
|
||||
continue
|
||||
}
|
||||
return task()
|
||||
})
|
||||
queue = turn.then(() => undefined, () => undefined)
|
||||
return turn
|
||||
if (exclusiveActive || inFlight.size >= (head.mode === 'exclusive' ? 1 : maxParallel)) return
|
||||
if (head.mode === 'exclusive') {
|
||||
if (inFlight.size > 0) return
|
||||
exclusiveActive = true
|
||||
}
|
||||
pendingQueue.shift()
|
||||
const flight = head.run().finally(() => {
|
||||
inFlight.delete(flight)
|
||||
if (head.mode === 'exclusive') exclusiveActive = false
|
||||
pump()
|
||||
})
|
||||
inFlight.add(flight)
|
||||
}
|
||||
}
|
||||
/** Every in-flight dispatch settled and nothing can start (the run is aborted at call time). */
|
||||
const drainDispatches = async (): Promise<void> => {
|
||||
// Abandon queued-unstarted tasks first, then await the live set until quiescent.
|
||||
pump()
|
||||
while (inFlight.size > 0) await Promise.allSettled([...inFlight])
|
||||
}
|
||||
|
||||
// Read through a call, not a bare property: the abort state genuinely
|
||||
@@ -253,36 +301,55 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
throw new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} not dispatched`)
|
||||
}
|
||||
const normalized = jsonNormalizeArgs(rawArgs)
|
||||
const outcome = await enqueue(async () => {
|
||||
const n = ++dispatches
|
||||
const subCallId = CallId(`${String(exec.callId)}:code:${n}`)
|
||||
const result = await registry.execute({
|
||||
callId: subCallId,
|
||||
name,
|
||||
arguments: normalized.dispatched,
|
||||
...exec.agent ? { agent: exec.agent } : {},
|
||||
parent: exec.token,
|
||||
signal: runController.signal,
|
||||
const n = ++dispatches
|
||||
const subCallId = CallId(`${String(exec.callId)}:code:${n}`)
|
||||
const input = {
|
||||
callId: subCallId,
|
||||
name,
|
||||
arguments: normalized.dispatched,
|
||||
...exec.agent ? { agent: exec.agent } : {},
|
||||
parent: exec.token,
|
||||
signal: runController.signal,
|
||||
}
|
||||
type DispatchOutcome = { isError: true; message: string } | { isError: false; value: JsonValue }
|
||||
const outcome = await new Promise<DispatchOutcome>((resolve, reject) => {
|
||||
pendingQueue.push({
|
||||
// Classified at submission against the same agent view the SDK
|
||||
// declared; fail-closed exclusive when undeclared/invalid.
|
||||
mode: registry.executionMode(input).kind,
|
||||
abandon: () => {
|
||||
reject(new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} tool call abandoned`))
|
||||
},
|
||||
run: async () => {
|
||||
exec.agent?.session.append('tool/code-dispatch-start', {
|
||||
parentCallId: exec.callId,
|
||||
subCallId,
|
||||
name,
|
||||
arguments: normalized.logged,
|
||||
})
|
||||
const result = await registry.execute(input)
|
||||
for (const context of result.additionalContexts ?? []) {
|
||||
exec.deferContext(context)
|
||||
}
|
||||
exec.agent?.session.append('tool/code-dispatch', {
|
||||
parentCallId: exec.callId,
|
||||
subCallId,
|
||||
name,
|
||||
// The SIBLING parse of the dispatched value: byte-identical JSON,
|
||||
// but a separate object — a tool mutating its args cannot desync
|
||||
// this record from what it actually received.
|
||||
arguments: normalized.logged,
|
||||
isError: result.isError,
|
||||
// The registry deep-froze this projection at result finalization;
|
||||
// append snapshots it again, so the log copy stays detached.
|
||||
content: result.content,
|
||||
})
|
||||
resolve(result.isError
|
||||
? { isError: true, message: result.error.message }
|
||||
: { isError: false, value: result.value })
|
||||
},
|
||||
})
|
||||
for (const context of result.additionalContexts ?? []) {
|
||||
exec.deferContext(context)
|
||||
}
|
||||
exec.agent?.session.append('tool/code-dispatch', {
|
||||
parentCallId: exec.callId,
|
||||
subCallId,
|
||||
name,
|
||||
// The SIBLING parse of the dispatched value: byte-identical JSON,
|
||||
// but a separate object — a tool mutating its args cannot desync
|
||||
// this record from what it actually received.
|
||||
arguments: normalized.logged,
|
||||
isError: result.isError,
|
||||
// The registry deep-froze this projection at result finalization;
|
||||
// append snapshots it again, so the log copy stays detached.
|
||||
content: result.content,
|
||||
})
|
||||
return result.isError
|
||||
? { isError: true as const, message: result.error.message }
|
||||
: { isError: false as const, value: result.value }
|
||||
pump()
|
||||
})
|
||||
// A budget expiry or outer cancel that lands while this call was in
|
||||
// flight already aborted the dispatch; stop the program now rather
|
||||
@@ -325,10 +392,11 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
signal: runController.signal,
|
||||
})
|
||||
} finally {
|
||||
// Abort sub-dispatches and drain the folded queue before closing the turn.
|
||||
// Abort sub-dispatches and drain every in-flight dispatch before
|
||||
// closing the turn (queued-unstarted ones are abandoned unlogged).
|
||||
// Binding failures remain observable through their individual promises.
|
||||
runController.abort('run_code settled')
|
||||
await queue
|
||||
await drainDispatches()
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
|
||||
@@ -534,6 +534,14 @@ export interface Config {
|
||||
* absent or mismatched. Under `code`, native names in `toolOrder` are invalid.
|
||||
*/
|
||||
mode?: ToolPresentationMode
|
||||
/**
|
||||
* Concurrency cap for a `run_code` program's overlapping sub-calls
|
||||
* (default 10, the loop scheduler's own default). Sub-calls follow the
|
||||
* native scheduling contract — only calls whose tools classify
|
||||
* concurrency-safe overlap; exclusive calls form barriers — so `1`
|
||||
* restores strictly serial dispatch. Must be a positive integer.
|
||||
*/
|
||||
maxParallelSubCalls?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -636,6 +644,7 @@ export class ToolRegistry extends Service {
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
mode: z.union(['native', 'code', 'both'] as const).default('native'),
|
||||
maxParallelSubCalls: z.natural().min(1).default(10),
|
||||
})
|
||||
|
||||
/** Internal staged view consumed by `dsh-agent-loop`'s parallel scheduler. */
|
||||
@@ -672,7 +681,7 @@ export class ToolRegistry extends Service {
|
||||
// the filterable global/scoped capability layers.
|
||||
this.codeTransport = this.mode === 'native'
|
||||
? undefined
|
||||
: createRunCodeTool(this, () => this.requireCodeRuntime())
|
||||
: createRunCodeTool(this, () => this.requireCodeRuntime(), config.maxParallelSubCalls ?? 10)
|
||||
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
|
||||
if (this.mode !== 'native') {
|
||||
ctx.systemPrompt.section({
|
||||
|
||||
@@ -253,7 +253,7 @@ Pass \`run_code\` the body of an async TypeScript function (erasable syntax only
|
||||
|
||||
- Call tools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.
|
||||
- A FAILED tool call rejects with \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose \`message\` is human-readable — \`try/catch\` it to handle and continue.
|
||||
- Calls execute sequentially, even under \`Promise.all\`.
|
||||
- Independent read-only calls MAY overlap under \`Promise.all\` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with \`await\`.
|
||||
- Emit results with \`return\` and/or \`console.log(...)\`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.
|
||||
|
||||
The available tools:`
|
||||
|
||||
@@ -42,6 +42,7 @@ class FakeRuntime extends CodeRuntime {
|
||||
|
||||
interface SetupOptions {
|
||||
mode?: Config['mode']
|
||||
maxParallelSubCalls?: number
|
||||
runtime?: false | { language?: string }
|
||||
toolOrder?: string[]
|
||||
}
|
||||
@@ -49,7 +50,7 @@ interface SetupOptions {
|
||||
async function setup(options: SetupOptions = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, { ...options.toolOrder ? { toolOrder: options.toolOrder } : {} })
|
||||
await ctx.plugin(ToolRegistry, { mode: options.mode ?? 'code' })
|
||||
await ctx.plugin(ToolRegistry, { mode: options.mode ?? 'code', ...options.maxParallelSubCalls !== undefined ? { maxParallelSubCalls: options.maxParallelSubCalls } : {} })
|
||||
let runtime: FakeRuntime | undefined
|
||||
if (options.runtime !== false) {
|
||||
await ctx.plugin(FakeRuntime, options.runtime ?? {})
|
||||
@@ -358,6 +359,155 @@ describe('mode-aware wire contribution', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the sub-dispatch scheduler (native concurrency contract)', () => {
|
||||
/** Register a tool whose calls resolve only when the test releases them; returns live-call telemetry. */
|
||||
function registerGated(ctx: Context, name: string, concurrencySafe: boolean) {
|
||||
const gates: (() => void)[] = []
|
||||
let live = 0
|
||||
let peak = 0
|
||||
const order: string[] = []
|
||||
ctx.tools.register(defineTool({
|
||||
name,
|
||||
description: `Gated tool ${name}.`,
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value }],
|
||||
},
|
||||
...concurrencySafe ? { isConcurrencySafe: () => true } : {},
|
||||
async execute(args, exec) {
|
||||
order.push(`start:${args.id}`)
|
||||
live++
|
||||
peak = Math.max(peak, live)
|
||||
// Abort-observing like a real tool: the run-scoped abort releases the
|
||||
// gate so the bridge's drain reaches quiescence.
|
||||
await new Promise<void>((release) => {
|
||||
gates.push(release)
|
||||
exec.signal.addEventListener('abort', () => { release() }, { once: true })
|
||||
})
|
||||
live--
|
||||
order.push(`end:${args.id}`)
|
||||
return `${name}:${args.id}`
|
||||
},
|
||||
}))
|
||||
const release = (): void => { gates.shift()?.() }
|
||||
const releaseAll = (): void => { while (gates.length > 0) gates.shift()!() }
|
||||
return { order, release, releaseAll, peakLive: () => peak, pending: () => gates.length }
|
||||
}
|
||||
|
||||
it('overlaps concurrency-safe calls under Promise.all and logs a start event per dispatch', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const gated = registerGated(ctx, 'safe_read', true)
|
||||
const { agent, events } = fakeAgent()
|
||||
runtime.behavior = async (request) => {
|
||||
const tools = request.bindings[0]!.functions
|
||||
const all = Promise.all([
|
||||
tools.safe_read!({ id: 'a' }),
|
||||
tools.safe_read!({ id: 'b' }),
|
||||
tools.safe_read!({ id: 'c' }),
|
||||
])
|
||||
// All three must be START-able without any completion (overlap proof).
|
||||
await expect.poll(() => gated.pending()).toBe(3)
|
||||
gated.releaseAll()
|
||||
return { logs: [], value: (await all).map(String).join(',') }
|
||||
}
|
||||
const result = await runCode(ctx, 'program', { agent })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(gated.peakLive()).toBe(3)
|
||||
if (result.isError) throw new Error('expected success')
|
||||
expect(result.value).toMatchObject({ result: 'safe_read:a,safe_read:b,safe_read:c' })
|
||||
// One start per dispatch, paired with its settle by subCallId, starts in submission order.
|
||||
const starts = events.filter(event => event.type === 'tool/code-dispatch-start').map(event => event.data as { subCallId: string })
|
||||
const settles = events.filter(event => event.type === 'tool/code-dispatch').map(event => event.data as { subCallId: string })
|
||||
expect(starts.map(start => start.subCallId)).toEqual(['call-1:code:1', 'call-1:code:2', 'call-1:code:3'])
|
||||
expect(new Set(settles.map(settle => settle.subCallId))).toEqual(new Set(starts.map(start => start.subCallId)))
|
||||
})
|
||||
|
||||
it('an exclusive call bars overlap: safe calls drain first, it runs alone, later calls wait', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const safe = registerGated(ctx, 'safe_read', true)
|
||||
const unsafe = registerGated(ctx, 'writer', false)
|
||||
runtime.behavior = async (request) => {
|
||||
const tools = request.bindings[0]!.functions
|
||||
const reads = [tools.safe_read!({ id: 'r1' }), tools.safe_read!({ id: 'r2' })]
|
||||
const write = tools.writer!({ id: 'w' })
|
||||
const tail = tools.safe_read!({ id: 'r3' })
|
||||
await expect.poll(() => safe.pending()).toBe(2)
|
||||
// The exclusive call must NOT have started while the pool is live.
|
||||
expect(unsafe.pending()).toBe(0)
|
||||
safe.releaseAll()
|
||||
await expect.poll(() => unsafe.pending()).toBe(1)
|
||||
// The trailing safe call must NOT start while the exclusive one runs.
|
||||
expect(safe.pending()).toBe(0)
|
||||
unsafe.release()
|
||||
await expect.poll(() => safe.pending()).toBe(1)
|
||||
safe.releaseAll()
|
||||
await Promise.all([...reads, write, tail])
|
||||
return { logs: [], value: 'ordered' }
|
||||
}
|
||||
const result = await runCode(ctx, 'program')
|
||||
expect(result.isError).toBe(false)
|
||||
expect(safe.order.slice(0, 2)).toEqual(['start:r1', 'start:r2'])
|
||||
expect(unsafe.order).toEqual(['start:w', 'end:w'])
|
||||
// r3 started only after w ended.
|
||||
expect(safe.order.indexOf('start:r3')).toBeGreaterThan(safe.order.indexOf('end:r1'))
|
||||
})
|
||||
|
||||
it('maxParallelSubCalls caps the overlap window', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code', maxParallelSubCalls: 2 })
|
||||
const gated = registerGated(ctx, 'safe_read', true)
|
||||
runtime.behavior = async (request) => {
|
||||
const tools = request.bindings[0]!.functions
|
||||
const all = Promise.all([
|
||||
tools.safe_read!({ id: 'a' }),
|
||||
tools.safe_read!({ id: 'b' }),
|
||||
tools.safe_read!({ id: 'c' }),
|
||||
])
|
||||
await expect.poll(() => gated.pending()).toBe(2)
|
||||
// The third call waits for a slot.
|
||||
expect(gated.pending()).toBe(2)
|
||||
gated.release()
|
||||
await expect.poll(() => gated.pending()).toBe(2)
|
||||
gated.releaseAll()
|
||||
await all
|
||||
return { logs: [], value: 'capped' }
|
||||
}
|
||||
const result = await runCode(ctx, 'program')
|
||||
expect(result.isError).toBe(false)
|
||||
expect(gated.peakLive()).toBe(2)
|
||||
})
|
||||
|
||||
it('a queued-unstarted call abandoned by run settlement logs no start event', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const gated = registerGated(ctx, 'writer', false)
|
||||
const { agent, events } = fakeAgent()
|
||||
const abandoned: string[] = []
|
||||
runtime.behavior = async (request) => {
|
||||
const tools = request.bindings[0]!.functions
|
||||
// First exclusive call occupies the pool; the second queues unstarted.
|
||||
// Both rejections are captured (abandonment fires only at settlement,
|
||||
// AFTER this program has already failed — awaiting it here would deadlock).
|
||||
tools.writer!({ id: 'w1' }).catch(() => 'settled-under-abort')
|
||||
tools.writer!({ id: 'w2' }).catch((error: unknown) => {
|
||||
abandoned.push(error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
await expect.poll(() => gated.pending()).toBe(1)
|
||||
// Fail the program while w1 is in flight and w2 is queued unstarted.
|
||||
throw new Error('program failed with a queued call')
|
||||
}
|
||||
const result = await runCode(ctx, 'program', { agent })
|
||||
expect(result.isError).toBe(true)
|
||||
const starts = events.filter(event => event.type === 'tool/code-dispatch-start').map(event => (event.data as { subCallId: string }).subCallId)
|
||||
const settles = events.filter(event => event.type === 'tool/code-dispatch').map(event => (event.data as { subCallId: string }).subCallId)
|
||||
// w1 started and settled under the abort; w2 never started and never
|
||||
// settled — no start event, no settle event, binding rejected with the
|
||||
// abandonment message at drain time.
|
||||
expect(starts).toEqual(['call-1:code:1'])
|
||||
expect(settles).toEqual(['call-1:code:1'])
|
||||
expect(abandoned).toEqual(['run_code run is over (run_code settled); writer tool call abandoned'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('the run_code dispatch bridge', () => {
|
||||
it('bridges tool calls, returns only the curated output, and logs one event per dispatch', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('renderToolsSdk', () => {
|
||||
// The fixed instruction lines the model relies on.
|
||||
expect(text).toContain('erasable syntax only')
|
||||
expect(text).toContain('rejects with `ToolCallError`')
|
||||
expect(text).toContain('sequentially, even under `Promise.all`')
|
||||
expect(text).toContain('MAY overlap under `Promise.all`')
|
||||
expect(text).toContain('lossless JSON')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user