/** * Schedules one assistant step's tool calls. Exclusive calls form barriers; * parallel calls use a bounded rolling pool and are reclassified before start. * Dispatch may overlap, while policy, results, and result context remain * model-ordered. Abort stops replenishment and drains started calls. * * Each advertised call records a balanced `tool/call`/`tool/result` pair. Calls * skipped after abort receive synthetic error results so replay stays valid. * @module dsh-agent-loop/tool-calls */ import type { Context } from 'cordis' import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm' import type { HookContext } from '@deepseek-ai/dsh-agent' import type { Session } from '@deepseek-ai/dsh-session' import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools' /** One tool call after argument parsing, ready to schedule. */ interface PlannedCall { block: ToolCallBlock exec: ToolExecutionInput } /** Settled dispatch awaiting model-order finalization. */ interface Slot { exec: ToolRunContext result: ToolExecutionResult needsPost: boolean } /** One scheduler group outcome, including a drained cancellation. */ interface GroupOutcome { consumed: number aborted: boolean } /** * Schedule one assistant step's tool calls by their live concurrency mode. * Started calls receive ordered results. Abort drains them, records synthetic * results for unstarted calls, and returns with the signal still aborted after * accepting started-call context into the batch FIFO owned by the caller. * The committed step's AgentLoop driver boundary supplies the initiating Agent * that becomes each explicit {@link ToolExecutionInput.agent}. * * @param ctx - loop context that owns the tool registry and carries the initiating Agent. * @param turn - current turn number. * @param step - current step number. * @param toolCalls - assistant calls in model order. * @param signal - abort signal shared by the step. * @param maxParallel - validated in-flight cap. * @param acceptContext - accepts committed result context into the active batch. */ export async function executeToolCalls( ctx: Context, turn: number, step: number, toolCalls: ToolCallBlock[], signal: AbortSignal, maxParallel: number, acceptContext: (context: HookContext) => void, ): Promise { const agent = ctx.agents.requireInitiator() const { session } = agent // Inputs are distinct because tools/execute wrappers may replace `exec.signal`. const planned: PlannedCall[] = toolCalls.map(block => ({ block, exec: { callId: block.id, name: block.name, arguments: parseArguments(block.arguments), agent, signal, }, })) let next = 0 while (next < planned.length) { // Commit before classifying again so registry changes affect unstarted calls. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition const first = planned[next]! const mode = ctx.tools.executionMode(first.exec).kind const group = mode === 'parallel' ? planned.slice(next) : [first] const outcome = await runGroup( ctx, turn, step, group, mode, signal, maxParallel, acceptContext, ) next += outcome.consumed if (outcome.aborted) { for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block) return } } } /** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */ function parseArguments(raw: string): unknown { try { return raw ? JSON.parse(raw) : {} } catch { return raw } } /** * Run one exclusive barrier or parallel pool. Later calls are reclassified * before start; an exclusive reclassification waits for the current pool to * drain and remains for the caller's next barrier. Results and contexts commit * in model order. Abort stops starts, drains and commits started calls, accepts * their contexts into the owning batch, records results for skipped calls, and * returns an aborted outcome. */ async function runGroup( ctx: Context, turn: number, step: number, group: PlannedCall[], mode: ToolExecutionMode['kind'], signal: AbortSignal, maxParallel: number, acceptContext: (context: HookContext) => void, ): Promise { const { session } = ctx.agents.requireInitiator() const slots: (Slot | undefined)[] = group.map(() => undefined) // Started slots retain their tool/call seq for result provenance. const callSeqs: number[] = group.map(() => -1) let nextToStart = 0 let committed = 0 let started = 0 let aborted: boolean = signal.aborted // `committed` advances only across contiguous model-order slots. const commitReady = async (): Promise => { while (committed < group.length) { const slot = slots[committed] if (slot === undefined) break const call = group[committed] const result = slot.needsPost ? await ctx.tools[TOOL_REGISTRY_SCHEDULER].finalize(slot.exec, slot.result) : ctx.tools[TOOL_REGISTRY_SCHEDULER].finish(slot.exec, slot.result) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!) for (const context of result.additionalContexts ?? []) acceptContext(context) committed++ } } const inFlight = new Map>() const startCall = async (index: number): Promise => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index const call = group[index]! callSeqs[index] = appendToolCall(session, turn, step, call.block) started++ const prepared = await ctx.tools[TOOL_REGISTRY_SCHEDULER].prepare(call.exec) switch (prepared.kind) { case 'dispatch': { const promise = ctx.tools[TOOL_REGISTRY_SCHEDULER].dispatch(prepared.exec).then((outcome) => { slots[index] = { exec: prepared.exec, result: outcome.result, needsPost: outcome.kind === 'post-result' } return index }) inFlight.set(index, promise) break } case 'post-result': slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: true } break case 'final-result': slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: false } break /* v8 ignore next -- closed-union exhaustiveness guard */ default: assertNever(prepared, 'tool-call scheduler prepare result') } } const fillPool = async (): Promise => { while (!aborted && nextToStart < group.length && inFlight.size < maxParallel) { // Re-read later modes after ordered commits so registry changes can create a barrier. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition const nextCall = group[nextToStart]! if (nextToStart > 0 && mode === 'parallel' && ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break await startCall(nextToStart) nextToStart++ await commitReady() // Abort may arrive while pre-execute awaits. if (signal.aborted) aborted = true } } // Ordered pre-execute may await; only dispatch/body overlaps. // TODO: Drain every started call before rethrowing a scheduler error; tool // bodies must not outlive the failed turn. await fillPool() while (inFlight.size > 0) { const settledIndex = await Promise.race(inFlight.values()) inFlight.delete(settledIndex) await commitReady() // Abort may arrive while a tool or ordered commit awaits. if (signal.aborted) aborted = true await fillPool() } if (aborted) { // Started calls and accepted context settle first; every remaining model // call then receives an ordered synthetic result before the turn aborts. for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block) return { consumed: group.length, aborted: true } } /* v8 ignore next -- unreachable: a non-aborted group commits every started call */ if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls') return { consumed: started, aborted: false } } /** Append the durable call/result pair for a model call skipped after cancellation. */ function appendSkippedToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): void { const callSeq = appendToolCall(session, turn, step, block) appendToolResult(session, turn, step, block, { content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], isError: true, error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, }, callSeq) } /** Append a started call and return its provenance sequence. */ function appendToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): number { const event = session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments }) return event.seq } /** Append a model-ordered result linked to its call event. */ function appendToolResult( session: Session, turn: number, step: number, block: ToolCallBlock, result: ToolExecutionResult, callSeq: number, ): void { session.append('tool/result', { turn, step, // Correlation stays with the loop's authoritative model-transcript call id; // registry results deliberately do not duplicate it. callId: block.id, content: result.content, isError: result.isError, ...result.error ? { error: result.error } : {}, // The tool's private presentation payload (e.g. a result-time diff), // persisted so a UI bridge reproduces the card on replay. ...result.meta !== undefined ? { meta: result.meta } : {}, }, { surfaceOp: 'append', sourceEventSeqs: [callSeq] }) }