Merge pull request #85 from ibrahimkzmv/feat.customizable-coordinator

feat: make coordinator configurable (model, prompt, tools, and runtime options)
This commit is contained in:
JackChen 2026-04-08 18:56:12 +08:00 committed by GitHub
commit 2022882bfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 257 additions and 12 deletions

View File

@ -165,6 +165,7 @@ export type {
// Orchestrator
OrchestratorConfig,
OrchestratorEvent,
CoordinatorConfig,
// Trace
TraceEventType,

View File

@ -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<TeamRunResult> {
async runTeam(
team: Team,
goal: string,
options?: { abortSignal?: AbortSignal; coordinator?: CoordinatorConfig },
): Promise<TeamRunResult> {
const agentConfigs = team.getAgents()
const coordinatorOverrides = options?.coordinator
// ------------------------------------------------------------------
// Short-circuit: skip coordinator for simple, single-action goals.
@ -899,12 +905,19 @@ 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,
toolPreset: coordinatorOverrides?.toolPreset,
tools: coordinatorOverrides?.tools,
disallowedTools: coordinatorOverrides?.disallowedTools,
loopDetection: coordinatorOverrides?.loopDetection,
timeoutMs: coordinatorOverrides?.timeoutMs,
}
const decompositionPrompt = this.buildDecompositionPrompt(goal, agentConfigs)
@ -1149,6 +1162,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) =>
@ -1157,12 +1211,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:',
@ -1173,7 +1229,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.',

View File

@ -422,6 +422,43 @@ export interface OrchestratorConfig {
readonly onApproval?: (completedTasks: readonly Task[], nextTasks: readonly Task[]) => Promise<boolean>
}
/**
* 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
/** 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
}
// ---------------------------------------------------------------------------
// Trace events — lightweight observability spans
// ---------------------------------------------------------------------------

View File

@ -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<LLMResponse> {
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,149 @@ 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, disallowedTools)', 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', 'grep'],
disallowedTools: ['grep'],
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')
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')
})
})
describe('config defaults', () => {