From eae8b8ce2e9109d1bf827d7c3d2c2804c38d90d4 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 14 Jul 2026 11:55:39 +0800 Subject: [PATCH] feat(ui): configure maxParallelToolCalls for factory-created agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The acp and stdio-agent plugins only forwarded `model` into their created agents, so every factory/ACP deployment was pinned to the agent-loop default parallel cap with no cordis.yml override. Add a `maxParallelToolCalls` config field (positive-integer validated) to both, threaded through the existing per-agent options path — symmetric with `model`. --- docs/config-catalog.md | 12 ++++++++++++ packages/ui/acp/README.md | 1 + packages/ui/acp/src/index.ts | 16 +++++++++++++--- packages/ui/acp/tests/stream-update.spec.ts | 2 ++ packages/ui/stdio-agent/README.md | 1 + packages/ui/stdio-agent/src/index.ts | 10 ++++++++++ .../ui/stdio-agent/tests/stdio-agent.spec.ts | 11 +++++++++++ 7 files changed, 50 insertions(+), 3 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3ef3f6fad7..0a242d31b1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -18,6 +18,12 @@ Requires: `agents` · `sessions` · `sessionPersistence` · `tools` · `userInte export interface AcpConfig { /** Model name for created agents (must have a registered adapter). */ model?: string + /** + * Maximum tool calls each created agent runs concurrently within one assistant + * step (a positive integer; the agent loop defaults it when omitted). `1` + * preserves fully serial execution. + */ + maxParallelToolCalls?: number /** * Transport stream override. Production omits this (the plugin wires * `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an @@ -591,6 +597,12 @@ Source: [`packages/skill/skill-local/src/index.ts:39`](../packages/skill/skill-l export interface Config { /** Model name for the `main` agent (must have a registered adapter). */ model: string + /** + * Maximum tool calls the `main` agent runs concurrently within one assistant + * step (a positive integer; the agent loop defaults it when omitted). `1` + * preserves fully serial execution. + */ + maxParallelToolCalls?: number /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index f4947e5d25..1043b13004 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -15,6 +15,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` | Key | Default | Meaning | |---|---|---| | `model` | — | Model name for created agents (must have a registered adapter). | +| `maxParallelToolCalls` | (agent-loop default) | Positive integer cap on tool calls each created agent runs concurrently within one assistant step; `1` is fully serial. | (No persona key: `dsh-system-prompt`'s own `persona` config supplies the global default section, so ACP-created agents render it without the bridge carrying prompt text. An agent-scoped same-name section may still shadow that default.) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 5a5ce9e51f..ade9e0ccd0 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -248,6 +248,12 @@ function stringArrayContent( export interface AcpConfig { /** Model name for created agents (must have a registered adapter). */ model?: string + /** + * Maximum tool calls each created agent runs concurrently within one assistant + * step (a positive integer; the agent loop defaults it when omitted). `1` + * preserves fully serial execution. + */ + maxParallelToolCalls?: number /** * Transport stream override. Production omits this (the plugin wires * `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an @@ -260,6 +266,9 @@ export interface AcpConfig { export const Config: Schema = Schema.object({ model: Schema.string(), + // A positive integer; a bad value (0, negative, fractional) fails config + // validation here rather than being silently dropped from cordis.yml. + maxParallelToolCalls: Schema.number().step(1).min(1), }) /** @@ -1010,12 +1019,13 @@ export function apply(ctx: Context, config: AcpConfig): void { * Build per-agent options from the plugin config, omitting absent fields * (exactOptionalPropertyTypes: never assign `undefined` to an optional key). * Exported for unit coverage of both the present and absent branches. - * @param config - the plugin config carrying the optional model name. - * @returns the per-agent options, with `model` present only when configured. + * @param config - the plugin config carrying the optional model name and parallel cap. + * @returns the per-agent options, with each field present only when configured. */ -export function agentOptions(config: AcpConfig): { model?: string } { +export function agentOptions(config: AcpConfig): { model?: string; maxParallelToolCalls?: number } { return { ...config.model !== undefined ? { model: config.model } : {}, + ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {}, } } diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index cb3eab3545..afd3e22686 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -818,5 +818,7 @@ describe('agentOptions', () => { it('includes only the fields present in config', () => { expect(agentOptions({})).toEqual({}) expect(agentOptions({ model: 'm' })).toEqual({ model: 'm' }) + expect(agentOptions({ maxParallelToolCalls: 3 })).toEqual({ maxParallelToolCalls: 3 }) + expect(agentOptions({ model: 'm', maxParallelToolCalls: 1 })).toEqual({ model: 'm', maxParallelToolCalls: 1 }) }) }) diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index 27f0d20be7..448089e904 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -26,6 +26,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | Key | Default | Routed to | |---|---|---| | `model` | (required) | the pre-created `main` agent's model | +| `maxParallelToolCalls` | (agent-loop default) | positive integer cap on tool calls the `main` agent runs concurrently within one assistant step (`1` is fully serial), routed to `dsh-agent-loop` | | `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index 1037c28112..6535c5b054 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -65,6 +65,12 @@ export const name = 'stdio-agent' export interface Config { /** Model name for the `main` agent (must have a registered adapter). */ model: string + /** + * Maximum tool calls the `main` agent runs concurrently within one assistant + * step (a positive integer; the agent loop defaults it when omitted). `1` + * preserves fully serial execution. + */ + maxParallelToolCalls?: number /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config; see dsh-system-prompt). */ @@ -87,6 +93,9 @@ export interface Config { export const Config: z = z.object({ model: z.string().required(), + // 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), persona: z.string(), // The array default is forced to undefined: ABSENT means "lexicographic // order" (the owning dsh-system-prompt schema does the same), while @@ -116,6 +125,7 @@ export function apply(ctx: Context, config: Config): void { id: AgentId('main'), model: config.model, cwd: process.cwd(), + ...config.maxParallelToolCalls !== undefined ? { maxParallelToolCalls: config.maxParallelToolCalls } : {}, ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, }], ...config.skills !== undefined ? { skills: config.skills } : {}, diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index 0668d25fb0..c5f9c075f6 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -136,6 +136,17 @@ describe('dsh-stdio-agent app', () => { await ctx.fiber.dispose() }) + it('forwards maxParallelToolCalls onto the pre-created agent when set', async () => { + const ctx = await mount({ + model: 'mock', + maxParallelToolCalls: 3, + persistenceRoot: '/tmp/dsh-stdio-agent-spec-parallel', + skills: await isolatedSkillsConfig(), + }) + expect(ctx.get('agents')?.get(AgentId('main'))?.options.maxParallelToolCalls).toBe(3) + await ctx.fiber.dispose() + }) + it('exposes its name and Config schema', () => { expect(stdioAgent.name).toBe('stdio-agent') expect(stdioAgent.Config).toBeDefined()