refactor(agent-loop): simplify parallel tool-call cap config
This commit is contained in:
@@ -6,7 +6,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS, ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
@@ -53,10 +53,14 @@ describe('ReactLoopAgent', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('exclusive-driver'))
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('first-driver'), { model: 'mock' }, session)
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, AgentId('first-driver'), { model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
)
|
||||
|
||||
expect(() => prepared.agent.ctx).toThrow('context is not bound')
|
||||
expect(() => prepareReactLoopAgent(ctx, AgentId('second-driver'), { model: 'mock' }, session))
|
||||
expect(() => prepareReactLoopAgent(
|
||||
ctx, AgentId('second-driver'), { model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
))
|
||||
.toThrow('already has a concrete agent driver')
|
||||
|
||||
await prepared.dispose()
|
||||
@@ -254,7 +258,9 @@ describe('ReactLoopAgent', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('test'))
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, AgentId('bare'), { model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
)
|
||||
const { agent } = prepared
|
||||
|
||||
prepared.markPublished()
|
||||
@@ -272,7 +278,9 @@ describe('ReactLoopAgent', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('pre-start-dispose'))
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('pre-start-dispose'), { model: 'mock' }, session)
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, AgentId('pre-start-dispose'), { model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
)
|
||||
|
||||
await prepared.dispose()
|
||||
expect(prepared.agent.status).toBe('disposed')
|
||||
@@ -369,7 +377,9 @@ describe('ReactLoopAgent', () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const session = ctx.sessions.create(SessionId('bare'))
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, AgentId('bare'), { model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
)
|
||||
const { agent } = prepared
|
||||
prepared.markPublished()
|
||||
const dispose = prepared.startDriver()
|
||||
|
||||
@@ -5,7 +5,7 @@ import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS, ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
@@ -528,7 +528,9 @@ describe('turn numbering continues across seeded sessions', () => {
|
||||
ctx2.llm.registerAdapter(['mock'], second)
|
||||
|
||||
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
|
||||
const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
)
|
||||
const forked = prepared.agent
|
||||
prepared.markPublished()
|
||||
ctx2.effect(() => prepared.startDriver())
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
@@ -20,14 +20,17 @@ import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
|
||||
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: [] })
|
||||
await ctx.plugin(AgentLoop, {
|
||||
agents: [],
|
||||
...maxParallelToolCalls === undefined ? {} : { maxParallelToolCalls },
|
||||
})
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
@@ -190,38 +193,28 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
|
||||
})
|
||||
|
||||
describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => {
|
||||
it('rejects invalid programmatic maxParallelToolCalls values before creating agents', async () => {
|
||||
const ctx = await harness(new MockAdapter([]))
|
||||
|
||||
expect(() => ctx.agentLoop.create(AgentId('bad-zero'), { model: 'mock', maxParallelToolCalls: 0 }))
|
||||
.toThrow('maxParallelToolCalls must be a positive integer')
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('bad-fractional'),
|
||||
sessionId: SessionId('bad-fractional-session'),
|
||||
agentOptions: { model: 'mock', maxParallelToolCalls: 1.5 },
|
||||
})).rejects.toThrow('maxParallelToolCalls must be a positive integer')
|
||||
it('rejects invalid global maxParallelToolCalls config at plugin load', async () => {
|
||||
await expect(harness(new MockAdapter([]), 0)).rejects.toThrow()
|
||||
await expect(harness(new MockAdapter([]), 1.5)).rejects.toThrow()
|
||||
})
|
||||
|
||||
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
|
||||
it('defensively rejects invalid caps when direct construction bypasses the config schema', () => {
|
||||
expect(() => new AgentLoop(new Context(), { agents: [], maxParallelToolCalls: 0 }))
|
||||
.toThrow('maxParallelToolCalls must be a positive integer')
|
||||
expect(() => new AgentLoop(new Context(), { agents: [], maxParallelToolCalls: 1.5 }))
|
||||
.toThrow('maxParallelToolCalls must be a positive integer')
|
||||
})
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
it('defaults the cap when direct construction bypasses the config schema', 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)
|
||||
|
||||
expect(gated.started).toEqual([])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(events(agent).some(e => e.type === 'assistant/message')).toBe(false)
|
||||
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')
|
||||
expect(() => new AgentLoop(ctx, { agents: [] })).not.toThrow()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('starts at most the cap, replenishing as calls settle', async () => {
|
||||
@@ -229,10 +222,10 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
|
||||
multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const ctx = await harness(adapter, 2)
|
||||
const gated = gatedParallelTool('p')
|
||||
ctx.tools.register(gated.tool)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 2 })
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
// Only 2 start initially (the cap).
|
||||
@@ -261,10 +254,10 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
|
||||
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const ctx = await harness(adapter, 1)
|
||||
const gated = gatedParallelTool('p')
|
||||
ctx.tools.register(gated.tool)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 1 })
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 1)
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
@@ -275,7 +268,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
|
||||
await waitForIdle(ctx, agent)
|
||||
})
|
||||
|
||||
it('applies the factory-wide Config default to agents that set no per-agent cap', async () => {
|
||||
it('applies the global Config cap to every agent created by the factory', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
|
||||
textResponse('done'),
|
||||
@@ -286,14 +279,12 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
|
||||
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.
|
||||
// The global cap of 1 must serialize every agent from this factory.
|
||||
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))
|
||||
@@ -304,18 +295,6 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
|
||||
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', () => {
|
||||
@@ -349,7 +328,7 @@ describe('tool-call scheduler: ordered middleware and additionalContext', () =>
|
||||
multiCall([{ id: 'c1', name: 'p', args: { id: '1' } }, { id: 'c2', name: 'p', args: { id: '2' } }]),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const ctx = await harness(adapter, 2)
|
||||
const gated = gatedParallelTool('p')
|
||||
ctx.tools.register(gated.tool)
|
||||
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
|
||||
@@ -467,14 +446,14 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
multiCall([1, 2, 3, 4].map(n => ({ id: `c${n}`, name: 'p', args: { id: String(n) } }))),
|
||||
textResponse('should never be requested'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const ctx = await harness(adapter, 2)
|
||||
const gated = gatedParallelTool('p')
|
||||
ctx.tools.register(gated.tool)
|
||||
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => ({
|
||||
...await next(),
|
||||
additionalContext: { content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 2 })
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 2)
|
||||
@@ -500,7 +479,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
]),
|
||||
textResponse('should never be requested'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const ctx = await harness(adapter, 2)
|
||||
const gated = gatedParallelTool('p')
|
||||
const exclusive: string[] = []
|
||||
ctx.tools.register(gated.tool)
|
||||
@@ -510,7 +489,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
async execute(args) { exclusive.push(args.id); return [{ type: 'text', text: 'x' }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', maxParallelToolCalls: 2 })
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 2)
|
||||
|
||||
Reference in New Issue
Block a user