@open-multi-agent/core 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -50,6 +50,7 @@ import { defaultWorkspaceDir } from '../tool/built-in/path-safety.js';
50
50
  import { Team } from '../team/team.js';
51
51
  import { TaskQueue } from '../task/queue.js';
52
52
  import { createTask, validateTaskDependencies } from '../task/task.js';
53
+ import { extractJSON, validateOutput } from '../agent/structured-output.js';
53
54
  import { Scheduler } from './scheduler.js';
54
55
  import { TokenBudgetExceededError } from '../errors.js';
55
56
  import { extractKeywords, keywordScore } from '../utils/keywords.js';
@@ -195,6 +196,23 @@ function buildAgent(config, toolRegistration) {
195
196
  });
196
197
  return new Agent(config, registry, executor);
197
198
  }
199
+ /**
200
+ * Apply the orchestrator's {@link OrchestratorConfig.defaultToolPreset} as a
201
+ * fallback grant for an agent that declares neither `tools` nor `toolPreset`.
202
+ *
203
+ * Built-in tools are opt-in (default-deny): an agent with no grant resolves to
204
+ * zero built-in tools. This fills that gap when the orchestrator opts in to a
205
+ * default. Per-agent grants always win — the default never widens an agent that
206
+ * already declares `tools` or `toolPreset`.
207
+ */
208
+ function applyDefaultToolPreset(config, defaultToolPreset) {
209
+ if (defaultToolPreset === undefined
210
+ || config.tools !== undefined
211
+ || config.toolPreset !== undefined) {
212
+ return config;
213
+ }
214
+ return { ...config, toolPreset: defaultToolPreset };
215
+ }
198
216
  /** Promise-based delay. */
199
217
  function sleep(ms) {
200
218
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -314,6 +332,10 @@ function parseTaskSpecs(raw) {
314
332
  maxRetries: typeof obj['maxRetries'] === 'number' ? obj['maxRetries'] : undefined,
315
333
  retryDelayMs: typeof obj['retryDelayMs'] === 'number' ? obj['retryDelayMs'] : undefined,
316
334
  retryBackoff: typeof obj['retryBackoff'] === 'number' ? obj['retryBackoff'] : undefined,
335
+ role: typeof obj['role'] === 'string' ? obj['role'] : undefined,
336
+ priority: obj['priority'] === 'low' || obj['priority'] === 'normal' || obj['priority'] === 'high' || obj['priority'] === 'critical'
337
+ ? obj['priority']
338
+ : undefined,
317
339
  });
318
340
  }
319
341
  return specs.length > 0 ? specs : null;
@@ -322,6 +344,47 @@ function parseTaskSpecs(raw) {
322
344
  return null;
323
345
  }
324
346
  }
347
+ function routeMatches(policy, selection) {
348
+ if (!policy)
349
+ return undefined;
350
+ const task = selection.task;
351
+ for (const rule of policy.rules) {
352
+ const match = rule.match;
353
+ if (match.phase !== undefined && match.phase !== selection.phase)
354
+ continue;
355
+ if (match.agent !== undefined && match.agent !== selection.agent)
356
+ continue;
357
+ if (match.taskRole !== undefined && match.taskRole !== task?.role)
358
+ continue;
359
+ if (match.taskPriority !== undefined && match.taskPriority !== task?.priority)
360
+ continue;
361
+ if (match.leaf !== undefined && match.leaf !== selection.leaf)
362
+ continue;
363
+ if (match.hasDependencies !== undefined && match.hasDependencies !== ((task?.dependsOn?.length ?? 0) > 0))
364
+ continue;
365
+ return rule.route;
366
+ }
367
+ return undefined;
368
+ }
369
+ function withModelRoute(config, route) {
370
+ if (!route)
371
+ return config;
372
+ return {
373
+ ...config,
374
+ model: route.model,
375
+ provider: route.provider ?? config.provider,
376
+ baseURL: route.baseURL ?? config.baseURL,
377
+ apiKey: route.apiKey ?? config.apiKey,
378
+ region: route.region ?? config.region,
379
+ };
380
+ }
381
+ function isLeafTask(task, tasks) {
382
+ for (const candidate of tasks) {
383
+ if (candidate.dependsOn?.includes(task.id))
384
+ return false;
385
+ }
386
+ return true;
387
+ }
325
388
  function buildRevealCoordinatorLines(revealContext, assignee) {
326
389
  return [
327
390
  '## Team context',
@@ -376,13 +439,19 @@ function buildTaskAgentTeamInfo(ctx, taskId, traceBase, delegationDepth, delegat
376
439
  }
377
440
  // Apply orchestrator-level defaults just like buildPool, then construct a
378
441
  // one-shot Agent for this delegation only.
379
- const effective = {
442
+ const route = routeMatches(ctx.modelRouting, {
443
+ phase: 'delegated',
444
+ agent: targetAgent,
445
+ task: ctx.taskById.get(taskId),
446
+ leaf: ctx.taskLeafById.get(taskId),
447
+ });
448
+ const effective = withModelRoute(applyDefaultToolPreset({
380
449
  ...targetConfig,
381
450
  provider: targetConfig.provider ?? ctx.config.defaultProvider,
382
451
  baseURL: targetConfig.baseURL ?? ctx.config.defaultBaseURL,
383
452
  apiKey: targetConfig.apiKey ?? ctx.config.defaultApiKey,
384
453
  cwd: targetConfig.cwd === undefined ? ctx.config.defaultCwd : targetConfig.cwd,
385
- };
454
+ }, ctx.config.defaultToolPreset), route);
386
455
  const tempAgent = buildAgent(effective, { includeDelegateTool: true });
387
456
  const nestedTeam = buildTaskAgentTeamInfo(ctx, taskId, traceBase, delegationDepth + 1, [...delegationChain, targetAgent]);
388
457
  const childOpts = {
@@ -461,6 +530,18 @@ async function executeQueue(queue, ctx) {
461
530
  });
462
531
  return;
463
532
  }
533
+ const agentConfig = team.getAgent(assignee);
534
+ if (!agentConfig) {
535
+ const msg = `Agent "${assignee}" not found in team for task "${task.title}".`;
536
+ queue.fail(task.id, msg);
537
+ config.onProgress?.({
538
+ type: 'error',
539
+ task: task.id,
540
+ agent: assignee,
541
+ data: msg,
542
+ });
543
+ return;
544
+ }
464
545
  const agent = pool.get(assignee);
465
546
  if (!agent) {
466
547
  const msg = `Agent "${assignee}" not found in pool for task "${task.title}".`;
@@ -503,9 +584,22 @@ async function executeQueue(queue, ctx) {
503
584
  ...traceBase,
504
585
  team: buildTaskAgentTeamInfo(ctx, task.id, traceBase, 0, [assignee]),
505
586
  };
506
- const taskStartMs = Date.now();
507
- let retryCount = 0;
508
- const result = await executeWithRetry(() => pool.run(assignee, prompt, runOptions, config.onAgentStream
587
+ const workerRoute = routeMatches(ctx.modelRouting, {
588
+ phase: 'worker',
589
+ agent: assignee,
590
+ task,
591
+ leaf: ctx.taskLeafById.get(task.id),
592
+ });
593
+ const routedAgent = workerRoute
594
+ ? buildAgent(withModelRoute(applyDefaultToolPreset({
595
+ ...agentConfig,
596
+ provider: agentConfig.provider ?? config.defaultProvider,
597
+ baseURL: agentConfig.baseURL ?? config.defaultBaseURL,
598
+ apiKey: agentConfig.apiKey ?? config.defaultApiKey,
599
+ cwd: agentConfig.cwd === undefined ? config.defaultCwd : agentConfig.cwd,
600
+ }, config.defaultToolPreset), workerRoute), { includeDelegateTool: true })
601
+ : undefined;
602
+ const streamCallback = config.onAgentStream
509
603
  ? (event) => {
510
604
  if (config.onTrace) {
511
605
  const streamMs = Date.now();
@@ -522,7 +616,12 @@ async function executeQueue(queue, ctx) {
522
616
  }
523
617
  config.onAgentStream(assignee, event);
524
618
  }
525
- : undefined), task, (retryData) => {
619
+ : undefined;
620
+ const taskStartMs = Date.now();
621
+ let retryCount = 0;
622
+ const result = await executeWithRetry(() => routedAgent
623
+ ? pool.runEphemeral(routedAgent, prompt, runOptions, streamCallback)
624
+ : pool.run(assignee, prompt, runOptions, streamCallback), task, (retryData) => {
526
625
  retryCount++;
527
626
  config.onProgress?.({
528
627
  type: 'task_retry',
@@ -571,27 +670,37 @@ async function executeQueue(queue, ctx) {
571
670
  });
572
671
  }
573
672
  if (result.success) {
574
- // Persist result into shared memory so other agents can read it
575
673
  const sharedMem = team.getSharedMemoryInstance();
674
+ // Opt-in consensus verification runs *before* the task is finalised so the
675
+ // verified outcome (accepted → revised, rejected → original) flows into the
676
+ // queue, shared memory, progress events, and agentResults as one consistent
677
+ // result. Judge usage is charged to the same parent budget as the rest of the run.
678
+ let effective = result;
679
+ if (task.verify && !ctx.budgetExceededTriggered) {
680
+ effective = await runTaskVerify(task, assignee, result, sharedMem, ctx);
681
+ }
682
+ // Reflect the verified result in the per-task record the caller receives.
683
+ ctx.agentResults.set(`${assignee}:${task.id}`, effective);
684
+ // Persist result into shared memory so other agents can read it
576
685
  if (sharedMem) {
577
- await sharedMem.write(assignee, `task:${task.id}:result`, result.output);
686
+ await sharedMem.write(assignee, `task:${task.id}:result`, effective.output);
578
687
  // Advance the turn counter so any TTL-tagged entries written during
579
688
  // this task can be expired by subsequent reads.
580
689
  sharedMem.advanceTurn();
581
690
  }
582
- const completedTask = queue.complete(task.id, result.output);
691
+ const completedTask = queue.complete(task.id, effective.output);
583
692
  completedThisRound.push(completedTask);
584
693
  config.onProgress?.({
585
694
  type: 'task_complete',
586
695
  task: task.id,
587
696
  agent: assignee,
588
- data: result,
697
+ data: effective,
589
698
  });
590
699
  config.onProgress?.({
591
700
  type: 'agent_complete',
592
701
  agent: assignee,
593
702
  task: task.id,
594
- data: result,
703
+ data: effective,
595
704
  });
596
705
  }
597
706
  else {
@@ -691,6 +800,251 @@ async function buildTaskPrompt(task, team, queue, revealContext) {
691
800
  }
692
801
  return lines.join('\n');
693
802
  }
803
+ /** Skeptic framing applied to every judge (refute mode and lens-mode base). */
804
+ const DEFAULT_VERIFIER_INSTRUCTION = 'You are a rigorous skeptic reviewing a proposed answer to the question shown below. ' +
805
+ 'Judge the answer against what that question actually asks: hunt for errors, unsupported ' +
806
+ 'claims, gaps, and faulty reasoning, then decide whether it withstands scrutiny.';
807
+ /** Per-judge review angles used in `lens` mode (assigned round-robin by index). */
808
+ const CONSENSUS_LENSES = [
809
+ 'factual correctness and logical soundness',
810
+ 'completeness and coverage of the question',
811
+ 'edge cases, failure modes, and counterexamples',
812
+ 'clarity, precision, and freedom from ambiguity',
813
+ 'hidden assumptions and unstated premises',
814
+ 'evidence, citations, and verifiability',
815
+ ];
816
+ /** Verdict contract appended to every judge prompt. */
817
+ const VERDICT_INSTRUCTION = 'Respond ONLY with a JSON object {"accept": <true|false>, "critique": "<concise reason>"}. ' +
818
+ 'Set "accept" to true only if the answer withstands scrutiny; otherwise set it false ' +
819
+ 'and explain the problem in "critique".';
820
+ /** Apply orchestrator defaults to a consensus agent config, mirroring buildPool. */
821
+ function applyConsensusDefaults(config, defaults) {
822
+ return {
823
+ ...config,
824
+ provider: config.provider ?? defaults.defaultProvider,
825
+ baseURL: config.baseURL ?? defaults.defaultBaseURL,
826
+ apiKey: config.apiKey ?? defaults.defaultApiKey,
827
+ cwd: config.cwd === undefined ? defaults.defaultCwd : config.cwd,
828
+ };
829
+ }
830
+ /** Build the user prompt sent to a single judge, always including the original question. */
831
+ function buildJudgePrompt(p) {
832
+ let instruction;
833
+ if (p.judgePrompt !== undefined) {
834
+ instruction = typeof p.judgePrompt === 'function' ? p.judgePrompt(p.judge) : p.judgePrompt;
835
+ }
836
+ else if (p.mode === 'lens') {
837
+ const lens = CONSENSUS_LENSES[p.judgeIndex % CONSENSUS_LENSES.length];
838
+ instruction = `${DEFAULT_VERIFIER_INSTRUCTION}\nFocus specifically on: ${lens}. ` +
839
+ 'If that angle is irrelevant to this question, accept the answer rather than inventing objections.';
840
+ }
841
+ else {
842
+ instruction = DEFAULT_VERIFIER_INSTRUCTION;
843
+ }
844
+ return [
845
+ instruction,
846
+ '',
847
+ '## Question',
848
+ p.prompt,
849
+ '',
850
+ '## Proposed answer',
851
+ p.answer,
852
+ '',
853
+ '## Your verdict',
854
+ VERDICT_INSTRUCTION,
855
+ ].join('\n');
856
+ }
857
+ /** Build the proposer prompt for a revision round, feeding back the prior answer and the dissent. */
858
+ function buildRevisePrompt(prompt, answer, dissent) {
859
+ return [
860
+ prompt,
861
+ '',
862
+ '## Your previous answer',
863
+ answer,
864
+ '',
865
+ '## Reviewer critiques to address',
866
+ ...dissent.map((d) => `- ${d}`),
867
+ '',
868
+ 'Revise the previous answer to address every critique above. Respond with the improved answer only.',
869
+ ].join('\n');
870
+ }
871
+ /** Parse a judge's raw output into an accept/critique decision. */
872
+ function parseJudgeVerdict(output, verdictSchema) {
873
+ let parsed;
874
+ try {
875
+ parsed = extractJSON(output);
876
+ }
877
+ catch {
878
+ return { accept: false, critique: 'Judge output was not valid JSON.' };
879
+ }
880
+ if (verdictSchema) {
881
+ try {
882
+ validateOutput(verdictSchema, parsed);
883
+ }
884
+ catch (err) {
885
+ return { accept: false, critique: `Verdict failed schema validation: ${err instanceof Error ? err.message : String(err)}` };
886
+ }
887
+ }
888
+ const obj = (parsed && typeof parsed === 'object' ? parsed : {});
889
+ const accept = typeof obj['accept'] === 'boolean' ? obj['accept'] : false;
890
+ const critique = typeof obj['critique'] === 'string' && obj['critique']
891
+ ? obj['critique']
892
+ : accept ? '' : 'No critique provided.';
893
+ return { accept, critique };
894
+ }
895
+ /**
896
+ * Run the judge/refutation loop over a proposed answer: judges run sequentially
897
+ * (so quorum and budget can stop the rest), dissent is recorded to shared memory
898
+ * and trace, and `onDissent` decides whether to revise, reject, or keep.
899
+ */
900
+ async function runConsensusCore(params) {
901
+ const { team, prompt, judges, mode, quorum, maxRounds, verdictSchema, onDissent, judgePrompt, budget, budgetBaseTokens, reviseProposer, defaults, onTrace, runId, } = params;
902
+ const pool = params.pool ?? new AgentPool(Math.max(1, defaults.maxConcurrency));
903
+ const sharedMem = team.getSharedMemoryInstance();
904
+ let answer = params.initialAnswer;
905
+ let usage = params.initialUsage;
906
+ const dissent = [];
907
+ let rounds = 0;
908
+ let accepted = false;
909
+ const overBudget = () => budget !== undefined && budgetBaseTokens + usage.input_tokens + usage.output_tokens > budget;
910
+ const runEphemeral = (config, text) => pool.runEphemeral(buildAgent(applyConsensusDefaults(config, defaults)), text);
911
+ // Proposer usage was already accumulated by the caller; bail before judging if it blew the budget.
912
+ if (overBudget()) {
913
+ return { answer, verdict: 'rejected', dissent, rounds, tokenUsage: usage };
914
+ }
915
+ let budgetHit = false;
916
+ for (let round = 1; round <= maxRounds; round++) {
917
+ rounds = round;
918
+ let acceptCount = 0;
919
+ const roundDissent = [];
920
+ for (let j = 0; j < judges.length; j++) {
921
+ const judge = judges[j];
922
+ const judgeText = buildJudgePrompt({ judge: judge.name, answer, prompt, mode, judgeIndex: j, judgePrompt });
923
+ const r = await runEphemeral(judge, judgeText);
924
+ usage = addUsage(usage, r.tokenUsage);
925
+ if (overBudget()) {
926
+ budgetHit = true;
927
+ break;
928
+ }
929
+ const verdict = parseJudgeVerdict(r.output, verdictSchema);
930
+ // Trace every verdict (accept or dissent); shared memory records dissent only.
931
+ if (onTrace) {
932
+ const now = Date.now();
933
+ emitTrace(onTrace, {
934
+ type: 'consensus',
935
+ runId: runId ?? '',
936
+ agent: judge.name,
937
+ round,
938
+ accepted: verdict.accept,
939
+ ...(verdict.accept ? {} : { dissent: verdict.critique }),
940
+ startMs: now,
941
+ endMs: now,
942
+ durationMs: 0,
943
+ });
944
+ }
945
+ if (verdict.accept) {
946
+ acceptCount++;
947
+ if (acceptCount >= quorum) {
948
+ accepted = true;
949
+ break;
950
+ }
951
+ }
952
+ else {
953
+ const labelled = `${judge.name}: ${verdict.critique}`;
954
+ roundDissent.push(labelled);
955
+ dissent.push(labelled);
956
+ if (sharedMem) {
957
+ await sharedMem.write(judge.name, `consensus:round:${round}:dissent`, verdict.critique);
958
+ }
959
+ }
960
+ }
961
+ if (budgetHit || accepted)
962
+ break;
963
+ // Round missed quorum. Revise (if rounds remain) or stop.
964
+ if (onDissent === 'revise' && round < maxRounds && reviseProposer) {
965
+ const r = await runEphemeral(reviseProposer, buildRevisePrompt(prompt, answer, roundDissent));
966
+ usage = addUsage(usage, r.tokenUsage);
967
+ if (r.success && r.output)
968
+ answer = r.output;
969
+ if (overBudget()) {
970
+ budgetHit = true;
971
+ break;
972
+ }
973
+ continue;
974
+ }
975
+ break;
976
+ }
977
+ const verdict = accepted || (!budgetHit && onDissent === 'keep') ? 'accepted' : 'rejected';
978
+ return { answer, verdict, dissent, rounds, tokenUsage: usage };
979
+ }
980
+ /**
981
+ * Run the per-task `verify` hook before a task is finalised: feed the task
982
+ * result into the consensus loop, fold judge usage into the run's cumulative
983
+ * budget, surface the verdict, and return the effective result — the accepted
984
+ * revision when judges revise it, otherwise the original. The caller uses this
985
+ * to finalise the task so the queue, shared memory, events, and agentResults
986
+ * all agree on the verified outcome.
987
+ */
988
+ async function runTaskVerify(task, assignee, result, sharedMem, ctx) {
989
+ const verify = task.verify;
990
+ const { team, config } = ctx;
991
+ const assigneeConfig = team.getAgents().find((a) => a.name === assignee);
992
+ const consensus = await runConsensusCore({
993
+ team,
994
+ prompt: task.description,
995
+ initialAnswer: result.output,
996
+ initialUsage: ZERO_USAGE,
997
+ budgetBaseTokens: ctx.cumulativeUsage.input_tokens + ctx.cumulativeUsage.output_tokens,
998
+ judges: verify.judges,
999
+ mode: verify.mode ?? 'refute',
1000
+ quorum: Math.min(verify.judges.length, Math.max(1, verify.quorum ?? Math.ceil(verify.judges.length / 2))),
1001
+ maxRounds: Math.max(1, verify.maxRounds ?? 2),
1002
+ verdictSchema: verify.verdictSchema,
1003
+ onDissent: verify.onDissent ?? 'revise',
1004
+ judgePrompt: verify.judgePrompt,
1005
+ budget: ctx.maxTokenBudget,
1006
+ reviseProposer: assigneeConfig,
1007
+ defaults: {
1008
+ defaultProvider: config.defaultProvider,
1009
+ defaultBaseURL: config.defaultBaseURL,
1010
+ defaultApiKey: config.defaultApiKey,
1011
+ defaultCwd: config.defaultCwd,
1012
+ maxConcurrency: config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY,
1013
+ },
1014
+ onTrace: config.onTrace,
1015
+ ...(ctx.runId ? { runId: ctx.runId } : {}),
1016
+ });
1017
+ ctx.cumulativeUsage = addUsage(ctx.cumulativeUsage, consensus.tokenUsage);
1018
+ // Surface the verdict as a task-level outcome so downstream agents and the
1019
+ // final synthesis can see whether the result survived scrutiny.
1020
+ if (sharedMem) {
1021
+ const summary = consensus.verdict === 'accepted'
1022
+ ? 'accepted'
1023
+ : `rejected${consensus.dissent.length ? `: ${consensus.dissent.join('; ')}` : ''}`;
1024
+ await sharedMem.write(assignee, `task:${task.id}:verdict`, summary);
1025
+ }
1026
+ const total = ctx.cumulativeUsage.input_tokens + ctx.cumulativeUsage.output_tokens;
1027
+ if (!ctx.budgetExceededTriggered && ctx.maxTokenBudget !== undefined && total > ctx.maxTokenBudget) {
1028
+ ctx.budgetExceededTriggered = true;
1029
+ const err = new TokenBudgetExceededError('orchestrator', total, ctx.maxTokenBudget);
1030
+ ctx.budgetExceededReason = err.message;
1031
+ config.onProgress?.({
1032
+ type: 'budget_exceeded',
1033
+ agent: assignee,
1034
+ task: task.id,
1035
+ data: err,
1036
+ });
1037
+ }
1038
+ // Only an *accepted* revision supersedes the task result; a rejected revision is
1039
+ // recorded as dissent but the caller finalises with the original output. Judge
1040
+ // usage rolls into the per-task usage (mirrors how delegation usage rolls in).
1041
+ const useRevision = consensus.verdict === 'accepted' && consensus.answer && consensus.answer !== result.output;
1042
+ return {
1043
+ ...result,
1044
+ output: useRevision ? consensus.answer : result.output,
1045
+ tokenUsage: addUsage(result.tokenUsage, consensus.tokenUsage),
1046
+ };
1047
+ }
694
1048
  // ---------------------------------------------------------------------------
695
1049
  // OpenMultiAgent
696
1050
  // ---------------------------------------------------------------------------
@@ -726,6 +1080,7 @@ export class OpenMultiAgent {
726
1080
  // disable the filesystem sandbox; a string sets a custom sandbox root.
727
1081
  defaultCwd: config.defaultCwd === undefined ? defaultWorkspaceDir() : config.defaultCwd,
728
1082
  maxTokenBudget: config.maxTokenBudget,
1083
+ defaultToolPreset: config.defaultToolPreset,
729
1084
  onApproval: config.onApproval,
730
1085
  onPlanReady: config.onPlanReady,
731
1086
  onAgentStream: config.onAgentStream,
@@ -770,14 +1125,14 @@ export class OpenMultiAgent {
770
1125
  */
771
1126
  async runAgent(config, prompt, options) {
772
1127
  const effectiveBudget = resolveTokenBudget(config.maxTokenBudget, this.config.maxTokenBudget);
773
- const effective = {
1128
+ const effective = applyDefaultToolPreset({
774
1129
  ...config,
775
1130
  provider: config.provider ?? this.config.defaultProvider,
776
1131
  baseURL: config.baseURL ?? this.config.defaultBaseURL,
777
1132
  apiKey: config.apiKey ?? this.config.defaultApiKey,
778
1133
  cwd: config.cwd === undefined ? this.config.defaultCwd : config.cwd,
779
1134
  maxTokenBudget: effectiveBudget,
780
- };
1135
+ }, this.config.defaultToolPreset);
781
1136
  const agent = buildAgent(effective);
782
1137
  this.config.onProgress?.({
783
1138
  type: 'agent_start',
@@ -858,14 +1213,14 @@ export class OpenMultiAgent {
858
1213
  // to avoid duplicate progress events and double completedTaskCount.
859
1214
  // Events are emitted here; counting is handled by buildTeamRunResult().
860
1215
  const effectiveBudget = resolveTokenBudget(bestAgent.maxTokenBudget, this.config.maxTokenBudget);
861
- const effective = {
1216
+ const effective = withModelRoute(applyDefaultToolPreset({
862
1217
  ...bestAgent,
863
1218
  provider: bestAgent.provider ?? this.config.defaultProvider,
864
1219
  baseURL: bestAgent.baseURL ?? this.config.defaultBaseURL,
865
1220
  apiKey: bestAgent.apiKey ?? this.config.defaultApiKey,
866
1221
  cwd: bestAgent.cwd === undefined ? this.config.defaultCwd : bestAgent.cwd,
867
1222
  maxTokenBudget: effectiveBudget,
868
- };
1223
+ }, this.config.defaultToolPreset), routeMatches(options?.modelRouting, { phase: 'short-circuit', agent: bestAgent.name }));
869
1224
  const agent = buildAgent(effective);
870
1225
  this.config.onProgress?.({
871
1226
  type: 'agent_start',
@@ -915,7 +1270,7 @@ export class OpenMultiAgent {
915
1270
  // ------------------------------------------------------------------
916
1271
  // Step 1: Coordinator decomposes goal into tasks
917
1272
  // ------------------------------------------------------------------
918
- const coordinatorConfig = {
1273
+ const coordinatorBaseConfig = {
919
1274
  name: 'coordinator',
920
1275
  model: coordinatorOverrides?.model ?? this.config.defaultModel,
921
1276
  ...(coordinatorOverrides?.adapter !== undefined ? { adapter: coordinatorOverrides.adapter } : {}),
@@ -942,6 +1297,7 @@ export class OpenMultiAgent {
942
1297
  loopDetection: coordinatorOverrides?.loopDetection,
943
1298
  timeoutMs: coordinatorOverrides?.timeoutMs,
944
1299
  };
1300
+ const coordinatorConfig = withModelRoute(coordinatorBaseConfig, routeMatches(options?.modelRouting, { phase: 'coordinator', agent: 'coordinator' }));
945
1301
  const decompositionPrompt = this.buildDecompositionPrompt(goal, agentConfigs);
946
1302
  const coordinatorAgent = buildAgent(coordinatorConfig);
947
1303
  const runId = this.config.onTrace ? generateRunId() : undefined;
@@ -1020,6 +1376,9 @@ export class OpenMultiAgent {
1020
1376
  },
1021
1377
  }
1022
1378
  : {}),
1379
+ modelRouting: options?.modelRouting,
1380
+ taskById: new Map(queue.list().map((task) => [task.id, task])),
1381
+ taskLeafById: new Map(queue.list().map((task) => [task.id, isLeafTask(task, queue.list())])),
1023
1382
  };
1024
1383
  const planTasks = queue.list();
1025
1384
  const planReadyStartMs = Date.now();
@@ -1098,10 +1457,11 @@ export class OpenMultiAgent {
1098
1457
  return this.buildTeamRunResult(agentResults, goal, taskRecords);
1099
1458
  }
1100
1459
  const synthesisPrompt = await this.buildSynthesisPrompt(goal, queue.list(), team);
1460
+ const synthesisAgent = buildAgent(withModelRoute(coordinatorBaseConfig, routeMatches(options?.modelRouting, { phase: 'synthesis', agent: 'coordinator' })));
1101
1461
  const synthTraceOptions = this.config.onTrace
1102
1462
  ? { onTrace: this.config.onTrace, runId: runId ?? '', traceAgent: 'coordinator' }
1103
1463
  : undefined;
1104
- const synthesisResult = await coordinatorAgent.run(synthesisPrompt, synthTraceOptions);
1464
+ const synthesisResult = await synthesisAgent.run(synthesisPrompt, synthTraceOptions);
1105
1465
  agentResults.set('coordinator', synthesisResult);
1106
1466
  cumulativeUsage = addUsage(cumulativeUsage, synthesisResult.tokenUsage);
1107
1467
  if (maxTokenBudget !== undefined
@@ -1200,10 +1560,94 @@ export class OpenMultiAgent {
1200
1560
  maxRetries: t.maxRetries,
1201
1561
  retryDelayMs: t.retryDelayMs,
1202
1562
  retryBackoff: t.retryBackoff,
1563
+ role: t.role,
1564
+ priority: t.priority,
1565
+ verify: t.verify,
1203
1566
  })), agentConfigs, queue);
1204
1567
  return this.executeExplicitTaskQueue(team, queue, options);
1205
1568
  }
1206
1569
  // -------------------------------------------------------------------------
1570
+ // Consensus
1571
+ // -------------------------------------------------------------------------
1572
+ /**
1573
+ * Run a proposer→judge consensus over a single prompt.
1574
+ *
1575
+ * The proposer emits an answer; judges try to refute it over up to
1576
+ * `maxRounds`, exiting early once `quorum` accept. Proposer and judge token
1577
+ * usage all count against the orchestrator's `maxTokenBudget` — crossing it
1578
+ * stops issuing further judge calls, exactly like delegation and `runTasks`.
1579
+ */
1580
+ async runConsensus(team, prompt, options) {
1581
+ const proposers = Array.isArray(options.proposer) ? options.proposer : [options.proposer];
1582
+ if (proposers.length === 0) {
1583
+ throw new Error('runConsensus: at least one proposer is required.');
1584
+ }
1585
+ if (options.judges.length === 0) {
1586
+ throw new Error('runConsensus: at least one judge is required.');
1587
+ }
1588
+ const mode = options.mode ?? 'refute';
1589
+ const maxRounds = Math.max(1, options.maxRounds ?? 2);
1590
+ const quorum = Math.min(options.judges.length, Math.max(1, options.quorum ?? Math.ceil(options.judges.length / 2)));
1591
+ const onDissent = options.onDissent ?? 'revise';
1592
+ const budget = this.config.maxTokenBudget;
1593
+ const defaults = {
1594
+ defaultProvider: this.config.defaultProvider,
1595
+ defaultBaseURL: this.config.defaultBaseURL,
1596
+ defaultApiKey: this.config.defaultApiKey,
1597
+ defaultCwd: this.config.defaultCwd,
1598
+ maxConcurrency: this.config.maxConcurrency,
1599
+ };
1600
+ const pool = new AgentPool(Math.max(1, this.config.maxConcurrency));
1601
+ let usage = ZERO_USAGE;
1602
+ // Step 2: run proposer(s); accumulate usage and honour the budget before judging.
1603
+ const candidates = [];
1604
+ for (const proposerConfig of proposers) {
1605
+ const r = await pool.runEphemeral(buildAgent(applyConsensusDefaults(proposerConfig, defaults)), prompt);
1606
+ usage = addUsage(usage, r.tokenUsage);
1607
+ if (r.success && r.output)
1608
+ candidates.push(r.output);
1609
+ if (budget !== undefined && usage.input_tokens + usage.output_tokens > budget) {
1610
+ this.config.onProgress?.({
1611
+ type: 'budget_exceeded',
1612
+ agent: proposerConfig.name,
1613
+ data: new TokenBudgetExceededError(proposerConfig.name, usage.input_tokens + usage.output_tokens, budget),
1614
+ });
1615
+ return {
1616
+ answer: candidates.join('\n\n---\n\n'),
1617
+ verdict: 'rejected',
1618
+ dissent: [],
1619
+ rounds: 0,
1620
+ tokenUsage: usage,
1621
+ };
1622
+ }
1623
+ }
1624
+ // Every proposer failed or returned empty output: there is nothing to judge.
1625
+ // Bail with a rejected verdict so an empty answer can never come back accepted.
1626
+ if (candidates.length === 0) {
1627
+ return { answer: '', verdict: 'rejected', dissent: [], rounds: 0, tokenUsage: usage };
1628
+ }
1629
+ return runConsensusCore({
1630
+ team,
1631
+ prompt,
1632
+ initialAnswer: candidates.join('\n\n---\n\n'),
1633
+ initialUsage: usage,
1634
+ budgetBaseTokens: 0,
1635
+ judges: options.judges,
1636
+ mode,
1637
+ quorum,
1638
+ maxRounds,
1639
+ verdictSchema: options.verdictSchema,
1640
+ onDissent,
1641
+ judgePrompt: options.judgePrompt,
1642
+ budget,
1643
+ reviseProposer: proposers[0],
1644
+ defaults,
1645
+ onTrace: this.config.onTrace,
1646
+ runId: this.config.onTrace ? generateRunId() : undefined,
1647
+ pool,
1648
+ });
1649
+ }
1650
+ // -------------------------------------------------------------------------
1207
1651
  // Observability
1208
1652
  // -------------------------------------------------------------------------
1209
1653
  /**
@@ -1398,6 +1842,9 @@ export class OpenMultiAgent {
1398
1842
  budgetExceededTriggered: false,
1399
1843
  budgetExceededReason: undefined,
1400
1844
  taskMetrics: new Map(),
1845
+ modelRouting: options?.modelRouting,
1846
+ taskById: new Map(queue.list().map((task) => [task.id, task])),
1847
+ taskLeafById: new Map(queue.list().map((task) => [task.id, isLeafTask(task, queue.list())])),
1401
1848
  };
1402
1849
  await executeQueue(queue, ctx);
1403
1850
  const taskRecords = queue.list().map((task) => ({
@@ -1443,6 +1890,9 @@ export class OpenMultiAgent {
1443
1890
  maxRetries: spec.maxRetries,
1444
1891
  retryDelayMs: spec.retryDelayMs,
1445
1892
  retryBackoff: spec.retryBackoff,
1893
+ role: spec.role,
1894
+ priority: spec.priority,
1895
+ verify: spec.verify,
1446
1896
  });
1447
1897
  const titleKey = normalizeTitle(spec.title);
1448
1898
  if ((titleCounts.get(titleKey) ?? 0) === 1) {
@@ -1488,14 +1938,14 @@ export class OpenMultiAgent {
1488
1938
  buildPool(agentConfigs) {
1489
1939
  const pool = new AgentPool(this.config.maxConcurrency);
1490
1940
  for (const config of agentConfigs) {
1491
- const effective = {
1941
+ const effective = applyDefaultToolPreset({
1492
1942
  ...config,
1493
1943
  model: config.model,
1494
1944
  provider: config.provider ?? this.config.defaultProvider,
1495
1945
  baseURL: config.baseURL ?? this.config.defaultBaseURL,
1496
1946
  apiKey: config.apiKey ?? this.config.defaultApiKey,
1497
1947
  cwd: config.cwd === undefined ? this.config.defaultCwd : config.cwd,
1498
- };
1948
+ }, this.config.defaultToolPreset);
1499
1949
  pool.add(buildAgent(effective, { includeDelegateTool: true }));
1500
1950
  }
1501
1951
  return pool;