ai-runtime-engine 2.7.0 → 2.9.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 +108 -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 +95 -6
- package/dist/agents/task.js +40 -2
- package/dist/agents/worker.d.ts +32 -1
- package/dist/agents/worker.js +150 -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/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 +628 -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 +5 -0
- package/dist/index.js +7 -0
- package/dist/orchestration/executor.d.ts +26 -1
- package/dist/orchestration/executor.js +50 -9
- package/dist/orchestration/orchestrator.d.ts +6 -0
- package/dist/orchestration/orchestrator.js +18 -1
- package/dist/runtime/events.d.ts +44 -0
- package/dist/runtime/events.js +4 -0
- package/dist/runtime/runtime.d.ts +90 -0
- package/dist/runtime/runtime.js +510 -20
- 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
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persisted agent tasks (Phase 3.5) — the schema, and the ONE way anything reads them back.
|
|
3
|
+
*
|
|
4
|
+
* An `Execution` is a JSON file in the user's home. Invariant 21 says persisted agent state is
|
|
5
|
+
* schema-validated on read, and the reason is concrete: a record's `innerPlan` is EXECUTED on resume and
|
|
6
|
+
* its hashes decide whether completed work may be reused, so a malformed record is not a display problem
|
|
7
|
+
* — it is an execution problem. Validation is fail-closed per record: anything that does not parse is
|
|
8
|
+
* dropped whole and counted, never coerced or half-read.
|
|
9
|
+
*
|
|
10
|
+
* READING IS NOT WRITING. `parseAgentTasks` returns a filtered VIEW and never touches the stored record,
|
|
11
|
+
* so merely listing or acquiring an execution cannot destroy tasks it could not parse. The dropped count
|
|
12
|
+
* travels with the view so a silently shrinking list is visible instead of inferred.
|
|
13
|
+
*/
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
/** Structural only. A persisted inner plan still faces `validatePlan` against the envelope before it runs. */
|
|
16
|
+
const planStep = z
|
|
17
|
+
.object({
|
|
18
|
+
id: z.string().min(1),
|
|
19
|
+
description: z.string(),
|
|
20
|
+
skill: z.string().optional(),
|
|
21
|
+
tool: z.string().optional(),
|
|
22
|
+
agent: z.string().optional(),
|
|
23
|
+
motivatedBy: z.array(z.string()).optional(),
|
|
24
|
+
input: z.unknown().optional(),
|
|
25
|
+
dependsOn: z.array(z.string()).optional(),
|
|
26
|
+
status: z.enum(['pending', 'running', 'succeeded', 'failed', 'skipped']),
|
|
27
|
+
})
|
|
28
|
+
.strict();
|
|
29
|
+
const executionPlan = z.object({ id: z.string(), goal: z.string(), version: z.number(), steps: z.array(planStep), reason: z.string().optional() }).strict();
|
|
30
|
+
const observation = z
|
|
31
|
+
.object({
|
|
32
|
+
stepId: z.string(),
|
|
33
|
+
skill: z.string().optional(),
|
|
34
|
+
tool: z.string().optional(),
|
|
35
|
+
agent: z.string().optional(),
|
|
36
|
+
agentTaskId: z.string().optional(),
|
|
37
|
+
ok: z.boolean(),
|
|
38
|
+
output: z.string().optional(),
|
|
39
|
+
error: z.string().optional(),
|
|
40
|
+
code: z.string().optional(),
|
|
41
|
+
data: z.unknown().optional(),
|
|
42
|
+
artifacts: z.array(z.unknown()).optional(),
|
|
43
|
+
callsUsed: z.number().optional(),
|
|
44
|
+
})
|
|
45
|
+
.passthrough();
|
|
46
|
+
const evidence = z.object({ kind: z.string(), ref: z.string().optional(), detail: z.string().optional(), stepId: z.string().optional() }).passthrough();
|
|
47
|
+
const finding = z
|
|
48
|
+
.object({
|
|
49
|
+
id: z.string().min(1),
|
|
50
|
+
agentId: z.string(),
|
|
51
|
+
agentTaskId: z.string(),
|
|
52
|
+
type: z.string(),
|
|
53
|
+
subject: z.string().optional(),
|
|
54
|
+
claim: z.string(),
|
|
55
|
+
verdict: z.string().optional(),
|
|
56
|
+
executionCoverage: z.number().min(0).max(1),
|
|
57
|
+
confidence: z.number().min(0).max(1),
|
|
58
|
+
evidence: z.array(evidence),
|
|
59
|
+
artifacts: z.array(z.unknown()),
|
|
60
|
+
sourceSteps: z.array(z.string()),
|
|
61
|
+
provenance: z.object({ executionId: z.string().optional(), planVersion: z.number() }).passthrough(),
|
|
62
|
+
status: z.enum(['active', 'superseded', 'contradicted']),
|
|
63
|
+
supersededBy: z.string().optional(),
|
|
64
|
+
createdAt: z.number(),
|
|
65
|
+
})
|
|
66
|
+
.passthrough();
|
|
67
|
+
const checkpoint = z
|
|
68
|
+
.object({
|
|
69
|
+
at: z.number(),
|
|
70
|
+
planVersion: z.number(),
|
|
71
|
+
gitHead: z.string().optional(),
|
|
72
|
+
fileHashes: z.record(z.string(), z.string()),
|
|
73
|
+
configHash: z.string().optional(),
|
|
74
|
+
skillVersions: z.record(z.string(), z.string()),
|
|
75
|
+
completedSteps: z.array(z.string()),
|
|
76
|
+
mcpTools: z.record(z.string(), z.string()).optional(),
|
|
77
|
+
})
|
|
78
|
+
.passthrough();
|
|
79
|
+
/** A counter read back into arithmetic: a whole number, never negative, never absurd. */
|
|
80
|
+
const count = z.number().int().min(0).max(1_000_000);
|
|
81
|
+
/** Bounds on the per-record collections, so one task cannot make an execution file unreadable. */
|
|
82
|
+
const FINDINGS_MAX = 200;
|
|
83
|
+
const DIAGNOSTICS_MAX = 200;
|
|
84
|
+
const AGENT_STATES = ['created', 'queued', 'running', 'completed', 'failed', 'cancelled', 'waiting_for_input', 'waiting_for_clarification', 'paused'];
|
|
85
|
+
/**
|
|
86
|
+
* The record schema. `v` is a LITERAL: a record written by a future, wider version is dropped rather
|
|
87
|
+
* than parsed as this shape — reinterpreting an unknown layout is how a resume executes something its
|
|
88
|
+
* author never wrote.
|
|
89
|
+
*/
|
|
90
|
+
export const persistedAgentTask = z
|
|
91
|
+
.object({
|
|
92
|
+
v: z.literal(1),
|
|
93
|
+
agentTaskId: z.string().min(1),
|
|
94
|
+
agentId: z.string().min(1),
|
|
95
|
+
stepId: z.string().min(1),
|
|
96
|
+
state: z.enum(AGENT_STATES),
|
|
97
|
+
createdAt: z.number(),
|
|
98
|
+
updatedAt: z.number(),
|
|
99
|
+
startedAt: z.number().optional(),
|
|
100
|
+
endedAt: z.number().optional(),
|
|
101
|
+
interruption: z.object({ kind: z.enum(['crash', 'pause', 'parent-cancel']), at: z.number(), detail: z.string().optional() }).strict().optional(),
|
|
102
|
+
provenance: z.object({ executionId: z.string().optional(), planVersion: z.number() }).strict(),
|
|
103
|
+
agentDefHash: z.string().min(1),
|
|
104
|
+
envelopeHash: z.string().min(1),
|
|
105
|
+
stepInputHash: z.string().min(1),
|
|
106
|
+
attempt: z.number().int().min(1),
|
|
107
|
+
innerPlanVersion: z.number().optional(),
|
|
108
|
+
innerPlan: executionPlan.optional(),
|
|
109
|
+
innerCompletedSteps: z.array(z.string()),
|
|
110
|
+
innerObservations: z.array(observation),
|
|
111
|
+
innerObservationsOmitted: z.number().int().min(0),
|
|
112
|
+
innerCheckpoint: checkpoint.optional(),
|
|
113
|
+
pendingInner: z.object({ kind: z.literal('clarification'), question: z.string(), at: z.number() }).strict().optional(),
|
|
114
|
+
// Bounded and non-negative. These are read back into budget arithmetic and into rendered status
|
|
115
|
+
// lines, so a tampered or corrupted file must not be able to inject a negative or absurd count.
|
|
116
|
+
innerSteps: z.object({ total: count, succeeded: count }).strict(),
|
|
117
|
+
callsReserved: count,
|
|
118
|
+
callsUsed: count,
|
|
119
|
+
callsRefunded: count,
|
|
120
|
+
toolCallsUsed: count,
|
|
121
|
+
findings: z.array(finding).max(FINDINGS_MAX),
|
|
122
|
+
diagnostics: z.array(z.unknown()).max(DIAGNOSTICS_MAX),
|
|
123
|
+
failure: z.object({ code: z.string(), message: z.string() }).passthrough().optional(),
|
|
124
|
+
})
|
|
125
|
+
.strict();
|
|
126
|
+
/**
|
|
127
|
+
* Validate the agent tasks on an execution. Pure: it reads, it never writes. Callers that then persist
|
|
128
|
+
* the execution are choosing to drop the unparseable records — reading alone never does.
|
|
129
|
+
*/
|
|
130
|
+
export function parseAgentTasks(exec) {
|
|
131
|
+
const raw = exec.agentTasks;
|
|
132
|
+
if (!Array.isArray(raw))
|
|
133
|
+
return { tasks: [], dropped: 0 };
|
|
134
|
+
const tasks = [];
|
|
135
|
+
let dropped = 0;
|
|
136
|
+
for (const entry of raw) {
|
|
137
|
+
const parsed = persistedAgentTask.safeParse(entry);
|
|
138
|
+
if (!parsed.success) {
|
|
139
|
+
dropped += 1;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
tasks.push(parsed.data);
|
|
143
|
+
}
|
|
144
|
+
return { tasks, dropped };
|
|
145
|
+
}
|
|
146
|
+
/** One task by id, validated — the lookup every resume path uses. */
|
|
147
|
+
export function findAgentTask(exec, agentTaskId) {
|
|
148
|
+
return parseAgentTasks(exec).tasks.find((t) => t.agentTaskId === agentTaskId);
|
|
149
|
+
}
|
|
@@ -16,6 +16,10 @@ export interface CaptureInput {
|
|
|
16
16
|
skills: Skill[];
|
|
17
17
|
completedSteps: string[];
|
|
18
18
|
clock?: Clock;
|
|
19
|
+
/** Phase 3.5: plan-referenced MCP tool id → a hash of its live declaration. An MCP server is remote
|
|
20
|
+
* and mutable: it can change a tool's schema, or drop it, while a plan sits paused. That is drift in
|
|
21
|
+
* exactly the same sense as an edited file, and it was invisible before. */
|
|
22
|
+
mcpTools?: Record<string, string>;
|
|
19
23
|
}
|
|
20
24
|
export declare function captureCheckpoint(input: CaptureInput): Checkpoint;
|
|
21
25
|
export interface Reconciliation {
|
|
@@ -23,4 +27,4 @@ export interface Reconciliation {
|
|
|
23
27
|
reasons: string[];
|
|
24
28
|
}
|
|
25
29
|
/** Recompute the fingerprint and compare to a checkpoint. Any difference is drift. */
|
|
26
|
-
export declare function reconcile(checkpoint: Checkpoint, root: string, skills: Skill[]): Reconciliation;
|
|
30
|
+
export declare function reconcile(checkpoint: Checkpoint, root: string, skills: Skill[], mcpTools?: Record<string, string>): Reconciliation;
|
|
@@ -85,10 +85,11 @@ export function captureCheckpoint(input) {
|
|
|
85
85
|
...(cfg !== undefined ? { configHash: cfg } : {}),
|
|
86
86
|
skillVersions,
|
|
87
87
|
completedSteps: [...input.completedSteps],
|
|
88
|
+
...(input.mcpTools && Object.keys(input.mcpTools).length ? { mcpTools: input.mcpTools } : {}),
|
|
88
89
|
};
|
|
89
90
|
}
|
|
90
91
|
/** Recompute the fingerprint and compare to a checkpoint. Any difference is drift. */
|
|
91
|
-
export function reconcile(checkpoint, root, skills) {
|
|
92
|
+
export function reconcile(checkpoint, root, skills, mcpTools) {
|
|
92
93
|
const reasons = [];
|
|
93
94
|
// Compare directly so an ADDITION (absent at capture, present now — e.g. git initialized, config
|
|
94
95
|
// added) also counts as drift, not just a change to something already captured.
|
|
@@ -110,5 +111,16 @@ export function reconcile(checkpoint, root, skills) {
|
|
|
110
111
|
else if (cur !== ver)
|
|
111
112
|
reasons.push(`skill ${id} version changed (${ver} → ${cur})`);
|
|
112
113
|
}
|
|
114
|
+
// Phase 3.5: an absent `mcpTools` means the checkpoint predates this dimension — "nothing to
|
|
115
|
+
// compare", never "everything changed". A tool that vanished is drift: the plan references it.
|
|
116
|
+
if (checkpoint.mcpTools) {
|
|
117
|
+
for (const [id, hash] of Object.entries(checkpoint.mcpTools)) {
|
|
118
|
+
const cur = mcpTools?.[id];
|
|
119
|
+
if (cur === undefined)
|
|
120
|
+
reasons.push(`MCP tool ${id} is no longer available`);
|
|
121
|
+
else if (cur !== hash)
|
|
122
|
+
reasons.push(`MCP tool ${id} changed shape`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
113
125
|
return { drifted: reasons.length > 0, reasons };
|
|
114
126
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { ExecutionPlan } from '../orchestration/plan.js';
|
|
8
8
|
import type { StepObservation } from '../orchestration/executor.js';
|
|
9
9
|
import type { ArtifactRef } from '../runtime/types.js';
|
|
10
|
+
import type { AgentTaskRecord } from '../agents/task.js';
|
|
10
11
|
export type ExecutionStatus = 'planned' | 'running' | 'paused' | 'waiting_for_input' | 'waiting_for_clarification' | 'completed' | 'failed' | 'cancelled';
|
|
11
12
|
/** A time-boxed claim on an execution by one process. */
|
|
12
13
|
export interface Lease {
|
|
@@ -23,6 +24,10 @@ export interface Checkpoint {
|
|
|
23
24
|
configHash?: string;
|
|
24
25
|
skillVersions: Record<string, string>;
|
|
25
26
|
completedSteps: string[];
|
|
27
|
+
/** Phase 3.5: plan-referenced MCP tool id → a hash of its declaration at plan time. A server that
|
|
28
|
+
* changed a tool's shape under a paused plan is drift, exactly like an edited file. Absent on every
|
|
29
|
+
* pre-3.5 checkpoint, which reconcile must read as "nothing to compare", never as "everything changed". */
|
|
30
|
+
mcpTools?: Record<string, string>;
|
|
26
31
|
}
|
|
27
32
|
/** A plan's estimated vs available model-call budget (Phase 22), carried on a budget-paused execution. */
|
|
28
33
|
export interface BudgetInfo {
|
|
@@ -40,6 +45,10 @@ export interface PendingInput {
|
|
|
40
45
|
question?: string;
|
|
41
46
|
action?: string;
|
|
42
47
|
budget?: BudgetInfo;
|
|
48
|
+
/** Phase 3.5: set when the wait belongs to an AGENT's inner run. The answer is then routed into that
|
|
49
|
+
* task's inner resume — appending it to the OUTER goal would replan and destroy every sibling's
|
|
50
|
+
* progress. Absent ⇒ the wait is the outer run's, which is the pre-3.5 behaviour unchanged. */
|
|
51
|
+
agentTaskId?: string;
|
|
43
52
|
}
|
|
44
53
|
export interface Execution {
|
|
45
54
|
id: string;
|
|
@@ -57,6 +66,20 @@ export interface Execution {
|
|
|
57
66
|
createdAt: number;
|
|
58
67
|
updatedAt: number;
|
|
59
68
|
lease?: Lease;
|
|
69
|
+
/** Phase 3.5: budgeted step-calls charged across every run AND resume of this execution — the unit
|
|
70
|
+
* `foldCalls` counts, NOT provider requests (planning calls are deliberately outside the pool).
|
|
71
|
+
*
|
|
72
|
+
* ACCOUNTING, NOT A CAP. It is deliberately not subtracted from `maxCalls` on resume: `AI_MAX_CALLS`
|
|
73
|
+
* bounds a RUN, and "raise it and resume" is how a partially-budgeted plan makes progress (Phase 22).
|
|
74
|
+
* Treating it as a cumulative task ceiling would make `AI_MAX_CALLS=1` un-resumable — the first run
|
|
75
|
+
* spends the whole allowance and every resume gets a budget of zero. See DECISIONS D132.
|
|
76
|
+
* Optional because records written before 3.5 do not have it: always read `?? 0`. */
|
|
77
|
+
callsUsed?: number;
|
|
78
|
+
/** Phase 3.5: the agent tasks this execution owns, in creation order. Schema-validated on read. */
|
|
79
|
+
agentTasks?: AgentTaskRecord[];
|
|
80
|
+
/** Phase 3.5, diagnostic: how many persisted agent tasks failed validation on the last read. Counted
|
|
81
|
+
* so a silently shrinking list is visible; the dropped records are never rendered as content. */
|
|
82
|
+
agentTasksDropped?: number;
|
|
60
83
|
}
|
|
61
84
|
export declare const RESUMABLE: ReadonlySet<ExecutionStatus>;
|
|
62
85
|
export declare const TERMINAL: ReadonlySet<ExecutionStatus>;
|
|
@@ -21,6 +21,26 @@ export interface AcquireResult {
|
|
|
21
21
|
execution?: Execution;
|
|
22
22
|
reason?: string;
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Why a mid-run progress commit was accepted or refused (Phase 3.5).
|
|
26
|
+
*
|
|
27
|
+
* Every refusal means the same thing to the caller: SOMEONE ELSE now owns this execution's fate, and
|
|
28
|
+
* this process must stop — not retry, not carry on discarding writes. A run that keeps executing after a
|
|
29
|
+
* refused commit doubles every tool side effect and every model call while another owner re-runs the
|
|
30
|
+
* same steps, so the caller is required to abort. That is why this is an enum and not a boolean: a
|
|
31
|
+
* boolean invites an ignored return value at the call site, which is exactly the bug.
|
|
32
|
+
*/
|
|
33
|
+
export type CommitOutcome = 'ok'
|
|
34
|
+
/** The record is gone from disk (deleted under us). */
|
|
35
|
+
| 'missing'
|
|
36
|
+
/** Another owner holds the lease, or ours is gone — writing would clobber a live run. */
|
|
37
|
+
| 'foreign-lease'
|
|
38
|
+
/** The lease was released out from under us. Never write leaseless: an unleased record is claimable. */
|
|
39
|
+
| 'released'
|
|
40
|
+
/** Disk reached a terminal status. TERMINAL IS STICKY — a snapshot from before it may not undo it. */
|
|
41
|
+
| 'terminal'
|
|
42
|
+
/** Someone paused us. `paused` is not terminal, but this owner cannot produce it, so it is not ours to overwrite. */
|
|
43
|
+
| 'paused';
|
|
24
44
|
export declare class ExecutionStore {
|
|
25
45
|
private readonly area;
|
|
26
46
|
readonly owner: string;
|
|
@@ -42,6 +62,23 @@ export declare class ExecutionStore {
|
|
|
42
62
|
/** Claim an execution. Fails if a DIFFERENT owner holds a live lease; steals an expired one. Re-reads
|
|
43
63
|
* after writing to detect losing a concurrent steal (best-effort — no OS lock). */
|
|
44
64
|
acquire(id: string): AcquireResult;
|
|
65
|
+
/**
|
|
66
|
+
* THE mid-run write (Phase 3.5). Every incremental commit point goes through here and nothing else
|
|
67
|
+
* writes an execution while a run is in flight, so all four ordering rules live in one place:
|
|
68
|
+
*
|
|
69
|
+
* 1. TERMINAL IS STICKY. A commit carries a snapshot taken before it was built; if the record reached
|
|
70
|
+
* `completed`/`failed`/`cancelled` in the meantime, that verdict stands. Without this, a cancel is
|
|
71
|
+
* undone by the next in-flight wave's commit milliseconds later.
|
|
72
|
+
* 2. `paused` IS NOT OURS TO OVERWRITE. A running owner never writes `paused`, so finding it on disk
|
|
73
|
+
* means someone else did — and the pre-3.5 behaviour (clobber it at end of run) is far worse here,
|
|
74
|
+
* because commits are frequent: the pause would be erased within one batch, before any heartbeat
|
|
75
|
+
* tick could observe it.
|
|
76
|
+
* 3. NEVER WRITE LEASELESS. An unleased record is claimable by any process; writing one back without
|
|
77
|
+
* a lease invites a second owner to acquire and re-run everything this owner is still doing.
|
|
78
|
+
* 4. THE DISK LEASE WINS. Lease bookkeeping belongs to acquire/heartbeat; a progress commit carries a
|
|
79
|
+
* possibly-stale copy and must not push it back.
|
|
80
|
+
*/
|
|
81
|
+
commitProgress(exec: Execution): CommitOutcome;
|
|
45
82
|
/** Write only if we still hold the lease (or it's free/expired) — never clobber a live different owner. */
|
|
46
83
|
commit(exec: Execution): boolean;
|
|
47
84
|
/** Renew this owner's lease. Returns false if we no longer hold it. */
|
package/dist/executions/store.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { hostname } from 'node:os';
|
|
11
11
|
import { systemClock } from '../util/clock.js';
|
|
12
|
+
import { TERMINAL } from './execution.js';
|
|
12
13
|
export class ExecutionStore {
|
|
13
14
|
area;
|
|
14
15
|
owner;
|
|
@@ -85,6 +86,38 @@ export class ExecutionStore {
|
|
|
85
86
|
return { ok: false, reason: 'lost the acquire race' };
|
|
86
87
|
return { ok: true, execution: confirmed };
|
|
87
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* THE mid-run write (Phase 3.5). Every incremental commit point goes through here and nothing else
|
|
91
|
+
* writes an execution while a run is in flight, so all four ordering rules live in one place:
|
|
92
|
+
*
|
|
93
|
+
* 1. TERMINAL IS STICKY. A commit carries a snapshot taken before it was built; if the record reached
|
|
94
|
+
* `completed`/`failed`/`cancelled` in the meantime, that verdict stands. Without this, a cancel is
|
|
95
|
+
* undone by the next in-flight wave's commit milliseconds later.
|
|
96
|
+
* 2. `paused` IS NOT OURS TO OVERWRITE. A running owner never writes `paused`, so finding it on disk
|
|
97
|
+
* means someone else did — and the pre-3.5 behaviour (clobber it at end of run) is far worse here,
|
|
98
|
+
* because commits are frequent: the pause would be erased within one batch, before any heartbeat
|
|
99
|
+
* tick could observe it.
|
|
100
|
+
* 3. NEVER WRITE LEASELESS. An unleased record is claimable by any process; writing one back without
|
|
101
|
+
* a lease invites a second owner to acquire and re-run everything this owner is still doing.
|
|
102
|
+
* 4. THE DISK LEASE WINS. Lease bookkeeping belongs to acquire/heartbeat; a progress commit carries a
|
|
103
|
+
* possibly-stale copy and must not push it back.
|
|
104
|
+
*/
|
|
105
|
+
commitProgress(exec) {
|
|
106
|
+
const disk = this.get(exec.id);
|
|
107
|
+
if (!disk)
|
|
108
|
+
return 'missing';
|
|
109
|
+
if (TERMINAL.has(disk.status))
|
|
110
|
+
return 'terminal';
|
|
111
|
+
if (disk.status === 'paused' && exec.status !== 'paused')
|
|
112
|
+
return 'paused';
|
|
113
|
+
if (!disk.lease)
|
|
114
|
+
return 'released';
|
|
115
|
+
if (disk.lease.owner !== this.owner)
|
|
116
|
+
return 'foreign-lease';
|
|
117
|
+
exec.lease = disk.lease;
|
|
118
|
+
this.save(exec);
|
|
119
|
+
return 'ok';
|
|
120
|
+
}
|
|
88
121
|
/** Write only if we still hold the lease (or it's free/expired) — never clobber a live different owner. */
|
|
89
122
|
commit(exec) {
|
|
90
123
|
const disk = this.get(exec.id);
|
package/dist/index.d.ts
CHANGED
|
@@ -101,6 +101,11 @@ export type { Finding, FindingEvidence, FindingEvidenceKind, FindingStatus } fro
|
|
|
101
101
|
export type { AdmissionRejection, AdmissionResult } from './agents/admit.js';
|
|
102
102
|
export type { StepObservationCode } from './orchestration/executor.js';
|
|
103
103
|
export { PLAN_STEP_STATUSES } from './orchestration/plan.js';
|
|
104
|
+
export { parseAgentTasks, findAgentTask } from './executions/agentTasks.js';
|
|
105
|
+
export type { AgentTasksRead } from './executions/agentTasks.js';
|
|
106
|
+
export { AGENT_TERMINAL, AGENT_RESUMABLE, agentTaskView } from './agents/task.js';
|
|
107
|
+
export type { AgentTaskView } from './agents/task.js';
|
|
108
|
+
export type { ProgressSnapshot } from './orchestration/executor.js';
|
|
104
109
|
export { mcpToolId } from './mcp/toolAdapter.js';
|
|
105
110
|
export { MCP_PROTOCOL_VERSION } from './mcp/protocol.js';
|
|
106
111
|
export type { McpServerConfig, McpServerState, McpServerStatus, McpTransportKind } from './mcp/manager.js';
|
package/dist/index.js
CHANGED
|
@@ -74,6 +74,13 @@ export { deriveCapabilities, deriveCapabilitiesOffline, candidatesFrom, candidat
|
|
|
74
74
|
export { ActionCapabilityRegistry, capabilityReportFrom } from './capabilities/registry.js';
|
|
75
75
|
export { CURATED_VOCABULARY, curated, isCurated } from './capabilities/vocabulary.js';
|
|
76
76
|
export { PLAN_STEP_STATUSES } from './orchestration/plan.js';
|
|
77
|
+
// ── Agent persistence (Phase 3.5) — reading back what a run left on disk ──
|
|
78
|
+
// `parseAgentTasks` is exported because it is the ONLY correct way to read agent tasks off an
|
|
79
|
+
// Execution: the raw array is unvalidated JSON from a file, and a host that reads it directly would
|
|
80
|
+
// trust records this version cannot parse. The state sets are exported with it because "is this task
|
|
81
|
+
// finished?" must have one answer, not one per caller.
|
|
82
|
+
export { parseAgentTasks, findAgentTask } from './executions/agentTasks.js';
|
|
83
|
+
export { AGENT_TERMINAL, AGENT_RESUMABLE, agentTaskView } from './agents/task.js';
|
|
77
84
|
// ── MCP connectivity (Phase 3.2) — external servers as ordinary Runtime tools ──
|
|
78
85
|
// The CONFIG + STATUS surface is public; the client, transports, and manager internals are not, so the
|
|
79
86
|
// wire implementation stays free to change without a breaking release.
|
|
@@ -10,7 +10,10 @@ import type { ExecutionPlan, PlanStep } from './plan.js';
|
|
|
10
10
|
import type { ReserveAgentCalls } from './budget.js';
|
|
11
11
|
import type { ArtifactRef } from '../runtime/types.js';
|
|
12
12
|
/** Why a step ended the way it did, when the reason is not simply "the skill/tool said so". */
|
|
13
|
-
export type StepObservationCode = 'cancelled' | 'agent-failed' | 'agent-timeout' | 'agent-call-budget' | 'agent-tool-budget' | 'finding-contract' | 'agent-not-enabled'
|
|
13
|
+
export type StepObservationCode = 'cancelled' | 'agent-failed' | 'agent-timeout' | 'agent-call-budget' | 'agent-tool-budget' | 'finding-contract' | 'agent-not-enabled'
|
|
14
|
+
/** Phase 3.5: the agent's inner run needs an answer. NOT a failure — the step stays `pending` so a
|
|
15
|
+
* resume re-runs it rather than skipping its dependents. */
|
|
16
|
+
| 'agent-waiting';
|
|
14
17
|
export interface StepObservation {
|
|
15
18
|
stepId: string;
|
|
16
19
|
skill?: string;
|
|
@@ -31,6 +34,22 @@ export interface StepObservation {
|
|
|
31
34
|
/** Phase 3.4, agent steps only: METERED inner model calls. Charging the actual is the refund. */
|
|
32
35
|
callsUsed?: number;
|
|
33
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* A quiescent moment in `executePlan`, handed to `ExecuteDeps.onProgress` (Phase 3.5).
|
|
39
|
+
*
|
|
40
|
+
* Every fire point is between awaits — no `Promise.all` is in flight and every semaphore slot has been
|
|
41
|
+
* released — so the plan's step statuses are consistent and the snapshot describes a state the run could
|
|
42
|
+
* legitimately be resumed from. `observations` is a DELTA (what appeared since the previous fire), so a
|
|
43
|
+
* sink appends rather than de-duplicating; `callsUsed` is cumulative for this `executePlan` call.
|
|
44
|
+
*/
|
|
45
|
+
export interface ProgressSnapshot {
|
|
46
|
+
at: 'wave-partition' | 'batch' | 'budget-stop' | 'plan-end';
|
|
47
|
+
plan: ExecutionPlan;
|
|
48
|
+
observations: StepObservation[];
|
|
49
|
+
callsUsed: number;
|
|
50
|
+
/** Step ids whose agent is waiting for input. Non-empty ⇒ the run is resumable, NOT failed. */
|
|
51
|
+
waiting: string[];
|
|
52
|
+
}
|
|
34
53
|
export interface ExecuteResult {
|
|
35
54
|
ok: boolean;
|
|
36
55
|
plan: ExecutionPlan;
|
|
@@ -38,6 +57,8 @@ export interface ExecuteResult {
|
|
|
38
57
|
/** Phase 22: true when execution paused BEFORE a wave because running it would exceed `callBudget`.
|
|
39
58
|
* Not a failure — the completed steps stand and the rest can resume with a raised budget. */
|
|
40
59
|
stoppedForBudget?: boolean;
|
|
60
|
+
/** Phase 3.5: steps left waiting for input. Non-empty ⇒ resumable; callers must not report `failed`. */
|
|
61
|
+
waiting?: string[];
|
|
41
62
|
/** Model calls (skill steps) actually executed this run. */
|
|
42
63
|
callsUsed?: number;
|
|
43
64
|
}
|
|
@@ -68,6 +89,10 @@ export interface ExecuteDeps {
|
|
|
68
89
|
}) => Promise<StepObservation>;
|
|
69
90
|
/** Phase 3.4: what an agent step reserves. The SAME function the pre-flight estimate uses. */
|
|
70
91
|
reserve?: ReserveAgentCalls;
|
|
92
|
+
/** Phase 3.5: called at each quiescent point so progress reaches disk BEFORE the next wave starts
|
|
93
|
+
* (invariant 18). Synchronous and must not throw — the executor does not own persistence policy and
|
|
94
|
+
* will not try to recover from a sink that fails. */
|
|
95
|
+
onProgress?: (snapshot: ProgressSnapshot) => void;
|
|
71
96
|
}
|
|
72
97
|
/** Execute a plan. Runs DAG waves; within a wave, batches of at most maxParallelSteps run concurrently. */
|
|
73
98
|
export declare function executePlan(plan: ExecutionPlan, deps: ExecuteDeps): Promise<ExecuteResult>;
|
|
@@ -34,7 +34,11 @@ async function runStepInner(step, deps) {
|
|
|
34
34
|
if (step.agent) {
|
|
35
35
|
if (!deps.runAgent)
|
|
36
36
|
return { stepId: step.id, agent: step.agent, ok: false, code: 'agent-not-enabled', error: 'agent execution is not enabled' };
|
|
37
|
-
return
|
|
37
|
+
// `return await`, NOT a bare return. In an async function a returned promise is ADOPTED, not
|
|
38
|
+
// caught, so a bare return here escapes this try/catch entirely: a throwing agent rejected the
|
|
39
|
+
// whole executePlan instead of failing its own step, and left its wave-mates running unawaited.
|
|
40
|
+
// The skill and tool branches above both await, which is why only agents had this hole.
|
|
41
|
+
return await deps.runAgent(step, { reservation: stepCalls(step, Number.POSITIVE_INFINITY, deps.reserve), ...(deps.signal ? { signal: deps.signal } : {}) });
|
|
38
42
|
}
|
|
39
43
|
return { stepId: step.id, ok: false, error: 'step names neither a skill nor a tool' };
|
|
40
44
|
}
|
|
@@ -55,8 +59,20 @@ export async function executePlan(plan, deps) {
|
|
|
55
59
|
// a step carrying `agent` can enter this set, so with agents disabled it is provably empty and both
|
|
56
60
|
// branches below render exactly as 2.6.0.
|
|
57
61
|
const cancelled = new Set();
|
|
62
|
+
// Phase 3.5: steps whose agent asked for input. They are NOT failures — the step stays `pending`, its
|
|
63
|
+
// dependents are neither run nor skipped, and the run stops scheduling so it can be resumed.
|
|
64
|
+
const waiting = new Set();
|
|
58
65
|
let callsUsed = 0; // model calls = executed skill steps (tool steps are free)
|
|
59
66
|
let stoppedForBudget = false;
|
|
67
|
+
// Phase 3.5: observations are handed to the sink as a DELTA, so it appends instead of de-duplicating.
|
|
68
|
+
let emitted = 0;
|
|
69
|
+
const fire = (at) => {
|
|
70
|
+
if (!deps.onProgress)
|
|
71
|
+
return;
|
|
72
|
+
const delta = observations.slice(emitted);
|
|
73
|
+
emitted = observations.length;
|
|
74
|
+
deps.onProgress({ at, plan, observations: delta, callsUsed, waiting: [...waiting] });
|
|
75
|
+
};
|
|
60
76
|
for (const wave of executionWaves(plan.steps)) {
|
|
61
77
|
// A step runs only if all its dependencies succeeded; otherwise it is skipped.
|
|
62
78
|
const runnable = [];
|
|
@@ -89,12 +105,16 @@ export async function executePlan(plan, deps) {
|
|
|
89
105
|
runnable.push(step);
|
|
90
106
|
}
|
|
91
107
|
}
|
|
108
|
+
// Every skipped/cancelled verdict for this wave is now decided and nothing is in flight: the
|
|
109
|
+
// cheapest honest moment to get the wave's shape on disk, and it precedes the budget stop below.
|
|
110
|
+
fire('wave-partition');
|
|
92
111
|
// Phase 22 budget gate: if running this wave's skill steps would exceed the call budget, stop BEFORE
|
|
93
112
|
// it (phase granularity). Remaining steps keep status 'pending' and can resume with a raised budget.
|
|
94
113
|
if (deps.callBudget !== undefined) {
|
|
95
114
|
const waveCalls = foldCalls(runnable, deps.callBudget - callsUsed, deps.reserve);
|
|
96
115
|
if (waveCalls > 0 && callsUsed + waveCalls > deps.callBudget) {
|
|
97
116
|
stoppedForBudget = true;
|
|
117
|
+
fire('budget-stop');
|
|
98
118
|
break;
|
|
99
119
|
}
|
|
100
120
|
}
|
|
@@ -104,22 +124,43 @@ export async function executePlan(plan, deps) {
|
|
|
104
124
|
const results = await Promise.all(batch.map((s) => runStep(s, deps, limiters)));
|
|
105
125
|
results.forEach((obs, j) => {
|
|
106
126
|
const step = batch[j];
|
|
107
|
-
|
|
127
|
+
// A waiting agent is the one non-terminal step outcome: it is neither a success nor a failure, so
|
|
128
|
+
// it must not mark the step `failed` — that would skip every dependent and make the branch
|
|
129
|
+
// unrecoverable on resume, which is the opposite of what waiting means.
|
|
130
|
+
const isWaiting = !obs.ok && obs.code === 'agent-waiting';
|
|
131
|
+
step.status = isWaiting ? 'pending' : obs.ok ? 'succeeded' : 'failed';
|
|
108
132
|
if (step.skill)
|
|
109
133
|
callsUsed += 1;
|
|
110
134
|
// Charging what an agent actually SPENT is the refund: an aborted or cancelled task reports its
|
|
111
135
|
// metered total and the unspent reservation is simply never charged. No ledger, no refund path.
|
|
112
136
|
else if (step.agent)
|
|
113
137
|
callsUsed += obs.callsUsed ?? stepCalls(step, Number.POSITIVE_INFINITY, deps.reserve);
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
138
|
+
if (isWaiting)
|
|
139
|
+
waiting.add(step.id);
|
|
140
|
+
else {
|
|
141
|
+
if (!obs.ok && obs.code === 'cancelled')
|
|
142
|
+
cancelled.add(step.id);
|
|
143
|
+
if (!obs.ok)
|
|
144
|
+
failedOrSkipped.add(step.id);
|
|
145
|
+
}
|
|
118
146
|
observations.push(obs);
|
|
119
147
|
});
|
|
148
|
+
fire('batch');
|
|
120
149
|
}
|
|
150
|
+
// Something is waiting for a human. Scheduling further waves would run work whose inputs may change
|
|
151
|
+
// once the answer arrives, so the run stops here and resumes when it is answered.
|
|
152
|
+
if (waiting.size > 0)
|
|
153
|
+
break;
|
|
121
154
|
}
|
|
122
|
-
// Success only if every step succeeded AND we didn't stop early for budget.
|
|
123
|
-
const ok = !stoppedForBudget && plan.steps.every((s) => s.status === 'succeeded');
|
|
124
|
-
|
|
155
|
+
// Success only if every step succeeded AND we didn't stop early for budget or for an answer.
|
|
156
|
+
const ok = !stoppedForBudget && waiting.size === 0 && plan.steps.every((s) => s.status === 'succeeded');
|
|
157
|
+
fire('plan-end');
|
|
158
|
+
return {
|
|
159
|
+
ok,
|
|
160
|
+
plan,
|
|
161
|
+
observations,
|
|
162
|
+
callsUsed,
|
|
163
|
+
...(stoppedForBudget ? { stoppedForBudget: true } : {}),
|
|
164
|
+
...(waiting.size > 0 ? { waiting: [...waiting] } : {}),
|
|
165
|
+
};
|
|
125
166
|
}
|
|
@@ -54,6 +54,12 @@ export interface OrchestrateInput {
|
|
|
54
54
|
runAgent?: ExecuteDeps['runAgent'];
|
|
55
55
|
/** Phase 3.1: maps unregistered plan references to structured gaps. */
|
|
56
56
|
resolveGaps?: PlannerInput['resolveGaps'];
|
|
57
|
+
/** Phase 3.5: the plan is settled — approved if approval was required, and inside the call budget.
|
|
58
|
+
* Fired BEFORE the first step runs, so a crash during wave 1 still resumes against a real plan
|
|
59
|
+
* instead of replanning from the bare goal. */
|
|
60
|
+
onPlan?: (plan: ExecutionPlan) => void;
|
|
61
|
+
/** Phase 3.5: forwarded verbatim to `executePlan` — the per-wave/per-batch commit points. */
|
|
62
|
+
onProgress?: ExecuteDeps['onProgress'];
|
|
57
63
|
}
|
|
58
64
|
export interface OrchestrateOutcome {
|
|
59
65
|
status: OrchestrationStatus;
|
|
@@ -119,7 +119,10 @@ export async function orchestrate(input) {
|
|
|
119
119
|
summary: `This task looks like ~${estCalls} model call(s), but the budget is ${maxCalls}. Raise the budget (AI_MAX_CALLS or maxCalls) and re-run, or run the phases that fit with --partial (then resume as you raise it).`,
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
|
-
|
|
122
|
+
// Settled: approved (if required) and within budget. Everything a resume needs about the PLAN is
|
|
123
|
+
// known, and nothing has run yet.
|
|
124
|
+
input.onPlan?.(plan);
|
|
125
|
+
const exec = await executePlan(plan, { ...(input.onProgress ? { onProgress: input.onProgress } : {}), runSkill: input.runSkill, runTool: input.runTool, ...(input.runAgent ? { runAgent: input.runAgent } : {}), ...(reserve ? { reserve } : {}), ...(input.policy.maxParallelSteps ? { maxParallelSteps: input.policy.maxParallelSteps } : {}), ...(input.policy.limits ? { limits: input.policy.limits } : {}), ...(input.signal ? { signal: input.signal } : {}), ...(maxCalls !== undefined ? { callBudget: maxCalls } : {}) });
|
|
123
126
|
allObservations.push(...exec.observations);
|
|
124
127
|
if (exec.stoppedForBudget) {
|
|
125
128
|
const done = plan.steps.filter((s) => s.status === 'succeeded').length;
|
|
@@ -132,6 +135,20 @@ export async function orchestrate(input) {
|
|
|
132
135
|
summary: `Ran ${done} of ${plan.steps.length} step(s) within the ${maxCalls}-call budget. Raise the budget (AI_MAX_CALLS) and resume to continue.`,
|
|
133
136
|
};
|
|
134
137
|
}
|
|
138
|
+
// An agent is waiting for a human. This MUST precede the failure branch: replanning here would
|
|
139
|
+
// discard the plan the waiting task belongs to and orphan every sibling agent's completed work.
|
|
140
|
+
if (exec.waiting?.length) {
|
|
141
|
+
const asked = exec.observations.find((o) => o.code === 'agent-waiting');
|
|
142
|
+
const done = plan.steps.filter((st) => st.status === 'succeeded').length;
|
|
143
|
+
return {
|
|
144
|
+
status: 'waiting_for_clarification',
|
|
145
|
+
plan,
|
|
146
|
+
planHistory,
|
|
147
|
+
observations: [...allObservations],
|
|
148
|
+
clarification: asked?.error ?? 'an agent needs more information to continue',
|
|
149
|
+
summary: `Paused after ${done} of ${plan.steps.length} step(s): an agent needs an answer. Resume with it to continue.`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
135
152
|
if (exec.ok)
|
|
136
153
|
return { status: 'completed', plan, planHistory, observations: [...allObservations], summary: `completed "${plan.goal}" in ${plan.steps.length} step(s)` };
|
|
137
154
|
// Failed: gather evidence and replan (orchestrate only) or stop.
|
package/dist/runtime/events.d.ts
CHANGED
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
* Token-streaming extension point: `response.delta` is reserved here (content-bearing, redacted like
|
|
7
7
|
* everything else). Provider token streaming is NOT implemented in 1.0 — no delta is ever emitted yet —
|
|
8
8
|
* but declaring the arm keeps the host/emitter architecture ready for it without a breaking change.
|
|
9
|
+
*
|
|
10
|
+
* EVOLUTION CONTRACT: this union GROWS. A new arm is additive at runtime — an existing consumer keeps
|
|
11
|
+
* receiving the events it knows — but it breaks an exhaustive `switch` at COMPILE time. Consumers must
|
|
12
|
+
* carry a default case. Arms added this way are announced in the CHANGELOG.
|
|
9
13
|
*/
|
|
10
14
|
import type { Clock } from '../util/clock.js';
|
|
11
15
|
import type { ErrorCategory } from '../types.js';
|
|
12
16
|
import type { ExecutableMode, ModeSource, RuntimeMode, RuntimeStatus } from './types.js';
|
|
17
|
+
import type { AgentTaskState } from '../agents/task.js';
|
|
13
18
|
export type RuntimeEvent = {
|
|
14
19
|
type: 'runtime.started';
|
|
15
20
|
ts: number;
|
|
@@ -54,6 +59,45 @@ export type RuntimeEvent = {
|
|
|
54
59
|
ts: number;
|
|
55
60
|
runId: string;
|
|
56
61
|
text: string;
|
|
62
|
+
} | {
|
|
63
|
+
type: 'agent.task.started';
|
|
64
|
+
ts: number;
|
|
65
|
+
runId: string;
|
|
66
|
+
agentTaskId: string;
|
|
67
|
+
agentId: string;
|
|
68
|
+
stepId: string;
|
|
69
|
+
state: AgentTaskState;
|
|
70
|
+
} | {
|
|
71
|
+
type: 'agent.task.progress';
|
|
72
|
+
ts: number;
|
|
73
|
+
runId: string;
|
|
74
|
+
agentTaskId: string;
|
|
75
|
+
agentId: string;
|
|
76
|
+
stepId: string;
|
|
77
|
+
/** Per inner WAVE, never per inner step: a chatty agent would otherwise flood the ring buffer
|
|
78
|
+
* and push every other event out of it. */
|
|
79
|
+
innerSteps: {
|
|
80
|
+
total: number;
|
|
81
|
+
succeeded: number;
|
|
82
|
+
};
|
|
83
|
+
callsUsed: number;
|
|
84
|
+
toolCallsUsed: number;
|
|
85
|
+
} | {
|
|
86
|
+
type: 'agent.task.completed';
|
|
87
|
+
ts: number;
|
|
88
|
+
runId: string;
|
|
89
|
+
agentTaskId: string;
|
|
90
|
+
agentId: string;
|
|
91
|
+
stepId: string;
|
|
92
|
+
state: AgentTaskState;
|
|
93
|
+
innerSteps: {
|
|
94
|
+
total: number;
|
|
95
|
+
succeeded: number;
|
|
96
|
+
};
|
|
97
|
+
callsUsed: number;
|
|
98
|
+
toolCallsUsed: number;
|
|
99
|
+
/** How many findings were ADMITTED. Never the findings themselves. */
|
|
100
|
+
findings: number;
|
|
57
101
|
};
|
|
58
102
|
/** Distributive Omit so an event can be emitted without pre-stamping `ts`. */
|
|
59
103
|
type WithoutTs<T> = T extends unknown ? Omit<T, 'ts'> : never;
|