feat(agent-loop): run safe tool calls in parallel

This commit is contained in:
Dudu-0223
2026-07-13 11:02:21 +08:00
parent 4cda9dd03d
commit 7ea1bf119f
48 changed files with 1542 additions and 141 deletions
+15
View File
@@ -0,0 +1,15 @@
/**
* Loop-level tunable defaults shared between the plugin entry (`index.ts`) and
* the tool-call scheduler (`tool-calls.ts`). Kept in a leaf module so importing
* a default never pulls in the service class or the scheduler.
*
* @module dsh-agent-loop/constants
*/
/**
* Default cap on simultaneously in-flight tool calls within one assistant step,
* when {@link AgentOptions.maxParallelToolCalls} is unset. Matches the
* rolling-pool size Claude Code uses; a group larger than the cap is not
* truncated — the cap limits concurrency, not the group.
*/
export const DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10
+36
View File
@@ -29,6 +29,22 @@ declare module 'cordis' {
}
}
declare module '@deepseek-ai/dsh-agent' {
interface AgentOptions {
/**
* Maximum tool calls this agent runs concurrently within one assistant step
* (a positive integer; defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}).
* The loop's rolling pool starts up to this many parallel-safe calls at once
* and replenishes as each settles; `1` preserves the fully serial path.
* A merge-extensible field — the loop owns it (it neither the agent nor the
* subagent seam sets it), read in `runStep` when scheduling a parallel group.
*/
maxParallelToolCalls?: number
}
}
export { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
/**
* Plugin config: the agents to create — or resume, via `resumeSessionId` —
* declaratively at startup, so a cordis.yml deployment needs no code.
@@ -40,6 +56,11 @@ export interface Config {
id: AgentId
/** Optional workspace cwd for the config-created fresh session. */
cwd?: string
/**
* Maximum parallel-safe tool calls to run concurrently within one assistant
* step. Must be a positive integer; `1` preserves serial execution.
*/
maxParallelToolCalls?: number
/**
* If set, the config agent RESUMES this persisted session id instead of
* starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
@@ -81,6 +102,9 @@ export class AgentLoop extends Service implements AgentFactory {
model: z.string(),
cwd: z.string(),
resumeSessionId: z.string(),
// A positive integer; a bad value (0, negative, fractional) fails config
// validation here rather than being silently dropped from cordis.yml.
maxParallelToolCalls: z.number().step(1).min(1),
})).default([]),
}) as unknown as z<Config>
@@ -144,6 +168,7 @@ export class AgentLoop extends Service implements AgentFactory {
* @returns the running agent, owned by the calling fiber (no handle).
*/
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
this.validateAgentOptions(options)
this.assertAgentIdFree(id)
// Config/programmatic path: prepare the session and let start() fold its
// lifecycle into the agent's composite effect (so a fiber unload tears the
@@ -168,6 +193,7 @@ export class AgentLoop extends Service implements AgentFactory {
* @returns the handle whose dispose tears down exactly this agent.
*/
createAgent(options: CreateAgentOptions): AgentHandle {
this.validateAgentOptions(options.agentOptions ?? {})
// Check the agent id BEFORE preparing the session: register() would reject a
// duplicate id only AFTER the session enters the store, leaving an orphaned
// live session (and lazy persistence state) that blocks reuse of that id.
@@ -196,6 +222,7 @@ export class AgentLoop extends Service implements AgentFactory {
* @returns the handle for the agent resumed on the reconstructed session.
*/
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
this.validateAgentOptions(options.agentOptions ?? {})
// Read the service through `ctx.get('sessionPersistence')` — a direct
// global-store lookup keyed by the isolate symbol — NOT
// `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject
@@ -228,6 +255,7 @@ export class AgentLoop extends Service implements AgentFactory {
* AgentLoop's static inject, so they resolve fine).
*/
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
this.validateAgentOptions(options.agentOptions ?? {})
this.assertAgentIdFree(options.agentId)
const { meta, events } = await persistence.load(options.resumeSessionId)
// Re-check the agent id AFTER the await: the pre-load check above can go
@@ -267,6 +295,14 @@ export class AgentLoop extends Service implements AgentFactory {
}
}
/** Validate merge-extended options the loop owns before any session is prepared or loaded. */
private validateAgentOptions(options: AgentOptions): void {
const { maxParallelToolCalls } = options
if (maxParallelToolCalls !== undefined && (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1)) {
throw new Error('maxParallelToolCalls must be a positive integer')
}
}
/**
* Shared: construct a ReactLoopAgent over a PREPARED (not-yet-entered)
* session, then build the ONE composite effect that owns the whole agent
+19 -59
View File
@@ -10,7 +10,7 @@
import type { Context } from 'cordis'
import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
import type { ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { ContinuationDecision, PromptDecision } from '@deepseek-ai/dsh-agent'
import { canonicalHeader } from '@deepseek-ai/dsh-session'
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
@@ -18,6 +18,7 @@ import type { TransmissionLog } from './request-log.ts'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import { executeToolCalls } from './tool-calls.ts'
import type { ReactLoopAgent } from './agent.ts'
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
@@ -172,11 +173,12 @@ export interface LoopHandle {
* session('assistant/chunk')
* msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
* session('assistant/message' {content, usage?}) session records what actually ran
* each tool-call in msg (sequential, abort-checked):
* session('tool/call'); ctx.tools.execute() ⟵ tools/pre-execute (allow/deny/ask)
* → dispatch → tools/post-execute
* session('tool/result')
* append buffered post-execute additionalContext → session('context/message')(s)
* schedule tool-calls in msg by ctx.tools.executionMode (exclusive = barrier;
* consecutive parallel-safe = one rolling-pool group, ≤ maxParallelToolCalls in flight):
* each STARTED call: session('tool/call'); tools/pre-execute (MODEL order)
* → tools/execute dispatch/body (parallel pool) → tools/post-execute (MODEL order)
* session('tool/result') committed in MODEL order (slot-buffered)
* append buffered post-execute additionalContext (model order) → session('context/message')(s)
* drain steering → session('steering/message')
* session('step/end') ⟵ durable step boundary (no agent/* mirror)
* cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default
@@ -864,68 +866,26 @@ async function runStep(
)
}
// --- Tool execution (sequential; parallel execution is a TODO) ---
// ToolRegistry.execute converts tool failures (including aborts) into
// isError results, so abort is re-checked around every call here.
// --- Tool execution (scheduled by per-call concurrency safety) ---
// executeToolCalls groups the step's calls by ctx.tools.executionMode and runs
// parallel-safe runs through a rolling pool. Only dispatch/body overlaps:
// tools/pre-execute and tools/post-execute run in model order, tool/result is
// committed in model order, and the returned additionalContext buffer is
// ordered the same way. Tool failures (including aborts) become isError
// results; the scheduler re-checks the shared signal around calls and throws
// the abort so this step's caller ends the turn.
const toolCalls = message.content.filter(block => block.type === 'tool-call')
// Per-step buffer of `additionalContext` attached by tools/post-execute
// listeners. Appended as context/message(s) only AFTER every tool/result for
// the step, so a multi-call step keeps tool-call/result adjacency
// (interleaving context between a call's result and the next call's would
// break the pairing the next model request relies on).
const pendingContext: HookContext[] = []
for (const call of toolCalls) {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
let parsedArguments: unknown
try {
parsedArguments = call.arguments ? JSON.parse(call.arguments) : {}
} catch {
parsedArguments = call.arguments
}
// TODO(pre-tool-input-rewrite): tools/pre-execute deliberately cannot rewrite
// `arguments` — tool/call (the audit record) and assistant/message (the
// model-history source) are logged BEFORE execute, and live consumers (ACP,
// tool-bash presentation) read the pre-execution args, so an execution-only
// rewrite would desync the UI from what ran. Designing that consistently is
// its own proposed RFC (docs/rfc/proposed/feature/…-pre-tool-input-rewrite.md).
const result = await ctx.tools.execute({
callId: call.id,
name: call.name,
arguments: parsedArguments,
agent,
signal,
})
session.append('tool/result', {
turn, step,
// The correlation id MUST be the loop's authoritative call.id (the
// model-transcript id that deriveMessages turns into toolCallId), NOT
// result.callId — a post-execute waterfall listener returning a
// mismatched id would otherwise orphan the call↔result pairing in the
// next model request. A listener-internal id, if ever needed, belongs in
// a separate diagnostic field, never overloaded onto callId.
callId: call.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: [callEvent.seq] })
// Buffer (don't append yet) any post-execute additionalContext for this call.
if (result.additionalContext) pendingContext.push(result.additionalContext)
// signal CAN flip during the await above (abort() inside a tool);
// the analyzer can't see through the await boundary.
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
/* v8 ignore stop */
}
const pendingContext = await executeToolCalls(ctx, agent, turn, step, toolCalls, signal)
// Append buffered post-execute context AFTER every tool/result, preserving
// tool-call/result adjacency across the whole batch. inject() appends into the
// open turn (a context/message at its chronological position).
// open turn (a context/message at its chronological position). The scheduler
// returns the buffer in model call order.
for (const context of pendingContext) {
agent.inject(context.content, { source: context.source })
}
+326
View File
@@ -0,0 +1,326 @@
/**
* The agent loop's per-step tool-call scheduler. `runStep` (loop.ts) hands it
* the assistant message's `tool-call` blocks; this module parses each call's
* arguments once, classifies it via `ctx.tools.executionMode`, partitions the
* calls into ordered groups (one exclusive call, or a run of consecutive
* parallel-safe calls), and executes each group — a parallel group through a
* rolling pool bounded by the agent's `maxParallelToolCalls`.
*
* The session log stays the source of truth and is reconstructable regardless
* of dispatch timing: each STARTED call appends its own `tool/call` before its
* body runs, `tool/result` events are appended in MODEL order (slot-buffered
* behind a commit cursor), and buffered `additionalContext` is injected in model
* call order after every result. A `tool/call`'s log position may interleave
* with a sibling's `tool/result` as the pool replenishes; that is safe because
* `tool/call` is log-only and derived history pairs the assistant message's
* `tool-call` blocks with the ordered `tool/result`s by `callId`.
*
* @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_REGISTRY_SCHEDULER, type ToolExecution, type ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ReactLoopAgent } from './agent.ts'
import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
/** One tool call after argument parsing, ready to schedule. */
interface PlannedCall {
/** The model-transcript call (authoritative `id`/`name`/raw `arguments`). */
block: ToolCallBlock
/** The distinct per-call execution object handed to the tool pipeline. */
exec: ToolExecution
}
/** A settled call's slot, filled in model order before ordered finalization. */
interface Slot {
/** The raw dispatch/pre result. */
result: ToolExecutionResult
/** Whether the result still needs ordered `tools/post-execute` finalization. */
needsPost: boolean
}
/**
* Execute one assistant step's tool calls, honoring per-call concurrency safety.
*
* Appends `tool/call` (per started call) and `tool/result` (in model order) to
* the session, and returns the ordered `additionalContext` buffer for the loop
* to inject after the batch. On abort it drains only already-started calls to
* results, drops buffered context, and throws the abort error so `runTurn` owns
* the turn-end reason.
*
* @param ctx - the loop context (reaches `ctx.tools`).
* @param agent - the agent being driven (owns the session, options, and is
* passed to each `ToolExecution`).
* @param turn - the current turn number (for the session events).
* @param step - the current step number (for the session events).
* @param toolCalls - the assistant message's `tool-call` blocks, in model order.
* @param signal - the step's abort signal (shared by every call).
* @returns the per-step `additionalContext` buffer in model call order.
*/
export async function executeToolCalls(
ctx: Context,
agent: ReactLoopAgent,
turn: number,
step: number,
toolCalls: ToolCallBlock[],
signal: AbortSignal,
): Promise<HookContext[]> {
const { session, options } = agent
const maxParallel = options.maxParallelToolCalls ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
// Plan: parse each call's raw JSON arguments exactly once, and build one
// distinct ToolExecution per call so a `tools/execute` wrapper that mutates
// `exec` in place (e.g. replacing exec.signal with a per-call deadline) cannot
// race through a shared payload.
const planned: PlannedCall[] = toolCalls.map(block => ({
block,
exec: {
callId: block.id,
name: block.name,
arguments: parseArguments(block.arguments),
agent,
signal,
},
}))
// Partition into ordered groups: an exclusive call is its own group (a
// barrier), a run of consecutive parallel-safe calls is one group. Grouping
// uses executionMode so an exclusive tool between two reads splits them into
// separate ordered groups (no read/write race inside one assistant step).
const groups = groupByMode(ctx, planned)
const pendingContext: HookContext[] = []
for (const group of groups) {
// Groups are never empty (groupByMode only pushes non-empty runs/singletons).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- non-empty group
const first = group[0]!
if (group.length === 1 && ctx.tools.executionMode(first.exec).kind === 'exclusive') {
await runExclusive(ctx, session, turn, step, first, signal, pendingContext)
} else {
await runParallelGroup(ctx, session, turn, step, group, signal, maxParallel, pendingContext)
}
}
return pendingContext
}
/** Parse a model-produced raw arguments string, falling back to the raw string on invalid JSON (empty ⇒ `{}`). */
function parseArguments(raw: string): unknown {
try {
return raw ? JSON.parse(raw) : {}
} catch {
return raw
}
}
/**
* Group planned calls into ordered runs: each exclusive call is a singleton
* group; consecutive parallel-safe calls coalesce into one group. `executionMode`
* is queried once per call here and again by the caller to pick the exclusive
* fast-path — both reads are pure and cheap.
*/
function groupByMode(ctx: Context, planned: PlannedCall[]): PlannedCall[][] {
const groups: PlannedCall[][] = []
let run: PlannedCall[] = []
const flush = (): void => {
if (run.length > 0) {
groups.push(run)
run = []
}
}
for (const call of planned) {
if (ctx.tools.executionMode(call.exec).kind === 'parallel') {
run.push(call)
} else {
flush()
groups.push([call])
}
}
flush()
return groups
}
/**
* The exclusive single-call path keeps the public one-call pipeline sequential:
* abort-check, `tool/call`, pre/dispatch/post via `ctx.tools.execute`,
* `tool/result`, buffer context, post-await abort-check.
*/
async function runExclusive(
ctx: Context,
session: Session,
turn: number,
step: number,
call: PlannedCall,
signal: AbortSignal,
pendingContext: HookContext[],
): Promise<void> {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
const callSeq = appendToolCall(session, turn, step, call.block)
const result = await ctx.tools.execute(call.exec)
appendToolResult(session, turn, step, call.block, result, callSeq)
if (result.additionalContext) pendingContext.push(result.additionalContext)
// signal CAN flip during the await above (abort() inside a tool); the analyzer
// can't see through the await boundary.
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
/* v8 ignore stop */
}
/**
* The rolling-pool path for a group of parallel-safe calls. Starts calls in
* model order up to `maxParallel`, and whenever one settles starts the next
* unstarted call until the group is exhausted. Settled dispatches land in
* model-order slots; a commit cursor appends `tool/result` (and collects
* `additionalContext`) only while the next slot is ready, so the log stays
* model-ordered regardless of completion order.
*
* Abort: an already-aborted signal starts nothing and throws before any
* `tool/call`. An abort mid-group stops replenishment, awaits only the started
* calls, commits their results in order, drops buffered context, and throws.
*/
async function runParallelGroup(
ctx: Context,
session: Session,
turn: number,
step: number,
group: PlannedCall[],
signal: AbortSignal,
maxParallel: number,
pendingContext: HookContext[],
): Promise<void> {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
const slots: (Slot | undefined)[] = group.map(() => undefined)
// callSeqs[i] is the `tool/call` event seq for started slot i (its provenance
// for the matching tool/result). A slot is only committed after it is started,
// so its callSeq is always set by then.
const callSeqs: number[] = group.map(() => -1)
let nextToStart = 0
let committed = 0
let started = 0
let aborted: boolean = signal.aborted
// Advance the commit cursor over contiguous settled slots: run post-execute in
// model order, append each tool/result, and collect its additionalContext.
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
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
? await ctx.tools[TOOL_REGISTRY_SCHEDULER].finalize(call!.exec, slot.result)
: slot.result
// committed < group.length, so call and its callSeq (set at start) exist.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded index
appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!)
if (result.additionalContext) pendingContext.push(result.additionalContext)
committed++
}
}
const inFlight = new Map<number, Promise<number>>()
const startCall = async (index: number): Promise<void> => {
// index is always < group.length (bounded by every caller).
// 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(call.exec).then((result) => {
slots[index] = { result, needsPost: true }
return index
})
inFlight.set(index, promise)
break
}
case 'post-result':
slots[index] = { result: prepared.result, needsPost: true }
break
case 'final-result':
slots[index] = { 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) {
await startCall(nextToStart)
nextToStart++
await commitReady()
// The signal CAN flip while an ordered pre-execute listener is running.
if (signal.aborted) aborted = true
}
}
// Prime the pool up to the cap. Ordered pre-execute listeners may be async;
// dispatch/body is the only stage that overlaps across in-flight calls.
await fillPool()
while (inFlight.size > 0) {
const settledIndex = await Promise.race(inFlight.values())
inFlight.delete(settledIndex)
// Commit every contiguous settled slot now available.
await commitReady()
// The signal CAN flip during the await above (abort() inside a tool); the
// analyzer can't see through the await boundary. An abort stops the pool
// from starting any further calls, but already-started calls still drain.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (signal.aborted) aborted = true
await fillPool()
}
if (aborted) {
// Every started call has settled and committed in order; buffered context
// from this aborted step is dropped (not injected). Raise the abort so the
// existing runTurn catch owns turn/end reason selection. Unstarted calls
// beyond the cap never appended a tool/call.
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
throw new Error(String(signal.reason ?? 'aborted'))
}
// A defensive check the started count matches what we committed — a parallel
// group with no abort commits every started slot, and started === group.length.
/* v8 ignore next -- unreachable: a non-aborted group starts and commits all calls */
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
}
/** Append the `tool/call` audit event for one started call; returns its seq (the tool/result's provenance). */
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 one call's `tool/result`, keyed by the authoritative model-transcript call id and provenanced to its `tool/call`. */
function appendToolResult(
session: Session,
turn: number,
step: number,
block: ToolCallBlock,
result: ToolExecutionResult,
callSeq: number,
): void {
session.append('tool/result', {
turn, step,
// The correlation id MUST be the loop's authoritative call.id (the
// model-transcript id deriveMessages turns into toolCallId), NOT
// result.callId — a post-execute listener returning a mismatched id would
// otherwise orphan the call↔result pairing in the next model request.
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] })
}