ai-runtime-engine 2.7.0 → 2.8.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 +60 -0
- package/dist/agents/roles.d.ts +36 -0
- package/dist/agents/roles.js +44 -0
- package/dist/agents/synthesize.d.ts +44 -0
- package/dist/agents/synthesize.js +60 -0
- package/dist/agents/task.d.ts +58 -6
- package/dist/agents/task.js +18 -2
- package/dist/agents/worker.d.ts +23 -0
- package/dist/agents/worker.js +140 -19
- package/dist/cli/cli.js +1 -1
- package/dist/cli/interactive/lanes.d.ts +69 -0
- package/dist/cli/interactive/lanes.js +181 -0
- package/dist/executions/agentTasks.d.ts +627 -0
- package/dist/executions/agentTasks.js +149 -0
- package/dist/executions/checkpoint.d.ts +5 -1
- package/dist/executions/checkpoint.js +13 -1
- package/dist/executions/execution.d.ts +23 -0
- package/dist/executions/store.d.ts +37 -0
- package/dist/executions/store.js +33 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +7 -0
- package/dist/orchestration/executor.d.ts +26 -1
- package/dist/orchestration/executor.js +45 -8
- package/dist/orchestration/orchestrator.d.ts +6 -0
- package/dist/orchestration/orchestrator.js +18 -1
- package/dist/runtime/runtime.d.ts +60 -0
- package/dist/runtime/runtime.js +345 -18
- package/dist/security/redact.js +22 -10
- package/dist/util/hash.d.ts +19 -0
- package/dist/util/hash.js +39 -0
- package/package.json +1 -1
package/dist/runtime/runtime.js
CHANGED
|
@@ -31,6 +31,8 @@ import { TokenEstimator } from '../context/tokens.js';
|
|
|
31
31
|
import { ToolRegistry } from '../tools/registry.js';
|
|
32
32
|
import { resolvePermissions, clampMcpPermissions } from '../tools/permissions.js';
|
|
33
33
|
import { flattenClamp } from '../util/flatten.js';
|
|
34
|
+
import { wrapUntrusted } from '../tools/untrusted.js';
|
|
35
|
+
import { redact } from '../security/redact.js';
|
|
34
36
|
import { McpManager } from '../mcp/manager.js';
|
|
35
37
|
import { mcpTool, mcpToolId } from '../mcp/toolAdapter.js';
|
|
36
38
|
import { StaticMcpSource } from '../mcp/mcp.js';
|
|
@@ -52,6 +54,34 @@ import { executePlan } from '../orchestration/executor.js';
|
|
|
52
54
|
import { ExecutionStore } from '../executions/store.js';
|
|
53
55
|
import { RESUMABLE, TERMINAL } from '../executions/execution.js';
|
|
54
56
|
import { captureCheckpoint, reconcile } from '../executions/checkpoint.js';
|
|
57
|
+
import { AGENT_TERMINAL, AGENT_RESUMABLE } from '../agents/task.js';
|
|
58
|
+
import { parseAgentTasks } from '../executions/agentTasks.js';
|
|
59
|
+
import { stepIdentity } from '../agents/worker.js';
|
|
60
|
+
import { hashOf } from '../util/hash.js';
|
|
61
|
+
/** Checkpoints kept per execution. Only the newest is ever read; the rest are audit trail, and the
|
|
62
|
+
* record is rewritten on every commit, so this is a file-size bound (Phase 3.5). */
|
|
63
|
+
const CHECKPOINTS_MAX = 5;
|
|
64
|
+
/** Findings carried into a replan prompt. Bounded because they are rendered into a model call. */
|
|
65
|
+
const FINDINGS_BRIEF_MAX = 10;
|
|
66
|
+
/**
|
|
67
|
+
* What an observation looks like ON DISK (Phase 3.5).
|
|
68
|
+
*
|
|
69
|
+
* Two jobs, both at the persistence boundary. REDACTION: an observation carries tool output, which is
|
|
70
|
+
* arbitrary text from outside the process — a tool that prints a token would otherwise write it into
|
|
71
|
+
* the execution file verbatim, where it survives every later read. Every other egress (logs, telemetry,
|
|
72
|
+
* CLI) already redacts; the store did not, and Phase 3.5 writes far more of this content far more often.
|
|
73
|
+
* SLIMMING: findings already live on the agent task record in the same file, so keeping only their ids
|
|
74
|
+
* here stops every finding being stored twice and rewritten on every commit.
|
|
75
|
+
*
|
|
76
|
+
* The live observation is untouched — callers still receive full findings from the run itself.
|
|
77
|
+
*/
|
|
78
|
+
function persistableObservation(obs) {
|
|
79
|
+
const data = obs.data;
|
|
80
|
+
const slimmed = data && Array.isArray(data.findings)
|
|
81
|
+
? { ...obs, data: { ...(({ findings: _drop, ...rest }) => rest)(data), findingIds: data.findings.map((f) => f?.id).filter((id) => typeof id === 'string') } }
|
|
82
|
+
: obs;
|
|
83
|
+
return redact(slimmed);
|
|
84
|
+
}
|
|
55
85
|
import { ArtifactStore } from '../artifacts/artifacts.js';
|
|
56
86
|
import { compare } from '../comparison/comparator.js';
|
|
57
87
|
import { renderComparison } from '../comparison/render.js';
|
|
@@ -855,9 +885,11 @@ export class Runtime {
|
|
|
855
885
|
// Renew the lease while the (possibly long) run is in flight so it can't expire mid-run.
|
|
856
886
|
if (this.agentsEnabled)
|
|
857
887
|
this.liveRuns.set(exec.id, { controller });
|
|
858
|
-
|
|
888
|
+
// Phase 3.5: only when there is a store to write to — a stateless run keeps the 2.7.0 shape.
|
|
889
|
+
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)));
|
|
859
891
|
exec.status = this.execStatus(outcome.status);
|
|
860
|
-
exec.observations = outcome.observations;
|
|
892
|
+
exec.observations = outcome.observations.map(persistableObservation);
|
|
861
893
|
if (outcome.plan) {
|
|
862
894
|
exec.plan = outcome.plan;
|
|
863
895
|
exec.planVersion = outcome.plan.version;
|
|
@@ -865,12 +897,19 @@ export class Runtime {
|
|
|
865
897
|
}
|
|
866
898
|
if (outcome.status === 'waiting_for_approval')
|
|
867
899
|
exec.pending = { kind: 'approval', action: goal };
|
|
868
|
-
else if (outcome.status === 'waiting_for_clarification' && outcome.clarification)
|
|
869
|
-
|
|
900
|
+
else if (outcome.status === 'waiting_for_clarification' && outcome.clarification) {
|
|
901
|
+
// First-wins: if an AGENT is what is waiting, the pending slot records which one, so the answer
|
|
902
|
+
// is routed into that task's inner resume instead of being appended to the outer goal.
|
|
903
|
+
const waiter = this.electWaitingAgent(exec);
|
|
904
|
+
exec.pending = { kind: 'clarification', question: outcome.clarification, ...(waiter ? { agentTaskId: waiter.agentTaskId } : {}) };
|
|
905
|
+
}
|
|
870
906
|
else if (outcome.status === 'waiting_for_budget' && outcome.budget)
|
|
871
907
|
exec.pending = { kind: 'budget', budget: outcome.budget };
|
|
872
|
-
exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps }));
|
|
873
|
-
|
|
908
|
+
exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps, mcpTools: this.mcpToolHashes(exec.plan) }));
|
|
909
|
+
if (sink)
|
|
910
|
+
sink.finalize();
|
|
911
|
+
else
|
|
912
|
+
this._executions.commit(exec); // ownership-checked: never clobber a newer owner
|
|
874
913
|
this.recordOrchestration(mode, goal, outcome);
|
|
875
914
|
return this.mapOutcome(outcome, resolution, runId, exec.id, planning);
|
|
876
915
|
}
|
|
@@ -880,6 +919,110 @@ export class Runtime {
|
|
|
880
919
|
this._executions.release(exec.id);
|
|
881
920
|
}
|
|
882
921
|
}
|
|
922
|
+
/**
|
|
923
|
+
* THE mid-run persistence sink (Phase 3.5) — the only thing that writes an execution while it runs.
|
|
924
|
+
*
|
|
925
|
+
* Invariant 18 says everything needed for resume is on disk before the next wave starts, and the
|
|
926
|
+
* non-obvious part is WHAT that includes. Committing the plan, the completed steps and the agent
|
|
927
|
+
* records is not enough: the resume gate is `!recon.drifted && !!exec.plan`, and `recon` defaults to
|
|
928
|
+
* DRIFTED whenever `checkpoints` is empty. Since checkpoints were captured only after orchestration
|
|
929
|
+
* returned, a crash mid-run always drifted, always replanned, and re-ran every completed agent task —
|
|
930
|
+
* the exact thing this phase exists to prevent. So the sink captures a checkpoint too.
|
|
931
|
+
*
|
|
932
|
+
* Every refusal from the funnel ABORTS the run. A refused commit means another owner now owns this
|
|
933
|
+
* execution's fate; carrying on would call the same tools and burn the same model calls twice while
|
|
934
|
+
* that owner re-runs the identical steps, and every result would be discarded at the end anyway.
|
|
935
|
+
*/
|
|
936
|
+
runPersistence(exec, controller) {
|
|
937
|
+
// Fixed for each executePlan call: its `callsUsed` is cumulative for that call, so the pool must be
|
|
938
|
+
// `priorCalls + thisCall`, and `priorCalls` advances only when the call ends. Adding a per-fire delta
|
|
939
|
+
// instead would let the total go BACKWARDS across replan iterations and over-grant the resume pool.
|
|
940
|
+
let priorCalls = exec.callsUsed ?? 0;
|
|
941
|
+
let lastCheckpointed = '';
|
|
942
|
+
let stopped = false;
|
|
943
|
+
const commit = () => {
|
|
944
|
+
if (stopped)
|
|
945
|
+
return;
|
|
946
|
+
const outcome = this._executions.commitProgress(exec);
|
|
947
|
+
if (outcome === 'ok')
|
|
948
|
+
return;
|
|
949
|
+
// Stop trying to write, and stop the run itself.
|
|
950
|
+
stopped = true;
|
|
951
|
+
this.abortLiveRun(exec.id, outcome === 'paused' ? 'pause' : 'parent-cancel');
|
|
952
|
+
controller.abort();
|
|
953
|
+
};
|
|
954
|
+
/** Capture only when the completed set moved: `captureCheckpoint` hashes files and shells git. */
|
|
955
|
+
const checkpointIfMoved = () => {
|
|
956
|
+
const key = exec.completedSteps.join('\u0000');
|
|
957
|
+
if (key === lastCheckpointed)
|
|
958
|
+
return;
|
|
959
|
+
lastCheckpointed = key;
|
|
960
|
+
exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps, mcpTools: this.mcpToolHashes(exec.plan) }));
|
|
961
|
+
if (exec.checkpoints.length > CHECKPOINTS_MAX)
|
|
962
|
+
exec.checkpoints.splice(0, exec.checkpoints.length - CHECKPOINTS_MAX);
|
|
963
|
+
};
|
|
964
|
+
return {
|
|
965
|
+
/** The plan is settled and nothing has run: a crash in wave 1 must still resume against a plan. */
|
|
966
|
+
onPlan: (plan) => {
|
|
967
|
+
exec.plan = plan;
|
|
968
|
+
exec.planVersion = plan.version;
|
|
969
|
+
exec.completedSteps = plan.steps.filter((st) => st.status === 'succeeded').map((st) => st.id);
|
|
970
|
+
checkpointIfMoved();
|
|
971
|
+
commit();
|
|
972
|
+
},
|
|
973
|
+
onProgress: (snap) => {
|
|
974
|
+
exec.plan = snap.plan;
|
|
975
|
+
exec.completedSteps = snap.plan.steps.filter((st) => st.status === 'succeeded').map((st) => st.id);
|
|
976
|
+
if (snap.observations.length)
|
|
977
|
+
exec.observations = [...exec.observations, ...snap.observations.map(persistableObservation)];
|
|
978
|
+
exec.callsUsed = priorCalls + snap.callsUsed;
|
|
979
|
+
checkpointIfMoved();
|
|
980
|
+
commit();
|
|
981
|
+
// One executePlan call is over; its spend is now part of the floor for the next one.
|
|
982
|
+
if (snap.at === 'plan-end')
|
|
983
|
+
priorCalls = exec.callsUsed ?? priorCalls;
|
|
984
|
+
},
|
|
985
|
+
onRecord: (record) => {
|
|
986
|
+
const tasks = (exec.agentTasks ??= []);
|
|
987
|
+
const at = tasks.findIndex((t) => t.agentTaskId === record.agentTaskId);
|
|
988
|
+
// TERMINAL is sticky for a TASK too. A late write from an aborted worker must not reopen a task
|
|
989
|
+
// that already completed, failed or was cancelled.
|
|
990
|
+
if (at >= 0 && AGENT_TERMINAL.has(tasks[at].state) && !AGENT_TERMINAL.has(record.state))
|
|
991
|
+
return;
|
|
992
|
+
// Redacted at the boundary: findings, inner observations and diagnostics all carry text that
|
|
993
|
+
// came from tools and models, and this record is about to become a durable file.
|
|
994
|
+
// The inner workspace fingerprint. The OUTER checkpoint cannot stand in for it: `planPaths`
|
|
995
|
+
// reads the outer plan's step inputs, so files an agent touched through its own inner steps are
|
|
996
|
+
// invisible to it. Captured here because the worker has no workspace root — it is deliberately
|
|
997
|
+
// not given one.
|
|
998
|
+
if (record.innerPlan) {
|
|
999
|
+
record.innerCheckpoint = captureCheckpoint({ root: this.workspaceRoot, plan: record.innerPlan, skills: this.skills(), completedSteps: record.innerCompletedSteps });
|
|
1000
|
+
}
|
|
1001
|
+
const snapshot = redact({ ...record });
|
|
1002
|
+
if (at >= 0)
|
|
1003
|
+
tasks[at] = snapshot;
|
|
1004
|
+
else
|
|
1005
|
+
tasks.push(snapshot);
|
|
1006
|
+
// A task that FINISHED means its step succeeded. Waiting for the batch commit to record that
|
|
1007
|
+
// leaves a window where a crash finds a `completed` record — which is not resumable, so no
|
|
1008
|
+
// record is offered — and a step not in `completedSteps`, so the agent is simply re-run: a
|
|
1009
|
+
// second paid planning call, the tools fired twice, and two records for one step.
|
|
1010
|
+
if (record.state === 'completed' && !exec.completedSteps.includes(record.stepId)) {
|
|
1011
|
+
exec.completedSteps = [...exec.completedSteps, record.stepId];
|
|
1012
|
+
checkpointIfMoved();
|
|
1013
|
+
}
|
|
1014
|
+
commit();
|
|
1015
|
+
},
|
|
1016
|
+
/**
|
|
1017
|
+
* The terminal write. It goes through the SAME funnel as every mid-run commit, so a pause or a
|
|
1018
|
+
* cancel that landed while the run was finishing is not overwritten by its result: the plain
|
|
1019
|
+
* `commit()` only refuses a live FOREIGN lease, and pause/cancel release the lease as this very
|
|
1020
|
+
* owner — so nothing stopped the final write from resurrecting a cancelled run as `completed`.
|
|
1021
|
+
*/
|
|
1022
|
+
finalize: () => (stopped ? 'terminal' : this._executions.commitProgress(exec)),
|
|
1023
|
+
stopped: () => stopped,
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
883
1026
|
/** Run `fn` while heartbeating the execution lease so a long run never lets the lease expire. */
|
|
884
1027
|
async withHeartbeat(id, fn) {
|
|
885
1028
|
const timer = setInterval(() => this._executions.heartbeat(id), this._executions.heartbeatMs);
|
|
@@ -892,7 +1035,7 @@ export class Runtime {
|
|
|
892
1035
|
clearInterval(timer);
|
|
893
1036
|
}
|
|
894
1037
|
}
|
|
895
|
-
orchestrateInput(mode, goal, policy, routing, partial, requiredCapabilities, signal, provenance) {
|
|
1038
|
+
orchestrateInput(mode, goal, policy, routing, partial, requiredCapabilities, signal, provenance, sink, agentResume) {
|
|
896
1039
|
return {
|
|
897
1040
|
goal,
|
|
898
1041
|
mode,
|
|
@@ -912,7 +1055,10 @@ export class Runtime {
|
|
|
912
1055
|
resolveGaps: (missing) => this.resolveMissingRefs(missing, policy),
|
|
913
1056
|
// Phase 3.4: ONE runner source. `agents`, `runAgent` and `reserve` ride along only when agents are
|
|
914
1057
|
// enabled AND a definition exists, so with the flag off this object is KEY-identical to 2.6.0.
|
|
915
|
-
...this.orchestrateRunners(policy, signal, provenance),
|
|
1058
|
+
...this.orchestrateRunners(policy, signal, provenance, sink?.onRecord, agentResume),
|
|
1059
|
+
// Phase 3.5: the commit points. Present ONLY when there is a store to commit to, so a stateless
|
|
1060
|
+
// Runtime builds an OrchestrateInput key-identical to 2.7.0.
|
|
1061
|
+
...(sink ? { onPlan: sink.onPlan, onProgress: sink.onProgress } : {}),
|
|
916
1062
|
...(signal ? { signal } : {}),
|
|
917
1063
|
};
|
|
918
1064
|
}
|
|
@@ -1180,6 +1326,13 @@ export class Runtime {
|
|
|
1180
1326
|
return { ok: false, runId, mode: resolution, status: 'failed', response: { text: `cannot resume ${id}: ${acq.reason ?? 'unavailable'}` }, artifacts: [] };
|
|
1181
1327
|
}
|
|
1182
1328
|
const exec = acq.execution;
|
|
1329
|
+
// A resumed run is a live run: pause/cancel must be able to abort it, and a refused commit must be
|
|
1330
|
+
// able to stop it — both of which need a controller registered under this execution's id.
|
|
1331
|
+
const controller = new AbortController();
|
|
1332
|
+
// What was on the record BEFORE this resume. The terminal write rebuilds from here rather than
|
|
1333
|
+
// appending: the sink has already been appending this run's observations as they happened, so
|
|
1334
|
+
// appending the outcome's copy too would store every step of a resumed run twice.
|
|
1335
|
+
const observationsBefore = [...exec.observations];
|
|
1183
1336
|
try {
|
|
1184
1337
|
if (TERMINAL.has(exec.status))
|
|
1185
1338
|
return this.resultFromExecution(exec, resolution, runId);
|
|
@@ -1198,12 +1351,28 @@ export class Runtime {
|
|
|
1198
1351
|
return this.resultFromExecution(exec, resolution, runId);
|
|
1199
1352
|
}
|
|
1200
1353
|
}
|
|
1354
|
+
// An INNER wait needs its answer, exactly as an approval needs a decision. Without this gate the
|
|
1355
|
+
// ordinary `resume-execution <id>` (the CLI makes --answer optional) falls through to the replan
|
|
1356
|
+
// branch, which replaces the plan and orphans every sibling agent's completed work — destroying
|
|
1357
|
+
// progress in the one situation the wait exists to protect.
|
|
1358
|
+
if (exec.pending?.kind === 'clarification' && exec.pending.agentTaskId && !opts.clarificationAnswer) {
|
|
1359
|
+
return this.resultFromExecution(exec, resolution, runId);
|
|
1360
|
+
}
|
|
1361
|
+
// Phase 3.5: whoever was mid-flight when this execution stopped is gone. Put those records back in
|
|
1362
|
+
// `queued` with the reason BEFORE anything is scheduled against them.
|
|
1363
|
+
if (this.reconcileAgentTasks(exec, 'crash') > 0)
|
|
1364
|
+
this._executions.commit(exec);
|
|
1201
1365
|
// Reconcile against the checkpoint — drift forces a replan rather than a blind continue.
|
|
1202
1366
|
const checkpoint = exec.checkpoints[exec.checkpoints.length - 1];
|
|
1203
|
-
const recon = checkpoint ? reconcile(checkpoint, this.workspaceRoot, this.skills()) : { drifted: true, reasons: ['no checkpoint'] };
|
|
1204
|
-
|
|
1367
|
+
const recon = checkpoint ? reconcile(checkpoint, this.workspaceRoot, this.skills(), this.mcpToolHashes(exec.plan)) : { drifted: true, reasons: ['no checkpoint'] };
|
|
1368
|
+
// An INNER answer belongs to one agent task, never to the outer goal: appending it here would
|
|
1369
|
+
// replan the whole plan and discard every sibling agent's completed work.
|
|
1370
|
+
const innerAnswered = exec.pending?.kind === 'clarification' && !!exec.pending.agentTaskId && !!opts.clarificationAnswer;
|
|
1371
|
+
const goal = exec.pending?.kind === 'clarification' && opts.clarificationAnswer && !innerAnswered ? `${exec.goal}\n\nClarification: ${opts.clarificationAnswer}` : exec.goal;
|
|
1205
1372
|
// Re-read the budget from env so raising AI_MAX_CALLS before resuming actually takes effect (Phase 22).
|
|
1206
1373
|
const policy = resolvePolicy({ mode: 'orchestrate', overrides: { autonomy: 'autonomous', approval: 'none', ...(this.configPermissions ? { permissions: this.configPermissions } : {}) }, settings: this.settingsValue, env: { maxCostUsd: numFromEnv(this.env, 'AI_MAX_COST_USD'), maxCalls: numFromEnv(this.env, 'AI_MAX_CALLS') } });
|
|
1374
|
+
if (this.agentsEnabled)
|
|
1375
|
+
this.liveRuns.set(exec.id, { controller });
|
|
1207
1376
|
// A resumed run is never re-DERIVED — no surprise second model call on work already approved and
|
|
1208
1377
|
// mid-flight — but it does get the FREE post-plan check: a grant may have changed since it started.
|
|
1209
1378
|
const planning = this.settingsValue.capabilities?.planning
|
|
@@ -1212,18 +1381,37 @@ export class Runtime {
|
|
|
1212
1381
|
// Continue (skip completed steps) for an approved plan, a budget pause, or partial progress.
|
|
1213
1382
|
const approvedNow = exec.pending?.kind === 'approval';
|
|
1214
1383
|
const budgetPaused = exec.pending?.kind === 'budget';
|
|
1215
|
-
|
|
1384
|
+
// `innerAnswered` is its own arm: the plan is intact and one agent step needs re-running with its
|
|
1385
|
+
// answer. The partial-progress arm cannot cover it — a run where every agent asked before doing
|
|
1386
|
+
// anything has NO completed steps, which is precisely the two-waiting-agents case.
|
|
1387
|
+
const canContinue = !recon.drifted && !!exec.plan && (approvedNow || budgetPaused || innerAnswered || (exec.completedSteps.length > 0 && !exec.pending));
|
|
1388
|
+
const sink = this._executions.enabled ? this.runPersistence(exec, controller) : undefined;
|
|
1216
1389
|
let outcome;
|
|
1217
1390
|
if (canContinue && exec.plan) {
|
|
1218
1391
|
// Continue the plan, skipping completed steps. The call budget is ALWAYS enforced on the continue
|
|
1219
1392
|
// path (whether the pause was approval, budget, or partial-progress) so an approved-but-over-budget
|
|
1220
1393
|
// plan pauses for budget rather than silently exceeding it; a raised budget re-applies here.
|
|
1221
|
-
const
|
|
1394
|
+
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 } : {}) }));
|
|
1222
1396
|
if (exe.stoppedForBudget) {
|
|
1223
1397
|
const done = exe.plan.steps.filter((s) => s.status === 'succeeded').length;
|
|
1224
1398
|
const total = exe.plan.steps.length;
|
|
1225
1399
|
outcome = { status: 'waiting_for_budget', plan: exe.plan, planHistory: [exe.plan], observations: exe.observations, budget: { estCalls: foldCalls(exe.plan.steps, policy.maxCalls), maxCalls: policy.maxCalls, completedSteps: done, totalSteps: total }, summary: `Ran ${done} of ${total} step(s) within the ${policy.maxCalls}-call budget. Raise the budget (AI_MAX_CALLS) and resume to continue.` };
|
|
1226
1400
|
}
|
|
1401
|
+
else if (exe.waiting?.length) {
|
|
1402
|
+
// Answering one agent can simply reveal the next one. Reporting `failed` here (the pre-3.5
|
|
1403
|
+
// shape, which read `ok` alone) would mark a perfectly resumable run as dead and lie in the
|
|
1404
|
+
// summary while doing it.
|
|
1405
|
+
const asked = exe.observations.find((o) => o.code === 'agent-waiting');
|
|
1406
|
+
outcome = {
|
|
1407
|
+
status: 'waiting_for_clarification',
|
|
1408
|
+
plan: exe.plan,
|
|
1409
|
+
planHistory: [exe.plan],
|
|
1410
|
+
observations: exe.observations,
|
|
1411
|
+
clarification: asked?.error ?? 'an agent needs more information to continue',
|
|
1412
|
+
summary: `Still waiting: ${exe.waiting.length} agent step(s) need an answer.`,
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1227
1415
|
else {
|
|
1228
1416
|
outcome = { status: exe.ok ? 'completed' : 'failed', plan: exe.plan, planHistory: [exe.plan], observations: exe.observations, summary: exe.ok ? `resumed and completed "${exec.goal}"` : `resume did not complete "${exec.goal}"` };
|
|
1229
1417
|
}
|
|
@@ -1232,25 +1420,42 @@ export class Runtime {
|
|
|
1232
1420
|
// Drift, or a pending clarification answer, or no partial progress → replan from the goal. Re-resolve
|
|
1233
1421
|
// routing so env/config excludes (and learned prefer) still apply. A budget-paused replan stays
|
|
1234
1422
|
// partial so it keeps running-what-fits instead of reverting to notify-and-wait.
|
|
1235
|
-
|
|
1423
|
+
// Drift replaces the PLAN, not the facts. What the agents proved before the drift is carried
|
|
1424
|
+
// into the new planning context rather than silently orphaned.
|
|
1425
|
+
const brief = this.findingsBrief(exec);
|
|
1426
|
+
// The full argument list. Passing five positionals to a ten-parameter function silently left
|
|
1427
|
+
// this arm with no signal, no provenance, no sink and no resume lookup — so a replan persisted
|
|
1428
|
+
// no agent record AT ALL (onRecord is the only writer of `agentTasks`), could not be paused or
|
|
1429
|
+
// 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)));
|
|
1236
1431
|
}
|
|
1237
1432
|
exec.status = this.execStatus(outcome.status);
|
|
1238
|
-
exec.observations = [...
|
|
1433
|
+
exec.observations = [...observationsBefore, ...outcome.observations.map(persistableObservation)];
|
|
1239
1434
|
if (outcome.plan) {
|
|
1240
1435
|
exec.plan = outcome.plan;
|
|
1241
1436
|
exec.planVersion = outcome.plan.version;
|
|
1242
1437
|
exec.completedSteps = outcome.plan.steps.filter((s) => s.status === 'succeeded').map((s) => s.id);
|
|
1243
1438
|
}
|
|
1244
|
-
// Preserve a fresh budget pause; otherwise the pending state is resolved
|
|
1439
|
+
// Preserve a fresh budget pause; otherwise the pending state is resolved — except that answering
|
|
1440
|
+
// one agent's question may simply have revealed the NEXT one (rediscovery: the records are the
|
|
1441
|
+
// source of truth, so a second waiting task is re-elected rather than queued somewhere).
|
|
1245
1442
|
if (outcome.status === 'waiting_for_budget' && outcome.budget)
|
|
1246
1443
|
exec.pending = { kind: 'budget', budget: outcome.budget };
|
|
1444
|
+
else if (outcome.status === 'waiting_for_clarification' && outcome.clarification) {
|
|
1445
|
+
const waiter = this.electWaitingAgent(exec);
|
|
1446
|
+
exec.pending = { kind: 'clarification', question: outcome.clarification, ...(waiter ? { agentTaskId: waiter.agentTaskId } : {}) };
|
|
1447
|
+
}
|
|
1247
1448
|
else
|
|
1248
1449
|
delete exec.pending;
|
|
1249
|
-
exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps }));
|
|
1250
|
-
|
|
1450
|
+
exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps, mcpTools: this.mcpToolHashes(exec.plan) }));
|
|
1451
|
+
if (sink)
|
|
1452
|
+
sink.finalize();
|
|
1453
|
+
else
|
|
1454
|
+
this._executions.commit(exec);
|
|
1251
1455
|
return this.mapOutcome(outcome, resolution, runId, exec.id, planning);
|
|
1252
1456
|
}
|
|
1253
1457
|
finally {
|
|
1458
|
+
this.liveRuns.delete(exec.id);
|
|
1254
1459
|
this._executions.release(id);
|
|
1255
1460
|
}
|
|
1256
1461
|
}
|
|
@@ -1259,7 +1464,7 @@ export class Runtime {
|
|
|
1259
1464
|
* They used to drift: resume built its own pair with no agent runner, so a persisted plan containing
|
|
1260
1465
|
* an agent step would have failed every one of those steps.
|
|
1261
1466
|
*/
|
|
1262
|
-
orchestrateRunners(policy, signal, provenance) {
|
|
1467
|
+
orchestrateRunners(policy, signal, provenance, onRecord, agentResume) {
|
|
1263
1468
|
const envelopes = policy ? this.agentEnvelopes(policy) : [];
|
|
1264
1469
|
const byId = new Map(envelopes.map((e) => [e.agentId, e]));
|
|
1265
1470
|
return {
|
|
@@ -1275,7 +1480,11 @@ export class Runtime {
|
|
|
1275
1480
|
if (!envelope || !definition)
|
|
1276
1481
|
return { stepId: step.id, ok: false, code: 'agent-not-enabled', error: `no agent definition '${step.agent ?? ''}'` };
|
|
1277
1482
|
const innerSkills = this._skills.list().filter((sk) => envelope.skills.includes(sk.id));
|
|
1483
|
+
// Phase 3.5: continue a persisted task for THIS step, if one exists.
|
|
1484
|
+
const prior = agentResume?.(step);
|
|
1278
1485
|
const out = await runAgentTask(step, envelope, definition, {
|
|
1486
|
+
...(prior?.record ? { resume: prior.record } : {}),
|
|
1487
|
+
...(prior?.answer ? { resumeAnswer: prior.answer } : {}),
|
|
1279
1488
|
ai: this._ai,
|
|
1280
1489
|
clock: this.clock,
|
|
1281
1490
|
skills: innerSkills,
|
|
@@ -1295,6 +1504,7 @@ export class Runtime {
|
|
|
1295
1504
|
}
|
|
1296
1505
|
},
|
|
1297
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 }),
|
|
1507
|
+
...(onRecord ? { onRecord } : {}),
|
|
1298
1508
|
...(ctx.signal ?? signal ? { parentSignal: ctx.signal ?? signal } : {}),
|
|
1299
1509
|
...(provenance ? { provenance } : { provenance: { planVersion: 1 } }),
|
|
1300
1510
|
abortReason: () => {
|
|
@@ -1310,6 +1520,117 @@ export class Runtime {
|
|
|
1310
1520
|
: {}),
|
|
1311
1521
|
};
|
|
1312
1522
|
}
|
|
1523
|
+
/**
|
|
1524
|
+
* A bounded, fenced brief of what the agents have already established (Phase 3.5).
|
|
1525
|
+
*
|
|
1526
|
+
* On drift the plan is thrown away and the goal is replanned — but validated findings are facts about
|
|
1527
|
+
* the WORKSPACE, not about the plan's structure, so discarding them silently would make the run redo
|
|
1528
|
+
* work it had already proved. They are agent-authored text, so they cross a prompt boundary fenced,
|
|
1529
|
+
* exactly like every other untrusted string.
|
|
1530
|
+
*/
|
|
1531
|
+
findingsBrief(exec) {
|
|
1532
|
+
const active = parseAgentTasks(exec)
|
|
1533
|
+
.tasks.flatMap((t) => t.findings)
|
|
1534
|
+
.filter((f) => f.status === 'active')
|
|
1535
|
+
.slice(0, FINDINGS_BRIEF_MAX);
|
|
1536
|
+
if (!active.length)
|
|
1537
|
+
return undefined;
|
|
1538
|
+
const lines = active.map((f) => `- [${flattenClamp(f.type, 40)}] ${flattenClamp(f.subject ?? '', 60)}: ${flattenClamp(f.claim, 160)}`);
|
|
1539
|
+
return wrapUntrusted('prior-findings', `Already established by earlier agent work:\n${lines.join('\n')}`);
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Fingerprint the MCP tools a plan actually references (Phase 3.5). An MCP server is remote and
|
|
1543
|
+
* mutable: while a plan sits paused it can change a tool's input schema, change what it does, or drop
|
|
1544
|
+
* it entirely — and the plan would then be resumed against a tool that is no longer the tool it was
|
|
1545
|
+
* planned for. Hashing the live declaration makes that visible as ordinary drift.
|
|
1546
|
+
*
|
|
1547
|
+
* Non-MCP tools are deliberately absent: they are in-tree code covered by the config/skill hashes.
|
|
1548
|
+
*/
|
|
1549
|
+
mcpToolHashes(plan) {
|
|
1550
|
+
const out = {};
|
|
1551
|
+
if (!plan)
|
|
1552
|
+
return out;
|
|
1553
|
+
for (const step of plan.steps) {
|
|
1554
|
+
if (!step.tool || !step.tool.startsWith('mcp:') || out[step.tool])
|
|
1555
|
+
continue;
|
|
1556
|
+
const tool = this._tools.get(step.tool);
|
|
1557
|
+
// A missing tool hashes to a sentinel rather than being skipped — "gone" must be comparable, or a
|
|
1558
|
+
// removed server would silently look like no drift at all.
|
|
1559
|
+
out[step.tool] = tool ? hashOf({ description: tool.description, parameters: tool.parameters ?? null }) : 'absent';
|
|
1560
|
+
}
|
|
1561
|
+
return out;
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* Crash / pause / cancel reconciliation (Phase 3.5). A record left `running` or `created` describes a
|
|
1565
|
+
* worker that no longer exists — the process died, or the parent stopped it — so it goes back to
|
|
1566
|
+
* `queued` with an auditable reason. An interruption is deliberately NOT a state: the lifecycle does
|
|
1567
|
+
* not grow, only the explanation does.
|
|
1568
|
+
*
|
|
1569
|
+
* This runs on the RESUME path and inside pause/cancel. Resume alone is not enough: `cancelExecution`
|
|
1570
|
+
* writes a terminal status and resume early-returns on terminal, so a cancelled execution's `running`
|
|
1571
|
+
* records would stay `running` on disk forever, unstamped and unexplained.
|
|
1572
|
+
*
|
|
1573
|
+
* Records that fail validation are preserved in place, never dropped — reconciling is not a licence to
|
|
1574
|
+
* delete what this version could not parse.
|
|
1575
|
+
*/
|
|
1576
|
+
reconcileAgentTasks(exec, kind) {
|
|
1577
|
+
const raw = exec.agentTasks;
|
|
1578
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
1579
|
+
return 0;
|
|
1580
|
+
const { tasks, dropped } = parseAgentTasks(exec);
|
|
1581
|
+
const now = this.clock.now();
|
|
1582
|
+
const fixed = new Map();
|
|
1583
|
+
for (const t of tasks) {
|
|
1584
|
+
if (t.state !== 'running' && t.state !== 'created')
|
|
1585
|
+
continue;
|
|
1586
|
+
fixed.set(t.agentTaskId, { ...t, state: 'queued', interruption: { kind, at: now }, updatedAt: now });
|
|
1587
|
+
}
|
|
1588
|
+
if (dropped)
|
|
1589
|
+
exec.agentTasksDropped = dropped;
|
|
1590
|
+
if (fixed.size === 0)
|
|
1591
|
+
return 0;
|
|
1592
|
+
exec.agentTasks = raw.map((entry) => fixed.get(entry.agentTaskId ?? '') ?? entry);
|
|
1593
|
+
return fixed.size;
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* FIRST-WINS PENDING. `Execution.pending` is a single slot but two agents can be waiting at once, so
|
|
1597
|
+
* the earliest-created waiting task claims it. The others are not lost: once this one is answered and
|
|
1598
|
+
* the run continues, the next resume re-elects whichever task is still waiting — rediscovery, rather
|
|
1599
|
+
* than a queue that has to be kept in sync with the records that are already the source of truth.
|
|
1600
|
+
*/
|
|
1601
|
+
electWaitingAgent(exec) {
|
|
1602
|
+
return parseAgentTasks(exec).tasks.find((t) => t.state === 'waiting_for_clarification' && t.pendingInner);
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Which persisted task, if any, a plan step should CONTINUE. Bound by step-input hash, never by step
|
|
1606
|
+
* id: plan step ids (`s1`, `auto1`) are model-authored and recur across replans, so an id match would
|
|
1607
|
+
* hand one step's completed inner work to a different step with the same id and a different input.
|
|
1608
|
+
*/
|
|
1609
|
+
agentResumeLookup(exec, answer) {
|
|
1610
|
+
const tasks = parseAgentTasks(exec).tasks.filter((t) => AGENT_RESUMABLE.has(t.state));
|
|
1611
|
+
const answeringId = exec.pending?.agentTaskId;
|
|
1612
|
+
// A plan may legitimately contain two steps with identical identity (same agent, same input) — a
|
|
1613
|
+
// retry, or genuinely duplicated work. Without claiming, both would resolve to the SAME record and
|
|
1614
|
+
// the second would resume, and then overwrite, the first's inner work.
|
|
1615
|
+
const claimed = new Set();
|
|
1616
|
+
return (step) => {
|
|
1617
|
+
if (!step.agent)
|
|
1618
|
+
return undefined;
|
|
1619
|
+
const hash = stepIdentity(step);
|
|
1620
|
+
const record = tasks.find((t) => t.agentId === step.agent && t.stepInputHash === hash && !claimed.has(t.agentTaskId));
|
|
1621
|
+
if (!record)
|
|
1622
|
+
return undefined;
|
|
1623
|
+
claimed.add(record.agentTaskId);
|
|
1624
|
+
// If the files this agent worked on changed while the run was stopped, its completed inner steps
|
|
1625
|
+
// are no longer safe to skip — the same rule the outer plan already lives by.
|
|
1626
|
+
if (record.innerCheckpoint && reconcile(record.innerCheckpoint, this.workspaceRoot, this.skills()).drifted) {
|
|
1627
|
+
const { innerPlan: _drop, ...rest } = record;
|
|
1628
|
+
return { record: { ...rest, innerCompletedSteps: [], innerObservations: [], innerObservationsOmitted: 0 } };
|
|
1629
|
+
}
|
|
1630
|
+
// The answer belongs to exactly ONE task — the one that asked.
|
|
1631
|
+
return answer && record.agentTaskId === answeringId ? { record, answer } : { record };
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1313
1634
|
/** Abort a run that is in flight, recording WHY so a task can tell a pause from a cancellation. */
|
|
1314
1635
|
abortLiveRun(id, reason) {
|
|
1315
1636
|
const live = this.liveRuns.get(id);
|
|
@@ -1327,6 +1648,9 @@ export class Runtime {
|
|
|
1327
1648
|
// Phase 3.4: abort whatever is actually in flight, so a pause stops live agent work instead of only
|
|
1328
1649
|
// flipping a stored status. A no-op with agents disabled — `liveRuns` is empty then.
|
|
1329
1650
|
this.abortLiveRun(id, 'pause');
|
|
1651
|
+
// Phase 3.5: stamp whatever was mid-flight. Doing it here rather than only on resume is what stops a
|
|
1652
|
+
// record sitting at `running` with no worker behind it.
|
|
1653
|
+
this.reconcileAgentTasks(exec, 'pause');
|
|
1330
1654
|
this._executions.save(exec);
|
|
1331
1655
|
this._executions.release(id);
|
|
1332
1656
|
return true;
|
|
@@ -1338,6 +1662,9 @@ export class Runtime {
|
|
|
1338
1662
|
return false;
|
|
1339
1663
|
exec.status = 'cancelled';
|
|
1340
1664
|
this.abortLiveRun(id, 'parent-cancel'); // parent cancelled ⇒ every live agent controller aborted
|
|
1665
|
+
// Cancel is TERMINAL and resume early-returns on terminal, so if this did not reconcile here those
|
|
1666
|
+
// `running` records would stay `running` on disk forever, unstamped and unexplained.
|
|
1667
|
+
this.reconcileAgentTasks(exec, 'parent-cancel');
|
|
1341
1668
|
this._executions.save(exec);
|
|
1342
1669
|
this._executions.release(id);
|
|
1343
1670
|
return true;
|
package/dist/security/redact.js
CHANGED
|
@@ -37,21 +37,33 @@ export function redactString(input) {
|
|
|
37
37
|
}
|
|
38
38
|
/** Deep-redact any value, returning a scrubbed clone. Non-plain objects are stringified defensively. */
|
|
39
39
|
export function redact(value) {
|
|
40
|
-
return redactValue(value, new
|
|
40
|
+
return redactValue(value, new Set());
|
|
41
41
|
}
|
|
42
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The `ancestors` set holds the objects on the CURRENT PATH, and each is removed on the way back out.
|
|
44
|
+
* A global visited-set would be wrong: it cannot tell a cycle from a DAG, so the second appearance of a
|
|
45
|
+
* merely SHARED object becomes the string `'[circular]'`. That is not cosmetic — a shared array turning
|
|
46
|
+
* into a string changes the shape of redacted data, and anything that then validates it (the persisted
|
|
47
|
+
* agent-task schema does) rejects the whole record. Only a genuine cycle reports `[circular]`.
|
|
48
|
+
*/
|
|
49
|
+
function redactValue(value, ancestors) {
|
|
43
50
|
if (typeof value === 'string')
|
|
44
51
|
return redactString(value);
|
|
45
52
|
if (value === null || typeof value !== 'object')
|
|
46
53
|
return value;
|
|
47
|
-
if (
|
|
54
|
+
if (ancestors.has(value))
|
|
48
55
|
return '[circular]';
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
ancestors.add(value);
|
|
57
|
+
try {
|
|
58
|
+
if (Array.isArray(value))
|
|
59
|
+
return value.map((v) => redactValue(v, ancestors));
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const [k, v] of Object.entries(value)) {
|
|
62
|
+
out[k] = redactValue(v, ancestors);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
ancestors.delete(value);
|
|
55
68
|
}
|
|
56
|
-
return out;
|
|
57
69
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One short content hash, shared by everything that needs to ask "is this the same thing it was?".
|
|
3
|
+
*
|
|
4
|
+
* Phase 3.5 resume decisions turn on identity comparisons — is this the same agent definition, the same
|
|
5
|
+
* narrowed envelope, the same step input, the same MCP tool declaration — and each of those was about to
|
|
6
|
+
* grow its own hashing. They must agree, so there is exactly one implementation and one canonical JSON
|
|
7
|
+
* form: keys sorted at every depth, so `{a,b}` and `{b,a}` are the same object, which is the whole point
|
|
8
|
+
* when the thing being hashed came back from `JSON.parse`.
|
|
9
|
+
*/
|
|
10
|
+
/** 16 hex chars — the same width `checkpoint.ts` uses for file hashes. Identity, never security. */
|
|
11
|
+
export declare function hash16(text: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Stable JSON: object keys sorted at every depth, `undefined` dropped. Arrays keep their order (order is
|
|
14
|
+
* meaning in a plan's steps). Cycles are impossible in the persisted shapes this serves, and a stray one
|
|
15
|
+
* throws rather than hashing something arbitrary.
|
|
16
|
+
*/
|
|
17
|
+
export declare function canonicalJson(value: unknown): string;
|
|
18
|
+
/** `hash16(canonicalJson(value))` — the form every identity check in Phase 3.5 uses. */
|
|
19
|
+
export declare function hashOf(value: unknown): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One short content hash, shared by everything that needs to ask "is this the same thing it was?".
|
|
3
|
+
*
|
|
4
|
+
* Phase 3.5 resume decisions turn on identity comparisons — is this the same agent definition, the same
|
|
5
|
+
* narrowed envelope, the same step input, the same MCP tool declaration — and each of those was about to
|
|
6
|
+
* grow its own hashing. They must agree, so there is exactly one implementation and one canonical JSON
|
|
7
|
+
* form: keys sorted at every depth, so `{a,b}` and `{b,a}` are the same object, which is the whole point
|
|
8
|
+
* when the thing being hashed came back from `JSON.parse`.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
/** 16 hex chars — the same width `checkpoint.ts` uses for file hashes. Identity, never security. */
|
|
12
|
+
export function hash16(text) {
|
|
13
|
+
return createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Stable JSON: object keys sorted at every depth, `undefined` dropped. Arrays keep their order (order is
|
|
17
|
+
* meaning in a plan's steps). Cycles are impossible in the persisted shapes this serves, and a stray one
|
|
18
|
+
* throws rather than hashing something arbitrary.
|
|
19
|
+
*/
|
|
20
|
+
export function canonicalJson(value) {
|
|
21
|
+
const walk = (v) => {
|
|
22
|
+
if (v === null || typeof v !== 'object')
|
|
23
|
+
return v;
|
|
24
|
+
if (Array.isArray(v))
|
|
25
|
+
return v.map(walk);
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const k of Object.keys(v).sort()) {
|
|
28
|
+
const child = v[k];
|
|
29
|
+
if (child !== undefined)
|
|
30
|
+
out[k] = walk(child);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
};
|
|
34
|
+
return JSON.stringify(walk(value)) ?? 'null';
|
|
35
|
+
}
|
|
36
|
+
/** `hash16(canonicalJson(value))` — the form every identity check in Phase 3.5 uses. */
|
|
37
|
+
export function hashOf(value) {
|
|
38
|
+
return hash16(canonicalJson(value));
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-runtime-engine",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.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",
|