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', () => {