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.
- package/CHANGELOG.md +93 -0
- package/README.md +30 -0
- package/dist/agents/admit.d.ts +9 -1
- package/dist/agents/admit.js +10 -2
- package/dist/agents/envelope.d.ts +21 -0
- package/dist/agents/envelope.js +39 -5
- package/dist/agents/finding.d.ts +9 -3
- package/dist/agents/finding.js +14 -3
- package/dist/agents/task.d.ts +37 -0
- package/dist/agents/task.js +22 -0
- package/dist/agents/worker.d.ts +12 -1
- package/dist/agents/worker.js +14 -1
- package/dist/cli/cli.js +1 -1
- package/dist/cli/interactive/repl.js +52 -0
- package/dist/cli/interactive/session.d.ts +8 -1
- package/dist/cli/interactive/session.js +62 -10
- package/dist/executions/agentTasks.d.ts +76 -75
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/orchestration/executor.js +5 -1
- package/dist/orchestration/orchestrator.d.ts +2 -1
- package/dist/orchestration/planner.d.ts +2 -1
- package/dist/runtime/config.js +1 -1
- package/dist/runtime/events.d.ts +44 -0
- package/dist/runtime/events.js +4 -0
- package/dist/runtime/runtime.d.ts +70 -3
- package/dist/runtime/runtime.js +288 -27
- package/dist/runtime/types.d.ts +6 -0
- package/package.json +1 -1
package/dist/runtime/runtime.js
CHANGED
|
@@ -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
|
-
|
|
473
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
|
1050
|
-
// always on but only fires on a validation failure, adding metadata to an unchanged error.
|
|
1051
|
-
|
|
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,
|
|
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
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
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
|
-
|
|
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
|
|
1095
|
-
|
|
1096
|
-
.
|
|
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
|
|
1099
|
-
|
|
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,
|
|
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,
|
|
1468
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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);
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -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": "
|
|
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",
|