ai-runtime-engine 2.9.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,50 @@ 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
+
8
52
  ## [2.9.0] — 2026-09-05
9
53
 
10
54
  Agent work becomes VISIBLE. Concurrent agent steps render as live lanes in the interactive terminal,
@@ -797,6 +841,7 @@ Initial release: the provider-agnostic AI **router** — capability-based routin
797
841
  scoring, evidence validation, fallback, health tracking, learning-based scoring, multi-model verification,
798
842
  budgets, MCP tools, OpenAPI-based adapter generation, and the `AI` class + CLI.
799
843
 
844
+ [3.0.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v3.0.0
800
845
  [2.9.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.9.0
801
846
  [2.8.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.8.0
802
847
  [2.7.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.7.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;
@@ -79,6 +79,9 @@ export interface AgentWorkerDeps {
79
79
  * `finish()` — the inner plan, every completed inner step, every inner model call — is lost to a
80
80
  * crash, and the resume has nothing to skip. Synchronous; must not throw. */
81
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;
82
85
  /** Phase 3.5: a persisted record to CONTINUE instead of minting a fresh one. The caller proves it
83
86
  * belongs to THIS step by step-input hash before passing it. */
84
87
  resume?: AgentTaskRecord;
@@ -372,7 +372,10 @@ export async function runAgentTask(step, envelope, definition, deps) {
372
372
  // Bounded: diagnostics accumulate across attempts, and every one of them is rewritten to disk on
373
373
  // every commit. Keeping the most recent is the useful half.
374
374
  record.diagnostics = [...record.diagnostics, ...admission.rejected].slice(-DIAGNOSTICS_KEPT);
375
- 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 })) {
376
379
  return finish('failed', { code: 'finding-contract', message: 'the agent did not satisfy its declared output contract' });
377
380
  }
378
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.9.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.
@@ -43,7 +43,8 @@ export interface OrchestrateInput {
43
43
  routing?: RoutingPreferences;
44
44
  /** Phase 22: run the phases that fit the call budget and pause resumably (vs. the default notify-and-wait). */
45
45
  partial?: boolean;
46
- /** Phase 3.1: pre-rendered action-capability snapshot for the planner prompt (opt-in). */
46
+ /** Phase 3.1: pre-rendered, fenced action-capability snapshot for the planner prompt. Present by
47
+ * default from 3.0.0; `runtime.capabilities.catalog: false` removes it. */
47
48
  capabilityCatalog?: string;
48
49
  /** Phase 3.3: pre-rendered "Required capabilities" block for the planner prompt (opt-in). */
49
50
  requiredCapabilities?: string;
@@ -18,7 +18,8 @@ export interface PlannerInput {
18
18
  reason?: string;
19
19
  /** Observations from a prior attempt, to inform a replan. */
20
20
  priorObservations?: string[];
21
- /** Phase 3.1: a pre-rendered, capped, fenced action-capability snapshot. Absent the prompt is
21
+ /** Phase 3.1: a pre-rendered, capped, fenced action-capability snapshot (fenced for real as of
22
+ * 3.0.0, and present by default from it). Absent ⇒ the prompt is
22
23
  * byte-identical to 2.3.0 (the catalog flag is off by default). */
23
24
  capabilityCatalog?: string;
24
25
  /** Phase 3.3: a pre-rendered, clamped block naming the capabilities the goal was derived to need and
@@ -40,7 +40,7 @@ const agentDefinition = z
40
40
  .strict();
41
41
  /** An agent definition id: the same prompt-safe shape an MCP server id must have. */
42
42
  const AGENT_ID_RE = /^[a-z0-9][a-z0-9_-]{0,32}$/;
43
- const runtimeSettings = z.object({ defaultMode: z.enum(RUNTIME_MODES).optional(), defaultStrategy: z.enum(STRATEGIES).optional(), context: z.object({ maxTokens: z.number().optional(), verifyLoss: z.boolean().optional(), summarize: z.boolean().optional() }).strict().optional(), skills: z.object({ paths: z.array(z.string()).optional(), packages: z.array(z.string()).optional(), autoload: z.boolean().optional() }).strict().optional(), embedding: z.object({ provider: z.enum(['local', 'openai-compatible']), baseUrl: z.string().optional(), apiKeyEnv: z.string().optional(), model: z.string().optional() }).strict().optional(), intent: z.object({ aiFallback: z.boolean().optional() }).strict().optional(), organization: z.string().optional(), storage: z.object({ encrypt: z.boolean(), keyEnv: z.string() }).strict().optional(), capabilities: z.object({ catalog: z.boolean().optional(), planning: z.boolean().optional(), aliases: z.record(z.string(), z.string()).optional(), pins: z.record(z.string(), z.string()).optional() }).strict().optional(), concurrency: z.object({ maxParallelSteps: z.number().optional(), perTool: z.record(z.string(), z.number()).optional(), perSkill: z.record(z.string(), z.number()).optional(), perProvider: z.record(z.string(), z.number()).optional(), perAgent: z.record(z.string(), z.number()).optional() }).strict().optional(), agents: z.object({ enabled: z.boolean().optional(), maxToolCalls: z.number().optional(), maxDurationMs: z.number().optional(), maxInnerCalls: z.number().optional(), definitions: z.record(z.string().regex(AGENT_ID_RE, 'an agent definition id must be lowercase kebab/snake (max 33 chars)'), agentDefinition).optional() }).strict().optional() }).strict();
43
+ const runtimeSettings = z.object({ defaultMode: z.enum(RUNTIME_MODES).optional(), defaultStrategy: z.enum(STRATEGIES).optional(), context: z.object({ maxTokens: z.number().optional(), verifyLoss: z.boolean().optional(), summarize: z.boolean().optional() }).strict().optional(), skills: z.object({ paths: z.array(z.string()).optional(), packages: z.array(z.string()).optional(), autoload: z.boolean().optional() }).strict().optional(), embedding: z.object({ provider: z.enum(['local', 'openai-compatible']), baseUrl: z.string().optional(), apiKeyEnv: z.string().optional(), model: z.string().optional() }).strict().optional(), intent: z.object({ aiFallback: z.boolean().optional() }).strict().optional(), organization: z.string().optional(), storage: z.object({ encrypt: z.boolean(), keyEnv: z.string() }).strict().optional(), capabilities: z.object({ catalog: z.boolean().optional(), planning: z.boolean().optional(), aliases: z.record(z.string(), z.string()).optional(), pins: z.record(z.string(), z.string()).optional() }).strict().optional(), concurrency: z.object({ maxParallelSteps: z.number().optional(), perTool: z.record(z.string(), z.number()).optional(), perSkill: z.record(z.string(), z.number()).optional(), perProvider: z.record(z.string(), z.number()).optional(), perAgent: z.record(z.string(), z.number()).optional() }).strict().optional(), agents: z.object({ enabled: z.boolean().optional(), decompose: z.boolean().optional(), maxToolCalls: z.number().optional(), maxDurationMs: z.number().optional(), maxInnerCalls: z.number().optional(), definitions: z.record(z.string().regex(AGENT_ID_RE, 'an agent definition id must be lowercase kebab/snake (max 33 chars)').refine((id) => !id.startsWith('auto_'), 'the `auto_` prefix is reserved for agents the runtime derives — pick another id'), agentDefinition).optional() }).strict().optional() }).strict();
44
44
  const learning = z.object({ enabled: z.boolean().optional() }).strict();
45
45
  const verification = z.object({ enabled: z.boolean().optional() }).strict();
46
46
  const budget = z.object({ maxCostUsd: z.number().optional(), maxCalls: z.number().optional() }).strict();
@@ -178,6 +178,15 @@ export declare class Runtime {
178
178
  * This run's agent envelopes. THE ONLY call site of `narrowEnvelope` — never re-derive an inner
179
179
  * catalog, a permission clamp, or a reservation anywhere else (see the header of agents/envelope.ts).
180
180
  */
181
+ /**
182
+ * Agents synthesized from the registry for this goal (Phase 3.7). Empty unless
183
+ * `runtime.agents.decompose` is on — so with the flag off nothing about planning changes.
184
+ *
185
+ * Deterministic and offline: no model call, no clock, no randomness. That is a requirement, not a
186
+ * preference — a derived definition is hashed into `agentDefHash`, and a resume that synthesized
187
+ * even slightly differently would discard every persisted inner plan as stale.
188
+ */
189
+ private derivedAgents;
181
190
  private agentEnvelopes;
182
191
  /** The MCP server manager: `list()`, `status(id)`, `test(id)`, `addServer`, `removeServer`, `setEnabled`. */
183
192
  mcp(): McpManager;
@@ -299,9 +308,17 @@ export declare class Runtime {
299
308
  * skipped — no skill ran, so there is no success/failure to learn (recording them would teach noise). */
300
309
  private recordOrchestration;
301
310
  /**
302
- * A capped, FENCED action-capability snapshot for the planner prompt (Phase 3.1, opt-in). Untrusted
303
- * sources (anything not an in-tree builtin) have their descriptions fenced, and the block is bounded so
304
- * a large catalog can never dominate the prompt.
311
+ * A capped, FENCED action-capability snapshot for the planner prompt (Phase 3.1; ON by default from
312
+ * 3.0.0 set `runtime.capabilities.catalog: false` to remove it).
313
+ *
314
+ * The fence is real as of 3.0.0 and was not before: this block carries ids that come from MCP servers
315
+ * and third-party skills, and it renders them as trusted-looking prompt structure on every planning
316
+ * iteration of every run. Flattening (`promptSafe`) bounds their shape but says nothing about their
317
+ * provenance, so the whole block is wrapped as untrusted data. Three comments claimed "fenced" while
318
+ * no fence existed; shipping that ON by default would have made a false safety claim load-bearing.
319
+ *
320
+ * Both halves are bounded. The blocked-skill list had no cap at all — measured at ~24k characters
321
+ * with 300 blocked skills, silently, in every prompt.
305
322
  */
306
323
  private capabilityCatalogText;
307
324
  /** Any call/cost ceiling declared in the config file's `budget:` block (router-level, not policy). */
@@ -373,6 +390,26 @@ export declare class Runtime {
373
390
  * an agent step would have failed every one of those steps.
374
391
  */
375
392
  private orchestrateRunners;
393
+ /**
394
+ * Reconcile findings that contradict each other, across ALL of this execution's agent tasks
395
+ * (Phase 3.7).
396
+ *
397
+ * `resolveConflicts` has existed since 3.4 with no caller, so two agents reaching opposite
398
+ * conclusions about the same subject both stayed `active` — and both were rendered into the next
399
+ * planning prompt, as if the runtime had no opinion about which was better supported. It does: it
400
+ * weighs evidence-based `confidence`, with `executionCoverage` only as a tiebreak.
401
+ *
402
+ * Runs at the ONE point new findings can appear — a task reaching a terminal state — and writes the
403
+ * outcome back onto the owning records, so a supersession survives a restart rather than being
404
+ * recomputed (and possibly recomputed differently) on every read.
405
+ *
406
+ * EVERY finding is passed in, not just the active ones. Resolving over the active subset makes the
407
+ * result depend on the order tasks happen to finish: a finding that beat a weak rival in wave 1 can
408
+ * itself lose in wave 2, and the wave-1 loser is then left pointing at a superseded finding — a
409
+ * broken chain nothing heals. Re-resolving the whole set each round is order-independent and gives
410
+ * the same answer as one pass over the final set.
411
+ */
412
+ private resolveFindingConflicts;
376
413
  /**
377
414
  * A bounded, fenced brief of what the agents have already established (Phase 3.5).
378
415
  *
@@ -55,6 +55,9 @@ import { ExecutionStore } from '../executions/store.js';
55
55
  import { RESUMABLE, TERMINAL } from '../executions/execution.js';
56
56
  import { captureCheckpoint, reconcile } from '../executions/checkpoint.js';
57
57
  import { AGENT_TERMINAL, AGENT_RESUMABLE, agentTaskView } from '../agents/task.js';
58
+ import { synthesizeAgents } from '../agents/synthesize.js';
59
+ import { isDerivedAgentId } from '../agents/roles.js';
60
+ import { resolveConflicts } from '../agents/finding.js';
58
61
  import { parseAgentTasks } from '../executions/agentTasks.js';
59
62
  import { stepIdentity } from '../agents/worker.js';
60
63
  import { hashOf } from '../util/hash.js';
@@ -460,6 +463,12 @@ export class Runtime {
460
463
  registerAgent(id, def) {
461
464
  if (!/^[a-z0-9][a-z0-9_-]{0,32}$/.test(id))
462
465
  throw new AIError(`invalid agent id '${id}' — use lowercase letters, digits, '_' or '-' (max 33 chars)`, { category: 'CONFIG' });
466
+ // The same reservation the config schema enforces. Without it here, a host could register
467
+ // `auto_investigate` and — with decompose on — produce two catalog rows with one id, where the
468
+ // derived one wins both lookups and the SAME step resolves to a different envelope depending on a
469
+ // flag. Reserving a namespace in only one of its two doors reserves nothing.
470
+ if (isDerivedAgentId(id))
471
+ throw new AIError(`agent id '${id}' uses the reserved 'auto_' prefix — that namespace belongs to agents the runtime derives`, { category: 'CONFIG' });
463
472
  this.agentDefs.set(id, def);
464
473
  return this;
465
474
  }
@@ -471,13 +480,34 @@ export class Runtime {
471
480
  * This run's agent envelopes. THE ONLY call site of `narrowEnvelope` — never re-derive an inner
472
481
  * catalog, a permission clamp, or a reservation anywhere else (see the header of agents/envelope.ts).
473
482
  */
474
- agentEnvelopes(policy) {
475
- if (!this.agentsEnabled || this.agentDefs.size === 0)
483
+ /**
484
+ * Agents synthesized from the registry for this goal (Phase 3.7). Empty unless
485
+ * `runtime.agents.decompose` is on — so with the flag off nothing about planning changes.
486
+ *
487
+ * Deterministic and offline: no model call, no clock, no randomness. That is a requirement, not a
488
+ * preference — a derived definition is hashed into `agentDefHash`, and a resume that synthesized
489
+ * even slightly differently would discard every persisted inner plan as stale.
490
+ */
491
+ derivedAgents() {
492
+ if (!this.agentsEnabled || this.settingsValue.agents?.decompose !== true)
493
+ return [];
494
+ const capabilities = this._capabilities.list().map((c) => ({ id: c.id, effects: c.effects, providers: this._capabilities.providersOf(c.id).map((p) => p.providerId) }));
495
+ return synthesizeAgents({ capabilities, parentTools: this._tools.ids() });
496
+ }
497
+ agentEnvelopes(policy, derived = []) {
498
+ // Derived definitions are passed IN and never stored: `agentDefs` lives for the process, so writing
499
+ // a per-goal agent into it would leak that agent into every later run on this Runtime.
500
+ const entries = [
501
+ ...[...this.agentDefs].map(([id, d]) => [id, d, 'authored']),
502
+ ...derived.map((d) => [d.id, d.definition, 'derived']),
503
+ ];
504
+ if (!this.agentsEnabled || entries.length === 0)
476
505
  return [];
477
506
  const routing = this.effectiveRouting();
478
- return [...this.agentDefs].map(([id, definition]) => narrowEnvelope({
507
+ return entries.map(([id, definition, provenance]) => narrowEnvelope({
479
508
  agentId: id,
480
509
  definition,
510
+ provenance,
481
511
  parentTools: this._tools.ids(),
482
512
  parentSkills: this.skills().map((sk) => ({ id: sk.id, ...(sk.tools ? { tools: sk.tools } : {}) })),
483
513
  parentPermissions: policy.permissions,
@@ -1028,6 +1058,10 @@ export class Runtime {
1028
1058
  exec.completedSteps = [...exec.completedSteps, record.stepId];
1029
1059
  checkpointIfMoved();
1030
1060
  }
1061
+ // A finished task is the only moment new findings can appear, so it is the only moment two of
1062
+ // them can start disagreeing.
1063
+ if (AGENT_TERMINAL.has(record.state))
1064
+ this.resolveFindingConflicts(exec);
1031
1065
  commit();
1032
1066
  },
1033
1067
  /**
@@ -1070,9 +1104,10 @@ export class Runtime {
1070
1104
  ...(routing ? { routing } : {}),
1071
1105
  ...(partial ? { partial: true } : {}),
1072
1106
  ...(this.approval ? { approval: this.approval } : {}),
1073
- // Phase 3.1: the capability snapshot is opt-in (`runtime.capabilities.catalog`); the gap resolver is
1074
- // always on but only fires on a validation failure, adding metadata to an unchanged error.
1075
- ...(this.settingsValue.capabilities?.catalog ? { capabilityCatalog: this.capabilityCatalogText() } : {}),
1107
+ // Phase 3.1, ON by default from 3.0.0 (`runtime.capabilities.catalog: false` removes it); the gap
1108
+ // resolver is always on but only fires on a validation failure, adding metadata to an unchanged error.
1109
+ // 3.0.0: ON by default. `catalog: false` removes it — the only way to get the 2.9.0 prompt back.
1110
+ ...(this.settingsValue.capabilities?.catalog !== false ? { capabilityCatalog: this.capabilityCatalogText() } : {}),
1076
1111
  // Phase 3.3: the derived-requirement block (opt-in, pre-rendered + clamped). Absent ⇒ the
1077
1112
  // OrchestrateInput/PlannerInput objects are key-identical to 2.5.1.
1078
1113
  ...(requiredCapabilities ? { requiredCapabilities } : {}),
@@ -1102,11 +1137,19 @@ export class Runtime {
1102
1137
  this._learning.record({ goalType: this.goalType(goal), mode, ok: outcome.status === 'completed', skills: this.planSkillRefs(outcome.plan) });
1103
1138
  }
1104
1139
  /**
1105
- * A capped, FENCED action-capability snapshot for the planner prompt (Phase 3.1, opt-in). Untrusted
1106
- * sources (anything not an in-tree builtin) have their descriptions fenced, and the block is bounded so
1107
- * a large catalog can never dominate the prompt.
1140
+ * A capped, FENCED action-capability snapshot for the planner prompt (Phase 3.1; ON by default from
1141
+ * 3.0.0 set `runtime.capabilities.catalog: false` to remove it).
1142
+ *
1143
+ * The fence is real as of 3.0.0 and was not before: this block carries ids that come from MCP servers
1144
+ * and third-party skills, and it renders them as trusted-looking prompt structure on every planning
1145
+ * iteration of every run. Flattening (`promptSafe`) bounds their shape but says nothing about their
1146
+ * provenance, so the whole block is wrapped as untrusted data. Three comments claimed "fenced" while
1147
+ * no fence existed; shipping that ON by default would have made a false safety claim load-bearing.
1148
+ *
1149
+ * Both halves are bounded. The blocked-skill list had no cap at all — measured at ~24k characters
1150
+ * with 300 blocked skills, silently, in every prompt.
1108
1151
  */
1109
- capabilityCatalogText(maxEntries = 40) {
1152
+ capabilityCatalogText(maxEntries = 40, maxBlocked = 15) {
1110
1153
  const caps = this._capabilities.list();
1111
1154
  if (caps.length === 0)
1112
1155
  return '';
@@ -1116,17 +1159,21 @@ export class Runtime {
1116
1159
  .providersOf(c.id)
1117
1160
  .map((p) => `${promptSafe(p.providerId)}${p.availability === 'available' ? '' : ` (${p.availability})`}`)
1118
1161
  .join(', ');
1119
- lines.push(` - capability "${promptSafe(c.id)}" [${c.effects.join('/')}] ${promptSafe(providers, 200)}`);
1162
+ // Every segment is clamped, `effects` included: it is an array off a declaration, so a hostile or
1163
+ // simply careless source can make one row arbitrarily long.
1164
+ lines.push(` - capability "${promptSafe(c.id)}" [${promptSafe(c.effects.join('/'), 40)}] → ${promptSafe(providers, 200)}`);
1120
1165
  }
1121
1166
  const more = caps.length > maxEntries ? `\n …and ${caps.length - maxEntries} more (see /capabilities)` : '';
1122
1167
  // Skills hidden by a missing tool — the "why can't you do this" answer the planner needs.
1123
1168
  const usable = new Set(this.skills().map((sk) => sk.id));
1124
- const blocked = this._skills
1125
- .list()
1126
- .filter((sk) => !usable.has(sk.id))
1169
+ const blockedAll = this._skills.list().filter((sk) => !usable.has(sk.id));
1170
+ const blocked = blockedAll
1171
+ .slice(0, maxBlocked)
1127
1172
  .map((sk) => ` - skill "${promptSafe(sk.id)}" needs tool(s) ${promptSafe((sk.tools ?? []).filter((t) => !this._tools.ids().includes(t)).join(', '), 200)} (not registered)`);
1128
- const unavailable = blocked.length ? `\nUnavailable (do not use):\n${blocked.join('\n')}` : '';
1129
- return `Action capabilities:\n${lines.join('\n')}${more}${unavailable}`;
1173
+ const blockedMore = blockedAll.length > maxBlocked ? `\n …and ${blockedAll.length - maxBlocked} more` : '';
1174
+ const unavailable = blocked.length ? `\nUnavailable (do not use):\n${blocked.join('\n')}${blockedMore}` : '';
1175
+ // The ids inside come from MCP servers and third-party skills. Fenced as data, not structure.
1176
+ return wrapUntrusted('capability-catalog', `Action capabilities:\n${lines.join('\n')}${more}${unavailable}`);
1130
1177
  }
1131
1178
  /** Any call/cost ceiling declared in the config file's `budget:` block (router-level, not policy). */
1132
1179
  configBudget() {
@@ -1503,8 +1550,12 @@ export class Runtime {
1503
1550
  */
1504
1551
  orchestrateRunners(policy, opts) {
1505
1552
  const { runId, signal, provenance, onRecord, agentResume } = opts;
1506
- const envelopes = policy ? this.agentEnvelopes(policy) : [];
1553
+ const derived = this.derivedAgents();
1554
+ const envelopes = policy ? this.agentEnvelopes(policy, derived) : [];
1507
1555
  const byId = new Map(envelopes.map((e) => [e.agentId, e]));
1556
+ // Definitions come from a LOCAL map, not the process-lifetime field: a derived agent exists for
1557
+ // this run only, and must not be findable by any later one.
1558
+ const defsById = new Map([...this.agentDefs, ...derived.map((d) => [d.id, d.definition])]);
1508
1559
  return {
1509
1560
  runSkill: (skillId, input) => this.runSkill(skillId, input).then((o) => ({ result: o.result, validation: o.validation })),
1510
1561
  runTool: (toolId, input) => this.runTool(toolId, input),
@@ -1514,13 +1565,14 @@ export class Runtime {
1514
1565
  reserve: (step) => (step.agent ? byId.get(step.agent)?.reservation ?? 1 : 0),
1515
1566
  runAgent: async (step, ctx) => {
1516
1567
  const envelope = byId.get(step.agent ?? '');
1517
- const definition = this.agentDefs.get(step.agent ?? '');
1568
+ const definition = defsById.get(step.agent ?? '');
1518
1569
  if (!envelope || !definition)
1519
1570
  return { stepId: step.id, ok: false, code: 'agent-not-enabled', error: `no agent definition '${step.agent ?? ''}'` };
1520
1571
  const innerSkills = this._skills.list().filter((sk) => envelope.skills.includes(sk.id));
1521
1572
  // Phase 3.5: continue a persisted task for THIS step, if one exists.
1522
1573
  const prior = agentResume?.(step);
1523
1574
  const out = await runAgentTask(step, envelope, definition, {
1575
+ ...(derived.some((d) => d.id === step.agent) ? { derived: true } : {}),
1524
1576
  ...(prior?.record ? { resume: prior.record } : {}),
1525
1577
  ...(prior?.answer ? { resumeAnswer: prior.answer } : {}),
1526
1578
  ai: this._ai,
@@ -1579,6 +1631,52 @@ export class Runtime {
1579
1631
  : {}),
1580
1632
  };
1581
1633
  }
1634
+ /**
1635
+ * Reconcile findings that contradict each other, across ALL of this execution's agent tasks
1636
+ * (Phase 3.7).
1637
+ *
1638
+ * `resolveConflicts` has existed since 3.4 with no caller, so two agents reaching opposite
1639
+ * conclusions about the same subject both stayed `active` — and both were rendered into the next
1640
+ * planning prompt, as if the runtime had no opinion about which was better supported. It does: it
1641
+ * weighs evidence-based `confidence`, with `executionCoverage` only as a tiebreak.
1642
+ *
1643
+ * Runs at the ONE point new findings can appear — a task reaching a terminal state — and writes the
1644
+ * outcome back onto the owning records, so a supersession survives a restart rather than being
1645
+ * recomputed (and possibly recomputed differently) on every read.
1646
+ *
1647
+ * EVERY finding is passed in, not just the active ones. Resolving over the active subset makes the
1648
+ * result depend on the order tasks happen to finish: a finding that beat a weak rival in wave 1 can
1649
+ * itself lose in wave 2, and the wave-1 loser is then left pointing at a superseded finding — a
1650
+ * broken chain nothing heals. Re-resolving the whole set each round is order-independent and gives
1651
+ * the same answer as one pass over the final set.
1652
+ */
1653
+ resolveFindingConflicts(exec) {
1654
+ const { tasks } = parseAgentTasks(exec);
1655
+ const all = tasks.flatMap((t) => t.findings);
1656
+ if (all.length < 2)
1657
+ return;
1658
+ const resolved = new Map(resolveConflicts(all).map((f) => [f.id, f]));
1659
+ let changed = false;
1660
+ const next = tasks.map((t) => {
1661
+ const findings = t.findings.map((f) => {
1662
+ const r = resolved.get(f.id);
1663
+ if (!r || (r.status === f.status && r.supersededBy === f.supersededBy))
1664
+ return f;
1665
+ changed = true;
1666
+ const next = { ...f, status: r.status };
1667
+ if (r.supersededBy)
1668
+ next.supersededBy = r.supersededBy;
1669
+ else
1670
+ delete next.supersededBy;
1671
+ return next;
1672
+ });
1673
+ return changed ? { ...t, findings } : t;
1674
+ });
1675
+ if (!changed)
1676
+ return;
1677
+ const byId = new Map(next.map((t) => [t.agentTaskId, t]));
1678
+ exec.agentTasks = (exec.agentTasks ?? []).map((entry) => byId.get(entry.agentTaskId ?? '') ?? entry);
1679
+ }
1582
1680
  /**
1583
1681
  * A bounded, fenced brief of what the agents have already established (Phase 3.5).
1584
1682
  *
@@ -120,6 +120,12 @@ export interface RuntimeSettings {
120
120
  * the worker's hard inner-model-call ceiling. */
121
121
  agents?: {
122
122
  enabled?: boolean;
123
+ /** Phase 3.7, default OFF: offer the goal a bounded, read-shaped agent per shipped ROLE, with no
124
+ * operator-authored definition. The roles and their objectives are in-tree; nothing the model
125
+ * writes becomes an objective, a tool id or a permission.
126
+ *
127
+ * REQUIRES `enabled: true` — on its own this does nothing, because agent execution is off. */
128
+ decompose?: boolean;
123
129
  maxToolCalls?: number;
124
130
  maxDurationMs?: number;
125
131
  maxInnerCalls?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-runtime-engine",
3
- "version": "2.9.0",
3
+ "version": "3.0.0",
4
4
  "description": "AI Runtime \u2014 a provider-agnostic AI runtime and orchestration platform. Point it at whatever AI providers you have; it routes each task to the best available model. Ships the `ai-runtime` CLI and the `Runtime`/`AI` library API.",
5
5
  "type": "module",
6
6
  "license": "ISC",