diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9a586fa495..dbee6b350f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -76,6 +76,11 @@ Source: [`packages/ui/acp-agent/src/index.ts:31`](../packages/ui/acp-agent/src/i export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ agents?: AgentLoopConfig['agents'] + /** + * The factory-wide default concurrent tool-call cap applied to every agent + * this bundle creates (see dsh-agent-loop's `Config.maxParallelToolCalls`). + */ + maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ @@ -108,6 +113,16 @@ Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` ```ts config-catalog /** Plugin configuration for declarative startup agents. */ export interface Config { + /** + * Default concurrent tool-call cap applied to every agent this factory + * creates (declarative startup agents and factory callers such as the ACP, + * stdio, and SDK front doors that go through `create`/`createAgent`/`resume`). + * A positive integer; a per-agent `maxParallelToolCalls` overrides it, and an + * agent with neither falls back to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}. + * This is the single `cordis.yml` knob that reaches agents whose front door + * does not expose its own cap field. + */ + maxParallelToolCalls?: number /** Agents created or resumed at plugin startup. */ agents: (AgentOptions & { /** Registry identity for the live agent. */ @@ -964,7 +979,7 @@ export interface Config { export type ToolPresentationMode = 'native' | 'code' | 'both' ``` -Source: [`packages/core/tools/src/index.ts:391`](../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:389`](../packages/core/tools/src/index.ts) ## `@deepseek-ai/dsh-user-approval` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 864a89dbfa..830184e804 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:364`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:374`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -257,7 +257,7 @@ async execute(exec: ToolExecutionInput): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:447`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:445`](../../packages/core/tools/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 9a9a1706fc..42c11a84f0 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -347,7 +347,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `AgentId` is branded. `AgentOptions` is merge-extensible and currently includes `model?`. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. +`AgentStatus` is `'idle' | 'running' | 'disposed'`, and `AgentId` is branded. `AgentOptions` is merge-extensible and currently includes `model?` and `maxParallelToolCalls?` (the loop's per-agent concurrent tool-call cap; the owning field, defaulted by `AgentLoop.Config` and, absent that, by `DEFAULT_MAX_PARALLEL_TOOL_CALLS`). Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default. The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits. diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 078e10ac29..5d4973ffe8 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -30,20 +30,18 @@ interface ToolDefinition extends ToolSchema { * * It may inspect the parsed `args` (`unknown` — a hand-rolled definition * receives the raw parsed value; `defineTool` schema-validates first and - * returns `false` on invalid args, so an eventual `ToolArgsError` is produced - * only if the tool actually executes). The check performs no I/O and receives - * no live `Agent` or mutable `ToolExecution`. + * returns `false` on invalid args). The check performs no I/O and receives no + * live `Agent` or mutable `ToolExecution`. * - * Declaring `true` is a contract: the tool body must NOT mutate the parent - * agent's session or other parent-owned async state during `execute` (no - * `exec.agent.session.append(...)`, no `agent.inject(...)`). Its only parent- + * Declaring `true` is a contract: during `execute` the tool body must NOT + * mutate the parent agent's session or other parent-owned async state (no + * `exec.agent.session.append(...)`, no `agent.inject(...)`); its only parent- * step outputs are the returned content, `meta`, structured error, and - * `additionalContext` carried through the loop's ordered post-execute path. - * The narrow exception is a synchronous, side-effect-only recorder whose - * updates are commutative OR fail closed for concurrent calls by the same - * session (the `fs/observed` version recorder is the worked example: its - * WeakMap record is last-writer-wins, and a stale observation only makes a - * later write/edit fail closed at its in-lock version CAS). + * `additionalContext` on the loop's ordered post-execute path. A synchronous, + * side-effect-only recorder whose updates are commutative or fail closed for + * concurrent same-session calls is the one exception (`fs/observed` is the + * worked example). Full contract and rationale: the parallel-tool-call RFC + * (docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). */ isConcurrencySafe?(args: unknown): boolean /** diff --git a/docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md index c8cd96622a..23e0f0d22e 100644 --- a/docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md +++ b/docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md @@ -45,11 +45,11 @@ A parallel-safe declaration is a contract. The tool body must not mutate the par The loop waits for the model stream to finish and logs one authoritative `assistant/message` before scheduling tools. Streaming tool execution is out of scope. -For each assistant step, `packages/core/agent-loop/src/tool-calls.ts` parses each call's raw JSON arguments exactly once, creates one distinct `ToolExecution` object per call, asks `ctx.tools.executionMode(exec)`, and partitions calls into ordered groups. A group is either one exclusive call or a run of consecutive parallel calls. `loop.ts` calls the helper so the turn/step lifecycle remains readable. +For each assistant step, `packages/core/agent-loop/src/tool-calls.ts` parses each call's raw JSON arguments exactly once, creates one distinct `ToolExecution` object per call, asks `ctx.tools.executionMode(exec)`, and partitions calls into ordered groups. A group is either one exclusive call or a run of consecutive parallel calls; grouping classifies each call exactly once. `loop.ts` calls the helper so the turn/step lifecycle remains readable. -Parallelism is per agent. `AgentOptions.maxParallelToolCalls` is a positive integer, defaults to `DEFAULT_MAX_PARALLEL_TOOL_CALLS` (`10`), and is accepted through the config-created agent path. Setting it to `1` preserves serial execution for that agent. Both the TypeScript `AgentOptions` vocabulary and the `AgentLoop.Config` schemastery object validate the cap, so invalid `cordis.yml` values fail during config validation. +Parallelism is per agent. `AgentOptions.maxParallelToolCalls` is a positive integer, defaults to `DEFAULT_MAX_PARALLEL_TOOL_CALLS` (`10`), and reaches an agent three ways in precedence order: the per-agent option, the factory-wide `AgentLoop.Config.maxParallelToolCalls` applied to every agent the loop creates (declarative startup agents and factory callers such as the ACP, stdio, and SDK front doors), then the built-in default. The factory default is the single `cordis.yml` knob for agents whose front door exposes no cap field of its own. Setting the value to `1` preserves serial execution for that agent. The TypeScript `AgentOptions` vocabulary and both `AgentLoop.Config` fields validate the cap, so invalid `cordis.yml` values fail during config validation. -Within a parallel group, execution uses a rolling pool: start calls in model order up to `maxParallelToolCalls`, and whenever one call settles, start the next unstarted call until the group is exhausted. A group larger than the cap is not truncated; the cap limits simultaneous in-flight calls only. +Every group runs through the same rolling pool: start calls in model order up to `maxParallelToolCalls`, and whenever one call settles, start the next unstarted call until the group is exhausted. An exclusive group is a pool of one — a barrier — so the loop needs no separate serial path. A group larger than the cap is not truncated; the cap limits simultaneous in-flight calls only. Only the dispatch/body stage runs concurrently. Generic middleware that can shape ordering-sensitive state remains ordered: `tools/pre-execute` and `tools/post-execute` run in model call order. `@deepseek-ai/dsh-tools` exposes the symbol-keyed internal `TOOL_REGISTRY_SCHEDULER` view so `dsh-agent-loop` can split prepare, dispatch, and finalize without adding named staged service methods to `ctx.tools`; ordinary callers still use the one-call `execute(exec)` API. `tools/execute` around-dispatch listeners run with the dispatch they wrap, so wrappers must be reentrant across distinct `ToolExecution` objects. The shipped timeout policy is per-call: every call owns its mutable `exec` and deadline. diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md index fac4f4819a..594c4a8c82 100644 --- a/packages/core/agent-core/README.md +++ b/packages/core/agent-core/README.md @@ -39,11 +39,11 @@ This is the [interface/implementation/consumer seam](../../../docs/rfc/implement ```ts import type { Config } from '@deepseek-ai/dsh-agent-core' -// { agents?, persona?, toolOrder?, tools?, skills? } — the schema intersects the owner schemas, -// so validation and defaulting can never drift from the owners. +// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, skills? } — the schema +// intersects the owner schemas, so validation and defaulting can never drift from the owners. ``` -The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. +The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `maxParallelToolCalls` to `agent-loop` as the factory-wide default concurrent tool-call cap for every agent it creates; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. ## Why a code bundle, not a shared YAML include diff --git a/packages/core/agent-core/src/index.ts b/packages/core/agent-core/src/index.ts index 14ffbd6133..fcb4e87f1e 100644 --- a/packages/core/agent-core/src/index.ts +++ b/packages/core/agent-core/src/index.ts @@ -46,6 +46,11 @@ export interface SkillConfig { export interface Config { /** The agent-loop `agents` list (see dsh-agent-loop's `Config`). */ agents?: AgentLoopConfig['agents'] + /** + * The factory-wide default concurrent tool-call cap applied to every agent + * this bundle creates (see dsh-agent-loop's `Config.maxParallelToolCalls`). + */ + maxParallelToolCalls?: AgentLoopConfig['maxParallelToolCalls'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ @@ -95,5 +100,8 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(invariants) ctx.plugin(toolBash) ctx.plugin(toolSkill, config.skills?.tool ?? {}) - ctx.plugin(AgentLoop, { agents: config.agents ?? [] }) + ctx.plugin(AgentLoop, { + agents: config.agents ?? [], + ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {}, + }) } diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index 0bf0660364..fe240da8ed 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -117,6 +117,16 @@ describe('dsh-agent-core bundle', () => { await ctx.fiber.dispose() }) + it('forwards the factory-wide maxParallelToolCalls default to agents without a per-agent cap', async () => { + const ctx = await mount({ + agents: [{ id: AgentId('main'), model: 'mock' }], + maxParallelToolCalls: 3, + }) + const main = ctx.get('agents')?.get(AgentId('main')) + expect(main?.options.maxParallelToolCalls).toBe(3) + await ctx.fiber.dispose() + }) + it('tolerates a schema-bypassing direct apply (the ?? fallbacks fire)', async () => { // ctx.plugin validates + defaults the bundle config first; a direct apply // skips the schema, so the forwarding `?? []` / `?? ''` are what fire. diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 17447a0b1d..86df80cd8e 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -344,6 +344,16 @@ export { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' /** Plugin configuration for declarative startup agents. */ export interface Config { + /** + * Default concurrent tool-call cap applied to every agent this factory + * creates (declarative startup agents and factory callers such as the ACP, + * stdio, and SDK front doors that go through `create`/`createAgent`/`resume`). + * A positive integer; a per-agent `maxParallelToolCalls` overrides it, and an + * agent with neither falls back to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}. + * This is the single `cordis.yml` knob that reaches agents whose front door + * does not expose its own cap field. + */ + maxParallelToolCalls?: number /** Agents created or resumed at plugin startup. */ agents: (AgentOptions & { /** Registry identity for the live agent. */ @@ -366,6 +376,9 @@ export class AgentLoop extends Service implements AgentFactory { /** Runtime schema for declarative agents. */ static Config = z.object({ + // The factory-wide default cap; a per-agent value overrides it. A positive + // integer, validated here so a bad cordis.yml value fails at load. + maxParallelToolCalls: z.number().step(1).min(1), agents: z.array(z.object({ id: z.string().required(), model: z.string(), @@ -410,6 +423,22 @@ export class AgentLoop extends Service implements AgentFactory { } } + /** + * Merge the factory-wide default cap into one agent's options. A per-agent + * `maxParallelToolCalls` wins; otherwise the `Config.maxParallelToolCalls` + * default applies, reaching factory callers (ACP/stdio/SDK front doors) whose + * own config does not set a cap. Absent both, the loop falls back to + * {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS} at schedule time. + * @param options - the caller-supplied agent options. + * @returns options with the default cap applied when the caller omitted one. + */ + private withFactoryDefaults(options: AgentOptions): AgentOptions { + if (options.maxParallelToolCalls !== undefined || this.config.maxParallelToolCalls === undefined) { + return options + } + return { ...options, maxParallelToolCalls: this.config.maxParallelToolCalls } + } + /** * Create an agent on a fresh per-run session, owned by the accessing fiber. * Constructor-driven config calls use the loop fiber itself. @@ -419,13 +448,14 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the published running agent. */ create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent { - validateAgentOptions(options) + const resolved = this.withFactoryDefaults(options) + validateAgentOptions(resolved) const loopCtx = this.runtime.ctx const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id) try { const sessionId = SessionId(`${id}-session-${randomUUID()}`) const session = loopCtx.sessions.prepare(sessionId, { meta }) - const agent = transaction.prepare(options, session) + const agent = transaction.prepare(resolved, session) transaction.publish('startup') return agent } catch (error: unknown) { @@ -443,7 +473,8 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the published handle. */ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise { - validateAgentOptions(options.agentOptions ?? {}) + const agentOptions = this.withFactoryDefaults(options.agentOptions ?? {}) + validateAgentOptions(agentOptions) const transaction = new AgentCreationTransaction( this.runtime.ctx, ownerCtx, @@ -456,7 +487,7 @@ export class AgentLoop extends Service implements AgentFactory { ...options.seed === undefined ? {} : { seed: options.seed }, ...options.meta === undefined ? {} : { meta: options.meta }, }) - const agent = transaction.prepare(options.agentOptions ?? {}, session) + const agent = transaction.prepare(agentOptions, session) await transaction.waitFor(options.setup?.(agent.ctx)) transaction.assertActive() return transaction.publish('startup') @@ -475,7 +506,6 @@ export class AgentLoop extends Service implements AgentFactory { * @returns the published handle. */ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise { - validateAgentOptions(options.agentOptions ?? {}) const persistence = this.runtime.ctx.get('sessionPersistence') if (persistence === undefined) { throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)') @@ -489,6 +519,8 @@ export class AgentLoop extends Service implements AgentFactory { persistence: SessionPersistence, options: ResumeAgentOptions, ): Promise { + const agentOptions = this.withFactoryDefaults(options.agentOptions ?? {}) + validateAgentOptions(agentOptions) const transaction = new AgentCreationTransaction( this.runtime.ctx, ownerCtx, @@ -508,7 +540,7 @@ export class AgentLoop extends Service implements AgentFactory { ...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength }, }, }) - const agent = transaction.prepare(options.agentOptions ?? {}, session) + const agent = transaction.prepare(agentOptions, session) await transaction.waitFor(options.setup?.(agent.ctx)) transaction.assertActive() return transaction.publish('resume') diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index 6ee1dc5906..d117823a43 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -3,8 +3,9 @@ * 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`. + * parallel-safe calls), and runs every group through the same rolling pool + * bounded by the agent's `maxParallelToolCalls` — an exclusive group is a pool + * of one. * * 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 @@ -95,16 +96,13 @@ export async function executeToolCalls( // separate ordered groups (no read/write race inside one assistant step). const groups = groupByMode(ctx, planned) + // Every group runs through the same rolling pool: an exclusive call is a + // singleton group (pool of one, a barrier), a parallel-safe run is one group + // bounded by the cap. `groupByMode` already classified each call, so the loop + // does not re-query `executionMode` here. 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) - } + await runGroup(ctx, session, turn, step, group, signal, maxParallel, pendingContext) } return pendingContext } @@ -135,8 +133,8 @@ function parseArguments(raw: string): unknown { /** * 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. + * is the sole classification point — the caller runs every group through the + * rolling pool without re-querying it. The read is pure and cheap. */ function groupByMode(ctx: Context, planned: PlannedCall[]): PlannedCall[][] { const groups: PlannedCall[][] = [] @@ -167,46 +165,19 @@ function assertMaxParallelToolCalls(maxParallel: number): void { } /** - * 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 { - /* 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. + * The rolling-pool path for one ordered group. A singleton exclusive group runs + * as a pool of one (a barrier); a parallel-safe run 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( +async function runGroup( ctx: Context, session: Session, turn: number, diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 987b358720..a270ad6da1 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -274,6 +274,48 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => gated.release('2') await waitForIdle(ctx, agent) }) + + it('applies the factory-wide Config default to agents that set no per-agent cap', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('done'), + ]) + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + // Factory default of 1 (no per-agent cap set below) must serialize. + await ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 1 }) + ctx.llm.registerAdapter(['mock'], adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + expect(agent.options.maxParallelToolCalls).toBe(1) + + agent.send([{ type: 'text', text: 'go' }]) + await until(() => gated.started.length === 1) + await new Promise(r => setTimeout(r, 5)) + expect(gated.started).toEqual(['1']) + gated.release('1') + await until(() => gated.started.length === 2) + gated.release('2') + await waitForIdle(ctx, agent) + }) + + it('lets a per-agent cap override the factory-wide Config default', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 1 }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([])) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 4 }) + expect(agent.options.maxParallelToolCalls).toBe(4) + }) }) describe('tool-call scheduler: ordered middleware and additionalContext', () => { diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 3575554ac1..4f1741ab39 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -142,20 +142,18 @@ export interface ToolDefinition extends ToolSchema { * * It may inspect the parsed `args` (`unknown` — a hand-rolled definition * receives the raw parsed value; `defineTool` schema-validates first and - * returns `false` on invalid args, so an eventual `ToolArgsError` is produced - * only if the tool actually executes). The check performs no I/O and receives - * no live `Agent` or mutable `ToolExecution`. + * returns `false` on invalid args). The check performs no I/O and receives no + * live `Agent` or mutable `ToolExecution`. * - * Declaring `true` is a contract: the tool body must NOT mutate the parent - * agent's session or other parent-owned async state during `execute` (no - * `exec.agent.session.append(...)`, no `agent.inject(...)`). Its only parent- + * Declaring `true` is a contract: during `execute` the tool body must NOT + * mutate the parent agent's session or other parent-owned async state (no + * `exec.agent.session.append(...)`, no `agent.inject(...)`); its only parent- * step outputs are the returned content, `meta`, structured error, and - * `additionalContext` carried through the loop's ordered post-execute path. - * The narrow exception is a synchronous, side-effect-only recorder whose - * updates are commutative OR fail closed for concurrent calls by the same - * session (the `fs/observed` version recorder is the worked example: its - * WeakMap record is last-writer-wins, and a stale observation only makes a - * later write/edit fail closed at its in-lock version CAS). + * `additionalContext` on the loop's ordered post-execute path. A synchronous, + * side-effect-only recorder whose updates are commutative or fail closed for + * concurrent same-session calls is the one exception (`fs/observed` is the + * worked example). Full contract and rationale: the parallel-tool-call RFC + * (docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md). */ isConcurrencySafe?(args: unknown): boolean /**