mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
# Conflicts: # .agents/notes/implemented/feature/2026-06-30-interception-seams.md # docs/config-catalog.md # docs/cookbook/adding-a-tool.i18n.yaml # docs/cookbook/adding-a-tool.md # docs/cookbook/adding-a-tool.zh.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/tools.md # docs/event-producer-consumer.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/bash/tool-bash/src/index.ts # packages/core/agent-loop/src/tool-calls.ts # packages/core/agent-loop/tests/cancel.spec.ts # packages/core/agent-loop/tests/contract-regressions.spec.ts # packages/core/agent-loop/tests/tool-calls.spec.ts # packages/core/tools/README.md # packages/core/tools/src/index.ts # packages/core/tools/tests/code-mode.spec.ts # packages/core/tools/tests/tools.spec.ts # packages/fs/tool-fs-search/tests/integration.spec.ts # packages/fs/tool-fs-search/tests/tools.spec.ts # packages/fs/tool-fs/tests/integration.spec.ts # packages/mcp/mcp-client/src/tools.ts # packages/timeout/timeout-policy/tests/timeout-policy.spec.ts # packages/web/tool-web/tests/integration.spec.ts # packages/web/tool-web/tests/tool-web.spec.ts
257 lines
9.9 KiB
TypeScript
257 lines
9.9 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
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<GroupOutcome> {
|
|
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<void> => {
|
|
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<number, Promise<number>>()
|
|
|
|
const startCall = async (index: number): Promise<void> => {
|
|
// 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<void> => {
|
|
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: {
|
|
message: 'tool call aborted before dispatch',
|
|
info: { 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?.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] })
|
|
}
|