ai-runtime-engine 2.9.0 → 3.0.1

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.
Files changed (85) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +30 -0
  3. package/dist/agents/admit.d.ts +9 -1
  4. package/dist/agents/admit.js +10 -2
  5. package/dist/agents/envelope.d.ts +21 -0
  6. package/dist/agents/envelope.js +39 -5
  7. package/dist/agents/finding.d.ts +9 -3
  8. package/dist/agents/finding.js +14 -3
  9. package/dist/agents/worker.d.ts +3 -0
  10. package/dist/agents/worker.js +4 -1
  11. package/dist/cli/cli.js +8 -1
  12. package/dist/cli/commands/cleanup.js +11 -3
  13. package/dist/cli/commands/doctor.js +1 -1
  14. package/dist/cli/commands/run.js +6 -0
  15. package/dist/cli/commands/skills.js +9 -2
  16. package/dist/cli/interactive/repl.js +12 -2
  17. package/dist/cli/interactive/session.d.ts +2 -0
  18. package/dist/cli/interactive/session.js +6 -2
  19. package/dist/config/schema.js +19 -1
  20. package/dist/conversations/conversations.d.ts +6 -1
  21. package/dist/conversations/conversations.js +15 -8
  22. package/dist/core/fallback/fallback.d.ts +7 -0
  23. package/dist/core/fallback/fallback.js +15 -2
  24. package/dist/core/health/monitor.d.ts +6 -0
  25. package/dist/core/health/monitor.js +15 -2
  26. package/dist/core/router/confidence.js +10 -5
  27. package/dist/core/router/dimensions.d.ts +3 -1
  28. package/dist/core/router/dimensions.js +15 -5
  29. package/dist/core/router/filter.js +25 -6
  30. package/dist/core/router/normalize.js +2 -0
  31. package/dist/core/router/router.js +16 -2
  32. package/dist/core/router/scorer.d.ts +3 -0
  33. package/dist/core/router/scorer.js +17 -2
  34. package/dist/discovery/openapi.js +3 -2
  35. package/dist/executions/agentTasks.d.ts +4 -4
  36. package/dist/generation/generateAdapter.js +3 -1
  37. package/dist/index.d.ts +4 -2
  38. package/dist/index.js +3 -2
  39. package/dist/mcp/protocol.js +4 -1
  40. package/dist/memory/bm25.d.ts +7 -0
  41. package/dist/memory/bm25.js +17 -1
  42. package/dist/memory/memory.d.ts +7 -1
  43. package/dist/memory/memory.js +18 -4
  44. package/dist/orchestration/orchestrator.d.ts +2 -1
  45. package/dist/orchestration/planner.d.ts +2 -1
  46. package/dist/plugin/ai.d.ts +6 -0
  47. package/dist/plugin/ai.js +17 -2
  48. package/dist/providers/estimate.d.ts +25 -0
  49. package/dist/providers/estimate.js +55 -0
  50. package/dist/providers/factory.d.ts +3 -0
  51. package/dist/providers/factory.js +26 -5
  52. package/dist/providers/httpClient.js +4 -0
  53. package/dist/providers/httpProvider.js +4 -3
  54. package/dist/providers/mock/mockProvider.js +4 -3
  55. package/dist/runtime/config.d.ts +4 -3
  56. package/dist/runtime/config.js +14 -23
  57. package/dist/runtime/events.d.ts +6 -0
  58. package/dist/runtime/runtime.d.ts +43 -5
  59. package/dist/runtime/runtime.js +133 -25
  60. package/dist/runtime/types.d.ts +8 -1
  61. package/dist/store/area.d.ts +1 -1
  62. package/dist/store/area.js +34 -10
  63. package/dist/store/crypto.d.ts +27 -13
  64. package/dist/store/crypto.js +101 -23
  65. package/dist/store/errors.d.ts +11 -0
  66. package/dist/store/errors.js +14 -0
  67. package/dist/store/store.d.ts +21 -1
  68. package/dist/store/store.js +74 -19
  69. package/dist/telemetry/sinks/file.js +4 -2
  70. package/dist/telemetry/sinks/otlp.d.ts +12 -2
  71. package/dist/telemetry/sinks/otlp.js +39 -24
  72. package/dist/telemetry/telemetry.d.ts +5 -0
  73. package/dist/telemetry/telemetry.js +4 -0
  74. package/dist/tools/builtins/shell.d.ts +30 -3
  75. package/dist/tools/builtins/shell.js +218 -7
  76. package/dist/tools/untrusted.d.ts +1 -1
  77. package/dist/tools/untrusted.js +5 -3
  78. package/dist/types.d.ts +14 -0
  79. package/dist/verification/verify.js +10 -3
  80. package/docs/GUIDE.md +66 -1
  81. package/docs/README.md +1 -1
  82. package/docs/architecture.md +5 -1
  83. package/docs/router.md +1 -1
  84. package/docs/security.md +26 -7
  85. package/package.json +4 -2
package/CHANGELOG.md CHANGED
@@ -5,6 +5,112 @@ 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.1] — 2026-09-07
9
+
10
+ **Hardening pass.** A correctness, security, test, documentation, and release-cleanup pass over a
11
+ full-repo audit of 3.0. The architecture is unchanged — every change extends an existing seam and is
12
+ backward compatible (with one documented downgrade caveat on the encryption format). The test suite grew
13
+ from 892 to 971 and CI now runs a Node 22 + 24 matrix with a tarball-contents audit, a doc-link/anchor
14
+ check, and a fail-closed test-count integrity floor.
15
+
16
+ ### Security
17
+
18
+ - **Shell tool: eval-capable invocations now require approval, even when allowlisted.** Allowlisting a
19
+ binary like `npm` or `node` no longer silently permits arbitrary code through its arguments —
20
+ interpreters run with inline-code/preload flags (`node -e`/`-r`, `python -c/-m`, `perl -M`, the `awk`
21
+ family, …), package/script runners (`npx`, `npm exec`/`run`/`init`, `deno task`, …), container
22
+ `run`/`exec`, argv-indirection wrappers (`env`, `xargs`, `nice`, `timeout`, `find -exec`, …), and a
23
+ non-default `make -f` are escalated to interactive approval (headless runs fail closed). The allowlist
24
+ matches an absolute path by basename but never a relative or workspace-internal one (realpath-checked).
25
+ This backstop is best-effort; `docs/security.md` documents its limits and accepted residuals.
26
+ - **Store files are created owner-only** (`0600` files, `0700` directories) on POSIX.
27
+ - **Encryption at rest v2:** the AES-256-GCM key is now derived with **scrypt** over a per-record salt
28
+ (the `aienc2` envelope) instead of a bare SHA-256, so a passphrase key resists brute force. Legacy
29
+ `aienc1` and plaintext records still read and upgrade on rewrite. A decryption failure is a typed
30
+ `StoreDecryptError` — honestly "wrong key OR tampering OR corruption" — and now surfaces instead of
31
+ reading as a silently empty store. *Downgrade caveat: a pre-3.0.1 runtime cannot read `aienc2` records.*
32
+ - Untrusted-content fencing neutralizes any fence label (not just its own); the verifier's model-authored
33
+ `reason` is flattened/clamped and its prompt inputs are fenced; the MCP schema sanitizer uses a
34
+ null-prototype object so a `__proto__` key cannot pollute.
35
+
36
+ ### Fixed
37
+
38
+ - **Config schema accepted four documented keys it used to reject.** `parseConfig` (the public export) now
39
+ accepts `learning`, `verification`, `budget`, and `policy` at the root; before this a config using any of
40
+ them failed to load.
41
+ - **Routing now always enforces context-fit**, not only when a cost/latency constraint is set; and
42
+ cost/context **estimates account for multimodal input parts** (an image is no longer estimated as zero).
43
+ - **`models: 'auto'`** resolves deterministically offline (defaultModel → kind catalog); a zero-model
44
+ result is a loud CONFIG error, not a silently unroutable provider.
45
+ - **Streaming fallback no longer garbles output**: a failed streamed attempt is explicitly abandoned via a
46
+ new `response.stream_abandoned` runtime event, and `response.streamed` is accurate under fallback.
47
+ - **OTLP telemetry is flushed on close**, so short-lived runs no longer drop sub-batch events.
48
+ - **Store lock** stale-steal is atomic (ownership token); **conversation metadata** writes serialize
49
+ through it; the **HTTP client** no longer sleeps out a backoff after the final retry; a stale provider
50
+ **cooldown** now reads as routable again; provider `listModels()`/`discover()` return copies.
51
+ - **CLI `--json --yes`** on `cleanup` and `skills --scaffold` now applies the operation and reports the
52
+ result, instead of printing a preview and ignoring `--yes`. `ai-runtime mcp show <id>` was added.
53
+
54
+ ### Changed
55
+
56
+ - **The four validated-but-ignored config fields are now enforced:** `ProviderConfig.weightOverrides`
57
+ (per-provider score weights; keys restricted to the scoring dimensions), `TaskDefinition.qualityFloor`
58
+ (excludes lower-tier models; a model with no tier is not excluded), `CapabilityRequirement.weight`
59
+ (weights capability fit and confidence alike), and `constraints.minimumConfidence` (raises the effective
60
+ confidence floor; it flags, it does not fail the run).
61
+ - **The memory tokenizer is Unicode-aware** — accented Latin, Cyrillic, Arabic, and CJK are now
62
+ searchable; pure-ASCII tokenization is byte-identical. *Note: learned goal keys for non-ASCII goals
63
+ re-key (old degenerate records are orphaned, never mixed); session-cached embedding vectors shift.*
64
+
65
+ ### Notes for consumers
66
+
67
+ - The `RuntimeEvent` union gained a `response.stream_abandoned` arm. New arms are additive at runtime but
68
+ break an exhaustive `switch` at compile time — carry a default case.
69
+
70
+ ## [3.0.0] — 2026-09-05
71
+
72
+ **AI Runtime 3.0.** The 3.x arc set out to make the runtime reason about *what it can do*, talk to tools
73
+ it did not ship with, delegate bounded work to agents, and survive being killed in the middle of it.
74
+ That is now true end to end, and `tests/integration/runtime-3-demo.test.ts` runs the whole of it —
75
+ derive, decompose, delegate, crash, resume — offline, inside a call budget, with a leak scan.
76
+
77
+ Upgrading from 2.9.0 requires no config change. One documented default changes; see below.
78
+
79
+ ### Added
80
+
81
+ - **Auto-decomposition** (`runtime.agents.decompose`, default OFF) — a goal can delegate to a bounded,
82
+ read-shaped agent with no operator-authored definition. Three roles ship in-tree; no model-authored
83
+ string ever becomes an objective, a tool id, or a permission, and `auto_` is a reserved config
84
+ namespace so a derived id can never shadow an authored one.
85
+ - **Conflict supersession is wired.** `resolveConflicts` shipped in 3.4 with no caller, so two agents
86
+ reaching opposite conclusions about the same subject both stayed active and both reached the next
87
+ planning prompt. Resolution now runs when a task finishes, weighs evidence-based confidence, and
88
+ persists — and it is order-independent, so the answer does not depend on which agent happens to
89
+ finish first.
90
+
91
+ ### Changed
92
+
93
+ - **`runtime.capabilities.catalog` now defaults to ON.** Set `catalog: false` to remove the block.
94
+ Before flipping it, the block was made to match its own documentation: three comments across three
95
+ files described it as fenced when nothing fenced it, and its blocked-skill half had no cap at all
96
+ (~24k characters with 300 blocked skills, silently, in every prompt). Both are fixed.
97
+
98
+ ### Fixed
99
+
100
+ - **A throwing agent took down the whole run.** `executor.ts` returned the agent runner's promise from
101
+ inside a `try` — and an async function *adopts* a returned promise rather than awaiting it, so the
102
+ rejection escaped the executor's own catch. The plan failed instead of the step, and wave-mates were
103
+ left running unawaited. Present since 2.7.0.
104
+ - Derived permissions are a ceiling rather than a default, so a synthesized definition cannot request
105
+ write access even when the parent has it.
106
+
107
+ ### Notes for consumers
108
+
109
+ - The `RuntimeEvent` union gained agent arms in 2.9.0 and may gain more. New arms are additive at
110
+ runtime but break an exhaustive `switch` at compile time — carry a default case.
111
+ - `capabilities.planning` is skipped whenever any call or cost budget is set, and a resumed run is
112
+ never re-derived. Both are deliberate and now asserted by tests rather than only documented.
113
+
8
114
  ## [2.9.0] — 2026-09-05
9
115
 
10
116
  Agent work becomes VISIBLE. Concurrent agent steps render as live lanes in the interactive terminal,
@@ -797,6 +903,8 @@ Initial release: the provider-agnostic AI **router** — capability-based routin
797
903
  scoring, evidence validation, fallback, health tracking, learning-based scoring, multi-model verification,
798
904
  budgets, MCP tools, OpenAPI-based adapter generation, and the `AI` class + CLI.
799
905
 
906
+ [3.0.1]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v3.0.1
907
+ [3.0.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v3.0.0
800
908
  [2.9.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.9.0
801
909
  [2.8.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.8.0
802
910
  [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.1');
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.
@@ -77,6 +77,13 @@ mcp
77
77
  .option(...configOpt)
78
78
  .option('--json', 'print as JSON')
79
79
  .action((id, o) => mcpCommand(id, o));
80
+ mcp
81
+ .command('show')
82
+ .argument('<id>', 'server id')
83
+ .description('Show one server in detail — the unambiguous form for an id that collides with a subcommand name (add/remove/enable/disable/test)')
84
+ .option(...configOpt)
85
+ .option('--json', 'print as JSON')
86
+ .action((id, o) => mcpCommand(id, o));
80
87
  mcp
81
88
  .command('add')
82
89
  .argument('<id>', 'a short id for the server (lowercase, kebab/snake)')
@@ -74,11 +74,19 @@ export async function cleanupCommand(opts) {
74
74
  return;
75
75
  }
76
76
  const plan = planCleanup(rt, Date.now());
77
- if (opts.json)
78
- return print(JSON.stringify(plan, null, 2));
77
+ const removable = toRemove(plan);
78
+ // `--json` changes the OUTPUT FORMAT only; it does not suppress `--yes`. Without `--yes` (or with
79
+ // `--dry-run`) it emits a plan preview; with `--yes` it applies and reports the result. It never
80
+ // prompts (machine-facing). The JSON always carries `mode` so a caller can tell plan from applied.
81
+ if (opts.json) {
82
+ if (opts.yes && !opts.dryRun && removable > 0) {
83
+ const applied = applyCleanup(rt, plan);
84
+ return print(JSON.stringify({ mode: 'applied', plan, applied }, null, 2));
85
+ }
86
+ return print(JSON.stringify({ mode: 'plan', plan }, null, 2));
87
+ }
79
88
  for (const line of renderPlan(plan))
80
89
  print(line);
81
- const removable = toRemove(plan);
82
90
  if (removable === 0) {
83
91
  print('\nNothing to remove.');
84
92
  return;
@@ -75,7 +75,7 @@ export function renderDoctor(r) {
75
75
  if (!r.providers.length)
76
76
  lines.push(' (none configured — run `ai-runtime setup`)');
77
77
  for (const p of r.providers)
78
- lines.push(` ${p.authenticated ? '✓' : '✗'} ${p.id.padEnd(14)} ${p.authenticated ? 'authenticated' : 'not configured'} [${p.state}] ${p.models} models`);
78
+ lines.push(` ${p.authenticated ? '✓' : '✗'} ${p.id.padEnd(14)} ${p.authenticated ? 'authenticated' : 'not configured'} [${p.state}] ${p.models} models${p.models === 0 ? ' ⚠ no routable models — list models explicitly or set defaultModel' : ''}`);
79
79
  lines.push(`Models: ${r.models.total} known, ${r.models.usableProviders} provider(s) usable`);
80
80
  lines.push('', 'Store:');
81
81
  if (!r.store.enabled)
@@ -32,6 +32,12 @@ async function runWith(rt, input, options) {
32
32
  printChunk(e.text);
33
33
  streamedAny = true;
34
34
  }
35
+ else if (e.type === 'response.stream_abandoned') {
36
+ // Mark a boundary so the discarded partial is not read as part of what follows. Do not promise a
37
+ // retry — abandonment also fires when the failed attempt was the last candidate.
38
+ printChunk('\n↩ discarded incomplete response\n');
39
+ streamedAny = false;
40
+ }
35
41
  });
36
42
  }
37
43
  if (options.mode) {
@@ -51,8 +51,15 @@ export async function skillsCommand(opts) {
51
51
  return print(JSON.stringify({ ok: false, error: draft.error }, null, 2));
52
52
  return print(`Could not scaffold a skill: ${draft.error}`);
53
53
  }
54
- if (opts.json)
55
- return print(JSON.stringify({ ok: true, manifest: draft.manifest, yaml: draft.yaml }, null, 2));
54
+ // `--json` changes the OUTPUT FORMAT only; with `--yes` it SAVES and reports the path, without it
55
+ // returns the draft. It never prompts (machine-facing). `mode` distinguishes draft from saved.
56
+ if (opts.json) {
57
+ if (opts.yes) {
58
+ const savedPath = rt.saveScaffoldedSkill(draft.manifest);
59
+ return print(JSON.stringify({ ok: true, mode: 'saved', path: savedPath, manifest: draft.manifest }, null, 2));
60
+ }
61
+ return print(JSON.stringify({ ok: true, mode: 'draft', manifest: draft.manifest, yaml: draft.yaml }, null, 2));
62
+ }
56
63
  print(`Drafted skill "${draft.manifest.id}":\n`);
57
64
  print(draft.yaml);
58
65
  const proceed = opts.yes || (await confirm(`Save to .ai-runtime/skills/${draft.manifest.id}.skill.yaml?`));
@@ -88,8 +88,10 @@ export async function startRepl(configPath) {
88
88
  if (!line.trim())
89
89
  return;
90
90
  try {
91
- mkdirSync(dirname(historyPath), { recursive: true });
92
- appendFileSync(historyPath, `${line}\n`);
91
+ // Owner-only: history holds raw prompt lines under the store home (0700 dir / 0600 file, matching
92
+ // the store's own record hardening). Mode applies only on creation; POSIX-only, a no-op on Windows.
93
+ mkdirSync(dirname(historyPath), { recursive: true, mode: 0o700 });
94
+ appendFileSync(historyPath, `${line}\n`, { mode: 0o600 });
93
95
  }
94
96
  catch {
95
97
  /* history is a convenience; a write failure must never break the REPL */
@@ -121,6 +123,14 @@ export async function startRepl(configPath) {
121
123
  streamedThisRun = true;
122
124
  return;
123
125
  }
126
+ if (e.type === 'response.stream_abandoned') {
127
+ // The partial answer just streamed from this provider is being discarded; mark a clear boundary so
128
+ // whatever follows is not read as a seamless continuation of it. Not necessarily a retry — this also
129
+ // fires when the failed attempt was the last candidate.
130
+ printChunk('\n↩ discarded incomplete response\n');
131
+ streamedThisRun = false;
132
+ return;
133
+ }
124
134
  const lane = lanes.observe(e);
125
135
  if (lane) {
126
136
  if (!runInFlight)
@@ -22,8 +22,10 @@ export declare class ReplSession {
22
22
  private conversationId?;
23
23
  private dryRunMode;
24
24
  private streaming;
25
+ private readonly env;
25
26
  constructor(runtime: Runtime, opts?: {
26
27
  streaming?: boolean;
28
+ env?: NodeJS.ProcessEnv;
27
29
  });
28
30
  currentMode(): RuntimeMode;
29
31
  private views;
@@ -76,9 +76,13 @@ export class ReplSession {
76
76
  conversationId;
77
77
  dryRunMode = false;
78
78
  streaming;
79
+ env;
79
80
  constructor(runtime, opts = {}) {
80
81
  this.runtime = runtime;
81
82
  this.streaming = opts.streaming ?? false;
83
+ // The env the /budget display reads. Injectable so tests need not mutate the real process.env; the
84
+ // enforcement path already resolves budgets from the runtime's injected env.
85
+ this.env = opts.env ?? process.env;
82
86
  }
83
87
  currentMode() {
84
88
  return this.mode;
@@ -202,8 +206,8 @@ export class ReplSession {
202
206
  case 'budget':
203
207
  return {
204
208
  lines: [
205
- `call budget (AI_MAX_CALLS): ${process.env.AI_MAX_CALLS ?? '(unset — no limit)'}`,
206
- `cost budget (AI_MAX_COST_USD): ${process.env.AI_MAX_COST_USD ?? '(unset — no limit)'}`,
209
+ `call budget (AI_MAX_CALLS): ${this.env.AI_MAX_CALLS ?? '(unset — no limit)'}`,
210
+ `cost budget (AI_MAX_COST_USD): ${this.env.AI_MAX_COST_USD ?? '(unset — no limit)'}`,
207
211
  'over budget: notify-and-wait by default; add --partial (one-shot) to run the phases that fit and pause.',
208
212
  ],
209
213
  };
@@ -11,6 +11,8 @@ const EVIDENCE = ['unsupported', 'unknown', 'inferred', 'documented', 'verified'
11
11
  const GROUPS = ['input', 'output', 'intelligence', 'agent'];
12
12
  const KINDS = ['openai-compatible', 'gemini', 'groq', 'anthropic', 'ollama', 'custom', 'mock'];
13
13
  export const KEY_LIKE = /^(sk-|gsk_|Bearer\s|[A-Za-z0-9_-]{40,}$)/;
14
+ /** The seven scoring-weight dimensions. A `weightOverrides` block may only name these keys. */
15
+ const SCORE_WEIGHT_KEYS = ['capabilityFit', 'quality', 'reliability', 'historicalSuccess', 'latency', 'cost', 'userPreference'];
14
16
  const capabilityRequirement = z.object({
15
17
  group: z.enum(GROUPS),
16
18
  key: z.string().min(1),
@@ -50,10 +52,22 @@ const providerConfig = z
50
52
  privacyClass: z.enum(['local', 'cloud']).optional(),
51
53
  wireShape: z.enum(['openai', 'anthropic']).optional(),
52
54
  headers: z.record(z.string()).optional(),
53
- weightOverrides: z.record(z.number()).optional(),
55
+ weightOverrides: z.record(z.enum(SCORE_WEIGHT_KEYS), z.number()).optional(),
54
56
  })
55
57
  .strict()
56
58
  .refine((p) => !(['openai-compatible', 'custom'].includes(p.kind) && !p.baseUrl), { message: 'openai-compatible/custom providers require a baseUrl' });
59
+ /**
60
+ * The four router-level blocks that Runtime settings also accept. Defined once here — the single source
61
+ * of truth used by `routerConfig` below — so `parseConfig` (the public export) accepts them. Before 3.0.1
62
+ * the root schema rejected them and they were only tolerated via parseRuntimeConfig's fold-back; that
63
+ * fold-back is gone, and the runtime-config layer now receives them transitively through parseConfig.
64
+ */
65
+ const learningConfig = z.object({ enabled: z.boolean().optional() }).strict();
66
+ const verificationConfig = z.object({ enabled: z.boolean().optional() }).strict();
67
+ const budgetConfig = z.object({ maxCostUsd: z.number().optional(), maxCalls: z.number().optional() }).strict();
68
+ const policyConfig = z
69
+ .object({ allowProviders: z.array(z.string()).optional(), denyProviders: z.array(z.string()).optional(), requireLocal: z.boolean().optional(), maxCostUsd: z.number().optional(), strategy: z.enum(STRATEGIES).optional() })
70
+ .strict();
57
71
  const routerConfig = z
58
72
  .object({
59
73
  providers: z.array(providerConfig),
@@ -65,6 +79,10 @@ const routerConfig = z
65
79
  .strict()
66
80
  .optional(),
67
81
  telemetry: z.object({ enabled: z.boolean().optional(), sink: z.enum(['memory', 'file', 'otlp']).optional(), storePrompts: z.literal(false).optional(), path: z.string().optional(), endpoint: z.string().optional(), headersEnv: z.string().refine((v) => v === undefined || !KEY_LIKE.test(v), { message: 'headersEnv must be an env-var NAME, not a header/token value' }).optional() }).strict().optional(),
82
+ learning: learningConfig.optional(),
83
+ verification: verificationConfig.optional(),
84
+ budget: budgetConfig.optional(),
85
+ policy: policyConfig.optional(),
68
86
  tasks: z.array(taskDefinition).optional(),
69
87
  })
70
88
  .strict();
@@ -20,11 +20,16 @@ export interface ConversationMeta {
20
20
  updatedAt: number;
21
21
  turns: number;
22
22
  }
23
+ /** Serializes a critical section. Injected from the RuntimeStore's advisory lock; defaults to a no-op. */
24
+ export type WithLock = <T>(fn: () => T) => T;
23
25
  export declare class ConversationStore {
24
26
  private readonly area;
25
27
  private readonly clock;
28
+ private readonly withLock;
26
29
  private counter;
27
- constructor(area: Area, clock?: Clock);
30
+ constructor(area: Area, clock?: Clock, opts?: {
31
+ withLock?: WithLock;
32
+ });
28
33
  get enabled(): boolean;
29
34
  /** Start a conversation; returns its id. */
30
35
  start(id?: string): string;