diff --git a/packages/agent-core/src/agent/swarm/coordinator.ts b/packages/agent-core/src/agent/swarm/coordinator.ts index a80605b35..6b9016e68 100644 --- a/packages/agent-core/src/agent/swarm/coordinator.ts +++ b/packages/agent-core/src/agent/swarm/coordinator.ts @@ -1,6 +1,7 @@ import { mapWithConcurrency } from './concurrency'; import { parsePlan } from './parse'; import { + ALLOWED_WORKER_TOOLS, DEFAULT_WORKER_TOOLS, PLANNER_SYSTEM_PROMPT, SYNTHESIZER_SYSTEM_PROMPT, @@ -73,7 +74,9 @@ export class SwarmCoordinator { const out = await this.deps.spawnSubagent({ profileName: `swarm:${st.role}`, systemPrompt: st.systemPrompt, - tools: st.toolAllowlist ?? DEFAULT_WORKER_TOOLS, + tools: (st.toolAllowlist ?? DEFAULT_WORKER_TOOLS).filter((t) => + ALLOWED_WORKER_TOOLS.includes(t), + ), prompt: st.prompt, description: st.role, signal: this.deps.signal, @@ -82,6 +85,7 @@ export class SwarmCoordinator { st.status = 'done'; this.progress(`✓ ${st.role}: done`); } catch (err) { + if (this.deps.signal.aborted) throw err; st.status = 'failed'; st.error = err instanceof Error ? err.message : String(err); this.progress(`✗ ${st.role}: failed (${st.error})`); diff --git a/packages/agent-core/src/agent/swarm/prompts.ts b/packages/agent-core/src/agent/swarm/prompts.ts index 1d5e9bd2e..3d7087605 100644 --- a/packages/agent-core/src/agent/swarm/prompts.ts +++ b/packages/agent-core/src/agent/swarm/prompts.ts @@ -1,16 +1,13 @@ import type { SwarmPlan } from './types'; -/** Read-only default tool set for workers; planner may widen via toolAllowlist. */ -export const DEFAULT_WORKER_TOOLS: string[] = ['Read', 'Grep', 'Glob', 'WebSearch', 'FetchURL']; +/** Read-only default tool set for workers; planner may widen via toolAllowlist within the allowlist. */ +export const DEFAULT_WORKER_TOOLS: readonly string[] = ['Read', 'Grep', 'Glob', 'WebSearch', 'FetchURL']; -/** Tool names a worker is allowed to request (excludes Agent/Swarm dispatch tools). */ -export const ALLOWED_WORKER_TOOLS: string[] = [ +/** Tool names a worker is allowed to request. Read-only for Phase 1 (no Write/Edit/Bash, no dispatch tools). */ +export const ALLOWED_WORKER_TOOLS: readonly string[] = [ 'Read', - 'Write', - 'Edit', 'Grep', 'Glob', - 'Bash', 'WebSearch', 'FetchURL', 'ReadMediaFile', diff --git a/packages/agent-core/test/swarm/coordinator.test.ts b/packages/agent-core/test/swarm/coordinator.test.ts index 55e796d25..756fb069e 100644 --- a/packages/agent-core/test/swarm/coordinator.test.ts +++ b/packages/agent-core/test/swarm/coordinator.test.ts @@ -80,4 +80,37 @@ describe('SwarmCoordinator.run', () => { expect(result).toBe('SYNTH'); expect(onProgress.mock.calls.some((c) => /failed/i.test(String(c[0])))).toBe(true); }); + + it('strips disallowed tools (Agent/Bash) from a planner-supplied allowlist', async () => { + const planWithBadTools = JSON.stringify({ + subtasks: [{ role: 'X', systemPrompt: 's', prompt: 'p', toolAllowlist: ['Agent', 'Read', 'Bash'] }], + }); + const spawn = vi.fn(async (args) => { + if (args.profileName === 'swarm-planner') return { result: planWithBadTools }; + if (args.profileName === 'swarm-synthesizer') return { result: 'S' }; + return { result: 'w' }; + }); + const coordinator = new SwarmCoordinator({ spawnSubagent: spawn, signal: new AbortController().signal }); + await coordinator.run('x'); + const worker = (spawn as ReturnType).mock.calls + .map((c) => c[0]) + .find((c) => c.profileName === 'swarm:X'); + expect(worker?.tools).toEqual(['Read']); + }); + + it('propagates abort instead of swallowing it (no synthesis after cancel)', async () => { + const controller = new AbortController(); + const PLAN = JSON.stringify({ subtasks: [{ role: 'A', systemPrompt: 's', prompt: 'p' }] }); + const spawn = vi.fn(async (args) => { + if (args.profileName === 'swarm-planner') return { result: PLAN }; + controller.abort(); + const e = new Error('aborted'); + e.name = 'AbortError'; + throw e; + }); + const coordinator = new SwarmCoordinator({ spawnSubagent: spawn, signal: controller.signal }); + await expect(coordinator.run('x')).rejects.toThrow(); + const profiles = (spawn as ReturnType).mock.calls.map((c) => c[0].profileName); + expect(profiles).not.toContain('swarm-synthesizer'); + }); });