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 CHANGED
@@ -5,6 +5,97 @@ All notable changes to `ai-runtime` are documented here. The format follows
5
5
  Versioning](https://semver.org/). Development history and rationale live in
6
6
  [docs/DECISIONS.md](docs/DECISIONS.md) and [docs/PROGRESS.md](docs/PROGRESS.md).
7
7
 
8
+ ## [3.0.0] — 2026-09-05
9
+
10
+ **AI Runtime 3.0.** The 3.x arc set out to make the runtime reason about *what it can do*, talk to tools
11
+ it did not ship with, delegate bounded work to agents, and survive being killed in the middle of it.
12
+ That is now true end to end, and `tests/integration/runtime-3-demo.test.ts` runs the whole of it —
13
+ derive, decompose, delegate, crash, resume — offline, inside a call budget, with a leak scan.
14
+
15
+ Upgrading from 2.9.0 requires no config change. One documented default changes; see below.
16
+
17
+ ### Added
18
+
19
+ - **Auto-decomposition** (`runtime.agents.decompose`, default OFF) — a goal can delegate to a bounded,
20
+ read-shaped agent with no operator-authored definition. Three roles ship in-tree; no model-authored
21
+ string ever becomes an objective, a tool id, or a permission, and `auto_` is a reserved config
22
+ namespace so a derived id can never shadow an authored one.
23
+ - **Conflict supersession is wired.** `resolveConflicts` shipped in 3.4 with no caller, so two agents
24
+ reaching opposite conclusions about the same subject both stayed active and both reached the next
25
+ planning prompt. Resolution now runs when a task finishes, weighs evidence-based confidence, and
26
+ persists — and it is order-independent, so the answer does not depend on which agent happens to
27
+ finish first.
28
+
29
+ ### Changed
30
+
31
+ - **`runtime.capabilities.catalog` now defaults to ON.** Set `catalog: false` to remove the block.
32
+ Before flipping it, the block was made to match its own documentation: three comments across three
33
+ files described it as fenced when nothing fenced it, and its blocked-skill half had no cap at all
34
+ (~24k characters with 300 blocked skills, silently, in every prompt). Both are fixed.
35
+
36
+ ### Fixed
37
+
38
+ - **A throwing agent took down the whole run.** `executor.ts` returned the agent runner's promise from
39
+ inside a `try` — and an async function *adopts* a returned promise rather than awaiting it, so the
40
+ rejection escaped the executor's own catch. The plan failed instead of the step, and wave-mates were
41
+ left running unawaited. Present since 2.7.0.
42
+ - Derived permissions are a ceiling rather than a default, so a synthesized definition cannot request
43
+ write access even when the parent has it.
44
+
45
+ ### Notes for consumers
46
+
47
+ - The `RuntimeEvent` union gained agent arms in 2.9.0 and may gain more. New arms are additive at
48
+ runtime but break an exhaustive `switch` at compile time — carry a default case.
49
+ - `capabilities.planning` is skipped whenever any call or cost budget is set, and a resumed run is
50
+ never re-derived. Both are deliberate and now asserted by tests rather than only documented.
51
+
52
+ ## [2.9.0] — 2026-09-05
53
+
54
+ Agent work becomes VISIBLE. Concurrent agent steps render as live lanes in the interactive terminal,
55
+ `/agents` lists and stops them, `/status` surfaces work that is unfinished, and the lifecycle events
56
+ they all read from are finally declared rather than cast. Additive: with `runtime.agents` absent no
57
+ agent event is ever emitted, so the lane region never opens and run output is identical to 2.8.0.
58
+
59
+ ### Added
60
+
61
+ - **Agent lifecycle event arms** — `agent.task.started`, `agent.task.progress`, `agent.task.completed`
62
+ on `RuntimeEvent`. Metadata only: ids, states and counts. `findings` is a COUNT, because a finding's
63
+ claim is agent-authored text and an event is the one surface a host may forward anywhere.
64
+ - **`agent.task.progress` per inner wave**, plus one at plan end — bounded at waves + 1 per task, never
65
+ per inner step.
66
+ - **A multi-lane display** (`src/cli/interactive/lanes.ts`) — one live row per concurrent agent, pure
67
+ and offline-testable: terminal width, colour and the animation tick are all parameters. Piped output,
68
+ `NO_COLOR` and dumb terminals get append-only transition lines instead of cursor movement.
69
+ - **`/agents`** — list agent tasks with progress, spend and finding counts; **`/agents stop <id>`**
70
+ stops one, and reports every outcome including the ones that change nothing.
71
+ - **`/status`** now names unfinished agent work, and says which task is waiting for an answer.
72
+ - **`AgentTaskView`** + `agentTaskView()` — the public read shape. The record's persistence internals
73
+ (inner plan, inner observations, raw findings, diagnostics) stay internal.
74
+
75
+ ### Changed
76
+
77
+ - **The event union's evolution contract is now documented**: a new arm is additive at runtime but
78
+ breaks an exhaustive `switch` at COMPILE time. Consumers must carry a default case. Arms added this
79
+ way are announced here.
80
+ - `AgentTaskView` (2.8.0) — the read RESULT of `parseAgentTasks` — is renamed `AgentTasksRead`, freeing
81
+ the name for the per-task view above. 2.8.0 was never published, so no released consumer names it.
82
+
83
+ ### Fixed
84
+
85
+ - **The agent events were a type lie.** Removing the `as never` cast made them *look* typed, but a
86
+ single object literal with a union-typed `type` compiles while carrying properties from every
87
+ constituent — so `agent.task.started` shipped four fields it does not declare. The emit now narrows to
88
+ one arm per branch, and a key-set test covers what the type system still cannot.
89
+ - **`runId` was missing, then wrong.** The events carried none at all, and the fix left
90
+ `orchestrateRunners` with a positional tail whose resume call site stamped the execution id instead.
91
+ It is now a required field of an options object.
92
+ - **`/agents stop` was cosmetic.** `AGENT_RESUMABLE` excludes `cancelled`, so a resume offered no record
93
+ for the stopped step and simply re-ran the agent — a second paid planning call after a human asked it
94
+ to stop. It also wedged an execution permanently when the stopped task held the pending slot, and
95
+ overwrote a concurrent writer's work.
96
+ - `/quit` was reachable but absent from `/help`, alongside `/agents`. A test now asserts every command
97
+ is documented.
98
+
8
99
  ## [2.8.0] — 2026-09-05
9
100
 
10
101
  Agent work now **survives a crash**. Until this release an agent's progress existed only in memory: the
@@ -750,6 +841,8 @@ Initial release: the provider-agnostic AI **router** — capability-based routin
750
841
  scoring, evidence validation, fallback, health tracking, learning-based scoring, multi-model verification,
751
842
  budgets, MCP tools, OpenAPI-based adapter generation, and the `AI` class + CLI.
752
843
 
844
+ [3.0.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v3.0.0
845
+ [2.9.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.9.0
753
846
  [2.8.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.8.0
754
847
  [2.7.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.7.0
755
848
  [2.6.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.6.0
package/README.md CHANGED
@@ -80,10 +80,40 @@ You describe *what you want*; the runtime picks *which model* runs it. A few ide
80
80
  stateless mode).
81
81
  - **Add your own skills** — drop a manifest into `.ai-runtime/skills/`, or install a skill pack from npm.
82
82
  - **Steer routing** — hard-`exclude` or soft-`prefer` providers/models via config, per-run, or env vars.
83
+ - **Connect MCP servers** — `mcp:` in the config brings a server's tools in as ordinary permission-gated
84
+ tools, deny-by-default per server. `ai-runtime mcp` and `/mcp` show what is connected.
85
+ - **Delegate to agents** — a plan step can hand bounded work to an agent with its own narrowed catalog,
86
+ permissions and budget, which reports back structured findings. `/agents` lists them; `/agents stop`
87
+ stops one. Turn it on with `runtime.agents.enabled`.
88
+ - **Survive a crash mid-run** — agent progress is committed as it happens, so resuming finishes the job
89
+ instead of redoing it. `ai-runtime resume-execution <id>`.
83
90
  - Everything that acts is **permission-gated and workspace-jailed** (see [Safety](#safety)).
84
91
 
85
92
  ---
86
93
 
94
+ ## What's new in 3.0
95
+
96
+ The 3.x arc made the runtime reason about *what it can do*, work with tools it didn't ship with, delegate
97
+ bounded work, and survive being killed in the middle of it.
98
+
99
+ - **Action capabilities** — the runtime knows it can `read_file` or `run_tests` independently of which
100
+ tool or skill provides it, and tells you what's missing when a goal needs something it hasn't got.
101
+ - **MCP** — a zero-dependency client over stdio and streamable HTTP. Server tools become ordinary tools,
102
+ with per-server grants that are off until you say otherwise.
103
+ - **Agents** — a third kind of plan step. An agent gets an envelope narrowed from the parent's catalog and
104
+ permissions, a call budget it cannot exceed, and an output contract its findings must satisfy. Approving
105
+ a plan shows you that envelope, because approving a delegation blind is approving a blank cheque.
106
+ - **Durable agent work** — plan, inner steps, findings and spend are on disk before the next wave starts,
107
+ so a `kill -9` costs you the step in flight and nothing else.
108
+ - **Auto-decomposition** (opt-in) — a goal can delegate to read-shaped agents nobody configured. The roles
109
+ and their objectives ship in-tree; nothing the model writes becomes an objective, a tool, or a
110
+ permission.
111
+
112
+ Upgrading from 2.x needs no config change. One default moved: the capability catalog is now included in
113
+ planning prompts — set `runtime.capabilities.catalog: false` to remove it.
114
+
115
+ ---
116
+
87
117
  ## Configuration
88
118
 
89
119
  Three places, and only these:
@@ -65,5 +65,13 @@ export declare function admitFindings(input: AdmitFindingsInput): AdmissionResul
65
65
  * output contract: then a violation, or admitting nothing at all, is a contract failure. With no
66
66
  * declared contract findings are best-effort - rejections are diagnostics and the step's success is
67
67
  * decided by its inner plan alone.
68
+ *
69
+ * `emptyIsFailure: false` keeps the contract's SHAPE rules (types, cap, subject) while making "found
70
+ * nothing worth reporting" an ordinary outcome rather than a failure. Derived agents pass it: their
71
+ * contract is mandatory precisely so their output stays bounded, but none of the shipped tools emit
72
+ * `data.findings`, so requiring at least one finding would fail every derived task against the
73
+ * runtime's own toolset — a feature that cannot succeed out of the box.
68
74
  */
69
- export declare function contractFailed(result: AdmissionResult): boolean;
75
+ export declare function contractFailed(result: AdmissionResult, opts?: {
76
+ emptyIsFailure?: boolean;
77
+ }): boolean;
@@ -121,9 +121,17 @@ function validateFinding(f) {
121
121
  * output contract: then a violation, or admitting nothing at all, is a contract failure. With no
122
122
  * declared contract findings are best-effort - rejections are diagnostics and the step's success is
123
123
  * decided by its inner plan alone.
124
+ *
125
+ * `emptyIsFailure: false` keeps the contract's SHAPE rules (types, cap, subject) while making "found
126
+ * nothing worth reporting" an ordinary outcome rather than a failure. Derived agents pass it: their
127
+ * contract is mandatory precisely so their output stays bounded, but none of the shipped tools emit
128
+ * `data.findings`, so requiring at least one finding would fail every derived task against the
129
+ * runtime's own toolset — a feature that cannot succeed out of the box.
124
130
  */
125
- export function contractFailed(result) {
131
+ export function contractFailed(result, opts = {}) {
126
132
  if (!result.contractDeclared)
127
133
  return false;
128
- return result.contractViolated || result.admitted.length === 0;
134
+ if (result.contractViolated)
135
+ return true;
136
+ return opts.emptyIsFailure !== false && result.admitted.length === 0;
129
137
  }
@@ -49,5 +49,26 @@ export interface NarrowEnvelopeInput {
49
49
  maxDurationMs: number;
50
50
  maxInnerCalls: number;
51
51
  };
52
+ /**
53
+ * Where the definition came from (Phase 3.7). `authored` keeps the 3.4 rule that an omitted field
54
+ * INHERITS the parent's reach — an operator wrote that definition, and omission is their choice.
55
+ * `derived` INVERTS it: an omitted field means nothing at all.
56
+ *
57
+ * The inversion lives here rather than in the synthesizer on purpose. If the synthesizer did the
58
+ * bounding, a forgotten field there would silently hand a machine-generated agent the parent's whole
59
+ * catalog and every permission — it would fail OPEN. Here, a synthesizer bug produces an agent that
60
+ * can do nothing: loud, and safe. There is still exactly one enforcement point.
61
+ */
62
+ provenance?: 'authored' | 'derived';
52
63
  }
64
+ /**
65
+ * What a DERIVED agent may do: read, and reason about what it read. Every other dimension is explicitly
66
+ * `false` rather than absent, because `clampPermissions` treats an absent field as "inherit" — so an
67
+ * explicit denial is the only thing that actually denies. Writes remain the province of a definition a
68
+ * human wrote and an operator configured.
69
+ */
70
+ export declare const DERIVED_PERMISSIONS: PermissionPolicy;
71
+ /** Tighter ceilings for an agent nobody authored. A definition may still only lower them. */
72
+ export declare const DERIVED_MAX_TOOL_CALLS = 8;
73
+ export declare const DERIVED_MAX_INNER_CALLS = 2;
53
74
  export declare function narrowEnvelope(input: NarrowEnvelopeInput): AgentEnvelope;
@@ -15,6 +15,26 @@ import { clampPermissions } from '../tools/permissions.js';
15
15
  import { flattenClamp } from '../util/flatten.js';
16
16
  /** The definition-authored objective reaches a model prompt, so it is bounded like any other source text. */
17
17
  export const OBJECTIVE_MAX = 240;
18
+ /**
19
+ * What a DERIVED agent may do: read, and reason about what it read. Every other dimension is explicitly
20
+ * `false` rather than absent, because `clampPermissions` treats an absent field as "inherit" — so an
21
+ * explicit denial is the only thing that actually denies. Writes remain the province of a definition a
22
+ * human wrote and an operator configured.
23
+ */
24
+ export const DERIVED_PERMISSIONS = {
25
+ fsRead: true,
26
+ fsWrite: false,
27
+ shell: false,
28
+ shellAllowedCommands: [],
29
+ gitWrite: false,
30
+ gitCommit: false,
31
+ gitPush: false,
32
+ network: false,
33
+ mcp: { servers: {} },
34
+ };
35
+ /** Tighter ceilings for an agent nobody authored. A definition may still only lower them. */
36
+ export const DERIVED_MAX_TOOL_CALLS = 8;
37
+ export const DERIVED_MAX_INNER_CALLS = 2;
18
38
  /** A cap a definition may only LOWER, never raise, and never below 1. */
19
39
  function lowerOnly(deflt, requested) {
20
40
  return Math.max(1, Math.min(deflt, requested ?? deflt));
@@ -25,17 +45,31 @@ export function narrowEnvelope(input) {
25
45
  // (1) Tools: intersect with the parent. A definition entry naming something the parent does not have
26
46
  // is simply absent — it can never ADD a tool.
27
47
  const parentTools = new Set(input.parentTools);
28
- const tools = uniqSorted((def.tools ?? input.parentTools).filter((t) => parentTools.has(t)));
48
+ const derived = input.provenance === 'derived';
49
+ // A derived definition that names no tools gets NONE. An authored one inherits the parent's, which is
50
+ // the 3.4 behaviour and stays unchanged.
51
+ const tools = uniqSorted((def.tools ?? (derived ? [] : input.parentTools)).filter((t) => parentTools.has(t)));
29
52
  // (2) Skills: intersect with the parent, then drop any skill that needs a tool outside the envelope.
30
53
  // That second clause is load-bearing, not tidiness: a skill's own `callTool` resolves straight off the
31
54
  // Runtime's registry with no allowlist check, so admitting a skill whose declared tools escape the
32
55
  // envelope would be a hole. Excluding it is the structural fix; the worker's membership check is
33
56
  // defense in depth.
34
57
  const inner = new Set(tools);
35
- const allowedSkills = def.skills ? new Set(def.skills) : undefined;
58
+ const allowedSkills = def.skills ? new Set(def.skills) : derived ? new Set() : undefined;
36
59
  const skills = uniqSorted(input.parentSkills.filter((s) => (!allowedSkills || allowedSkills.has(s.id)) && (s.tools ?? []).every((t) => inner.has(t))).map((s) => s.id));
37
60
  // (3) Permissions: minimum-merged and fully explicit (see `clampPermissions`).
38
- const permissions = clampPermissions(input.parentPermissions, def.permissions);
61
+ // For a derived agent DERIVED_PERMISSIONS is a CEILING, not a default: the definition is clamped
62
+ // against it first, so even an explicit `fsWrite: true` in a synthesized definition cannot grant
63
+ // writing. Using it as a mere default would leave the guarantee resting on the synthesizer never
64
+ // setting the field — which is true today, and is exactly the kind of thing that stops being true.
65
+ const permissions = derived
66
+ ? clampPermissions(input.parentPermissions, clampPermissions(DERIVED_PERMISSIONS, def.permissions))
67
+ : clampPermissions(input.parentPermissions, def.permissions);
68
+ // MCP is the one non-boolean permission dimension, and its clamp is a PER-KEY minimum: an empty
69
+ // override map means "no opinion", i.e. inherit every server grant — the opposite of what an empty
70
+ // map reads like. So a derived agent's MCP access is set explicitly rather than clamped to nothing.
71
+ if (derived)
72
+ permissions.mcp = { servers: {} };
39
73
  // (6) Routing: exclusions only ever GROW, preferences only ever shrink, so an agent can never
40
74
  // re-admit a provider the parent excluded, nor reach past a privacy or policy decision.
41
75
  const pr = input.parentRouting;
@@ -55,10 +89,10 @@ export function narrowEnvelope(input) {
55
89
  skills,
56
90
  permissions,
57
91
  // (4) Caps: a definition may only lower.
58
- maxToolCalls: lowerOnly(defaults.maxToolCalls, def.maxToolCalls),
92
+ maxToolCalls: lowerOnly(derived ? Math.min(defaults.maxToolCalls, DERIVED_MAX_TOOL_CALLS) : defaults.maxToolCalls, def.maxToolCalls),
59
93
  maxDurationMs: lowerOnly(defaults.maxDurationMs, def.maxDurationMs),
60
94
  // (5) The reservation is the same shape of number, and doubles as the hard inner-call ceiling.
61
- reservation: lowerOnly(defaults.maxInnerCalls, def.maxInnerCalls),
95
+ reservation: lowerOnly(derived ? Math.min(defaults.maxInnerCalls, DERIVED_MAX_INNER_CALLS) : defaults.maxInnerCalls, def.maxInnerCalls),
62
96
  ...(routing && Object.keys(routing).length ? { routing } : {}),
63
97
  // Requirements ADD to the parent's — more requirements is a narrower candidate set.
64
98
  ...(def.model?.requirements?.length ? { requirements: [...def.model.requirements] } : {}),
@@ -72,8 +72,14 @@ export declare function executionCoverage(steps: PlanStep[]): number;
72
72
  */
73
73
  export declare function confidenceOf(ev: FindingEvidence[], override?: unknown): number;
74
74
  /**
75
- * Resolve conflicts among active findings, weighing ONLY `confidence`. Two findings conflict when they
76
- * share a `type` + `subject`: a differing verdict makes the loser `contradicted`, and otherwise the
77
- * loser is `superseded`. `executionCoverage` breaks a tie and never enters the weight.
75
+ * Resolve conflicts among findings, weighing ONLY `confidence`. Two findings conflict when they share a
76
+ * `type` + `subject`: a differing verdict makes the loser `contradicted`, and otherwise the loser is
77
+ * `superseded`. `executionCoverage` breaks a tie and never enters the weight.
78
+ *
79
+ * IDEMPOTENT AND TOTAL: pass the whole set every time, including findings already marked. The winner of
80
+ * each group is restored to `active`, so re-running over a set whose membership grew produces the same
81
+ * answer as running once over the final set. Resolving only the currently-`active` subset instead makes
82
+ * the outcome depend on the order results ARRIVE — and leaves `supersededBy` pointing at a finding that
83
+ * was itself later superseded, a chain nothing heals.
78
84
  */
79
85
  export declare function resolveConflicts(findings: Finding[]): Finding[];
@@ -51,9 +51,15 @@ export function confidenceOf(ev, override) {
51
51
  return round2(c);
52
52
  }
53
53
  /**
54
- * Resolve conflicts among active findings, weighing ONLY `confidence`. Two findings conflict when they
55
- * share a `type` + `subject`: a differing verdict makes the loser `contradicted`, and otherwise the
56
- * loser is `superseded`. `executionCoverage` breaks a tie and never enters the weight.
54
+ * Resolve conflicts among findings, weighing ONLY `confidence`. Two findings conflict when they share a
55
+ * `type` + `subject`: a differing verdict makes the loser `contradicted`, and otherwise the loser is
56
+ * `superseded`. `executionCoverage` breaks a tie and never enters the weight.
57
+ *
58
+ * IDEMPOTENT AND TOTAL: pass the whole set every time, including findings already marked. The winner of
59
+ * each group is restored to `active`, so re-running over a set whose membership grew produces the same
60
+ * answer as running once over the final set. Resolving only the currently-`active` subset instead makes
61
+ * the outcome depend on the order results ARRIVE — and leaves `supersededBy` pointing at a finding that
62
+ * was itself later superseded, a chain nothing heals.
57
63
  */
58
64
  export function resolveConflicts(findings) {
59
65
  const groups = new Map();
@@ -69,6 +75,11 @@ export function resolveConflicts(findings) {
69
75
  continue;
70
76
  const ranked = [...group].sort((a, b) => b.confidence - a.confidence || b.executionCoverage - a.executionCoverage || (a.id < b.id ? -1 : 1));
71
77
  const winner = ranked[0];
78
+ // The winner is `active` by definition of having won — even if an earlier, smaller round had
79
+ // marked it a loser. This is what makes the function idempotent.
80
+ const top = out.get(winner.id);
81
+ top.status = 'active';
82
+ delete top.supersededBy;
72
83
  for (const loser of ranked.slice(1)) {
73
84
  const row = out.get(loser.id);
74
85
  const differingVerdict = loser.verdict !== undefined && winner.verdict !== undefined && loser.verdict !== winner.verdict;
@@ -92,6 +92,43 @@ export interface AgentTaskRecord {
92
92
  export declare const AGENT_TERMINAL: ReadonlySet<AgentTaskState>;
93
93
  /** States a persisted task may be resumed from. Exactly the complement of AGENT_TERMINAL. */
94
94
  export declare const AGENT_RESUMABLE: ReadonlySet<AgentTaskState>;
95
+ /**
96
+ * The PUBLIC read shape of an agent task (Phase 3.6) — what a host or a status line needs to render
97
+ * progress, and nothing else.
98
+ *
99
+ * It exists so the record's internals are not frozen into the public API. `innerPlan`,
100
+ * `innerObservations`, `innerCheckpoint`, raw `findings` and `diagnostics` are all persistence detail:
101
+ * they change as the phase evolves, they carry agent-authored content, and a host that reached into
102
+ * them would break on the next schema version. This shape is counts, ids and states — safe to freeze.
103
+ */
104
+ export interface AgentTaskView {
105
+ agentTaskId: string;
106
+ agentId: string;
107
+ stepId: string;
108
+ state: AgentTaskState;
109
+ innerSteps: {
110
+ total: number;
111
+ succeeded: number;
112
+ };
113
+ /** What the step reserved from the run's call budget, and what it has actually spent. */
114
+ callsReserved: number;
115
+ callsUsed: number;
116
+ toolCallsUsed: number;
117
+ /** How many findings were ADMITTED — never the findings themselves. */
118
+ findings: number;
119
+ /** Why a non-terminal task stopped, when it was stopped by something outside itself. */
120
+ interruption?: {
121
+ kind: 'crash' | 'pause' | 'parent-cancel';
122
+ at: number;
123
+ };
124
+ /** The question this task is waiting on, clamped. Present only while it is waiting. */
125
+ question?: string;
126
+ startedAt?: number;
127
+ endedAt?: number;
128
+ updatedAt: number;
129
+ }
130
+ /** Project a record onto the public view. The ONE place that mapping lives. */
131
+ export declare function agentTaskView(record: AgentTaskRecord): AgentTaskView;
95
132
  /** One row per agent-task state. Read this table; never re-derive a projection at a call site. */
96
133
  export interface ProjectionRow {
97
134
  step: PlanStepStatus;
@@ -9,6 +9,7 @@
9
9
  * normal state and records WHY in `interruption`, so the reason is auditable without growing the
10
10
  * lifecycle.
11
11
  */
12
+ import { flattenClamp } from '../util/flatten.js';
12
13
  /** Terminal agent-task states: reached once, never left. A commit may not move a task out of one. */
13
14
  export const AGENT_TERMINAL = new Set(['completed', 'failed', 'cancelled']);
14
15
  /** States a persisted task may be resumed from. Exactly the complement of AGENT_TERMINAL. */
@@ -20,6 +21,27 @@ export const AGENT_RESUMABLE = new Set([
20
21
  'waiting_for_clarification',
21
22
  'paused',
22
23
  ]);
24
+ /** Project a record onto the public view. The ONE place that mapping lives. */
25
+ export function agentTaskView(record) {
26
+ return {
27
+ agentTaskId: record.agentTaskId,
28
+ agentId: record.agentId,
29
+ stepId: record.stepId,
30
+ state: record.state,
31
+ innerSteps: record.innerSteps,
32
+ callsReserved: record.callsReserved,
33
+ callsUsed: record.callsUsed,
34
+ toolCallsUsed: record.toolCallsUsed,
35
+ findings: record.findings.length,
36
+ ...(record.interruption ? { interruption: { kind: record.interruption.kind, at: record.interruption.at } } : {}),
37
+ // The ONE untrusted string in this shape: model-authored, read back from a file some other
38
+ // version wrote, and rendered in a terminal. `.slice()` truncates but strips nothing.
39
+ ...(record.pendingInner ? { question: flattenClamp(record.pendingInner.question, 240) } : {}),
40
+ ...(record.startedAt !== undefined ? { startedAt: record.startedAt } : {}),
41
+ ...(record.endedAt !== undefined ? { endedAt: record.endedAt } : {}),
42
+ updatedAt: record.updatedAt,
43
+ };
44
+ }
23
45
  export const AGENT_TASK_PROJECTION = {
24
46
  created: { step: 'pending', exec: 'running', reachableIn34: true },
25
47
  queued: { step: 'pending', exec: 'running', reachableIn34: true },
@@ -55,8 +55,11 @@ export interface AgentWorkerDeps {
55
55
  ref?: ArtifactRef;
56
56
  unavailable: boolean;
57
57
  };
58
+ /** Lifecycle notifications. `agent.task.progress` fires once per inner WAVE — never per inner step:
59
+ * the inner executor runs one step at a time, so per-step would emit one event per step and a chatty
60
+ * agent would push every other event out of the emitter's ring buffer. */
58
61
  emit?: (event: {
59
- type: 'agent.task.started' | 'agent.task.completed';
62
+ type: 'agent.task.started' | 'agent.task.progress' | 'agent.task.completed';
60
63
  record: AgentTaskRecord;
61
64
  }) => void;
62
65
  parentSignal?: AbortSignal;
@@ -66,11 +69,19 @@ export interface AgentWorkerDeps {
66
69
  executionId?: string;
67
70
  planVersion: number;
68
71
  };
72
+ /** Phase 3.6: hands the caller a way to stop THIS task specifically, for as long as it is running.
73
+ * Without it the only lever is the run's own controller, which stops every agent at once. */
74
+ registerAbort?: (agentTaskId: string, abort: () => void) => void;
75
+ /** Phase 3.6: called when the task is no longer running, so the abort handle is not kept forever. */
76
+ releaseAbort?: (agentTaskId: string) => void;
69
77
  /** Phase 3.5: called whenever the record MATERIALLY changes, so inner progress reaches disk while the
70
78
  * agent is still running. Without a seam inside the inner run, everything between `running` and
71
79
  * `finish()` — the inner plan, every completed inner step, every inner model call — is lost to a
72
80
  * crash, and the resume has nothing to skip. Synchronous; must not throw. */
73
81
  onRecord?: (record: AgentTaskRecord) => void;
82
+ /** Phase 3.7: this agent was SYNTHESIZED, not authored. Its output contract bounds what it may
83
+ * report without obliging it to report anything. */
84
+ derived?: boolean;
74
85
  /** Phase 3.5: a persisted record to CONTINUE instead of minting a fresh one. The caller proves it
75
86
  * belongs to THIS step by step-input hash before passing it. */
76
87
  resume?: AgentTaskRecord;
@@ -120,6 +120,7 @@ export async function runAgentTask(step, envelope, definition, deps) {
120
120
  const skillResults = [];
121
121
  const finish = (state, failure) => {
122
122
  deps.parentSignal?.removeEventListener('abort', onParentAbort);
123
+ deps.releaseAbort?.(record.agentTaskId);
123
124
  record.state = state;
124
125
  record.endedAt = deps.clock.now();
125
126
  record.updatedAt = record.endedAt;
@@ -165,6 +166,7 @@ export async function runAgentTask(step, envelope, definition, deps) {
165
166
  record.state = 'running';
166
167
  record.startedAt = deps.clock.now();
167
168
  record.updatedAt = record.startedAt;
169
+ deps.registerAbort?.(record.agentTaskId, () => child.abort());
168
170
  deps.emit?.({ type: 'agent.task.started', record });
169
171
  deps.onRecord?.(record);
170
172
  // (6) THE TOOL SEAM - defense in depth behind narrowEnvelope. Always a structured denial, never a
@@ -261,7 +263,15 @@ export async function runAgentTask(step, envelope, definition, deps) {
261
263
  record.callsUsed = priorCalls + calls;
262
264
  record.toolCallsUsed = priorToolCalls + toolCalls;
263
265
  record.updatedAt = deps.clock.now();
266
+ record.innerSteps = { total: snap.plan.steps.length, succeeded: record.innerCompletedSteps.length };
264
267
  deps.onRecord?.(record);
268
+ // One event per WAVE, plus one at the end. `wave-partition` fires BEFORE the wave runs, so on
269
+ // its own it reports the count from before — for a single-wave inner plan (the common case)
270
+ // that means the only progress event says 0/N, which is exactly what `started` already said,
271
+ // and a display would read 0/N until the task simply finished. `plan-end` fires once and is the
272
+ // only snapshot carrying the final count. Still not per-step: waves + 1 events per task.
273
+ if (snap.at === 'wave-partition' || snap.at === 'plan-end')
274
+ deps.emit?.({ type: 'agent.task.progress', record });
265
275
  },
266
276
  runSkill: async (id, i) => {
267
277
  const out = await deps.runSkill(id, i, { permissions: envelope.permissions, signal: child.signal, ai: metered });
@@ -362,7 +372,10 @@ export async function runAgentTask(step, envelope, definition, deps) {
362
372
  // Bounded: diagnostics accumulate across attempts, and every one of them is rewritten to disk on
363
373
  // every commit. Keeping the most recent is the useful half.
364
374
  record.diagnostics = [...record.diagnostics, ...admission.rejected].slice(-DIAGNOSTICS_KEPT);
365
- if (contractFailed(admission)) {
375
+ // A DERIVED agent's contract bounds what it may report; it does not oblige it to report. Nothing in
376
+ // the shipped toolset emits `data.findings`, so demanding at least one would fail every derived task
377
+ // against the runtime's own tools — the feature would be unusable without a bespoke tool.
378
+ if (contractFailed(admission, { emptyIsFailure: !deps.derived })) {
366
379
  return finish('failed', { code: 'finding-contract', message: 'the agent did not satisfy its declared output contract' });
367
380
  }
368
381
  if (expired())
package/dist/cli/cli.js CHANGED
@@ -21,7 +21,7 @@ import { mcpCommand, mcpAddCommand, mcpRemoveCommand, mcpEnableCommand, mcpTestC
21
21
  import { startRepl } from './interactive/repl.js';
22
22
  import { printError } from './render.js';
23
23
  const program = new Command();
24
- program.name('ai-runtime').description('Universal, provider-agnostic AI Runtime & Orchestration Platform').version('2.8.0');
24
+ program.name('ai-runtime').description('Universal, provider-agnostic AI Runtime & Orchestration Platform').version('3.0.0');
25
25
  const configOpt = ['-c, --config <path>', 'path to an ai-runtime config file'];
26
26
  // Bare `ai-runtime` (no subcommand) opens the interactive terminal. `allowExcessArguments(false)` keeps
27
27
  // a mistyped subcommand (e.g. `ai-runtime porviders`) failing fast instead of silently opening the REPL.
@@ -13,6 +13,7 @@ import { print, printChunk, printError } from '../render.js';
13
13
  import { summarizeWorkspace } from '../../runtime/workspace/workspace.js';
14
14
  import { ReplSession, SLASH_COMMANDS } from './session.js';
15
15
  import { makeCompleter } from './complete.js';
16
+ import { LaneSet, laneLines, frameDiff, frameRows } from './lanes.js';
16
17
  import { colorEnabled, bold, cyan, dim, gray, SPINNER_FRAMES, statusLine, clearLine } from './ansi.js';
17
18
  function banner(rt, colors) {
18
19
  const ws = rt.workspaceInfo();
@@ -96,6 +97,22 @@ export async function startRepl(configPath) {
96
97
  };
97
98
  let streamedThisRun = false;
98
99
  let spinner;
100
+ // Phase 3.6: concurrent agent steps render as live lanes. The region opens on the first agent event —
101
+ // long after the spinner has been stopped by the very first event of the run — so the two never own
102
+ // the cursor at the same time. With agents disabled no agent event is ever emitted, so the region
103
+ // never opens and output is byte-identical.
104
+ const lanes = new LaneSet();
105
+ let laneRows = 0;
106
+ let laneTick = 0;
107
+ // A late event — one arriving after the run has already returned — must not open a region on top of
108
+ // the prompt and leave `laneRows` set for the NEXT run's cursor arithmetic to walk into.
109
+ let runInFlight = false;
110
+ const closeLanes = () => {
111
+ if (laneRows > 0)
112
+ printChunk(frameDiff(laneRows, [], colors));
113
+ laneRows = 0;
114
+ lanes.clear();
115
+ };
99
116
  // Live progress + token streaming. Events are already redacted; a throwing observer can't break a run.
100
117
  rt.on((e) => {
101
118
  spinner?.stop(); // any event means work has started producing output — drop the spinner first
@@ -104,6 +121,30 @@ export async function startRepl(configPath) {
104
121
  streamedThisRun = true;
105
122
  return;
106
123
  }
124
+ const lane = lanes.observe(e);
125
+ if (lane) {
126
+ if (!runInFlight)
127
+ return; // see `runInFlight`
128
+ laneTick += 1;
129
+ if (colors) {
130
+ // The region must fit the viewport: `cursorUp` saturates at row 0, so a frame taller than the
131
+ // pane can never walk back to its own top and would redraw itself downward forever.
132
+ const budget = Math.max(1, (process.stdout.rows || 24) - 2);
133
+ const all = lanes.list();
134
+ const shown = all.length > budget ? all.slice(0, budget - 1) : all;
135
+ const lines = laneLines(shown, { cols: process.stdout.columns || 80, colors, tick: laneTick });
136
+ if (all.length > shown.length)
137
+ lines.push(dim(` … ${all.length - shown.length} more agent task(s)`, colors));
138
+ printChunk(frameDiff(laneRows, lines, colors));
139
+ laneRows = frameRows(lines);
140
+ }
141
+ else if (e.type !== 'agent.task.progress') {
142
+ // Piped, NO_COLOR or a dumb terminal: cursor games would be garbage in a log file, so report
143
+ // the transitions append-only instead. Progress ticks are dropped — in a log they are noise.
144
+ print(` · agent ${lane.agentId} @ ${lane.stepId}: ${lane.state}`);
145
+ }
146
+ return;
147
+ }
107
148
  const line = progressLine(e, colors);
108
149
  if (line)
109
150
  print(line);
@@ -125,18 +166,29 @@ export async function startRepl(configPath) {
125
166
  let result;
126
167
  streamedThisRun = false;
127
168
  spinner = startSpinner(colors);
169
+ runInFlight = true;
170
+ // Readline keeps echoing keypresses while the handler awaits, and the cursor sits inside the lane
171
+ // region — so a keystroke mid-run writes into the frame and its clear-to-end-of-screen erases the
172
+ // rows below it. Pausing buffers the input instead; it is resumed on both exits below.
173
+ rl.pause();
128
174
  try {
129
175
  result = await session.handle(line);
130
176
  }
131
177
  catch (err) {
178
+ runInFlight = false;
179
+ rl.resume();
132
180
  spinner.stop();
181
+ closeLanes();
133
182
  if (streamedThisRun)
134
183
  process.stdout.write('\n');
135
184
  printError(`error: ${err instanceof Error ? err.message : String(err)}`);
136
185
  rl.prompt();
137
186
  continue;
138
187
  }
188
+ runInFlight = false;
189
+ rl.resume();
139
190
  spinner.stop();
191
+ closeLanes(); // hand the cursor back before any result line is printed
140
192
  if (streamedThisRun)
141
193
  process.stdout.write('\n'); // close the streamed line before printing result lines
142
194
  if (result.clear)
@@ -11,7 +11,10 @@ export interface HandleResult {
11
11
  clear?: boolean;
12
12
  }
13
13
  /** Top-level slash commands, for REPL tab-completion (Phase 21b). Kept in sync with the `handle` dispatch. */
14
- export declare const SLASH_COMMANDS: readonly ["help", "status", "info", "doctor", "cleanup", "mode", "compare", "models", "config", "providers", "tools", "capabilities", "mcp", "skills", "memory", "conversations", "executions", "resume", "resume-execution", "pause", "cancel", "approve", "deny", "learning", "feedback", "permissions", "budget", "stream", "dry-run", "clear", "exit", "quit"];
14
+ export declare const SLASH_COMMANDS: readonly ["help", "status", "info", "doctor", "cleanup", "mode", "compare", "models", "config", "providers", "tools", "capabilities", "mcp", "skills", "memory", "conversations", "executions", "agents", "resume", "resume-execution", "pause", "cancel", "approve", "deny", "learning", "feedback", "permissions", "budget", "stream", "dry-run", "clear", "exit", "quit"];
15
+ /** Exported so a test can prove every reachable command is documented — the three touch points below
16
+ * are synced by hand, and `/agents` shipped tab-completable but absent from this list. */
17
+ export declare const HELP: string[];
15
18
  export declare class ReplSession {
16
19
  private readonly runtime;
17
20
  private mode;
@@ -36,6 +39,10 @@ export declare class ReplSession {
36
39
  private learning;
37
40
  private permissions;
38
41
  private conversationsList;
42
+ /** Agent tasks across this project's executions, newest first. */
43
+ private agentsList;
44
+ /** Stop one agent task. Every outcome is reported — a stop that looks like nothing happened is a bug. */
45
+ private agentStop;
39
46
  private executionsList;
40
47
  private resumeExecution;
41
48
  private resume;