/** * 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 or an internal scheduler failure stops replenishment * and drains started calls. * * Abort records synthetic error results for skipped calls so replay stays * valid. A terminal scheduler failure preserves already-recorded `tool/call` * events without fabricating results. * @module dsh-agent-loop/tool-calls */ import type { Context } from '@deepseek-ai/cordis' import { assertNever, createToolResultMessage, type ToolCallBlock } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } 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 /** Whether any committed result carried {@link ToolExecutionResult.concludesTurn}. */ concluded: boolean } /** * Schedule one assistant step's tool calls by their live concurrency mode. * Ordinary completion and abort commit started-call results in order. Abort * drains them, records synthetic results for unstarted calls, and returns with * the signal still aborted after accepting started-call context through the * caller-supplied acceptor (the machine stages it in its next-step inbox for the * step boundary). An internal scheduler failure stops new dispatches, drains * already-started dispatches, and rejects with the first failure without * fabricating tool results. * 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 acceptContext - accepts committed result context for the next step boundary. */ export async function executeToolCalls( ctx: Context, turn: number, step: number, toolCalls: ToolCallBlock[], signal: AbortSignal, acceptContext: (context: UserMessage) => void, ): Promise<{ concluded: boolean }> { 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 let concluded = false while (next < planned.length) { // Commit before classifying again so registry changes affect unstarted calls. // oxlint-disable-next-line typescript/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, acceptContext, ) next += outcome.consumed concluded ||= outcome.concluded if (outcome.aborted) { for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block) return { concluded } } } return { concluded } } /** 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. Scheduler failure drains dispatches without * committing synthetic recovery results. */ async function runGroup( ctx: Context, turn: number, step: number, group: PlannedCall[], mode: ToolExecutionMode['kind'], signal: AbortSignal, acceptContext: (context: UserMessage) => void, ): Promise { const { session } = ctx.agents.requireInitiator() const { maxParallelToolCalls } = ctx.agentLoop.config const slots: (Slot | undefined)[] = group.map(() => undefined) // Started slots retain their `tool/call` seq so the result can cite it. const callSeqs: number[] = group.map(() => -1) let nextToStart = 0 let committed = 0 let started = 0 let aborted: boolean = signal.aborted let concluded = false let schedulerFailure: { error: unknown } | undefined const throwSchedulerFailure = (): void => { if (schedulerFailure !== undefined) throw schedulerFailure.error } // `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) // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded index appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!) for (const context of result.additionalContexts ?? []) acceptContext(context) concluded ||= result.concludesTurn === true committed++ } } const inFlight = new Map>() const startCall = async (index: number): Promise => { // oxlint-disable-next-line typescript/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) throwSchedulerFailure() 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 }, (error: unknown) => { schedulerFailure ??= { error } 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 < maxParallelToolCalls) { // Re-read later modes after ordered commits so registry changes can create a barrier. // oxlint-disable-next-line typescript/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++ throwSchedulerFailure() await commitReady() throwSchedulerFailure() // Abort may arrive while pre-execute awaits. if (signal.aborted) aborted = true } } // Ordered pre-execute may await; only dispatch/body overlaps. A scheduler // failure stops new dispatches and reaches the turn boundary after every // already-started dispatch settles. try { await fillPool() while (inFlight.size > 0) { const settledIndex = await Promise.race(inFlight.values()) inFlight.delete(settledIndex) throwSchedulerFailure() await commitReady() throwSchedulerFailure() // Abort may arrive while a tool or ordered commit awaits. if (signal.aborted) aborted = true await fillPool() } } catch (error: unknown) { schedulerFailure ??= { error } await Promise.allSettled(inFlight.values()) throw schedulerFailure.error } 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, concluded } } /* 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, concluded } } /** 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: { message: 'tool call aborted before dispatch', info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, }, }, callSeq) } /** Append a started call and return the event seq that its result must cite. */ 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 { const message = createToolResultMessage({ callId: block.id, content: result.content, isError: result.isError, }) session.append('tool/result', { turn, step, message, ...result.error?.info ? { error: result.error.info } : {}, // 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] }) }