diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index a187283701..ae6f44d4dc 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -144,6 +144,13 @@ function groupByMode(ctx: Context, planned: PlannedCall[]): PlannedCall[][] { return groups } +/** Validate the live per-agent cap at the point it controls dispatch. */ +function assertMaxParallelToolCalls(maxParallel: number): void { + if (!Number.isInteger(maxParallel) || maxParallel < 1) { + throw new Error('maxParallelToolCalls must be a positive integer') + } +} + /** * The exclusive single-call path keeps the public one-call pipeline sequential: * abort-check, `tool/call`, pre/dispatch/post via `ctx.tools.execute`, @@ -196,6 +203,7 @@ async function runParallelGroup( ): Promise { /* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */ if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted')) + assertMaxParallelToolCalls(maxParallel) const slots: (Slot | undefined)[] = group.map(() => undefined) // callSeqs[i] is the `tool/call` event seq for started slot i (its provenance diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index d664946bc4..cd4e202b9b 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -202,6 +202,27 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => })).rejects.toThrow('maxParallelToolCalls must be a positive integer') }) + it('fails loud if maxParallelToolCalls is mutated invalid after agent creation', async () => { + const adapter = new MockAdapter([ + multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]), + textResponse('must not run after unanswered tool calls'), + ]) + const ctx = await harness(adapter) + const gated = gatedParallelTool('p') + ctx.tools.register(gated.tool) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 2 }) + ;(agent.options as { maxParallelToolCalls: number }).maxParallelToolCalls = 0 + + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + + expect(gated.started).toEqual([]) + expect(adapter.requests).toHaveLength(1) + expect(events(agent).filter(e => e.type === 'tool/call' || e.type === 'tool/result')).toEqual([]) + const turnEnd = events(agent).findLast(e => e.type === 'turn/end') + expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error') + }) + it('starts at most the cap, replenishing as calls settle', async () => { const adapter = new MockAdapter([ multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))), diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 92a6c20d92..f2eac7349b 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -971,7 +971,8 @@ export class ToolRegistry extends Service { const tool = this.get(exec.name, exec.agent) if (!tool?.isConcurrencySafe) return { kind: 'exclusive' } try { - return tool.isConcurrencySafe(exec.arguments) ? { kind: 'parallel' } : { kind: 'exclusive' } + const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments) + return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' } } catch { return { kind: 'exclusive' } } diff --git a/packages/core/tools/tests/execution-mode.spec.ts b/packages/core/tools/tests/execution-mode.spec.ts index b4c686188b..ca33baa143 100644 --- a/packages/core/tools/tests/execution-mode.spec.ts +++ b/packages/core/tools/tests/execution-mode.spec.ts @@ -99,6 +99,19 @@ describe('ToolRegistry.executionMode', () => { expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' }) }) + it('a truthy non-boolean classifier result fails closed to exclusive (raw definition)', async () => { + const ctx = await setup() + const raw = { + name: 'truthy', + description: 'classifier returns a truthy string', + parameters: { type: 'object', properties: {} }, + isConcurrencySafe() { return 'yes' }, + async execute() { return [] }, + } as unknown as ToolDefinition + ctx.tools.register(raw) + expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' }) + }) + it('a raw definition (no defineTool) receives the raw parsed value', async () => { const ctx = await setup() let seen: unknown