From 30369b05974bbb41086ff20365ab928292b329dc Mon Sep 17 00:00:00 2001 From: MrAvalonApple <74775400+ibrahimkazimov@users.noreply.github.com> Date: Tue, 7 Apr 2026 22:11:27 +0300 Subject: [PATCH 1/2] feat: add customizable coordinator options for runTeam and enhance system prompt --- src/index.ts | 1 + src/orchestrator/orchestrator.ts | 83 ++++++++++++++++++---- src/types.ts | 33 +++++++++ tests/orchestrator.test.ts | 117 +++++++++++++++++++++++++++++++ 4 files changed, 222 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index 20d8d1a..bc28b96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -165,6 +165,7 @@ export type { // Orchestrator OrchestratorConfig, OrchestratorEvent, + CoordinatorConfig, // Trace TraceEventType, diff --git a/src/orchestrator/orchestrator.ts b/src/orchestrator/orchestrator.ts index 169741d..b9a0f62 100644 --- a/src/orchestrator/orchestrator.ts +++ b/src/orchestrator/orchestrator.ts @@ -44,6 +44,7 @@ import type { AgentConfig, AgentRunResult, + CoordinatorConfig, OrchestratorConfig, OrchestratorEvent, Task, @@ -825,8 +826,13 @@ export class OpenMultiAgent { * @param team - A team created via {@link createTeam} (or `new Team(...)`). * @param goal - High-level natural-language goal for the team. */ - async runTeam(team: Team, goal: string, options?: { abortSignal?: AbortSignal }): Promise { + async runTeam( + team: Team, + goal: string, + options?: { abortSignal?: AbortSignal; coordinator?: CoordinatorConfig }, + ): Promise { const agentConfigs = team.getAgents() + const coordinatorOverrides = options?.coordinator // ------------------------------------------------------------------ // Short-circuit: skip coordinator for simple, single-action goals. @@ -871,12 +877,17 @@ export class OpenMultiAgent { // ------------------------------------------------------------------ const coordinatorConfig: AgentConfig = { name: 'coordinator', - model: this.config.defaultModel, - provider: this.config.defaultProvider, - baseURL: this.config.defaultBaseURL, - apiKey: this.config.defaultApiKey, - systemPrompt: this.buildCoordinatorSystemPrompt(agentConfigs), - maxTurns: 3, + model: coordinatorOverrides?.model ?? this.config.defaultModel, + provider: coordinatorOverrides?.provider ?? this.config.defaultProvider, + baseURL: coordinatorOverrides?.baseURL ?? this.config.defaultBaseURL, + apiKey: coordinatorOverrides?.apiKey ?? this.config.defaultApiKey, + systemPrompt: this.buildCoordinatorPrompt(agentConfigs, coordinatorOverrides), + maxTurns: coordinatorOverrides?.maxTurns ?? 3, + maxTokens: coordinatorOverrides?.maxTokens, + temperature: coordinatorOverrides?.temperature, + tools: coordinatorOverrides?.tools, + loopDetection: coordinatorOverrides?.loopDetection, + timeoutMs: coordinatorOverrides?.timeoutMs, } const decompositionPrompt = this.buildDecompositionPrompt(goal, agentConfigs) @@ -1121,6 +1132,47 @@ export class OpenMultiAgent { /** Build the system prompt given to the coordinator agent. */ private buildCoordinatorSystemPrompt(agents: AgentConfig[]): string { + return [ + 'You are a task coordinator responsible for decomposing high-level goals', + 'into concrete, actionable tasks and assigning them to the right team members.', + '', + this.buildCoordinatorRosterSection(agents), + '', + this.buildCoordinatorOutputFormatSection(), + '', + this.buildCoordinatorSynthesisSection(), + ].join('\n') + } + + /** Build coordinator system prompt with optional caller overrides. */ + private buildCoordinatorPrompt(agents: AgentConfig[], config?: CoordinatorConfig): string { + if (config?.systemPrompt) { + return [ + config.systemPrompt, + '', + this.buildCoordinatorRosterSection(agents), + '', + this.buildCoordinatorOutputFormatSection(), + '', + this.buildCoordinatorSynthesisSection(), + ].join('\n') + } + + const base = this.buildCoordinatorSystemPrompt(agents) + if (!config?.instructions) { + return base + } + + return [ + base, + '', + '## Additional Instructions', + config.instructions, + ].join('\n') + } + + /** Build the coordinator team roster section. */ + private buildCoordinatorRosterSection(agents: AgentConfig[]): string { const roster = agents .map( (a) => @@ -1129,12 +1181,14 @@ export class OpenMultiAgent { .join('\n') return [ - 'You are a task coordinator responsible for decomposing high-level goals', - 'into concrete, actionable tasks and assigning them to the right team members.', - '', '## Team Roster', roster, - '', + ].join('\n') + } + + /** Build the coordinator JSON output-format section. */ + private buildCoordinatorOutputFormatSection(): string { + return [ '## Output Format', 'When asked to decompose a goal, respond ONLY with a JSON array of task objects.', 'Each task must have:', @@ -1145,7 +1199,12 @@ export class OpenMultiAgent { '', 'Wrap the JSON in a ```json code fence.', 'Do not include any text outside the code fence.', - '', + ].join('\n') + } + + /** Build the coordinator synthesis guidance section. */ + private buildCoordinatorSynthesisSection(): string { + return [ '## When synthesising results', 'You will be given completed task outputs and asked to synthesise a final answer.', 'Write a clear, comprehensive response that addresses the original goal.', diff --git a/src/types.ts b/src/types.ts index 24f0e5c..c934360 100644 --- a/src/types.ts +++ b/src/types.ts @@ -422,6 +422,39 @@ export interface OrchestratorConfig { readonly onApproval?: (completedTasks: readonly Task[], nextTasks: readonly Task[]) => Promise } +/** + * Optional overrides for the temporary coordinator agent created by `runTeam`. + * + * All fields are optional. Unset fields fall back to orchestrator defaults + * (or coordinator built-in defaults where applicable). + */ +export interface CoordinatorConfig { + /** Coordinator model. Defaults to `OrchestratorConfig.defaultModel`. */ + readonly model?: string + readonly provider?: 'anthropic' | 'copilot' | 'grok' | 'openai' | 'gemini' + readonly baseURL?: string + readonly apiKey?: string + /** + * Full system prompt override. When set, this replaces the default + * coordinator preamble and decomposition guidance. + * + * Team roster, output format, and synthesis sections are still appended. + */ + readonly systemPrompt?: string + /** + * Additional instructions appended to the default coordinator prompt. + * Ignored when `systemPrompt` is provided. + */ + readonly instructions?: string + readonly maxTurns?: number + readonly maxTokens?: number + readonly temperature?: number + /** Tool names available to the coordinator. */ + readonly tools?: readonly string[] + readonly loopDetection?: LoopDetectionConfig + readonly timeoutMs?: number +} + // --------------------------------------------------------------------------- // Trace events — lightweight observability spans // --------------------------------------------------------------------------- diff --git a/tests/orchestrator.test.ts b/tests/orchestrator.test.ts index b80f119..0845159 100644 --- a/tests/orchestrator.test.ts +++ b/tests/orchestrator.test.ts @@ -42,6 +42,7 @@ function createMockAdapter(responses: string[]): LLMAdapter { * We need to do this at the module level because Agent calls createAdapter internally. */ let mockAdapterResponses: string[] = [] +let capturedChatOptions: LLMChatOptions[] = [] vi.mock('../src/llm/adapter.js', () => ({ createAdapter: async () => { @@ -49,6 +50,7 @@ vi.mock('../src/llm/adapter.js', () => ({ return { name: 'mock', async chat(_msgs: LLMMessage[], options: LLMChatOptions): Promise { + capturedChatOptions.push(options) const text = mockAdapterResponses[callIndex] ?? 'default mock response' callIndex++ return { @@ -94,6 +96,7 @@ function teamCfg(agents?: AgentConfig[]): TeamConfig { describe('OpenMultiAgent', () => { beforeEach(() => { mockAdapterResponses = [] + capturedChatOptions = [] }) describe('createTeam', () => { @@ -237,6 +240,120 @@ describe('OpenMultiAgent', () => { expect(result.success).toBe(true) }) + + it('supports coordinator model override without affecting workers', async () => { + mockAdapterResponses = [ + '```json\n[{"title": "Research", "description": "Research", "assignee": "worker-a"}]\n```', + 'worker output', + 'final synthesis', + ] + + const oma = new OpenMultiAgent({ + defaultModel: 'expensive-model', + defaultProvider: 'openai', + }) + const team = oma.createTeam('t', teamCfg([ + { ...agentConfig('worker-a'), model: 'worker-model' }, + ])) + + const result = await oma.runTeam(team, 'First research the topic, then synthesize findings', { + coordinator: { model: 'cheap-model' }, + }) + + expect(result.success).toBe(true) + expect(capturedChatOptions.length).toBe(3) + expect(capturedChatOptions[0]?.model).toBe('cheap-model') + expect(capturedChatOptions[1]?.model).toBe('worker-model') + expect(capturedChatOptions[2]?.model).toBe('cheap-model') + }) + + it('appends coordinator.instructions to the default system prompt', async () => { + mockAdapterResponses = [ + '```json\n[{"title": "Plan", "description": "Plan", "assignee": "worker-a"}]\n```', + 'done', + 'final', + ] + + const oma = new OpenMultiAgent({ + defaultModel: 'mock-model', + defaultProvider: 'openai', + }) + const team = oma.createTeam('t', teamCfg([ + { ...agentConfig('worker-a'), model: 'worker-model' }, + ])) + + await oma.runTeam(team, 'First implement, then verify', { + coordinator: { + instructions: 'Always create a testing task after implementation tasks.', + }, + }) + + const coordinatorPrompt = capturedChatOptions[0]?.systemPrompt ?? '' + expect(coordinatorPrompt).toContain('You are a task coordinator responsible') + expect(coordinatorPrompt).toContain('## Additional Instructions') + expect(coordinatorPrompt).toContain('Always create a testing task after implementation tasks.') + }) + + it('uses coordinator.systemPrompt override while still appending required sections', async () => { + mockAdapterResponses = [ + '```json\n[{"title": "Plan", "description": "Plan", "assignee": "worker-a"}]\n```', + 'done', + 'final', + ] + + const oma = new OpenMultiAgent({ + defaultModel: 'mock-model', + defaultProvider: 'openai', + }) + const team = oma.createTeam('t', teamCfg([ + { ...agentConfig('worker-a'), model: 'worker-model' }, + ])) + + await oma.runTeam(team, 'First implement, then verify', { + coordinator: { + systemPrompt: 'You are a custom coordinator for monorepo planning.', + }, + }) + + const coordinatorPrompt = capturedChatOptions[0]?.systemPrompt ?? '' + expect(coordinatorPrompt).toContain('You are a custom coordinator for monorepo planning.') + expect(coordinatorPrompt).toContain('## Team Roster') + expect(coordinatorPrompt).toContain('## Output Format') + expect(coordinatorPrompt).toContain('## When synthesising results') + expect(coordinatorPrompt).not.toContain('You are a task coordinator responsible') + }) + + it('applies advanced coordinator options (maxTokens, temperature, tools)', async () => { + mockAdapterResponses = [ + '```json\n[{"title": "Inspect", "description": "Inspect", "assignee": "worker-a"}]\n```', + 'worker output', + 'final synthesis', + ] + + const oma = new OpenMultiAgent({ + defaultModel: 'mock-model', + defaultProvider: 'openai', + }) + const team = oma.createTeam('t', teamCfg([ + { ...agentConfig('worker-a'), model: 'worker-model' }, + ])) + + await oma.runTeam(team, 'First inspect project, then produce output', { + coordinator: { + maxTurns: 5, + maxTokens: 1234, + temperature: 0, + tools: ['file_read'], + timeoutMs: 1500, + loopDetection: { maxRepetitions: 2, loopDetectionWindow: 3 }, + }, + }) + + expect(capturedChatOptions[0]?.maxTokens).toBe(1234) + expect(capturedChatOptions[0]?.temperature).toBe(0) + expect(capturedChatOptions[0]?.tools).toBeDefined() + expect(capturedChatOptions[0]?.tools?.map((t) => t.name)).toContain('file_read') + }) }) describe('config defaults', () => { From 0b57ffe3e97780959aeac7fb43ddf0515c559ce9 Mon Sep 17 00:00:00 2001 From: MrAvalonApple <74775400+ibrahimkazimov@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:34:25 +0300 Subject: [PATCH 2/2] feat: enhance CoordinatorConfig with toolPreset and disallowedTools options --- src/orchestrator/orchestrator.ts | 2 ++ src/types.ts | 4 ++++ tests/orchestrator.test.ts | 33 ++++++++++++++++++++++++++++++-- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/orchestrator/orchestrator.ts b/src/orchestrator/orchestrator.ts index b9a0f62..c765134 100644 --- a/src/orchestrator/orchestrator.ts +++ b/src/orchestrator/orchestrator.ts @@ -885,7 +885,9 @@ export class OpenMultiAgent { maxTurns: coordinatorOverrides?.maxTurns ?? 3, maxTokens: coordinatorOverrides?.maxTokens, temperature: coordinatorOverrides?.temperature, + toolPreset: coordinatorOverrides?.toolPreset, tools: coordinatorOverrides?.tools, + disallowedTools: coordinatorOverrides?.disallowedTools, loopDetection: coordinatorOverrides?.loopDetection, timeoutMs: coordinatorOverrides?.timeoutMs, } diff --git a/src/types.ts b/src/types.ts index c934360..98f0397 100644 --- a/src/types.ts +++ b/src/types.ts @@ -449,8 +449,12 @@ export interface CoordinatorConfig { readonly maxTurns?: number readonly maxTokens?: number readonly temperature?: number + /** Predefined tool preset for common coordinator use cases. */ + readonly toolPreset?: 'readonly' | 'readwrite' | 'full' /** Tool names available to the coordinator. */ readonly tools?: readonly string[] + /** Tool names explicitly denied to the coordinator. */ + readonly disallowedTools?: readonly string[] readonly loopDetection?: LoopDetectionConfig readonly timeoutMs?: number } diff --git a/tests/orchestrator.test.ts b/tests/orchestrator.test.ts index 0845159..8f4094b 100644 --- a/tests/orchestrator.test.ts +++ b/tests/orchestrator.test.ts @@ -323,7 +323,7 @@ describe('OpenMultiAgent', () => { expect(coordinatorPrompt).not.toContain('You are a task coordinator responsible') }) - it('applies advanced coordinator options (maxTokens, temperature, tools)', async () => { + it('applies advanced coordinator options (maxTokens, temperature, tools, disallowedTools)', async () => { mockAdapterResponses = [ '```json\n[{"title": "Inspect", "description": "Inspect", "assignee": "worker-a"}]\n```', 'worker output', @@ -343,7 +343,8 @@ describe('OpenMultiAgent', () => { maxTurns: 5, maxTokens: 1234, temperature: 0, - tools: ['file_read'], + tools: ['file_read', 'grep'], + disallowedTools: ['grep'], timeoutMs: 1500, loopDetection: { maxRepetitions: 2, loopDetectionWindow: 3 }, }, @@ -353,6 +354,34 @@ describe('OpenMultiAgent', () => { expect(capturedChatOptions[0]?.temperature).toBe(0) expect(capturedChatOptions[0]?.tools).toBeDefined() expect(capturedChatOptions[0]?.tools?.map((t) => t.name)).toContain('file_read') + expect(capturedChatOptions[0]?.tools?.map((t) => t.name)).not.toContain('grep') + }) + + it('supports coordinator.toolPreset and intersects with tools allowlist', async () => { + mockAdapterResponses = [ + '```json\n[{"title": "Inspect", "description": "Inspect", "assignee": "worker-a"}]\n```', + 'worker output', + 'final synthesis', + ] + + const oma = new OpenMultiAgent({ + defaultModel: 'mock-model', + defaultProvider: 'openai', + }) + const team = oma.createTeam('t', teamCfg([ + { ...agentConfig('worker-a'), model: 'worker-model' }, + ])) + + await oma.runTeam(team, 'First inspect project, then produce output', { + coordinator: { + toolPreset: 'readonly', + tools: ['file_read', 'bash'], + }, + }) + + const coordinatorToolNames = capturedChatOptions[0]?.tools?.map((t) => t.name) ?? [] + expect(coordinatorToolNames).toContain('file_read') + expect(coordinatorToolNames).not.toContain('bash') }) })