ai-runtime-engine 2.8.0 → 3.0.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.
@@ -54,7 +54,10 @@ import { executePlan } from '../orchestration/executor.js';
54
54
  import { ExecutionStore } from '../executions/store.js';
55
55
  import { RESUMABLE, TERMINAL } from '../executions/execution.js';
56
56
  import { captureCheckpoint, reconcile } from '../executions/checkpoint.js';
57
- import { AGENT_TERMINAL, AGENT_RESUMABLE } from '../agents/task.js';
57
+ import { AGENT_TERMINAL, AGENT_RESUMABLE, agentTaskView } from '../agents/task.js';
58
+ import { synthesizeAgents } from '../agents/synthesize.js';
59
+ import { isDerivedAgentId } from '../agents/roles.js';
60
+ import { resolveConflicts } from '../agents/finding.js';
58
61
  import { parseAgentTasks } from '../executions/agentTasks.js';
59
62
  import { stepIdentity } from '../agents/worker.js';
60
63
  import { hashOf } from '../util/hash.js';
@@ -159,6 +162,8 @@ export class Runtime {
159
162
  /** Live runs, so a pause/cancel can abort the agent tasks actually in flight. Only ever populated
160
163
  * when agents are enabled, so pause/cancel are unchanged with the flag off. */
161
164
  liveRuns = new Map();
165
+ /** Phase 3.6: agent tasks running RIGHT NOW in this process, and how to stop each one on its own. */
166
+ liveAgentTasks = new Map();
162
167
  /** The config file's `budget:` ceilings, kept only so the 3.3 pre-pass can decline a model call. */
163
168
  _configBudget;
164
169
  approval;
@@ -458,6 +463,12 @@ export class Runtime {
458
463
  registerAgent(id, def) {
459
464
  if (!/^[a-z0-9][a-z0-9_-]{0,32}$/.test(id))
460
465
  throw new AIError(`invalid agent id '${id}' — use lowercase letters, digits, '_' or '-' (max 33 chars)`, { category: 'CONFIG' });
466
+ // The same reservation the config schema enforces. Without it here, a host could register
467
+ // `auto_investigate` and — with decompose on — produce two catalog rows with one id, where the
468
+ // derived one wins both lookups and the SAME step resolves to a different envelope depending on a
469
+ // flag. Reserving a namespace in only one of its two doors reserves nothing.
470
+ if (isDerivedAgentId(id))
471
+ throw new AIError(`agent id '${id}' uses the reserved 'auto_' prefix — that namespace belongs to agents the runtime derives`, { category: 'CONFIG' });
461
472
  this.agentDefs.set(id, def);
462
473
  return this;
463
474
  }
@@ -469,13 +480,34 @@ export class Runtime {
469
480
  * This run's agent envelopes. THE ONLY call site of `narrowEnvelope` — never re-derive an inner
470
481
  * catalog, a permission clamp, or a reservation anywhere else (see the header of agents/envelope.ts).
471
482
  */
472
- agentEnvelopes(policy) {
473
- if (!this.agentsEnabled || this.agentDefs.size === 0)
483
+ /**
484
+ * Agents synthesized from the registry for this goal (Phase 3.7). Empty unless
485
+ * `runtime.agents.decompose` is on — so with the flag off nothing about planning changes.
486
+ *
487
+ * Deterministic and offline: no model call, no clock, no randomness. That is a requirement, not a
488
+ * preference — a derived definition is hashed into `agentDefHash`, and a resume that synthesized
489
+ * even slightly differently would discard every persisted inner plan as stale.
490
+ */
491
+ derivedAgents() {
492
+ if (!this.agentsEnabled || this.settingsValue.agents?.decompose !== true)
493
+ return [];
494
+ const capabilities = this._capabilities.list().map((c) => ({ id: c.id, effects: c.effects, providers: this._capabilities.providersOf(c.id).map((p) => p.providerId) }));
495
+ return synthesizeAgents({ capabilities, parentTools: this._tools.ids() });
496
+ }
497
+ agentEnvelopes(policy, derived = []) {
498
+ // Derived definitions are passed IN and never stored: `agentDefs` lives for the process, so writing
499
+ // a per-goal agent into it would leak that agent into every later run on this Runtime.
500
+ const entries = [
501
+ ...[...this.agentDefs].map(([id, d]) => [id, d, 'authored']),
502
+ ...derived.map((d) => [d.id, d.definition, 'derived']),
503
+ ];
504
+ if (!this.agentsEnabled || entries.length === 0)
474
505
  return [];
475
506
  const routing = this.effectiveRouting();
476
- return [...this.agentDefs].map(([id, definition]) => narrowEnvelope({
507
+ return entries.map(([id, definition, provenance]) => narrowEnvelope({
477
508
  agentId: id,
478
509
  definition,
510
+ provenance,
479
511
  parentTools: this._tools.ids(),
480
512
  parentSkills: this.skills().map((sk) => ({ id: sk.id, ...(sk.tools ? { tools: sk.tools } : {}) })),
481
513
  parentPermissions: policy.permissions,
@@ -854,7 +886,14 @@ export class Runtime {
854
886
  // entire flag-off delta on this path.
855
887
  const planning = await this.capabilityPlanning(effectiveGoal, policy, routing);
856
888
  try {
857
- const outcome = await orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, routing, partial, planning?.block, this.agentsEnabled ? controller.signal : undefined, { planVersion: 1 }));
889
+ const outcome = await orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, {
890
+ ...(routing ? { routing } : {}),
891
+ ...(partial ? { partial } : {}),
892
+ ...(planning?.block ? { requiredCapabilities: planning.block } : {}),
893
+ ...(this.agentsEnabled ? { signal: controller.signal } : {}),
894
+ provenance: { planVersion: 1 },
895
+ runId,
896
+ }));
858
897
  this.recordOrchestration(mode, goal, outcome);
859
898
  return this.mapOutcome(outcome, resolution, runId, undefined, planning);
860
899
  }
@@ -887,7 +926,15 @@ export class Runtime {
887
926
  this.liveRuns.set(exec.id, { controller });
888
927
  // Phase 3.5: only when there is a store to write to — a stateless run keeps the 2.7.0 shape.
889
928
  const sink = this._executions.enabled ? this.runPersistence(exec, controller) : undefined;
890
- const outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, routing, partial, planning?.block, this.agentsEnabled ? controller.signal : undefined, { executionId: exec.id, planVersion: exec.planVersion }, sink)));
929
+ const outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, {
930
+ ...(routing ? { routing } : {}),
931
+ ...(partial ? { partial } : {}),
932
+ ...(planning?.block ? { requiredCapabilities: planning.block } : {}),
933
+ ...(this.agentsEnabled ? { signal: controller.signal } : {}),
934
+ provenance: { executionId: exec.id, planVersion: exec.planVersion },
935
+ ...(sink ? { sink } : {}),
936
+ runId,
937
+ })));
891
938
  exec.status = this.execStatus(outcome.status);
892
939
  exec.observations = outcome.observations.map(persistableObservation);
893
940
  if (outcome.plan) {
@@ -1011,6 +1058,10 @@ export class Runtime {
1011
1058
  exec.completedSteps = [...exec.completedSteps, record.stepId];
1012
1059
  checkpointIfMoved();
1013
1060
  }
1061
+ // A finished task is the only moment new findings can appear, so it is the only moment two of
1062
+ // them can start disagreeing.
1063
+ if (AGENT_TERMINAL.has(record.state))
1064
+ this.resolveFindingConflicts(exec);
1014
1065
  commit();
1015
1066
  },
1016
1067
  /**
@@ -1035,7 +1086,14 @@ export class Runtime {
1035
1086
  clearInterval(timer);
1036
1087
  }
1037
1088
  }
1038
- orchestrateInput(mode, goal, policy, routing, partial, requiredCapabilities, signal, provenance, sink, agentResume) {
1089
+ /**
1090
+ * Build the OrchestrateInput. The optional half is an OBJECT, not a positional tail: this function
1091
+ * grew to ten parameters and a caller that passed five of them silently got no sink, no signal and no
1092
+ * provenance — a replan that persisted nothing and could not be cancelled. Named fields cannot be
1093
+ * short-counted.
1094
+ */
1095
+ orchestrateInput(mode, goal, policy, opts = {}) {
1096
+ const { routing, partial, requiredCapabilities, signal, provenance, sink, agentResume, runId } = opts;
1039
1097
  return {
1040
1098
  goal,
1041
1099
  mode,
@@ -1046,16 +1104,23 @@ export class Runtime {
1046
1104
  ...(routing ? { routing } : {}),
1047
1105
  ...(partial ? { partial: true } : {}),
1048
1106
  ...(this.approval ? { approval: this.approval } : {}),
1049
- // Phase 3.1: the capability snapshot is opt-in (`runtime.capabilities.catalog`); the gap resolver is
1050
- // always on but only fires on a validation failure, adding metadata to an unchanged error.
1051
- ...(this.settingsValue.capabilities?.catalog ? { capabilityCatalog: this.capabilityCatalogText() } : {}),
1107
+ // Phase 3.1, ON by default from 3.0.0 (`runtime.capabilities.catalog: false` removes it); the gap
1108
+ // resolver is always on but only fires on a validation failure, adding metadata to an unchanged error.
1109
+ // 3.0.0: ON by default. `catalog: false` removes it — the only way to get the 2.9.0 prompt back.
1110
+ ...(this.settingsValue.capabilities?.catalog !== false ? { capabilityCatalog: this.capabilityCatalogText() } : {}),
1052
1111
  // Phase 3.3: the derived-requirement block (opt-in, pre-rendered + clamped). Absent ⇒ the
1053
1112
  // OrchestrateInput/PlannerInput objects are key-identical to 2.5.1.
1054
1113
  ...(requiredCapabilities ? { requiredCapabilities } : {}),
1055
1114
  resolveGaps: (missing) => this.resolveMissingRefs(missing, policy),
1056
1115
  // Phase 3.4: ONE runner source. `agents`, `runAgent` and `reserve` ride along only when agents are
1057
1116
  // enabled AND a definition exists, so with the flag off this object is KEY-identical to 2.6.0.
1058
- ...this.orchestrateRunners(policy, signal, provenance, sink?.onRecord, agentResume),
1117
+ ...this.orchestrateRunners(policy, {
1118
+ runId: runId ?? 'unknown',
1119
+ ...(signal ? { signal } : {}),
1120
+ ...(provenance ? { provenance } : {}),
1121
+ ...(sink?.onRecord ? { onRecord: sink.onRecord } : {}),
1122
+ ...(agentResume ? { agentResume } : {}),
1123
+ }),
1059
1124
  // Phase 3.5: the commit points. Present ONLY when there is a store to commit to, so a stateless
1060
1125
  // Runtime builds an OrchestrateInput key-identical to 2.7.0.
1061
1126
  ...(sink ? { onPlan: sink.onPlan, onProgress: sink.onProgress } : {}),
@@ -1072,11 +1137,19 @@ export class Runtime {
1072
1137
  this._learning.record({ goalType: this.goalType(goal), mode, ok: outcome.status === 'completed', skills: this.planSkillRefs(outcome.plan) });
1073
1138
  }
1074
1139
  /**
1075
- * A capped, FENCED action-capability snapshot for the planner prompt (Phase 3.1, opt-in). Untrusted
1076
- * sources (anything not an in-tree builtin) have their descriptions fenced, and the block is bounded so
1077
- * a large catalog can never dominate the prompt.
1140
+ * A capped, FENCED action-capability snapshot for the planner prompt (Phase 3.1; ON by default from
1141
+ * 3.0.0 set `runtime.capabilities.catalog: false` to remove it).
1142
+ *
1143
+ * The fence is real as of 3.0.0 and was not before: this block carries ids that come from MCP servers
1144
+ * and third-party skills, and it renders them as trusted-looking prompt structure on every planning
1145
+ * iteration of every run. Flattening (`promptSafe`) bounds their shape but says nothing about their
1146
+ * provenance, so the whole block is wrapped as untrusted data. Three comments claimed "fenced" while
1147
+ * no fence existed; shipping that ON by default would have made a false safety claim load-bearing.
1148
+ *
1149
+ * Both halves are bounded. The blocked-skill list had no cap at all — measured at ~24k characters
1150
+ * with 300 blocked skills, silently, in every prompt.
1078
1151
  */
1079
- capabilityCatalogText(maxEntries = 40) {
1152
+ capabilityCatalogText(maxEntries = 40, maxBlocked = 15) {
1080
1153
  const caps = this._capabilities.list();
1081
1154
  if (caps.length === 0)
1082
1155
  return '';
@@ -1086,17 +1159,21 @@ export class Runtime {
1086
1159
  .providersOf(c.id)
1087
1160
  .map((p) => `${promptSafe(p.providerId)}${p.availability === 'available' ? '' : ` (${p.availability})`}`)
1088
1161
  .join(', ');
1089
- lines.push(` - capability "${promptSafe(c.id)}" [${c.effects.join('/')}] ${promptSafe(providers, 200)}`);
1162
+ // Every segment is clamped, `effects` included: it is an array off a declaration, so a hostile or
1163
+ // simply careless source can make one row arbitrarily long.
1164
+ lines.push(` - capability "${promptSafe(c.id)}" [${promptSafe(c.effects.join('/'), 40)}] → ${promptSafe(providers, 200)}`);
1090
1165
  }
1091
1166
  const more = caps.length > maxEntries ? `\n …and ${caps.length - maxEntries} more (see /capabilities)` : '';
1092
1167
  // Skills hidden by a missing tool — the "why can't you do this" answer the planner needs.
1093
1168
  const usable = new Set(this.skills().map((sk) => sk.id));
1094
- const blocked = this._skills
1095
- .list()
1096
- .filter((sk) => !usable.has(sk.id))
1169
+ const blockedAll = this._skills.list().filter((sk) => !usable.has(sk.id));
1170
+ const blocked = blockedAll
1171
+ .slice(0, maxBlocked)
1097
1172
  .map((sk) => ` - skill "${promptSafe(sk.id)}" needs tool(s) ${promptSafe((sk.tools ?? []).filter((t) => !this._tools.ids().includes(t)).join(', '), 200)} (not registered)`);
1098
- const unavailable = blocked.length ? `\nUnavailable (do not use):\n${blocked.join('\n')}` : '';
1099
- return `Action capabilities:\n${lines.join('\n')}${more}${unavailable}`;
1173
+ const blockedMore = blockedAll.length > maxBlocked ? `\n …and ${blockedAll.length - maxBlocked} more` : '';
1174
+ const unavailable = blocked.length ? `\nUnavailable (do not use):\n${blocked.join('\n')}${blockedMore}` : '';
1175
+ // The ids inside come from MCP servers and third-party skills. Fenced as data, not structure.
1176
+ return wrapUntrusted('capability-catalog', `Action capabilities:\n${lines.join('\n')}${more}${unavailable}`);
1100
1177
  }
1101
1178
  /** Any call/cost ceiling declared in the config file's `budget:` block (router-level, not policy). */
1102
1179
  configBudget() {
@@ -1392,7 +1469,7 @@ export class Runtime {
1392
1469
  // path (whether the pause was approval, budget, or partial-progress) so an approved-but-over-budget
1393
1470
  // plan pauses for budget rather than silently exceeding it; a raised budget re-applies here.
1394
1471
  const resumeLookup = this.agentResumeLookup(exec, opts.clarificationAnswer);
1395
- const exe = await this.withHeartbeat(exec.id, () => executePlan(exec.plan, { ...this.orchestrateRunners(policy, opts.signal ?? controller.signal, { executionId: exec.id, planVersion: exec.planVersion }, sink?.onRecord, resumeLookup), ...(sink ? { onProgress: sink.onProgress } : {}), maxParallelSteps: policy.maxParallelSteps ?? 2, ...(policy.limits ? { limits: policy.limits } : {}), skip: new Set(exec.completedSteps), ...(policy.maxCalls !== undefined ? { callBudget: policy.maxCalls } : {}), ...(opts.signal ? { signal: opts.signal } : {}) }));
1472
+ const exe = await this.withHeartbeat(exec.id, () => executePlan(exec.plan, { ...this.orchestrateRunners(policy, { runId, signal: opts.signal ?? controller.signal, provenance: { executionId: exec.id, planVersion: exec.planVersion }, ...(sink?.onRecord ? { onRecord: sink.onRecord } : {}), agentResume: resumeLookup }), ...(sink ? { onProgress: sink.onProgress } : {}), maxParallelSteps: policy.maxParallelSteps ?? 2, ...(policy.limits ? { limits: policy.limits } : {}), skip: new Set(exec.completedSteps), ...(policy.maxCalls !== undefined ? { callBudget: policy.maxCalls } : {}), ...(opts.signal ? { signal: opts.signal } : {}) }));
1396
1473
  if (exe.stoppedForBudget) {
1397
1474
  const done = exe.plan.steps.filter((s) => s.status === 'succeeded').length;
1398
1475
  const total = exe.plan.steps.length;
@@ -1427,7 +1504,14 @@ export class Runtime {
1427
1504
  // this arm with no signal, no provenance, no sink and no resume lookup — so a replan persisted
1428
1505
  // no agent record AT ALL (onRecord is the only writer of `agentTasks`), could not be paused or
1429
1506
  // cancelled, and elected `pending` from stale records.
1430
- outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput('orchestrate', brief ? `${goal}\n\n${brief}` : goal, policy, this.effectiveRouting(), budgetPaused, undefined, this.agentsEnabled ? controller.signal : undefined, { executionId: exec.id, planVersion: exec.planVersion }, sink)));
1507
+ outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput('orchestrate', brief ? `${goal}\n\n${brief}` : goal, policy, {
1508
+ ...(this.effectiveRouting() ? { routing: this.effectiveRouting() } : {}),
1509
+ ...(budgetPaused ? { partial: true } : {}),
1510
+ ...(this.agentsEnabled ? { signal: controller.signal } : {}),
1511
+ provenance: { executionId: exec.id, planVersion: exec.planVersion },
1512
+ ...(sink ? { sink } : {}),
1513
+ runId,
1514
+ })));
1431
1515
  }
1432
1516
  exec.status = this.execStatus(outcome.status);
1433
1517
  exec.observations = [...observationsBefore, ...outcome.observations.map(persistableObservation)];
@@ -1464,9 +1548,14 @@ export class Runtime {
1464
1548
  * They used to drift: resume built its own pair with no agent runner, so a persisted plan containing
1465
1549
  * an agent step would have failed every one of those steps.
1466
1550
  */
1467
- orchestrateRunners(policy, signal, provenance, onRecord, agentResume) {
1468
- const envelopes = policy ? this.agentEnvelopes(policy) : [];
1551
+ orchestrateRunners(policy, opts) {
1552
+ const { runId, signal, provenance, onRecord, agentResume } = opts;
1553
+ const derived = this.derivedAgents();
1554
+ const envelopes = policy ? this.agentEnvelopes(policy, derived) : [];
1469
1555
  const byId = new Map(envelopes.map((e) => [e.agentId, e]));
1556
+ // Definitions come from a LOCAL map, not the process-lifetime field: a derived agent exists for
1557
+ // this run only, and must not be findable by any later one.
1558
+ const defsById = new Map([...this.agentDefs, ...derived.map((d) => [d.id, d.definition])]);
1470
1559
  return {
1471
1560
  runSkill: (skillId, input) => this.runSkill(skillId, input).then((o) => ({ result: o.result, validation: o.validation })),
1472
1561
  runTool: (toolId, input) => this.runTool(toolId, input),
@@ -1476,13 +1565,14 @@ export class Runtime {
1476
1565
  reserve: (step) => (step.agent ? byId.get(step.agent)?.reservation ?? 1 : 0),
1477
1566
  runAgent: async (step, ctx) => {
1478
1567
  const envelope = byId.get(step.agent ?? '');
1479
- const definition = this.agentDefs.get(step.agent ?? '');
1568
+ const definition = defsById.get(step.agent ?? '');
1480
1569
  if (!envelope || !definition)
1481
1570
  return { stepId: step.id, ok: false, code: 'agent-not-enabled', error: `no agent definition '${step.agent ?? ''}'` };
1482
1571
  const innerSkills = this._skills.list().filter((sk) => envelope.skills.includes(sk.id));
1483
1572
  // Phase 3.5: continue a persisted task for THIS step, if one exists.
1484
1573
  const prior = agentResume?.(step);
1485
1574
  const out = await runAgentTask(step, envelope, definition, {
1575
+ ...(derived.some((d) => d.id === step.agent) ? { derived: true } : {}),
1486
1576
  ...(prior?.record ? { resume: prior.record } : {}),
1487
1577
  ...(prior?.answer ? { resumeAnswer: prior.answer } : {}),
1488
1578
  ai: this._ai,
@@ -1503,8 +1593,29 @@ export class Runtime {
1503
1593
  return { unavailable: true };
1504
1594
  }
1505
1595
  },
1506
- emit: (e) => this.emitter.emit({ type: e.type, agentTaskId: e.record.agentTaskId, agentId: e.record.agentId, stepId: e.record.stepId, state: e.record.state, innerSteps: e.record.innerSteps, callsUsed: e.record.callsUsed, toolCallsUsed: e.record.toolCallsUsed, findings: e.record.findings.length }),
1596
+ // METADATA ONLY, and ONE BRANCH PER ARM. Building a single object with a union-typed
1597
+ // `type` compiles while carrying properties from every constituent — TypeScript's
1598
+ // excess-property check against a union admits anything present in SOME arm — so
1599
+ // `agent.task.started` was shipping innerSteps/callsUsed/findings it does not declare.
1600
+ // Narrowing to a literal first makes an extra property a compile error again; the
1601
+ // key-set test covers what the type system still cannot.
1602
+ emit: (e) => {
1603
+ const base = { runId, agentTaskId: e.record.agentTaskId, agentId: e.record.agentId, stepId: e.record.stepId };
1604
+ if (e.type === 'agent.task.started') {
1605
+ this.emitter.emit({ type: 'agent.task.started', ...base, state: e.record.state });
1606
+ return;
1607
+ }
1608
+ if (e.type === 'agent.task.progress') {
1609
+ this.emitter.emit({ type: 'agent.task.progress', ...base, innerSteps: e.record.innerSteps, callsUsed: e.record.callsUsed, toolCallsUsed: e.record.toolCallsUsed });
1610
+ return;
1611
+ }
1612
+ // `findings` is a COUNT: a claim is agent-authored text and an event is the one
1613
+ // surface a host may forward anywhere, so no content crosses it.
1614
+ this.emitter.emit({ type: 'agent.task.completed', ...base, state: e.record.state, innerSteps: e.record.innerSteps, callsUsed: e.record.callsUsed, toolCallsUsed: e.record.toolCallsUsed, findings: e.record.findings.length });
1615
+ },
1507
1616
  ...(onRecord ? { onRecord } : {}),
1617
+ registerAbort: (id, abort) => this.liveAgentTasks.set(id, { abort, agentId: envelope.agentId, stepId: step.id, ...(provenance?.executionId ? { executionId: provenance.executionId } : {}) }),
1618
+ releaseAbort: (id) => this.liveAgentTasks.delete(id),
1508
1619
  ...(ctx.signal ?? signal ? { parentSignal: ctx.signal ?? signal } : {}),
1509
1620
  ...(provenance ? { provenance } : { provenance: { planVersion: 1 } }),
1510
1621
  abortReason: () => {
@@ -1520,6 +1631,52 @@ export class Runtime {
1520
1631
  : {}),
1521
1632
  };
1522
1633
  }
1634
+ /**
1635
+ * Reconcile findings that contradict each other, across ALL of this execution's agent tasks
1636
+ * (Phase 3.7).
1637
+ *
1638
+ * `resolveConflicts` has existed since 3.4 with no caller, so two agents reaching opposite
1639
+ * conclusions about the same subject both stayed `active` — and both were rendered into the next
1640
+ * planning prompt, as if the runtime had no opinion about which was better supported. It does: it
1641
+ * weighs evidence-based `confidence`, with `executionCoverage` only as a tiebreak.
1642
+ *
1643
+ * Runs at the ONE point new findings can appear — a task reaching a terminal state — and writes the
1644
+ * outcome back onto the owning records, so a supersession survives a restart rather than being
1645
+ * recomputed (and possibly recomputed differently) on every read.
1646
+ *
1647
+ * EVERY finding is passed in, not just the active ones. Resolving over the active subset makes the
1648
+ * result depend on the order tasks happen to finish: a finding that beat a weak rival in wave 1 can
1649
+ * itself lose in wave 2, and the wave-1 loser is then left pointing at a superseded finding — a
1650
+ * broken chain nothing heals. Re-resolving the whole set each round is order-independent and gives
1651
+ * the same answer as one pass over the final set.
1652
+ */
1653
+ resolveFindingConflicts(exec) {
1654
+ const { tasks } = parseAgentTasks(exec);
1655
+ const all = tasks.flatMap((t) => t.findings);
1656
+ if (all.length < 2)
1657
+ return;
1658
+ const resolved = new Map(resolveConflicts(all).map((f) => [f.id, f]));
1659
+ let changed = false;
1660
+ const next = tasks.map((t) => {
1661
+ const findings = t.findings.map((f) => {
1662
+ const r = resolved.get(f.id);
1663
+ if (!r || (r.status === f.status && r.supersededBy === f.supersededBy))
1664
+ return f;
1665
+ changed = true;
1666
+ const next = { ...f, status: r.status };
1667
+ if (r.supersededBy)
1668
+ next.supersededBy = r.supersededBy;
1669
+ else
1670
+ delete next.supersededBy;
1671
+ return next;
1672
+ });
1673
+ return changed ? { ...t, findings } : t;
1674
+ });
1675
+ if (!changed)
1676
+ return;
1677
+ const byId = new Map(next.map((t) => [t.agentTaskId, t]));
1678
+ exec.agentTasks = (exec.agentTasks ?? []).map((entry) => byId.get(entry.agentTaskId ?? '') ?? entry);
1679
+ }
1523
1680
  /**
1524
1681
  * A bounded, fenced brief of what the agents have already established (Phase 3.5).
1525
1682
  *
@@ -1631,6 +1788,110 @@ export class Runtime {
1631
1788
  return answer && record.agentTaskId === answeringId ? { record, answer } : { record };
1632
1789
  };
1633
1790
  }
1791
+ /**
1792
+ * Every agent task this Runtime can see, newest execution first (Phase 3.6). Live ones (running in
1793
+ * this process) and persisted ones are the same list: a task's record IS its status, so there is no
1794
+ * second source to disagree with.
1795
+ */
1796
+ agentTasks(executionId) {
1797
+ const executions = executionId ? this._executions.list().filter((e) => e.id === executionId) : this._executions.list();
1798
+ return executions.flatMap((e) => parseAgentTasks(e).tasks.map(agentTaskView));
1799
+ }
1800
+ /**
1801
+ * Stop one agent task. Four cases, and NONE of them is a silent no-op — a `stop` that appears to do
1802
+ * nothing is indistinguishable from a bug:
1803
+ *
1804
+ * - running in this process: abort it, then let the worker record `cancelled` as it unwinds;
1805
+ * - persisted and not finished (another process, or a dead one): write `cancelled` with a
1806
+ * `parent-cancel` interruption, so the record stops claiming it is queued or running;
1807
+ * - already finished: report that, and change nothing — a terminal state is sticky;
1808
+ * - unknown id: say so.
1809
+ */
1810
+ stopAgentTask(agentTaskId) {
1811
+ const live = this.liveAgentTasks.get(agentTaskId);
1812
+ if (live) {
1813
+ live.abort();
1814
+ // Deliberately no `state`: the abort is cooperative and the worker has not unwound yet, so
1815
+ // claiming `cancelled` here would report a state that has not happened.
1816
+ return { ok: true, reason: 'aborted a task running in this process' };
1817
+ }
1818
+ const owning = this._executions.list().find((e) => parseAgentTasks(e).tasks.some((t) => t.agentTaskId === agentTaskId));
1819
+ if (!owning)
1820
+ return { ok: false, reason: 'no such agent task' };
1821
+ // A run this process is executing owns its own record. Writing it from the side would fight the
1822
+ // run's sink, and TAKING ITS LEASE would be worse: the sink refuses a leaseless commit and treats
1823
+ // the refusal as a stop signal, so releasing here would abort the entire run to stop one task.
1824
+ if (this.liveRuns.has(owning.id)) {
1825
+ return { ok: false, reason: `that task belongs to a run in flight — /cancel ${owning.id} stops the whole run` };
1826
+ }
1827
+ // Claim the execution properly. A live lease held elsewhere means another process is mid-run: its
1828
+ // next commit would rewrite the file from its own memory and silently drop this edit, so refuse
1829
+ // rather than pretend.
1830
+ const acq = this._executions.acquire(owning.id);
1831
+ if (!acq.ok || !acq.execution)
1832
+ return { ok: false, reason: `the execution is busy elsewhere (${acq.reason ?? 'unavailable'})` };
1833
+ const exec = acq.execution;
1834
+ try {
1835
+ // Re-read EVERYTHING from the claimed copy. The listing was a snapshot: the task may have
1836
+ // finished since, and writing the snapshot back would revert its own counters and findings.
1837
+ const fresh = parseAgentTasks(exec).tasks.find((t) => t.agentTaskId === agentTaskId);
1838
+ if (!fresh)
1839
+ return { ok: false, reason: 'the task is no longer on the record' };
1840
+ if (AGENT_TERMINAL.has(fresh.state))
1841
+ return { ok: false, state: fresh.state, reason: `already ${fresh.state}` };
1842
+ const now = this.clock.now();
1843
+ const stopped = { ...fresh, state: 'cancelled', interruption: { kind: 'parent-cancel', at: now }, endedAt: now, updatedAt: now };
1844
+ // A cancelled task is not asking anything any more; leaving the question on it would keep
1845
+ // advertising a prompt nobody can answer.
1846
+ delete stopped.pendingInner;
1847
+ exec.agentTasks = (exec.agentTasks ?? []).map((entry) => (entry.agentTaskId === agentTaskId ? stopped : entry));
1848
+ // WITHOUT THIS THE COMMAND IS COSMETIC. `AGENT_RESUMABLE` excludes `cancelled`, so a resume
1849
+ // offers no record for this step and the executor runs the agent again from scratch — a second
1850
+ // paid planning call and the tools fired again, after a human asked it to stop.
1851
+ //
1852
+ // But mark the step done only when the CURRENT plan still contains that exact step. Step ids
1853
+ // recur across replans, so an id from a retired plan could name a completely different step,
1854
+ // and marking it done would silently skip work that was never even started.
1855
+ const stepStillThere = exec.plan?.steps.some((st) => st.id === stopped.stepId && stepIdentity(st) === stopped.stepInputHash);
1856
+ if (stepStillThere && !exec.completedSteps.includes(stopped.stepId))
1857
+ exec.completedSteps = [...exec.completedSteps, stopped.stepId];
1858
+ // A stopped task cannot answer, so a pending wait belonging to it would strand the execution:
1859
+ // resume early-returns on an unanswered inner clarification, and re-election only considers
1860
+ // tasks that are still waiting. Hand the slot to another waiter, or clear it.
1861
+ if (exec.pending?.kind === 'clarification' && exec.pending.agentTaskId === agentTaskId) {
1862
+ const next = this.electWaitingAgent(exec);
1863
+ if (next?.pendingInner)
1864
+ exec.pending = { kind: 'clarification', question: next.pendingInner.question, agentTaskId: next.agentTaskId };
1865
+ else
1866
+ delete exec.pending;
1867
+ }
1868
+ // `commit`, not `commitProgress`. The funnel's rules exist to stop an IN-FLIGHT RUN overwriting
1869
+ // a decision made while it was finishing — terminal is sticky, a pause is not the runner's to
1870
+ // erase. This is not a run: it holds the lease it just acquired, it changes one task record, and
1871
+ // it never touches `exec.status`, so a completed execution stays completed. Refusing here would
1872
+ // instead make a stale non-terminal task on a finished execution permanently unstoppable.
1873
+ if (!this._executions.commit(exec))
1874
+ return { ok: false, reason: 'another owner holds this execution' };
1875
+ this.emitter.emit({
1876
+ type: 'agent.task.completed',
1877
+ // A stop is out-of-band: it belongs to no run, so this id identifies the OPERATION. It is
1878
+ // deliberately fresh rather than an execution id borrowed to look like a run id.
1879
+ runId: nextRunId(),
1880
+ agentTaskId,
1881
+ agentId: stopped.agentId,
1882
+ stepId: stopped.stepId,
1883
+ state: 'cancelled',
1884
+ innerSteps: stopped.innerSteps,
1885
+ callsUsed: stopped.callsUsed,
1886
+ toolCallsUsed: stopped.toolCallsUsed,
1887
+ findings: stopped.findings.length,
1888
+ });
1889
+ return { ok: true, state: 'cancelled', reason: stepStillThere ? 'marked a persisted task cancelled' : 'marked a persisted task cancelled (its step is no longer in the plan)' };
1890
+ }
1891
+ finally {
1892
+ this._executions.release(owning.id);
1893
+ }
1894
+ }
1634
1895
  /** Abort a run that is in flight, recording WHY so a task can tell a pause from a cancellation. */
1635
1896
  abortLiveRun(id, reason) {
1636
1897
  const live = this.liveRuns.get(id);
@@ -120,6 +120,12 @@ export interface RuntimeSettings {
120
120
  * the worker's hard inner-model-call ceiling. */
121
121
  agents?: {
122
122
  enabled?: boolean;
123
+ /** Phase 3.7, default OFF: offer the goal a bounded, read-shaped agent per shipped ROLE, with no
124
+ * operator-authored definition. The roles and their objectives are in-tree; nothing the model
125
+ * writes becomes an objective, a tool id or a permission.
126
+ *
127
+ * REQUIRES `enabled: true` — on its own this does nothing, because agent execution is off. */
128
+ decompose?: boolean;
123
129
  maxToolCalls?: number;
124
130
  maxDurationMs?: number;
125
131
  maxInnerCalls?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-runtime-engine",
3
- "version": "2.8.0",
3
+ "version": "3.0.0",
4
4
  "description": "AI Runtime \u2014 a provider-agnostic AI runtime and orchestration platform. Point it at whatever AI providers you have; it routes each task to the best available model. Ships the `ai-runtime` CLI and the `Runtime`/`AI` library API.",
5
5
  "type": "module",
6
6
  "license": "ISC",