From a183267e8dafc0124d969c6ccf7e7cbc67b792ab Mon Sep 17 00:00:00 2001 From: Jack Chen Date: Sun, 26 Jul 2026 10:55:20 +0800 Subject: [PATCH 1/2] fix(core): fail closed on invalid coordinator plans --- docs/task-scheduling.md | 8 +- packages/core/README.md | 6 +- packages/core/src/orchestrator/coordinator.ts | 165 +++++++++++++----- .../core/src/orchestrator/orchestrator.ts | 78 +++++++-- packages/core/src/types.ts | 7 +- packages/core/tests/orchestrator.test.ts | 107 +++++++----- packages/core/tests/runteam-verify.test.ts | 14 +- 7 files changed, 264 insertions(+), 121 deletions(-) diff --git a/docs/task-scheduling.md b/docs/task-scheduling.md index 69e3c70..e2af730 100644 --- a/docs/task-scheduling.md +++ b/docs/task-scheduling.md @@ -126,10 +126,10 @@ state. Assignments earlier in one scheduler call are not folded into that same call; in event-driven execution the next ready-task call can observe tasks already marked `in_progress`. -Coordinator plans that name an agent outside the roster emit an -`INVALID_ASSIGNEE` warning, clear that assignment, and use the configured -scheduler by default. Set `strictAssignees: true` to stop before task execution -with a structured validation error instead. +Coordinator plans that name an agent outside the roster fail validation before +task execution by default. Set `strictAssignees: false` only to retain the +legacy behavior of emitting an `INVALID_ASSIGNEE` warning, clearing that +assignment, and using the configured scheduler. ## Approval modes diff --git a/packages/core/README.md b/packages/core/README.md index 739679f..726b0d7 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -157,9 +157,9 @@ const orchestrator = new OpenMultiAgent({ | `composite` | Ranks tasks by blocked dependents, hard-filters with `AgentSelector`, then maximizes `fitWeight * fit + loadWeight * (1 - normalizedCurrentLoad)` | Criticality, capability fit, and current load should influence one decision | Agents may declare `description`, `capabilities`, `costTier`, and -`latencyClass`, and tasks may add hard `requires` constraints; set -`strictAssignees: true` to fail fast when a coordinator plan names an agent -outside the roster. Weight semantics, load normalization, `NO_ELIGIBLE_AGENT` +`latencyClass`, and tasks may add hard `requires` constraints. Coordinator +plans fail fast by default when they name an agent outside the roster; set +`strictAssignees: false` only to retain legacy reassignment. Weight semantics, load normalization, `NO_ELIGIBLE_AGENT` and `INVALID_ASSIGNEE` behavior, approval compatibility, and progress-event migration are covered in [Task scheduling and dispatch](https://github.com/open-multi-agent/open-multi-agent/blob/main/docs/task-scheduling.md). diff --git a/packages/core/src/orchestrator/coordinator.ts b/packages/core/src/orchestrator/coordinator.ts index fc4f074..fd767a1 100644 --- a/packages/core/src/orchestrator/coordinator.ts +++ b/packages/core/src/orchestrator/coordinator.ts @@ -8,6 +8,7 @@ */ import type { RunOptions } from '../agent/runner.js' +import { extractJSON } from '../agent/structured-output.js' import type { AgentConfig, AgentRunResult, @@ -35,6 +36,7 @@ import { withModelRoute, routeMatches, } from './agent-config.js' +import { z } from 'zod' /** * Partial verify config that the coordinator can emit in task JSON. @@ -70,6 +72,119 @@ export interface ParsedTaskSpec { verify?: ConsensusVerifyOptions | CoordinatorVerifySpec | true } +const coordinatorVerifySchema = z.union([ + z.literal(true), + z.object({ + mode: z.enum(['refute', 'lens']).optional(), + quorum: z.number().int().positive().optional(), + maxRounds: z.number().int().positive().optional(), + onDissent: z.enum(['revise', 'reject', 'keep']).optional(), + }).strict(), +]) + +const coordinatorTaskSpecSchema = z.object({ + title: z.string().trim().min(1), + description: z.string().trim().min(1), + assignee: z.string().trim().min(1).optional(), + dependsOn: z.array(z.string().trim().min(1)).optional(), + memoryScope: z.enum(['dependencies', 'all']).optional(), + maxRetries: z.number().int().nonnegative().optional(), + retryDelayMs: z.number().finite().nonnegative().optional(), + retryBackoff: z.number().finite().positive().optional(), + role: z.string().trim().min(1).optional(), + priority: z.enum(['low', 'normal', 'high', 'critical']).optional(), + requires: z.object({ + requiredTools: z.array(z.string().trim().min(1)).optional(), + requiredCapabilities: z.array(z.string().trim().min(1)).optional(), + requiredBackend: z.enum(['llm', 'process', 'acp']).optional(), + requiredProvider: z.string().trim().min(1).optional(), + }).strict().optional(), + verify: coordinatorVerifySchema.optional(), +}).strict() + +const coordinatorTaskSpecsSchema = z.array(coordinatorTaskSpecSchema).min(1) + +/** + * Build the coordinator output contract for one team. The roster and task + * titles are runtime facts, so they belong in the same schema that triggers + * the Agent's single structured-output repair attempt. + */ +export function buildCoordinatorTaskSpecsSchema( + agents: readonly AgentConfig[], + strictAssignees: boolean, +) { + const roster = new Set(agents.map((agent) => agent.name)) + const normalizeTitle = (title: string) => title.toLowerCase().trim() + + return coordinatorTaskSpecsSchema.superRefine((tasks, ctx) => { + const titleIndexes = new Map() + for (let index = 0; index < tasks.length; index++) { + const key = normalizeTitle(tasks[index]!.title) + const indexes = titleIndexes.get(key) ?? [] + indexes.push(index) + titleIndexes.set(key, indexes) + } + + for (let index = 0; index < tasks.length; index++) { + const task = tasks[index]! + if (strictAssignees && task.assignee !== undefined && !roster.has(task.assignee)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [index, 'assignee'], + message: `Unknown assignee "${task.assignee}".`, + }) + } + for (const [dependencyIndex, dependency] of (task.dependsOn ?? []).entries()) { + const matches = titleIndexes.get(normalizeTitle(dependency)) ?? [] + if (matches.length !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [index, 'dependsOn', dependencyIndex], + message: matches.length === 0 + ? `Unknown dependency "${dependency}".` + : `Ambiguous dependency "${dependency}".`, + }) + } + } + } + + for (const [title, indexes] of titleIndexes) { + if (indexes.length > 1) { + for (const index of indexes) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [index, 'title'], + message: `Duplicate task title "${title}".`, + }) + } + } + } + + const visiting = new Set() + const visited = new Set() + const visit = (index: number): boolean => { + if (visiting.has(index)) return true + if (visited.has(index)) return false + visiting.add(index) + const task = tasks[index]! + for (const dependency of task.dependsOn ?? []) { + const matches = titleIndexes.get(normalizeTitle(dependency)) ?? [] + if (matches.length === 1 && visit(matches[0]!)) return true + } + visiting.delete(index) + visited.add(index) + return false + } + if (tasks.some((_, index) => visit(index))) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [], + message: 'Cyclic task dependency detected.', + }) + } + }) +} + export interface InvalidAssigneeIssue { readonly taskTitle: string readonly assignee: string @@ -139,50 +254,9 @@ export function parseCoordinatorVerify(raw: unknown): CoordinatorVerifySpec | tr * or as a bare array. Returns `null` when no valid array can be extracted. */ export function parseTaskSpecs(raw: string): ParsedTaskSpec[] | null { - // Strategy 1: look for a fenced JSON block - const fenceMatch = raw.match(/```json\s*([\s\S]*?)```/) - const candidate = fenceMatch ? fenceMatch[1]! : raw - - // Strategy 2: find the first '[' and last ']' - const arrayStart = candidate.indexOf('[') - const arrayEnd = candidate.lastIndexOf(']') - if (arrayStart === -1 || arrayEnd === -1 || arrayEnd <= arrayStart) { - return null - } - - const jsonSlice = candidate.slice(arrayStart, arrayEnd + 1) try { - const parsed: unknown = JSON.parse(jsonSlice) - if (!Array.isArray(parsed)) return null - - const specs: ParsedTaskSpec[] = [] - for (const item of parsed) { - if (typeof item !== 'object' || item === null) continue - const obj = item as Record - if (typeof obj['title'] !== 'string') continue - if (typeof obj['description'] !== 'string') continue - - specs.push({ - title: obj['title'], - description: obj['description'], - assignee: typeof obj['assignee'] === 'string' ? obj['assignee'] : undefined, - dependsOn: Array.isArray(obj['dependsOn']) - ? (obj['dependsOn'] as unknown[]).filter((x): x is string => typeof x === 'string') - : undefined, - memoryScope: obj['memoryScope'] === 'all' ? 'all' : undefined, - maxRetries: typeof obj['maxRetries'] === 'number' ? obj['maxRetries'] : undefined, - retryDelayMs: typeof obj['retryDelayMs'] === 'number' ? obj['retryDelayMs'] : undefined, - retryBackoff: typeof obj['retryBackoff'] === 'number' ? obj['retryBackoff'] : undefined, - role: typeof obj['role'] === 'string' ? obj['role'] : undefined, - priority: obj['priority'] === 'low' || obj['priority'] === 'normal' || obj['priority'] === 'high' || obj['priority'] === 'critical' - ? obj['priority'] - : undefined, - requires: parseTaskRequirements(obj['requires']), - verify: parseCoordinatorVerify(obj['verify']), - }) - } - - return specs.length > 0 ? specs : null + const parsed = coordinatorTaskSpecsSchema.safeParse(extractJSON(raw)) + return parsed.success ? parsed.data as ParsedTaskSpec[] : null } catch { return null } @@ -384,8 +458,7 @@ export function buildCoordinatorOutputFormatSection(hasVerifyJudges?: boolean): ' 3. Avoid adding a dependency just because the information "would be useful" or matches general best practice; if the manifest gives no indication X consumes that input, prefer to leave it out.', ' 4. When uncertain, prefer fewer dependencies over more — extra parents cost parallelism and tokens.', '', - 'Wrap the JSON in a ```json code fence.', - 'Do not include any text outside the code fence.', + 'Return only the JSON array. Do not use Markdown code fences or extra text.', ) return lines.join('\n') } @@ -408,7 +481,7 @@ export function buildDecompositionPrompt(goal: string, agents: AgentConfig[]): s `## Goal`, goal, '', - 'Return ONLY the JSON task array in a ```json code fence.', + 'Return ONLY the JSON task array. Do not use a Markdown code fence.', ].join('\n') } diff --git a/packages/core/src/orchestrator/orchestrator.ts b/packages/core/src/orchestrator/orchestrator.ts index 23189cb..c136547 100644 --- a/packages/core/src/orchestrator/orchestrator.ts +++ b/packages/core/src/orchestrator/orchestrator.ts @@ -79,7 +79,7 @@ import { Team } from '../team/team.js' import { TaskQueue } from '../task/queue.js' import { Checkpoint } from '../memory/checkpoint.js' import { InMemoryStore } from '../memory/store.js' -import { createTask, validateTaskDependencies } from '../task/task.js' +import { validateTaskDependencies } from '../task/task.js' import { validateTaskMetadata } from '../task/metadata.js' import { Scheduler } from './scheduler.js' import { CostBudgetExceededError, TokenBudgetExceededError } from '../errors.js' @@ -163,12 +163,13 @@ import { } from '../eval/online.js' import { - parseTaskSpecs, buildCoordinatorBaseConfig, + buildCoordinatorTaskSpecsSchema, buildDecompositionPrompt, runCoordinatorSynthesis, loadSpecsIntoQueue, findInvalidAssignees, + type ParsedTaskSpec, } from './coordinator.js' // --------------------------------------------------------------------------- @@ -247,7 +248,7 @@ export class OpenMultiAgent { * - `maxDelegationDepth`: 3 * - `schedulingStrategy`: `'dependency-first'` * - `schedulingWeights`: `{ fit: 0.7, load: 0.3 }` - * - `strictAssignees`: `false` + * - `strictAssignees`: `true` * - `defaultModel`: `'claude-opus-4-6'` * - `defaultProvider`: `'anthropic'` */ @@ -279,7 +280,7 @@ export class OpenMultiAgent { maxDelegationDepth: config.maxDelegationDepth ?? DEFAULT_MAX_DELEGATION_DEPTH, schedulingStrategy: config.schedulingStrategy ?? 'dependency-first', schedulingWeights: config.schedulingWeights ?? {}, - strictAssignees: config.strictAssignees ?? false, + strictAssignees: config.strictAssignees ?? true, executionRouter: config.executionRouter ?? new DeterministicRouter(), defaultModel: config.defaultModel ?? DEFAULT_MODEL, defaultProvider: config.defaultProvider ?? 'anthropic', @@ -860,7 +861,10 @@ export class OpenMultiAgent { ) const decompositionPrompt = buildDecompositionPrompt(goal, agentConfigs) - const coordinatorAgent = buildAgent(coordinatorConfig) + const coordinatorAgent = buildAgent({ + ...coordinatorConfig, + outputSchema: buildCoordinatorTaskSpecsSchema(agentConfigs, this.config.strictAssignees), + }) const runId = identity.runId const coordinatorDecomposeSpanId = this.config.onTrace ? generateSpanId() : undefined @@ -920,7 +924,32 @@ export class OpenMultiAgent { // ------------------------------------------------------------------ // Step 2: Parse tasks from coordinator output // ------------------------------------------------------------------ - const taskSpecs = parseTaskSpecs(decompositionResult.output) + if (!decompositionResult.success && decompositionResult.errorInfo?.kind !== 'validation') { + this.config.onProgress?.({ + type: 'agent_complete', + agent: 'coordinator', + data: decompositionResult, + }) + this.config.onProgress?.({ + type: 'error', + data: { + code: 'COORDINATOR_DECOMPOSITION_FAILED', + error: decompositionResult.errorInfo, + }, + }) + return finish(this.buildTeamRunResult( + agentResults, + identity, + goal, + [], + decompositionResult.status ?? statusOnly('error'), + decompositionResult.errorInfo, + )) + } + + const taskSpecs = decompositionResult.success && Array.isArray(decompositionResult.structured) + ? decompositionResult.structured as ParsedTaskSpec[] + : null const queue = new TaskQueue() const scheduler = this.createScheduler() @@ -1001,16 +1030,33 @@ export class OpenMultiAgent { )) } } else { - // Coordinator failed to produce structured output — fall back to - // one task per agent using the goal as the description. - for (const agentConfig of agentConfigs) { - const task = createTask({ - title: `${agentConfig.name}: ${goal.slice(0, 80)}`, - description: goal, - assignee: agentConfig.name, - }) - queue.add(task) - } + // A coordinator plan is an execution boundary. Do not turn an invalid + // plan into a different topology: that could duplicate side effects. + const error = Object.assign( + new Error('Coordinator plan failed structured validation after repair.'), + { code: 'COORDINATOR_PLAN_INVALID' }, + ) + const classified = classifyRunFailure(error, { kind: 'validation' }) + this.config.onProgress?.({ + type: 'agent_complete', + agent: 'coordinator', + data: decompositionResult, + }) + this.config.onProgress?.({ + type: 'error', + data: { + code: 'COORDINATOR_PLAN_INVALID', + error: classified.errorInfo, + }, + }) + return finish(this.buildTeamRunResult( + agentResults, + identity, + goal, + [], + classified.status, + classified.errorInfo, + )) } // ------------------------------------------------------------------ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d984162..3cb9de0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1681,10 +1681,9 @@ export interface OrchestratorConfig { /** * Reject coordinator plans that name an assignee outside the team roster. * - * Defaults to `false`: invalid names are cleared, a structured `warning` - * progress event is emitted, and the configured scheduler assigns the task. - * When `true`, the run terminates with a structured `INVALID_ASSIGNEE` - * validation error before any planned task executes. + * Defaults to `true`: coordinator output that names an unknown agent is + * rejected before task execution. Set to `false` only to retain legacy + * behavior that clears the assignment and lets the scheduler choose. */ readonly strictAssignees?: boolean /** diff --git a/packages/core/tests/orchestrator.test.ts b/packages/core/tests/orchestrator.test.ts index c06b710..c52d911 100644 --- a/packages/core/tests/orchestrator.test.ts +++ b/packages/core/tests/orchestrator.test.ts @@ -971,9 +971,12 @@ describe('OpenMultiAgent', () => { expect(result.agentResults.has('coordinator')).toBe(true) }) - it('returns a validation failure for a coordinator-generated cyclic DAG', async () => { + it('repairs a coordinator-generated cyclic DAG before dispatch', async () => { mockAdapterResponses = [ '```json\n[{"title":"Task A","description":"Do A","assignee":"worker-a","dependsOn":["Task B"]},{"title":"Task B","description":"Do B","assignee":"worker-b","dependsOn":["Task A"]}]\n```', + '```json\n[{"title":"Research","description":"Research the topic","assignee":"worker-a"}]\n```', + 'research output', + 'final answer', ] const events: OrchestratorEvent[] = [] const oma = new OpenMultiAgent({ @@ -987,31 +990,56 @@ describe('OpenMultiAgent', () => { 'First research the topic, then produce a detailed report with recommendations.', ) - expect(result.success).toBe(false) - expect(result.status?.code).toBe('error') - expect(result.errorInfo).toMatchObject({ kind: 'validation' }) - expect(result.tasks).toEqual([]) - expect(capturedPrompts).toHaveLength(1) - expect(events).toContainEqual(expect.objectContaining({ - type: 'error', - data: expect.objectContaining({ code: 'INVALID_TASK_DEPENDENCIES' }), - })) + expect(result.success).toBe(true) + expect(result.tasks).toHaveLength(1) + expect(capturedPrompts).toHaveLength(4) + expect(events.some((event) => event.type === 'error')).toBe(false) }) - it('falls back to one-task-per-agent when coordinator output is unparseable', async () => { + it('fails closed when coordinator output remains unparseable after repair', async () => { mockAdapterResponses = [ 'I cannot produce JSON output', // invalid coordinator output - 'worker-a result', - 'worker-b result', - 'synthesis', + 'Still not JSON', // repair also fails ] - const oma = new OpenMultiAgent({ defaultModel: 'mock-model' }) + const events: OrchestratorEvent[] = [] + const oma = new OpenMultiAgent({ + defaultModel: 'mock-model', + onProgress: (event) => events.push(event), + }) const team = oma.createTeam('t', teamCfg()) const result = await oma.runTeam(team, 'First design the database schema, then implement the REST API endpoints') - expect(result.success).toBe(true) + expect(result.success).toBe(false) + expect(result.status?.code).toBe('error') + expect(result.errorInfo).toMatchObject({ kind: 'validation', code: 'COORDINATOR_PLAN_INVALID' }) + expect(result.tasks).toEqual([]) + expect(capturedPrompts).toHaveLength(2) + expect(events).toContainEqual(expect.objectContaining({ + type: 'error', + data: expect.objectContaining({ code: 'COORDINATOR_PLAN_INVALID' }), + })) + }) + + it('rejects a partially malformed coordinator plan instead of executing its valid subset', async () => { + const malformedPlan = '```json\n[' + + '{"title":"Research","description":"Research the topic","assignee":"worker-a"},' + + '{"title":"Broken","assignee":"worker-b"}' + + ']\n```' + mockAdapterResponses = [malformedPlan, malformedPlan] + + const oma = new OpenMultiAgent({ defaultModel: 'mock-model' }) + const team = oma.createTeam('t', teamCfg()) + + const result = await oma.runTeam( + team, + 'First research the topic, then produce a detailed report with recommendations.', + ) + + expect(result.success).toBe(false) + expect(result.tasks).toEqual([]) + expect(capturedPrompts).toHaveLength(2) }) it('uses schedulingStrategy for unassigned coordinator tasks', async () => { @@ -1112,13 +1140,14 @@ describe('OpenMultiAgent', () => { })) }) - it('warns and schedules a coordinator task with an invalid assignee by default', async () => { + it('warns and schedules a coordinator task with an invalid assignee only when legacy reassignment is explicit', async () => { mockAdapterResponses = [ '```json\n[{"title":"Research","description":"Research the topic","assignee":"ghost"}]\n```', ] const events: OrchestratorEvent[] = [] const oma = new OpenMultiAgent({ defaultModel: 'mock-model', + strictAssignees: false, onProgress: (event) => events.push(event), }) const team = oma.createTeam('t', teamCfg([agentConfig('worker-a')])) @@ -1143,14 +1172,14 @@ describe('OpenMultiAgent', () => { }) }) - it('returns a structured validation error for an invalid assignee in strict mode', async () => { + it('returns a structured validation error for an invalid assignee by default', async () => { mockAdapterResponses = [ '```json\n[{"title":"Research","description":"Research the topic","assignee":"ghost"}]\n```', + '```json\n[{"title":"Research","description":"Research the topic","assignee":"ghost"}]\n```', ] const events: OrchestratorEvent[] = [] const oma = new OpenMultiAgent({ defaultModel: 'mock-model', - strictAssignees: true, onProgress: (event) => events.push(event), }) const team = oma.createTeam('t', teamCfg([agentConfig('worker-a')])) @@ -1165,12 +1194,12 @@ describe('OpenMultiAgent', () => { expect(result.status?.code).toBe('error') expect(result.errorInfo).toMatchObject({ kind: 'validation', - code: 'INVALID_ASSIGNEE', + code: 'COORDINATOR_PLAN_INVALID', }) expect(result.tasks).toEqual([]) expect(events).toContainEqual(expect.objectContaining({ type: 'error', - data: expect.objectContaining({ code: 'INVALID_ASSIGNEE' }), + data: expect.objectContaining({ code: 'COORDINATOR_PLAN_INVALID' }), })) }) @@ -1470,17 +1499,13 @@ describe('OpenMultiAgent', () => { expect(result.agentResults.get('worker')?.output).toBe('worker output') }) - it('marks tasks with unknown coordinator dependencies as failed instead of dropping them', async () => { + it('fails closed when coordinator dependencies remain unknown after repair', async () => { + let coordinatorCalls = 0 let workerCalls = 0 - let synthesisPrompt = '' const coordinatorAdapter: LLMAdapter = { name: 'coordinator-mock', - async chat(messages: LLMMessage[]): Promise { - const prompt = extractUserPrompt(messages) - if (prompt.includes('Task Results')) { - synthesisPrompt = prompt - return textResponse('final with gap') - } + async chat(): Promise { + coordinatorCalls++ return textResponse('```json\n[{"title": "Use missing", "description": "Use a missing result", "assignee": "worker", "dependsOn": ["Missing research"]}]\n```') }, async *stream() { yield { type: 'done' as const, data: {} } }, @@ -1503,22 +1528,18 @@ describe('OpenMultiAgent', () => { coordinator: { adapter: coordinatorAdapter }, }) - const task = result.tasks?.find((t) => t.title === 'Use missing') - expect(task?.status).toBe('failed') - expect(synthesisPrompt).toContain('Unresolved dependency reference(s): Missing research') + expect(result.success).toBe(false) + expect(result.tasks).toEqual([]) + expect(coordinatorCalls).toBe(2) expect(workerCalls).toBe(0) }) - it('fails ambiguous title dependencies when coordinator emits duplicate task titles', async () => { - let synthesisPrompt = '' + it('fails closed when duplicate coordinator task titles remain ambiguous after repair', async () => { + let coordinatorCalls = 0 const coordinatorAdapter: LLMAdapter = { name: 'coordinator-mock', - async chat(messages: LLMMessage[]): Promise { - const prompt = extractUserPrompt(messages) - if (prompt.includes('Task Results')) { - synthesisPrompt = prompt - return textResponse('final with ambiguity') - } + async chat(): Promise { + coordinatorCalls++ return textResponse([ '```json', '[', @@ -1539,9 +1560,9 @@ describe('OpenMultiAgent', () => { coordinator: { adapter: coordinatorAdapter }, }) - const synth = result.tasks?.find((t) => t.title === 'Synthesize') - expect(synth?.status).toBe('failed') - expect(synthesisPrompt).toContain('Research (ambiguous duplicate title)') + expect(result.success).toBe(false) + expect(result.tasks).toEqual([]) + expect(coordinatorCalls).toBe(2) }) it('includes failed and skipped task sections in final synthesis prompt', async () => { diff --git a/packages/core/tests/runteam-verify.test.ts b/packages/core/tests/runteam-verify.test.ts index 6935ae7..02109d0 100644 --- a/packages/core/tests/runteam-verify.test.ts +++ b/packages/core/tests/runteam-verify.test.ts @@ -169,7 +169,7 @@ describe('runTeam verify hook integration', () => { expect(taskD!.verify).toBeUndefined() }) - it('verify field in coordinator JSON is ignored when invalid (null, number, string)', async () => { + it('rejects an invalid coordinator verify field instead of silently dropping it', async () => { const worker = agent('worker') const judge = agent('judge') @@ -179,6 +179,11 @@ describe('runTeam verify hook integration', () => { { title: 'Task E2', description: 'Do E2', assignee: 'worker', dependsOn: [], verify: 42 }, { title: 'Task E3', description: 'Do E3', assignee: 'worker', dependsOn: [], verify: 'yes' }, ]), + coordinatorPlan([ + { title: 'Task E1', description: 'Do E1', assignee: 'worker', dependsOn: [], verify: null }, + { title: 'Task E2', description: 'Do E2', assignee: 'worker', dependsOn: [], verify: 42 }, + { title: 'Task E3', description: 'Do E3', assignee: 'worker', dependsOn: [], verify: 'yes' }, + ]), ] const oma = new OpenMultiAgent({ defaultModel: 'mock-model' }) @@ -189,9 +194,8 @@ describe('runTeam verify hook integration', () => { verifyJudges: [judge], }) - expect(result.success).toBe(true) - for (const task of result.tasks ?? []) { - expect(task.verify).toBeUndefined() - } + expect(result.success).toBe(false) + expect(result.errorInfo).toMatchObject({ kind: 'validation', code: 'COORDINATOR_PLAN_INVALID' }) + expect(result.tasks).toEqual([]) }) }) From 692bd7cc970e4dcdbff3de56b3f1d6602484cfaa Mon Sep 17 00:00:00 2001 From: Jack Chen Date: Sun, 26 Jul 2026 12:46:59 +0800 Subject: [PATCH 2/2] docs(core): sync Chinese coordinator safety guidance --- packages/core/README_zh.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/README_zh.md b/packages/core/README_zh.md index 1d0d8ed..a0a1a9e 100644 --- a/packages/core/README_zh.md +++ b/packages/core/README_zh.md @@ -152,8 +152,9 @@ const orchestrator = new OpenMultiAgent({ | `composite` | 按阻塞的下游任务数排列任务,经 `AgentSelector` 硬过滤后,最大化 `fitWeight * fit + loadWeight * (1 - normalizedCurrentLoad)` | 需要在一次决策中同时考虑关键度、能力匹配与当前负载 | Agent 可声明 `description`、`capabilities`、`costTier` 与 `latencyClass`, -任务可通过 `requires` 声明硬性过滤条件;设置 `strictAssignees: true` 可在 -coordinator 计划引用 roster 之外的 Agent 时提前失败。权重语义、负载归一化、 +任务可通过 `requires` 声明硬性过滤条件。Coordinator 计划默认会在引用 +roster 之外的 Agent 时提前失败;仅在需要保留旧的自动重新分配行为时设置 +`strictAssignees: false`。权重语义、负载归一化、 `NO_ELIGIBLE_AGENT` 与 `INVALID_ASSIGNEE` 行为、审批兼容与 progress 事件迁移 见[任务调度与派发](https://github.com/open-multi-agent/open-multi-agent/blob/main/docs/task-scheduling.md)。