mirror of
https://github.com/JackChen-me/open-multi-agent.git
synced 2026-10-22 02:53:26 -05:00
Merge branch 'main' into agent/task-requirements-hard-constraints
This commit is contained in:
commit
edc33db1a4
8 files changed
+272
-127
No files matched your search
@@ -127,10 +127,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
|
||||
|
||||
|
||||
@@ -158,11 +158,12 @@ const orchestrator = new OpenMultiAgent({
|
||||
|
||||
Agents may declare `description`, `capabilities`, `costTier`, and
|
||||
`latencyClass`, and tasks may add hard `requires` constraints. All strategies
|
||||
fail before worker execution when those constraints cannot be satisfied; set
|
||||
`strictAssignees: true` to fail fast when a coordinator plan names an agent
|
||||
outside the roster. Weight semantics, load normalization, `NO_ELIGIBLE_AGENT`
|
||||
and `INVALID_ASSIGNEE` behavior, approval compatibility, and progress-event
|
||||
migration are covered in
|
||||
fail before worker execution when those constraints cannot be satisfied.
|
||||
Coordinator plans also 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).
|
||||
|
||||
## Capabilities
|
||||
|
||||
@@ -153,10 +153,11 @@ const orchestrator = new OpenMultiAgent({
|
||||
|
||||
Agent 可声明 `description`、`capabilities`、`costTier` 与 `latencyClass`,
|
||||
任务可通过 `requires` 声明硬约束。任何调度策略无法满足这些约束时,都会在
|
||||
worker 执行前失败;设置 `strictAssignees: true` 可在 coordinator 计划引用
|
||||
roster 之外的 Agent 时提前失败。权重语义、负载归一化、`NO_ELIGIBLE_AGENT`
|
||||
与 `INVALID_ASSIGNEE` 行为、审批兼容与 progress 事件迁移见
|
||||
[任务调度与派发](https://github.com/open-multi-agent/open-multi-agent/blob/main/docs/task-scheduling.md)。
|
||||
worker 执行前失败。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)。
|
||||
|
||||
## 核心能力
|
||||
|
||||
|
||||
@@ -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<string, number[]>()
|
||||
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<number>()
|
||||
const visited = new Set<number>()
|
||||
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<string, unknown>
|
||||
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')
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,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 {
|
||||
@@ -172,12 +172,13 @@ import {
|
||||
} from '../eval/online.js'
|
||||
|
||||
import {
|
||||
parseTaskSpecs,
|
||||
buildCoordinatorBaseConfig,
|
||||
buildCoordinatorTaskSpecsSchema,
|
||||
buildDecompositionPrompt,
|
||||
runCoordinatorSynthesis,
|
||||
loadSpecsIntoQueue,
|
||||
findInvalidAssignees,
|
||||
type ParsedTaskSpec,
|
||||
} from './coordinator.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -256,7 +257,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'`
|
||||
*/
|
||||
@@ -288,7 +289,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',
|
||||
@@ -893,7 +894,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
|
||||
|
||||
@@ -953,7 +957,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(options?.modelRouting, () => queue.list())
|
||||
@@ -1034,16 +1063,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,
|
||||
))
|
||||
}
|
||||
|
||||
const requirementIssues = validateTaskRequirements(
|
||||
|
||||
@@ -1691,10 +1691,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
|
||||
/**
|
||||
|
||||
@@ -1091,9 +1091,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({
|
||||
@@ -1107,31 +1110,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 () => {
|
||||
@@ -1263,13 +1291,14 @@ describe('OpenMultiAgent', () => {
|
||||
expect(capturedPrompts).toHaveLength(1)
|
||||
})
|
||||
|
||||
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')]))
|
||||
@@ -1294,14 +1323,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')]))
|
||||
@@ -1316,12 +1345,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' }),
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -1621,17 +1650,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<LLMResponse> {
|
||||
const prompt = extractUserPrompt(messages)
|
||||
if (prompt.includes('Task Results')) {
|
||||
synthesisPrompt = prompt
|
||||
return textResponse('final with gap')
|
||||
}
|
||||
async chat(): Promise<LLMResponse> {
|
||||
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: {} } },
|
||||
@@ -1654,22 +1679,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<LLMResponse> {
|
||||
const prompt = extractUserPrompt(messages)
|
||||
if (prompt.includes('Task Results')) {
|
||||
synthesisPrompt = prompt
|
||||
return textResponse('final with ambiguity')
|
||||
}
|
||||
async chat(): Promise<LLMResponse> {
|
||||
coordinatorCalls++
|
||||
return textResponse([
|
||||
'```json',
|
||||
'[',
|
||||
@@ -1690,9 +1711,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 () => {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user