Jelajahi Sumber

fix(agent-loop): fail closed on invalid parallel scheduling

Dudu-0223 2 bulan lalu
induk
melakukan
ca2dd34291

+ 8 - 0
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<void> {
   /* 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

+ 21 - 0
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) } }))),

+ 2 - 1
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' }
     }

+ 13 - 0
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