ai-runtime-engine 1.2.0 → 2.7.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.
Files changed (153) hide show
  1. package/CHANGELOG.md +602 -0
  2. package/dist/agents/admit.d.ts +69 -0
  3. package/dist/agents/admit.js +129 -0
  4. package/dist/agents/definition.d.ts +36 -0
  5. package/dist/agents/definition.js +9 -0
  6. package/dist/agents/envelope.d.ts +53 -0
  7. package/dist/agents/envelope.js +68 -0
  8. package/dist/agents/finding.d.ts +79 -0
  9. package/dist/agents/finding.js +80 -0
  10. package/dist/agents/task.d.ts +60 -0
  11. package/dist/agents/task.js +32 -0
  12. package/dist/agents/worker.d.ts +68 -0
  13. package/dist/agents/worker.js +256 -0
  14. package/dist/capabilities/capability.d.ts +117 -0
  15. package/dist/capabilities/capability.js +66 -0
  16. package/dist/capabilities/registry.d.ts +139 -0
  17. package/dist/capabilities/registry.js +413 -0
  18. package/dist/capabilities/vocabulary.d.ts +32 -0
  19. package/dist/capabilities/vocabulary.js +34 -0
  20. package/dist/cli/cli.js +56 -4
  21. package/dist/cli/commands/cleanup.js +29 -27
  22. package/dist/cli/commands/doctor.d.ts +14 -0
  23. package/dist/cli/commands/doctor.js +38 -8
  24. package/dist/cli/commands/executions.js +34 -25
  25. package/dist/cli/commands/info.d.ts +1 -0
  26. package/dist/cli/commands/info.js +11 -9
  27. package/dist/cli/commands/init.js +19 -0
  28. package/dist/cli/commands/inspect.d.ts +40 -1
  29. package/dist/cli/commands/inspect.js +157 -2
  30. package/dist/cli/commands/mcp.d.ts +45 -0
  31. package/dist/cli/commands/mcp.js +148 -0
  32. package/dist/cli/commands/route.js +21 -0
  33. package/dist/cli/commands/run.d.ts +2 -0
  34. package/dist/cli/commands/run.js +36 -4
  35. package/dist/cli/commands/skills.d.ts +2 -0
  36. package/dist/cli/commands/skills.js +29 -7
  37. package/dist/cli/interactive/ansi.d.ts +41 -0
  38. package/dist/cli/interactive/ansi.js +43 -0
  39. package/dist/cli/interactive/complete.d.ts +10 -0
  40. package/dist/cli/interactive/complete.js +19 -0
  41. package/dist/cli/interactive/repl.d.ts +3 -0
  42. package/dist/cli/interactive/repl.js +105 -16
  43. package/dist/cli/interactive/session.d.ts +12 -1
  44. package/dist/cli/interactive/session.js +83 -5
  45. package/dist/cli/render.d.ts +13 -0
  46. package/dist/cli/render.js +18 -0
  47. package/dist/cli/runtimeSession.d.ts +11 -0
  48. package/dist/cli/runtimeSession.js +17 -0
  49. package/dist/config/defaults.d.ts +3 -1
  50. package/dist/config/defaults.js +2 -0
  51. package/dist/config/schema.d.ts +1 -0
  52. package/dist/config/schema.js +2 -2
  53. package/dist/context/lossVerifier.d.ts +24 -0
  54. package/dist/context/lossVerifier.js +45 -0
  55. package/dist/context/summarize.d.ts +19 -0
  56. package/dist/context/summarize.js +53 -0
  57. package/dist/core/fallback/fallback.d.ts +8 -0
  58. package/dist/core/fallback/fallback.js +3 -1
  59. package/dist/core/router/executor.d.ts +6 -1
  60. package/dist/core/router/executor.js +9 -2
  61. package/dist/core/router/normalize.d.ts +2 -0
  62. package/dist/core/router/request.js +2 -0
  63. package/dist/core/router/router.d.ts +3 -0
  64. package/dist/core/router/router.js +7 -0
  65. package/dist/executions/execution.d.ts +13 -2
  66. package/dist/generation/generateAdapter.d.ts +14 -0
  67. package/dist/generation/generateAdapter.js +38 -0
  68. package/dist/generation/generateSkill.d.ts +26 -0
  69. package/dist/generation/generateSkill.js +51 -0
  70. package/dist/index.d.ts +44 -5
  71. package/dist/index.js +26 -2
  72. package/dist/mcp/client.d.ts +70 -0
  73. package/dist/mcp/client.js +221 -0
  74. package/dist/mcp/manager.d.ts +151 -0
  75. package/dist/mcp/manager.js +493 -0
  76. package/dist/mcp/protocol.d.ts +216 -0
  77. package/dist/mcp/protocol.js +149 -0
  78. package/dist/mcp/toolAdapter.d.ts +44 -0
  79. package/dist/mcp/toolAdapter.js +94 -0
  80. package/dist/mcp/transport.d.ts +109 -0
  81. package/dist/mcp/transport.js +383 -0
  82. package/dist/memory/embedders/hash.d.ts +12 -0
  83. package/dist/memory/embedders/hash.js +31 -0
  84. package/dist/memory/embedders/http.d.ts +25 -0
  85. package/dist/memory/embedders/http.js +48 -0
  86. package/dist/memory/memory.d.ts +19 -2
  87. package/dist/memory/memory.js +75 -11
  88. package/dist/memory/semantic.d.ts +17 -0
  89. package/dist/memory/semantic.js +29 -0
  90. package/dist/orchestration/budget.d.ts +30 -0
  91. package/dist/orchestration/budget.js +40 -0
  92. package/dist/orchestration/executor.d.ts +39 -1
  93. package/dist/orchestration/executor.js +64 -4
  94. package/dist/orchestration/orchestrator.d.ts +29 -1
  95. package/dist/orchestration/orchestrator.js +89 -8
  96. package/dist/orchestration/plan.d.ts +15 -1
  97. package/dist/orchestration/plan.js +23 -4
  98. package/dist/orchestration/planner.d.ts +19 -1
  99. package/dist/orchestration/planner.js +25 -5
  100. package/dist/plugin/ai.d.ts +4 -0
  101. package/dist/plugin/ai.js +9 -0
  102. package/dist/providers/httpClient.d.ts +25 -1
  103. package/dist/providers/httpClient.js +93 -0
  104. package/dist/providers/httpProvider.d.ts +1 -0
  105. package/dist/providers/httpProvider.js +67 -1
  106. package/dist/providers/mock/mockProvider.d.ts +3 -0
  107. package/dist/providers/mock/mockProvider.js +54 -0
  108. package/dist/providers/mock/scenarios.d.ts +7 -0
  109. package/dist/providers/provider.d.ts +6 -0
  110. package/dist/providers/wire/anthropicWire.js +34 -0
  111. package/dist/providers/wire/openaiWire.js +30 -0
  112. package/dist/providers/wire/types.d.ts +16 -0
  113. package/dist/runtime/config.js +50 -6
  114. package/dist/runtime/intent/aiClassifier.d.ts +19 -0
  115. package/dist/runtime/intent/aiClassifier.js +74 -0
  116. package/dist/runtime/models/modelProfile.d.ts +61 -0
  117. package/dist/runtime/models/modelProfile.js +139 -0
  118. package/dist/runtime/planning/deriveCapabilities.d.ts +95 -0
  119. package/dist/runtime/planning/deriveCapabilities.js +146 -0
  120. package/dist/runtime/policy.d.ts +10 -0
  121. package/dist/runtime/policy.js +9 -2
  122. package/dist/runtime/runtime.d.ts +173 -0
  123. package/dist/runtime/runtime.js +723 -50
  124. package/dist/runtime/types.d.ts +94 -2
  125. package/dist/skills/manifest.d.ts +3 -0
  126. package/dist/skills/manifest.js +24 -0
  127. package/dist/skills/registry.d.ts +16 -1
  128. package/dist/skills/registry.js +21 -1
  129. package/dist/skills/skill.d.ts +6 -1
  130. package/dist/store/area.d.ts +15 -1
  131. package/dist/store/area.js +19 -8
  132. package/dist/store/crypto.d.ts +21 -0
  133. package/dist/store/crypto.js +49 -0
  134. package/dist/store/paths.d.ts +5 -1
  135. package/dist/store/paths.js +6 -0
  136. package/dist/store/store.d.ts +15 -3
  137. package/dist/store/store.js +28 -7
  138. package/dist/telemetry/sinks/otlp.d.ts +31 -0
  139. package/dist/telemetry/sinks/otlp.js +76 -0
  140. package/dist/tools/builtins/filesystem.js +1 -0
  141. package/dist/tools/builtins/git.js +1 -0
  142. package/dist/tools/builtins/shell.js +1 -0
  143. package/dist/tools/permissions.d.ts +28 -0
  144. package/dist/tools/permissions.js +72 -0
  145. package/dist/tools/registry.d.ts +18 -2
  146. package/dist/tools/registry.js +22 -2
  147. package/dist/tools/tool.d.ts +4 -0
  148. package/dist/types.d.ts +11 -1
  149. package/dist/util/flatten.d.ts +11 -0
  150. package/dist/util/flatten.js +18 -0
  151. package/dist/util/semaphore.d.ts +19 -0
  152. package/dist/util/semaphore.js +60 -0
  153. package/package.json +24 -9
@@ -3,12 +3,16 @@
3
3
  * a RuntimeContext, executes chat through `AI.run()`, and streams redacted lifecycle events. It never
4
4
  * routes or scores; all model selection stays in the one router. Unimplemented modes degrade to chat.
5
5
  */
6
- import { resolve } from 'node:path';
6
+ import { resolve, join } from 'node:path';
7
+ import { mkdirSync, writeFileSync } from 'node:fs';
8
+ import { stringify as stringifyYaml } from 'yaml';
9
+ import { generateSkillManifest } from '../generation/generateSkill.js';
7
10
  import { AI } from '../plugin/ai.js';
8
11
  import { setCredentialResolver } from '../security/credentials.js';
9
12
  import { loadRuntimeConfig } from './config.js';
10
13
  import { detectWorkspace } from './workspace/workspace.js';
11
14
  import { HeuristicIntentClassifier } from './intent/classifier.js';
15
+ import { AIIntentClassifier } from './intent/aiClassifier.js';
12
16
  import { resolveMode, resolveStrategy } from './modes/modeResolver.js';
13
17
  import { resolvePolicy } from './policy.js';
14
18
  import { CHAT_TASK, buildChatRequest, toRuntimeResult, inputText } from './modes/chat.js';
@@ -20,10 +24,19 @@ import { MemoryStore } from '../memory/memory.js';
20
24
  import { classifyMemory } from '../memory/classifier.js';
21
25
  import { summarizeWorkspace } from './workspace/workspace.js';
22
26
  import { compileContext } from '../context/compiler.js';
27
+ import { verifyContextLoss } from '../context/lossVerifier.js';
28
+ import { summarizeOverBudget } from '../context/summarize.js';
23
29
  import { resolveContextBudget } from '../context/budget.js';
24
30
  import { TokenEstimator } from '../context/tokens.js';
25
31
  import { ToolRegistry } from '../tools/registry.js';
26
- import { resolvePermissions } from '../tools/permissions.js';
32
+ import { resolvePermissions, clampMcpPermissions } from '../tools/permissions.js';
33
+ import { flattenClamp } from '../util/flatten.js';
34
+ import { McpManager } from '../mcp/manager.js';
35
+ import { mcpTool, mcpToolId } from '../mcp/toolAdapter.js';
36
+ import { StaticMcpSource } from '../mcp/mcp.js';
37
+ import { ActionCapabilityRegistry, capabilityReportFrom } from '../capabilities/registry.js';
38
+ import { namespacedId } from '../capabilities/capability.js';
39
+ import { candidatesFrom, deriveCapabilities } from './planning/deriveCapabilities.js';
27
40
  import { filesystemTool } from '../tools/builtins/filesystem.js';
28
41
  import { shellTool } from '../tools/builtins/shell.js';
29
42
  import { gitTool } from '../tools/builtins/git.js';
@@ -32,6 +45,9 @@ import { fileAnalyzerSkill } from '../skills/builtins/fileAnalyzer.js';
32
45
  import { repositoryAnalyzerSkill } from '../skills/builtins/repositoryAnalyzer.js';
33
46
  import { discoverSkills, scanRepoForSkills, loadSkillSource, loadSkillPackage } from '../skills/discovery.js';
34
47
  import { orchestrate } from '../orchestration/orchestrator.js';
48
+ import { foldCalls } from '../orchestration/budget.js';
49
+ import { narrowEnvelope } from '../agents/envelope.js';
50
+ import { runAgentTask } from '../agents/worker.js';
35
51
  import { executePlan } from '../orchestration/executor.js';
36
52
  import { ExecutionStore } from '../executions/store.js';
37
53
  import { RESUMABLE, TERMINAL } from '../executions/execution.js';
@@ -40,7 +56,13 @@ import { ArtifactStore } from '../artifacts/artifacts.js';
40
56
  import { compare } from '../comparison/comparator.js';
41
57
  import { renderComparison } from '../comparison/render.js';
42
58
  import { LearningStore } from '../learning/learningStore.js';
43
- import { resolveRoutingPrefs, withPreferredProviders } from './routing.js';
59
+ import { resolveRoutingPrefs, withPreferredProviders, mergeRouting } from './routing.js';
60
+ import { loadModelProfile, resolveModelDirective, directiveToOverrides } from './models/modelProfile.js';
61
+ import { HashEmbedder } from '../memory/embedders/hash.js';
62
+ import { HttpEmbedder } from '../memory/embedders/http.js';
63
+ import { Credential } from '../security/credentials.js';
64
+ import { makeCodec, deriveKey } from '../store/crypto.js';
65
+ import { AIError } from '../core/fallback/errors.js';
44
66
  import { tokenize } from '../memory/bm25.js';
45
67
  let runCounter = 0;
46
68
  function nextRunId() {
@@ -54,6 +76,23 @@ function numFromEnv(env, key) {
54
76
  const n = Number(raw);
55
77
  return Number.isFinite(n) ? n : undefined;
56
78
  }
79
+ /**
80
+ * Clamp a source-controlled string before it is rendered into a MODEL PROMPT (invariant 15/16). Ids,
81
+ * provider ids, and tool lists come from tools/skills/packs — and from MCP servers in Phase 3.2 — so a
82
+ * rendered catalog row must never carry newlines, control characters, or unbounded text that could forge
83
+ * additional rows or smuggle instructions into the planner prompt.
84
+ */
85
+ function promptSafe(raw, max = 80) {
86
+ return flattenClamp(raw, max);
87
+ }
88
+ /** Merge gap lists first-wins by capabilityId: pre-pass, then post-plan check, then plan validation. */
89
+ function mergeGapsById(list) {
90
+ const out = new Map();
91
+ for (const g of list)
92
+ if (!out.has(g.capabilityId))
93
+ out.set(g.capabilityId, g);
94
+ return [...out.values()];
95
+ }
57
96
  export class Runtime {
58
97
  _ai;
59
98
  settingsValue;
@@ -65,20 +104,46 @@ export class Runtime {
65
104
  _memory;
66
105
  _conversations;
67
106
  estimator = new TokenEstimator();
107
+ /** ACTION-capability registry (Phase 3.1). Declared BEFORE the tool/skill registries so its
108
+ * ingest listeners are attached in time to capture the builtin registrations below. */
109
+ _capabilities = new ActionCapabilityRegistry();
110
+ /** MCP server lifecycle (Phase 3.2). Connection is LAZY: nothing is contacted until first use. */
111
+ _mcp;
112
+ mcpConnected = false;
113
+ mcpToolIds = new Set();
114
+ mcpWarnings = [];
68
115
  _tools = new ToolRegistry();
69
116
  _skills = new SkillRegistry();
70
117
  _executions;
71
118
  _artifacts;
72
119
  _learning;
73
120
  workspaceRoot;
121
+ _modelProfile;
74
122
  configPermissions;
123
+ /** The INJECTED clock. Agent deadlines are measured against it, never a real timer, so an offline
124
+ * test with a fake clock stays deterministic. */
125
+ clock;
126
+ /** Multi-agent core (Phase 3.4). False ⇒ every agent path is inert. */
127
+ agentsEnabled;
128
+ agentDefs = new Map();
129
+ /** Live runs, so a pause/cancel can abort the agent tasks actually in flight. Only ever populated
130
+ * when agents are enabled, so pause/cancel are unchanged with the flag off. */
131
+ liveRuns = new Map();
132
+ /** The config file's `budget:` ceilings, kept only so the 3.3 pre-pass can decline a model call. */
133
+ _configBudget;
75
134
  approval;
76
135
  _configFile;
77
136
  _loadedSkillSources = [];
78
137
  constructor(config = { router: { providers: [] } }, options = {}, workspace) {
79
- this._ai = new AI(config.router, options.ai);
138
+ // Phase 19: fold the config's per-provider concurrency caps into the AI (shared limiter across runs).
139
+ const perProvider = config.runtime?.concurrency?.perProvider;
140
+ const aiOptions = { ...options.ai, ...(perProvider ? { concurrency: { ...(options.ai?.concurrency ?? {}), perProvider } } : {}) };
141
+ this._ai = new AI(config.router, aiOptions);
80
142
  this.settingsValue = config.runtime ?? {};
81
- this.classifier = options.classifier ?? new HeuristicIntentClassifier();
143
+ // Base classifier: a caller-supplied one wins, else the deterministic heuristic. When
144
+ // `runtime.intent.aiFallback` is on, wrap it so an *ambiguous* heuristic result consults one model call.
145
+ const baseClassifier = options.classifier ?? new HeuristicIntentClassifier();
146
+ this.classifier = this.settingsValue.intent?.aiFallback ? new AIIntentClassifier(this._ai, baseClassifier) : baseClassifier;
82
147
  this.env = options.ai?.env ?? process.env;
83
148
  if (workspace)
84
149
  this.workspace = workspace;
@@ -87,19 +152,64 @@ export class Runtime {
87
152
  this.approval = options.host.approval;
88
153
  if (options.configFile)
89
154
  this._configFile = options.configFile;
155
+ const cfgBudget = config.router.budget?.maxCalls ?? config.router.budget?.maxCostUsd;
156
+ if (cfgBudget !== undefined)
157
+ this._configBudget = cfgBudget;
90
158
  // Local store (conversations + memory). Disabled → NullAreas (stateless mode).
91
159
  const clock = options.ai?.clock ?? systemClock;
160
+ this.clock = clock;
161
+ this.agentsEnabled = config.runtime?.agents?.enabled === true;
162
+ for (const [id, def] of Object.entries(config.runtime?.agents?.definitions ?? {}))
163
+ this.agentDefs.set(id, def);
92
164
  const root = workspace?.root ?? resolveCwd(options.workspace ?? options.host?.workspace);
93
165
  this.workspaceRoot = root;
94
- this._store = new RuntimeStore({ workspaceRoot: root, ...(workspace?.name ? { workspaceName: workspace.name } : {}), env: this.env, clock, enabled: options.persistence !== 'disabled' });
95
- this._memory = new MemoryStore(this._store, clock);
166
+ const persistenceEnabled = options.persistence !== 'disabled';
167
+ const cipher = persistenceEnabled ? this.buildCipher() : undefined; // throws a hard CONFIG error if encrypt:true but no key
168
+ this._store = new RuntimeStore({ workspaceRoot: root, ...(workspace?.name ? { workspaceName: workspace.name } : {}), env: this.env, clock, enabled: persistenceEnabled, ...(this.settingsValue.organization ? { organization: this.settingsValue.organization } : {}), ...(cipher ? { cipher } : {}) });
169
+ // MCP (Phase 3.2): construct always (cheap, connects nothing); a discovered tool becomes an ordinary
170
+ // Runtime Tool so runTool's permission/approval/signal machinery applies with no parallel path.
171
+ this._mcp = new McpManager({
172
+ ...(config.mcp?.servers ? { servers: config.mcp.servers } : {}),
173
+ ...(persistenceEnabled ? { store: this._store.mcp() } : {}),
174
+ env: this.env,
175
+ clock,
176
+ ...(options.ai?.fetchImpl ? { fetchImpl: options.ai.fetchImpl } : {}),
177
+ onToolsDiscovered: (serverId, tools) => this.registerMcpTools(serverId, tools),
178
+ onServerRemoved: (serverId) => this.deregisterMcpTools(serverId),
179
+ });
180
+ this._modelProfile = loadModelProfile(root); // models.md (per-mode/task routing); undefined if absent
181
+ this._memory = new MemoryStore(this._store, clock, this.buildEmbedder(options.ai?.fetchImpl));
96
182
  this._conversations = new ConversationStore(this._store.conversations(), clock);
97
183
  this._executions = new ExecutionStore(this._store.executions(), { clock });
98
184
  this._artifacts = new ArtifactStore(this._store.artifacts(), clock);
99
185
  this._learning = new LearningStore(this._store.learning());
100
186
  // Built-in tools + generic skills (QA-neutral). Users add more via registerTool/registerSkill.
101
- this._tools.register(filesystemTool).register(shellTool).register(gitTool);
187
+ // Action capabilities: wire live accessors + ingest listeners BEFORE any registration, then
188
+ // register the builtins as TRUSTED (the only code allowed to claim bare curated ids).
189
+ this._capabilities
190
+ .attachAccessors({
191
+ toolIds: () => this._tools.ids(),
192
+ skills: () => this._skills.list().map((s) => ({ id: s.id, ...(s.tools ? { tools: s.tools } : {}) })),
193
+ mcpAvailability: (serverId) => this.mcpAvailability(serverId),
194
+ })
195
+ .configure(this.settingsValue.capabilities ?? {});
196
+ // An MCP-backed tool ingests under the `mcp:` namespace (its source kind drives the resolution
197
+ // tie-break and `removeBySourcePrefix`); `mcpToolIds` is populated BEFORE register for exactly this.
198
+ this._tools.attach({
199
+ onRegister: (t, trusted) => (this.mcpToolIds.has(t.id) ? this._capabilities.ingestMcpTool(t) : this._capabilities.ingestTool(t, trusted)),
200
+ onUnregister: (id) => this._capabilities.removeSource(this.mcpToolIds.has(id) ? 'mcp' : 'tool', id),
201
+ });
202
+ this._skills.attach({ onRegister: (sk) => this._capabilities.ingestSkill(sk), onUnregister: (id) => this._capabilities.removeSource('skill', id) });
203
+ this._tools.register(filesystemTool, true).register(shellTool, true).register(gitTool, true);
102
204
  this._skills.register(fileAnalyzerSkill).register(repositoryAnalyzerSkill);
205
+ // Cache-seeded MCP catalog: a previously discovered tool is plan-referenceable BEFORE (or entirely
206
+ // without) a connection — at availability `unknown`, denying honestly at call time. No I/O beyond
207
+ // the store read the manager already did, so lazy connect is preserved.
208
+ for (const serverId of this._mcp.ids()) {
209
+ const cached = this._mcp.tools(serverId);
210
+ if (cached.length)
211
+ this.registerMcpTools(serverId, cached, { cached: true });
212
+ }
103
213
  // Host integration: attach an event sink and register a credential resolver if supplied.
104
214
  const host = options.host;
105
215
  this.emitter = new RuntimeEmitter({
@@ -113,6 +223,41 @@ export class Runtime {
113
223
  if (!hasChat)
114
224
  this._ai.registerTask(CHAT_TASK);
115
225
  }
226
+ /**
227
+ * Build the memory embedder from `runtime.embedding` (Phase 14). Absent → undefined → BM25 keyword
228
+ * retrieval (the offline default). `local` = the zero-dep deterministic HashEmbedder; `openai-compatible`
229
+ * = the HTTP adapter (key by env NAME via `Credential`; a missing `baseUrl` yields no embedder → BM25).
230
+ */
231
+ buildEmbedder(fetchImpl) {
232
+ const cfg = this.settingsValue.embedding;
233
+ if (!cfg)
234
+ return undefined;
235
+ if (cfg.provider === 'local')
236
+ return new HashEmbedder();
237
+ if (!cfg.baseUrl)
238
+ return undefined; // openai-compatible needs an endpoint; no URL → stay on BM25
239
+ return new HttpEmbedder({
240
+ baseUrl: cfg.baseUrl,
241
+ model: cfg.model ?? 'text-embedding-3-small',
242
+ credential: new Credential(cfg.apiKeyEnv, this.env),
243
+ ...(fetchImpl ? { fetchImpl } : {}),
244
+ });
245
+ }
246
+ /**
247
+ * Build the store cipher from `runtime.storage` (Phase 18). Absent / `encrypt:false` → undefined → a
248
+ * plaintext store (the default). `encrypt:true` resolves the key from the env var NAMED by `keyEnv` via
249
+ * `Credential` (never a value) and derives an AES-256 key; a missing key is a hard CONFIG error (silent
250
+ * plaintext would be a lie). The key lives only inside the returned codec — never logged or serialized.
251
+ */
252
+ buildCipher() {
253
+ const cfg = this.settingsValue.storage;
254
+ if (!cfg?.encrypt)
255
+ return undefined;
256
+ const key = new Credential(cfg.keyEnv, this.env).use();
257
+ if (!key)
258
+ throw new AIError(`storage encryption is enabled but the key env var ${cfg.keyEnv} is not set`, { category: 'CONFIG' });
259
+ return makeCodec(deriveKey(key));
260
+ }
116
261
  /** Build a Runtime from a workspace: load config (.ai-runtime/config.yaml > root fallback), detect workspace. */
117
262
  static async load(options = {}) {
118
263
  const workspaceRoot = resolveCwd(options.workspace ?? options.host?.workspace);
@@ -210,6 +355,36 @@ export class Runtime {
210
355
  return base;
211
356
  return withPreferredProviders(base, this._learning.preferredProviders());
212
357
  }
358
+ /** The loaded `models.md` routing profile (per-mode/per-task model directives), or undefined if none. */
359
+ modelProfile() {
360
+ return this._modelProfile;
361
+ }
362
+ /**
363
+ * Fold the `models.md` directive for this (mode, task) into the run — a soft prefer (universal, merged
364
+ * into routing), a strategy, or a hard pin (chat path, via the request escape hatch). Precedence is
365
+ * explicit per-run > models.md: the caller's own strategy/pin win, and prefer is unioned (never
366
+ * re-admitting an exclusion). No profile / no matching directive ⇒ the request is returned unchanged.
367
+ */
368
+ applyModelProfile(req, mode) {
369
+ if (!this._modelProfile)
370
+ return req;
371
+ const directive = resolveModelDirective(this._modelProfile, { mode, ...(mode === 'chat' ? { task: 'chat' } : {}) });
372
+ if (!directive)
373
+ return req;
374
+ const o = directiveToOverrides(directive);
375
+ const next = { ...req };
376
+ if (o.strategy && next.strategy === undefined)
377
+ next.strategy = o.strategy;
378
+ if ((o.provider || o.model) && next.request?.provider === undefined && next.request?.model === undefined) {
379
+ next.request = { ...(next.request ?? {}), ...(o.provider ? { provider: o.provider } : {}), ...(o.model ? { model: o.model } : {}) };
380
+ }
381
+ if (o.routing) {
382
+ const merged = mergeRouting(next.routing, o.routing);
383
+ if (merged)
384
+ next.routing = merged;
385
+ }
386
+ return next;
387
+ }
213
388
  /** Apply free-text feedback (e.g. "that worked" / "wrong root cause") to the most recent outcome. */
214
389
  feedback(text, opts) {
215
390
  return this._learning.feedback(text, opts ?? {});
@@ -246,18 +421,206 @@ export class Runtime {
246
421
  this._skills.register(skill);
247
422
  return this;
248
423
  }
424
+ /**
425
+ * Register an agent definition (Phase 3.4). Chainable, and inert unless `runtime.agents.enabled` is
426
+ * set — registering a definition grants nothing on its own, exactly like adding an MCP server.
427
+ */
428
+ registerAgent(id, def) {
429
+ if (!/^[a-z0-9][a-z0-9_-]{0,32}$/.test(id))
430
+ throw new AIError(`invalid agent id '${id}' — use lowercase letters, digits, '_' or '-' (max 33 chars)`, { category: 'CONFIG' });
431
+ this.agentDefs.set(id, def);
432
+ return this;
433
+ }
434
+ /** The agent definition ids this runtime knows (registered or configured). */
435
+ agents() {
436
+ return [...this.agentDefs.keys()].sort();
437
+ }
438
+ /**
439
+ * This run's agent envelopes. THE ONLY call site of `narrowEnvelope` — never re-derive an inner
440
+ * catalog, a permission clamp, or a reservation anywhere else (see the header of agents/envelope.ts).
441
+ */
442
+ agentEnvelopes(policy) {
443
+ if (!this.agentsEnabled || this.agentDefs.size === 0)
444
+ return [];
445
+ const routing = this.effectiveRouting();
446
+ return [...this.agentDefs].map(([id, definition]) => narrowEnvelope({
447
+ agentId: id,
448
+ definition,
449
+ parentTools: this._tools.ids(),
450
+ parentSkills: this.skills().map((sk) => ({ id: sk.id, ...(sk.tools ? { tools: sk.tools } : {}) })),
451
+ parentPermissions: policy.permissions,
452
+ ...(routing ? { parentRouting: routing } : {}),
453
+ defaults: {
454
+ maxToolCalls: this.settingsValue.agents?.maxToolCalls ?? 25,
455
+ maxDurationMs: this.settingsValue.agents?.maxDurationMs ?? 120_000,
456
+ maxInnerCalls: this.settingsValue.agents?.maxInnerCalls ?? 3,
457
+ },
458
+ }));
459
+ }
460
+ // ── MCP (Phase 3.2) ────────────────────────────────────────────────────────
461
+ /** The MCP server manager: `list()`, `status(id)`, `test(id)`, `addServer`, `removeServer`, `setEnabled`. */
462
+ mcp() {
463
+ return this._mcp;
464
+ }
465
+ /**
466
+ * Connect every enabled MCP server, discover their tools, and register them. LAZY BY DESIGN: a one-shot
467
+ * CLI command that never touches MCP pays nothing, and a failing server records its state instead of
468
+ * breaking construction. Idempotent — the second call is a no-op.
469
+ */
470
+ async connectMcp() {
471
+ if (this.mcpConnected)
472
+ return this._mcp.list();
473
+ this.mcpConnected = true;
474
+ return this._mcp.connectAll();
475
+ }
476
+ /** Whether any MCP server is configured at all (absent ⇒ MCP is entirely inert). */
477
+ hasMcpServers() {
478
+ return this._mcp.ids().length > 0;
479
+ }
480
+ /**
481
+ * Register one server's discovered tools as ordinary Runtime Tools (+ their namespaced action
482
+ * capabilities, via the tool registry's ingest listener). A generated id NEVER shadows an existing
483
+ * non-MCP tool: the collision is skipped and reported, because silently replacing `filesystem` would be
484
+ * a privilege swap.
485
+ */
486
+ registerMcpTools(serverId, tools, opts = {}) {
487
+ const fresh = new Set();
488
+ for (const decl of tools) {
489
+ const id = mcpToolId(serverId, decl.name);
490
+ if (this._tools.get(id) && !this.mcpToolIds.has(id)) {
491
+ this.mcpWarnings.push(`MCP tool '${id}' collides with a registered tool and was skipped`);
492
+ continue;
493
+ }
494
+ // A CACHED annotation must never RELAX a gate: `readOnlyHint` would lower the permission gate, and
495
+ // `destructiveHint: false` would remove the confirmation. Until a live handshake says otherwise the
496
+ // tool is write-like AND destructive. (The adapter also re-reads live annotations at call time —
497
+ // this is the registration-side half of the same rule.)
498
+ const gated = opts.cached ? { ...decl, readOnly: false, destructive: true } : decl;
499
+ this.mcpToolIds.add(id); // before register: the capability ingest hook routes on this set
500
+ this._tools.register(mcpTool({
501
+ call: (sid, t, a, o) => this._mcp.call(sid, t, a, o),
502
+ usable: (sid) => this._mcp.usable(sid),
503
+ liveTool: (sid, t) => this._mcp.liveTool(sid, t),
504
+ }, serverId, gated));
505
+ fresh.add(id);
506
+ }
507
+ // A live discovery is the AUTHORITY on what this server offers: anything we still have registered
508
+ // that the server no longer lists (a stale cache entry, a withdrawn tool) must go, or it would keep
509
+ // reporting itself as available and get planned against.
510
+ if (!opts.cached) {
511
+ for (const id of [...this.mcpToolIds]) {
512
+ if (!id.startsWith(`${serverId}.`) || fresh.has(id))
513
+ continue;
514
+ this._tools.unregister(id);
515
+ this.mcpToolIds.delete(id);
516
+ this._capabilities.removeSource('mcp', id);
517
+ }
518
+ }
519
+ this.publishMcpSource(serverId, tools);
520
+ }
521
+ /**
522
+ * The legacy declaration path (`AI.run({ mcp: true })`) — tools DECLARED to a model, never executed
523
+ * through here. It honors the same read/full split as execution (declaring a tool the executor would
524
+ * refuse just invites the model to plan around it) and is keyed by server id, so re-publishing with an
525
+ * empty list REVOKES it.
526
+ */
527
+ publishMcpSource(serverId, tools) {
528
+ const servers = this.permissions().mcp.servers;
529
+ const grant = Object.prototype.hasOwnProperty.call(servers, serverId) ? servers[serverId] : 'off';
530
+ const offered = grant === 'full' ? tools : grant === 'read' ? tools.filter((t) => t.readOnly) : [];
531
+ // Names are emitted ALREADY namespaced: McpRegistry only prefixes a name without a dot, so a server
532
+ // tool called `other.thing` would otherwise appear to the model inside another source's namespace.
533
+ const specs = offered.map((t) => ({ name: mcpToolId(serverId, t.name), description: `${t.description} (MCP server: ${serverId})`, ...(t.inputSchema ? { parameters: t.inputSchema } : {}) }));
534
+ this._ai.registerMcpSource(new StaticMcpSource(serverId, specs));
535
+ }
536
+ /** Drop a server's tools (removal / disable). */
537
+ deregisterMcpTools(serverId) {
538
+ for (const id of [...this.mcpToolIds]) {
539
+ if (!id.startsWith(`${serverId}.`))
540
+ continue;
541
+ this._tools.unregister(id);
542
+ this.mcpToolIds.delete(id);
543
+ }
544
+ this._capabilities.removeMcpServer(serverId);
545
+ this.publishMcpSource(serverId, []); // revoke the model-facing declarations too
546
+ }
547
+ /** Map a server's lifecycle state onto capability availability (the D7 derivation table). */
548
+ mcpAvailability(serverId) {
549
+ const st = this._mcp.status(serverId);
550
+ if (!st)
551
+ return 'unavailable';
552
+ if (!st.enabled || st.state === 'disabled')
553
+ return 'disabled';
554
+ if (st.state === 'connected' || st.state === 'degraded')
555
+ return 'available';
556
+ if (st.state === 'configured' || st.state === 'connecting')
557
+ return 'unknown'; // cache-seeded / in flight
558
+ return 'unavailable'; // auth_failed | unreachable
559
+ }
560
+ /** Warnings from MCP wiring (id collisions) plus the manager's own (invalid store files, etc.). */
561
+ mcpWarningsList() {
562
+ return [...this.mcpWarnings, ...this._mcp.warningsList()];
563
+ }
564
+ /**
565
+ * Release long-lived resources — today: MCP stdio child processes. A one-shot CLI command and the REPL
566
+ * both call this on completion/exit; without it a spawned server keeps the event loop alive.
567
+ */
568
+ async close() {
569
+ await this._mcp.close();
570
+ }
249
571
  /** Skills whose required tools are all registered. */
250
572
  skills() {
251
573
  return this._skills.match({ tools: this._tools.ids() });
252
574
  }
575
+ /**
576
+ * Scaffold a skill MANIFEST from a natural-language goal (Phase 21a). Drafts a `*.skill.yaml` via the
577
+ * model, validated by `parseManifest` + tool-membership + a dry compile. **Writes nothing** — pass the
578
+ * returned manifest to `saveScaffoldedSkill` on explicit confirm.
579
+ */
580
+ async scaffoldSkill(goal) {
581
+ return generateSkillManifest({ goal, tools: this._tools.ids(), ai: this._ai });
582
+ }
583
+ /**
584
+ * Write a scaffolded manifest into the consented `.ai-runtime/skills/` directory (Phase 21a). Only call
585
+ * this after the user has confirmed the drafted manifest. Returns the written path. The id was validated
586
+ * as kebab-case at generation; it is re-checked here so a hand-built manifest can't escape the skills dir.
587
+ */
588
+ saveScaffoldedSkill(manifest) {
589
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(manifest.id))
590
+ throw new AIError(`unsafe skill id: ${JSON.stringify(manifest.id)}`, { category: 'CONFIG' });
591
+ const dir = join(this.workspaceRoot, '.ai-runtime', 'skills');
592
+ mkdirSync(dir, { recursive: true });
593
+ const path = join(dir, `${manifest.id}.skill.yaml`);
594
+ writeFileSync(path, stringifyYaml(manifest));
595
+ return path;
596
+ }
597
+ /** The ACTION-capability registry (Phase 3.1): what this runtime can DO, and who provides it. */
598
+ capabilities() {
599
+ return this._capabilities;
600
+ }
253
601
  /** The resolved tool permissions for this runtime (config grants over deny-by-default defaults). */
254
602
  permissions() {
255
603
  return resolvePermissions(this.configPermissions);
256
604
  }
605
+ /**
606
+ * The ONE resolved-permission view for a run: config grants merged with a per-run override, with `mcp`
607
+ * merged by per-server MINIMUM so an override can only ever NARROW. Extracted from `toolContext`
608
+ * (Phase 3.3 / D9) so capability resolution and tool execution can never read different permissions.
609
+ * Never re-derive this merge at a call site: a plain `resolvePermissions({ ...config, ...override })`
610
+ * would let an override widen `read` to `full` and drop sibling servers.
611
+ */
612
+ resolvedPermissions(overrides) {
613
+ const permissions = resolvePermissions({ ...this.configPermissions, ...(overrides?.permissions ?? {}) });
614
+ // `mcp` is the first NESTED permission dimension, so a shallow spread would let an override REPLACE
615
+ // the configured grants — silently widening `read` to `full` and dropping sibling servers. It merges
616
+ // by per-server minimum instead: an override can only ever narrow (invariant: permissions never widen).
617
+ permissions.mcp = { servers: clampMcpPermissions(resolvePermissions(this.configPermissions).mcp.servers, overrides?.permissions?.mcp?.servers) };
618
+ return permissions;
619
+ }
257
620
  toolContext(overrides) {
258
621
  return {
259
622
  workspaceRoot: this.workspaceRoot,
260
- permissions: resolvePermissions({ ...this.configPermissions, ...(overrides?.permissions ?? {}) }),
623
+ permissions: this.resolvedPermissions(overrides),
261
624
  ...(this.approval ? { approval: this.approval } : {}),
262
625
  ...(overrides?.signal ? { signal: overrides.signal } : {}),
263
626
  };
@@ -277,7 +640,9 @@ export class Runtime {
277
640
  const toolCtx = this.toolContext(overrides);
278
641
  const ctx = {
279
642
  input,
280
- ai: this._ai,
643
+ // Phase 3.4: an agent worker passes its METERED facade here, so a skill's own model calls are
644
+ // counted against that agent's reservation. Absent ⇒ the real AI, byte-identical.
645
+ ai: overrides?.ai ?? this._ai,
281
646
  callTool: async (toolId, toolInput) => {
282
647
  const tool = this._tools.get(toolId);
283
648
  if (!tool)
@@ -306,7 +671,7 @@ export class Runtime {
306
671
  return this.emitter.recent();
307
672
  }
308
673
  async run(input) {
309
- const req = typeof input === 'string' ? { input } : input;
674
+ let req = typeof input === 'string' ? { input } : input;
310
675
  const runId = nextRunId();
311
676
  const text = inputText(req.input);
312
677
  this.emitter.emit({ type: 'runtime.started', runId, requested: req.mode ?? 'auto' });
@@ -319,6 +684,9 @@ export class Runtime {
319
684
  });
320
685
  const resolution = modeResult.resolution;
321
686
  this.emitter.emit({ type: 'mode.selected', runId, requested: resolution.requested, selected: resolution.selected, executed: resolution.executed, source: resolution.source, confidence: resolution.confidence });
687
+ // models.md (Phase 23): fold the per-mode/per-task model directive into this run's routing/strategy/pin
688
+ // — but never over an explicit per-run choice. Downstream strategy/routing/dispatch read the result.
689
+ req = this.applyModelProfile(req, resolution.executed);
322
690
  const strategy = resolveStrategy({
323
691
  ...(req.strategy !== undefined ? { explicit: req.strategy } : {}),
324
692
  ...(this.settingsValue.defaultStrategy !== undefined ? { configDefault: this.settingsValue.defaultStrategy } : {}),
@@ -339,7 +707,7 @@ export class Runtime {
339
707
  // Orchestration modes (plan/execute/orchestrate) plan + drive skills/tools; they do not use the
340
708
  // chat memory/context path. Clarification here comes from the planner, not mode ambiguity.
341
709
  if (resolution.executed === 'plan' || resolution.executed === 'execute' || resolution.executed === 'orchestrate' || resolution.executed === 'agent' || resolution.executed === 'debug') {
342
- return this.runOrchestration(resolution.executed, text, resolution, runId, policy, req.requestId, routing);
710
+ return this.runOrchestration(resolution.executed, text, resolution, runId, policy, req.requestId, routing, req.partial);
343
711
  }
344
712
  // Compare mode: fan out the same task across pins and analyze — read-only, no approval gate.
345
713
  if (resolution.executed === 'compare') {
@@ -352,14 +720,23 @@ export class Runtime {
352
720
  this.emitter.emit({ type: 'clarification.requested', runId, question: clarification.question });
353
721
  // Memory: retrieve relevant facts into context, and (unless this is a dry run) capture an explicit
354
722
  // "remember …". A dry run performs zero mutations, so it retrieves but never writes.
355
- const memTrace = this.applyMemory(text, !policy.dryRun);
723
+ const memTrace = await this.applyMemory(text, !policy.dryRun);
356
724
  // Compile the model context (workspace summary + memory facts + user system) under a token budget.
357
725
  const budget = resolveContextBudget({
358
726
  ...(req.context?.maxTokens !== undefined ? { perRun: req.context.maxTokens } : {}),
359
727
  ...(this.settingsValue.context?.maxTokens !== undefined ? { config: this.settingsValue.context.maxTokens } : {}),
360
728
  ...(this.env.AI_CONTEXT_MAX_TOKENS !== undefined ? { env: this.env.AI_CONTEXT_MAX_TOKENS } : {}),
361
729
  });
362
- const compiled = compileContext(this.contextBlocks(req, context.workspace, memTrace?.retrieved), { budgetTokens: budget, estimator: this.estimator });
730
+ // Optional abstractive summarization pre-pass (Phase 16, opt-in) shrinks over-budget non-critical
731
+ // blocks before the deterministic compile. Never on a dry run (it makes model calls). Failure → originals.
732
+ // `originalBlocks` is kept so loss verification can compare against the TRUE originals (pre-summary),
733
+ // catching loss introduced by summarization as well as by the compiler.
734
+ const originalBlocks = this.contextBlocks(req, context.workspace, memTrace?.retrieved);
735
+ let blocks = originalBlocks;
736
+ if (!policy.dryRun && this.settingsValue.context?.summarize) {
737
+ blocks = await summarizeOverBudget({ ai: this._ai, blocks, budgetTokens: budget, estimator: this.estimator });
738
+ }
739
+ const compiled = compileContext(blocks, { budgetTokens: budget, estimator: this.estimator });
363
740
  const contextReport = { metrics: compiled.metrics, validation: compiled.validation };
364
741
  // Dry-run: chat's only "action" is the model call itself, so a dry run makes NO call. It reports
365
742
  // what it would send (mode, compiled-context size) and performs zero mutations.
@@ -374,7 +751,24 @@ export class Runtime {
374
751
  result.memory = memTrace;
375
752
  return result;
376
753
  }
754
+ // Optional model-based context-loss verification (Phase 16, opt-in) — appends one advisory check to
755
+ // validation.checks; never blocks, never runs on a dry run (returned above). Failure → inconclusive.
756
+ if (this.settingsValue.context?.verifyLoss) {
757
+ compiled.validation.checks.push(await verifyContextLoss({ ai: this._ai, blocks: originalBlocks, compiled }));
758
+ }
377
759
  const runRequest = buildChatRequest(req, strategy, compiled.system || undefined, routing);
760
+ // Streaming (Phase 13): text-only, chat-mode only, never on a dry run (handled above). Each chunk is
761
+ // emitted as a `response.delta` lifecycle event (redacted by the emitter). We also accumulate the raw
762
+ // chunks so we can mark the result `streamed` when the streamed text IS the final answer.
763
+ const streaming = req.stream === true && req.output?.format !== 'json' && req.output?.format !== 'structured_output';
764
+ let streamedText = '';
765
+ if (streaming) {
766
+ runRequest.stream = true;
767
+ runRequest.onDelta = (chunk) => {
768
+ streamedText += chunk;
769
+ this.emitter.emit({ type: 'response.delta', runId, text: chunk });
770
+ };
771
+ }
378
772
  const runResult = await this._ai.run(runRequest);
379
773
  this.calibrate(runRequest.system, text, runResult);
380
774
  const sel = runResult.routing.selected;
@@ -393,6 +787,12 @@ export class Runtime {
393
787
  if (memTrace)
394
788
  result.memory = memTrace;
395
789
  result.context = contextReport;
790
+ // Mark `streamed` only when the streamed chunks ARE the final answer (a clean single stream). If a
791
+ // streamed attempt failed and a fallback produced different text, they won't match → not streamed, so
792
+ // a renderer reprints the authoritative final text.
793
+ if (streaming && runResult.ok && result.response && streamedText.length > 0 && (result.response.text ?? '') === streamedText) {
794
+ result.response.streamed = true;
795
+ }
396
796
  return result;
397
797
  }
398
798
  /** The persistent execution store and its owner-lease machinery. */
@@ -408,13 +808,29 @@ export class Runtime {
408
808
  return this._executions.list();
409
809
  }
410
810
  /** plan/execute/orchestrate/agent/debug: run the orchestrator and persist a resumable Execution. */
411
- async runOrchestration(mode, goal, resolution, runId, policy, requestId, routing) {
811
+ async runOrchestration(mode, goal, resolution, runId, policy, requestId, routing, partial) {
412
812
  const effectiveGoal = mode === 'debug' ? `Investigate and diagnose the following, gathering evidence before concluding: ${goal}` : goal;
813
+ // Phase 3.4: ONE controller per run, registered under the run id (and the execution id below) so
814
+ // pauseExecution/cancelExecution can abort agent work that is actually in flight. With agents
815
+ // disabled nothing consumes the signal, so this is inert.
816
+ const controller = new AbortController();
817
+ if (this.agentsEnabled)
818
+ this.liveRuns.set(runId, { controller });
413
819
  // plan mode and dry-run produce no durable work → run without persisting an execution.
414
820
  if (mode === 'plan' || policy.dryRun || !this._executions.enabled) {
415
- const outcome = await orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, routing));
416
- this.recordOrchestration(mode, goal, outcome);
417
- return this.mapOutcome(outcome, resolution, runId, undefined);
821
+ // Phase 3.3 (opt-in): derive + resolve this goal's capabilities BEFORE planning. Done here rather
822
+ // than in `orchestrateInput` because this scope has the resolved `policy`, so plan/execute/
823
+ // orchestrate/agent/debug all behave identically. `undefined` when the flag is off, which is the
824
+ // entire flag-off delta on this path.
825
+ const planning = await this.capabilityPlanning(effectiveGoal, policy, routing);
826
+ try {
827
+ const outcome = await orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, routing, partial, planning?.block, this.agentsEnabled ? controller.signal : undefined, { planVersion: 1 }));
828
+ this.recordOrchestration(mode, goal, outcome);
829
+ return this.mapOutcome(outcome, resolution, runId, undefined, planning);
830
+ }
831
+ finally {
832
+ this.liveRuns.delete(runId);
833
+ }
418
834
  }
419
835
  // Reserve the execution BEFORE running (persist-in-progress + claim the requestId under the store
420
836
  // lock so a concurrent retry with the same requestId dedupes to it).
@@ -431,9 +847,15 @@ export class Runtime {
431
847
  return this.resultFromExecution(reserved.exec, resolution, runId);
432
848
  }
433
849
  const exec = reserved.exec;
850
+ // Derive only AFTER the requestId dedup has admitted this as fresh work: an idempotent retry returns
851
+ // the existing execution above, and paying for a model call whose result is then discarded would
852
+ // spend budget to answer a question nobody asked.
853
+ const planning = await this.capabilityPlanning(effectiveGoal, policy, routing);
434
854
  try {
435
855
  // Renew the lease while the (possibly long) run is in flight so it can't expire mid-run.
436
- const outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, routing)));
856
+ if (this.agentsEnabled)
857
+ this.liveRuns.set(exec.id, { controller });
858
+ const outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, routing, partial, planning?.block, this.agentsEnabled ? controller.signal : undefined, { executionId: exec.id, planVersion: exec.planVersion })));
437
859
  exec.status = this.execStatus(outcome.status);
438
860
  exec.observations = outcome.observations;
439
861
  if (outcome.plan) {
@@ -445,12 +867,16 @@ export class Runtime {
445
867
  exec.pending = { kind: 'approval', action: goal };
446
868
  else if (outcome.status === 'waiting_for_clarification' && outcome.clarification)
447
869
  exec.pending = { kind: 'clarification', question: outcome.clarification };
870
+ else if (outcome.status === 'waiting_for_budget' && outcome.budget)
871
+ exec.pending = { kind: 'budget', budget: outcome.budget };
448
872
  exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps }));
449
873
  this._executions.commit(exec); // ownership-checked: never clobber a newer owner
450
874
  this.recordOrchestration(mode, goal, outcome);
451
- return this.mapOutcome(outcome, resolution, runId, exec.id);
875
+ return this.mapOutcome(outcome, resolution, runId, exec.id, planning);
452
876
  }
453
877
  finally {
878
+ this.liveRuns.delete(runId);
879
+ this.liveRuns.delete(exec.id);
454
880
  this._executions.release(exec.id);
455
881
  }
456
882
  }
@@ -466,7 +892,7 @@ export class Runtime {
466
892
  clearInterval(timer);
467
893
  }
468
894
  }
469
- orchestrateInput(mode, goal, policy, routing) {
895
+ orchestrateInput(mode, goal, policy, routing, partial, requiredCapabilities, signal, provenance) {
470
896
  return {
471
897
  goal,
472
898
  mode,
@@ -475,9 +901,19 @@ export class Runtime {
475
901
  tools: this._tools.ids(),
476
902
  policy,
477
903
  ...(routing ? { routing } : {}),
904
+ ...(partial ? { partial: true } : {}),
478
905
  ...(this.approval ? { approval: this.approval } : {}),
479
- runSkill: (id, input) => this.runSkill(id, input).then((o) => ({ result: o.result, validation: o.validation })),
480
- runTool: (id, input) => this.runTool(id, input),
906
+ // Phase 3.1: the capability snapshot is opt-in (`runtime.capabilities.catalog`); the gap resolver is
907
+ // always on but only fires on a validation failure, adding metadata to an unchanged error.
908
+ ...(this.settingsValue.capabilities?.catalog ? { capabilityCatalog: this.capabilityCatalogText() } : {}),
909
+ // Phase 3.3: the derived-requirement block (opt-in, pre-rendered + clamped). Absent ⇒ the
910
+ // OrchestrateInput/PlannerInput objects are key-identical to 2.5.1.
911
+ ...(requiredCapabilities ? { requiredCapabilities } : {}),
912
+ resolveGaps: (missing) => this.resolveMissingRefs(missing, policy),
913
+ // Phase 3.4: ONE runner source. `agents`, `runAgent` and `reserve` ride along only when agents are
914
+ // enabled AND a definition exists, so with the flag off this object is KEY-identical to 2.6.0.
915
+ ...this.orchestrateRunners(policy, signal, provenance),
916
+ ...(signal ? { signal } : {}),
481
917
  };
482
918
  }
483
919
  /** Record an EXECUTED orchestration outcome for learning. plan-only, dry-run, and waiting states are
@@ -489,19 +925,168 @@ export class Runtime {
489
925
  return;
490
926
  this._learning.record({ goalType: this.goalType(goal), mode, ok: outcome.status === 'completed', skills: this.planSkillRefs(outcome.plan) });
491
927
  }
928
+ /**
929
+ * A capped, FENCED action-capability snapshot for the planner prompt (Phase 3.1, opt-in). Untrusted
930
+ * sources (anything not an in-tree builtin) have their descriptions fenced, and the block is bounded so
931
+ * a large catalog can never dominate the prompt.
932
+ */
933
+ capabilityCatalogText(maxEntries = 40) {
934
+ const caps = this._capabilities.list();
935
+ if (caps.length === 0)
936
+ return '';
937
+ const lines = [];
938
+ for (const c of caps.slice(0, maxEntries)) {
939
+ const providers = this._capabilities
940
+ .providersOf(c.id)
941
+ .map((p) => `${promptSafe(p.providerId)}${p.availability === 'available' ? '' : ` (${p.availability})`}`)
942
+ .join(', ');
943
+ lines.push(` - capability "${promptSafe(c.id)}" [${c.effects.join('/')}] → ${promptSafe(providers, 200)}`);
944
+ }
945
+ const more = caps.length > maxEntries ? `\n …and ${caps.length - maxEntries} more (see /capabilities)` : '';
946
+ // Skills hidden by a missing tool — the "why can't you do this" answer the planner needs.
947
+ const usable = new Set(this.skills().map((sk) => sk.id));
948
+ const blocked = this._skills
949
+ .list()
950
+ .filter((sk) => !usable.has(sk.id))
951
+ .map((sk) => ` - skill "${promptSafe(sk.id)}" needs tool(s) ${promptSafe((sk.tools ?? []).filter((t) => !this._tools.ids().includes(t)).join(', '), 200)} (not registered)`);
952
+ const unavailable = blocked.length ? `\nUnavailable (do not use):\n${blocked.join('\n')}` : '';
953
+ return `Action capabilities:\n${lines.join('\n')}${more}${unavailable}`;
954
+ }
955
+ /** Any call/cost ceiling declared in the config file's `budget:` block (router-level, not policy). */
956
+ configBudget() {
957
+ return this._configBudget;
958
+ }
959
+ /** Deriver candidates: registry ids + one clamped label. Built by the SAME helper the CLI uses. */
960
+ capabilityCandidates() {
961
+ return candidatesFrom(this._capabilities.list());
962
+ }
963
+ /**
964
+ * Derive the capabilities this goal needs and resolve them (Phase 3.3, opt-in). Offline BM25 first —
965
+ * free and deterministic — with ONE model call only when the offline rung finds nothing. ADVISORY: it
966
+ * never blocks a run and never grants anything; a gap is metadata plus a line in the planner prompt.
967
+ */
968
+ async capabilityPlanning(goal, policy, routing) {
969
+ if (!this.settingsValue.capabilities?.planning)
970
+ return undefined;
971
+ const permissions = this.resolvedPermissions({ permissions: policy.permissions });
972
+ const candidates = this.capabilityCandidates();
973
+ if (candidates.length === 0)
974
+ return { derived: [], gaps: [], block: '', permissions };
975
+ // The model rung is skipped whenever ANY budget is set. `maxCalls` is denominated in plan skill steps
976
+ // (orchestrator.estimateCalls), so it cannot express a pre-plan call — spending one would exceed a
977
+ // limit only ExecutionPolicy is meant to author — and a COST ceiling is just as much a budget. The
978
+ // router-config `budget.maxCalls` is consulted directly because it deliberately does NOT flow into
979
+ // `policy.maxCalls`: threading it there would newly gate ORCHESTRATION for every existing config,
980
+ // which is a flag-off behavior change this phase must not make. Skipped on a dry run too. The
981
+ // offline rung still runs in every one of these cases and costs nothing.
982
+ const budgeted = policy.maxCalls !== undefined || policy.maxCostUsd !== undefined || this.configBudget() !== undefined;
983
+ const useModel = !policy.dryRun && !budgeted;
984
+ const res = await deriveCapabilities({ goal, candidates, ...(useModel ? { ai: this._ai } : {}), ...(routing ? { routing } : {}) });
985
+ if (res.required.length === 0)
986
+ return { derived: [], gaps: [], block: '', permissions };
987
+ const resolution = this._capabilities.resolve(res.required, { permissions });
988
+ return { derived: res.required, gaps: resolution.gaps, block: this.requiredCapabilitiesText(resolution), permissions };
989
+ }
990
+ /** The pre-rendered "Required capabilities" planner block (Phase 3.3) — clamped like the 3.1 catalog. */
991
+ requiredCapabilitiesText(res) {
992
+ const rows = [
993
+ ...res.satisfied.map((sat) => ` - "${promptSafe(sat.capabilityId)}" → use ${promptSafe(sat.chosen.providerId)}`),
994
+ ...res.gaps.map((g) => ` - "${promptSafe(g.capabilityId)}" → NOT AVAILABLE (${g.reason}) — do not plan a step that needs it`),
995
+ ];
996
+ return rows.length ? `Required capabilities for this goal (derived):\n${rows.join('\n')}` : '';
997
+ }
998
+ /** A step's capability provider id. An MCP-backed tool ingests under `mcp:` (see the ingest hook). */
999
+ providerIdForRef(ref) {
1000
+ if (ref.kind === 'skill')
1001
+ return namespacedId('skill', ref.id);
1002
+ return namespacedId(this.mcpToolIds.has(ref.id) ? 'mcp' : 'tool', ref.id);
1003
+ }
1004
+ /**
1005
+ * Resolve the capabilities the plan's OWN steps reference (Phase 3.3). ZERO model calls, purely
1006
+ * additive metadata, and the only path that can surface a real `permission` gap with a concrete
1007
+ * policyKey — the always-on validation path resolves namespaced MISSES, which have no providers and are
1008
+ * therefore always reason 'unknown'. It never blocks: a gap here is advice, and the tool's own
1009
+ * permission check remains the authority.
1010
+ */
1011
+ checkPlanCapabilities(plan, permissions, derived) {
1012
+ try {
1013
+ const ids = new Set();
1014
+ for (const step of plan.steps) {
1015
+ const ref = step.skill ? { kind: 'skill', id: step.skill } : step.tool ? { kind: 'tool', id: step.tool } : undefined;
1016
+ if (!ref)
1017
+ continue;
1018
+ for (const id of this._capabilities.capabilitiesOf(this.providerIdForRef(ref), ref.id))
1019
+ ids.add(id);
1020
+ }
1021
+ const all = [...ids].sort();
1022
+ const res = this._capabilities.resolve(all, { permissions });
1023
+ const reasonOf = new Map(res.gaps.map((g) => [g.capabilityId, g.reason]));
1024
+ /**
1025
+ * A provider can do more than the step will. `tool:filesystem` provides read_file AND write_file,
1026
+ * so a read-only step would otherwise be reported as needing write access — and the remedy would
1027
+ * tell the operator to GRANT it. Pushing an over-grant for work that may never happen is worse
1028
+ * than saying nothing, so a `permission` gap survives only when the GOAL derived that capability.
1029
+ * Every other reason (unknown / unavailable / disabled provider) is a fact about the plan whatever
1030
+ * the step does, and is always reported.
1031
+ */
1032
+ const keep = (id) => derived.has(id) || reasonOf.get(id) !== 'permission';
1033
+ return { required: all.filter(keep), gaps: res.gaps.filter((g) => keep(g.capabilityId)) };
1034
+ }
1035
+ catch {
1036
+ return { required: [], gaps: [] }; // a check bug can never fail a run the runtime could complete
1037
+ }
1038
+ }
1039
+ /**
1040
+ * Turn unregistered plan references into structured gaps (Phase 3.1's always-on upgrade), resolved
1041
+ * against the RUN's permissions rather than the raw config (Phase 3.3 / D9) — so what resolution
1042
+ * reports and what the executor will allow can never disagree.
1043
+ */
1044
+ resolveMissingRefs(missing, policy) {
1045
+ const ids = [...new Set(missing.map((m) => namespacedId(m.kind, m.id)))];
1046
+ return this._capabilities.resolve(ids, { permissions: this.resolvedPermissions({ permissions: policy.permissions }) }).gaps;
1047
+ }
1048
+ /**
1049
+ * The user-facing report for a set of required capability ids (Phase 3.1), against the runtime's
1050
+ * CONFIGURED posture. A specific run's gaps use that run's merged permissions instead (see
1051
+ * `resolveMissingRefs` and the capability-planning pre-pass); this entry point has no run in scope.
1052
+ */
1053
+ capabilityReport(required) {
1054
+ return this._capabilities.report(required, { permissions: this.permissions() });
1055
+ }
1056
+ /**
1057
+ * Project an orchestration status onto a persisted execution status. EXHAUSTIVE on purpose (Phase
1058
+ * 3.4): the previous catch-all silently persisted an unrecognized status as
1059
+ * `waiting_for_clarification` — resumable, answerable with a text answer, and wrong. The mapping of
1060
+ * the six existing members is unchanged.
1061
+ */
492
1062
  execStatus(s) {
493
- if (s === 'completed' || s === 'dry-run')
494
- return 'completed';
495
- if (s === 'failed')
496
- return 'failed';
497
- if (s === 'waiting_for_approval')
498
- return 'waiting_for_input';
499
- return 'waiting_for_clarification';
500
- }
501
- mapOutcome(outcome, resolution, runId, execId) {
1063
+ switch (s) {
1064
+ case 'completed':
1065
+ case 'dry-run':
1066
+ return 'completed';
1067
+ case 'failed':
1068
+ return 'failed';
1069
+ case 'waiting_for_approval':
1070
+ case 'waiting_for_budget':
1071
+ return 'waiting_for_input';
1072
+ case 'waiting_for_clarification':
1073
+ return 'waiting_for_clarification';
1074
+ default: {
1075
+ const never = s;
1076
+ return never;
1077
+ }
1078
+ }
1079
+ }
1080
+ mapOutcome(outcome, resolution, runId, execId, planning) {
502
1081
  const status = outcome.status === 'dry-run' ? 'completed' : outcome.status;
503
1082
  const ok = outcome.status === 'completed' || outcome.status === 'dry-run' || outcome.status.startsWith('waiting');
504
1083
  this.emitter.emit({ type: 'run.completed', runId, ok, status, confidence: ok ? 1 : 0 });
1084
+ // One report, three sources: the pre-pass (the goal), the post-plan check (the plan's own steps),
1085
+ // and the always-on validation upgrade (unregistered refs). The post-plan check is free — no model
1086
+ // call — so it runs wherever the flag is on, resumes included.
1087
+ const check = planning && outcome.plan ? this.checkPlanCapabilities(outcome.plan, planning.permissions, new Set(planning.derived)) : undefined;
1088
+ const gaps = mergeGapsById([...(planning?.gaps ?? []), ...(check?.gaps ?? []), ...(outcome.gaps ?? [])]);
1089
+ const required = [...new Set([...(planning?.derived ?? []), ...(check?.required ?? []), ...(outcome.gaps ?? []).map((g) => g.capabilityId)])];
505
1090
  return {
506
1091
  ok,
507
1092
  runId,
@@ -509,20 +1094,25 @@ export class Runtime {
509
1094
  status,
510
1095
  response: { text: outcome.summary },
511
1096
  ...(outcome.plan ? { plan: outcome.plan } : {}),
1097
+ ...(gaps.length ? { capabilityGaps: capabilityReportFrom(required, gaps, (id) => this._capabilities.providersOf(id)) } : {}),
512
1098
  execution: { ...(execId ? { id: execId } : {}), observations: outcome.observations, planHistory: outcome.planHistory },
513
1099
  ...(outcome.clarification ? { clarification: { question: outcome.clarification } } : {}),
514
- artifacts: [],
1100
+ // Phase 3.4: an agent step's admitted findings carry evidence artifacts. With agents disabled
1101
+ // there are no agent observations, so this is the same empty array as 2.6.0.
1102
+ artifacts: outcome.observations.flatMap((o) => o.artifacts ?? []),
515
1103
  };
516
1104
  }
517
1105
  resultFromExecution(exec, resolution, runId) {
518
1106
  const ok = exec.status !== 'failed' && exec.status !== 'cancelled';
519
- const status = exec.status === 'completed' ? 'completed' : exec.status === 'failed' || exec.status === 'cancelled' ? 'failed' : exec.status === 'waiting_for_clarification' ? 'waiting_for_clarification' : exec.status === 'waiting_for_input' ? 'waiting_for_approval' : 'completed';
1107
+ // waiting_for_input carries a pending kind a budget pause maps to waiting_for_budget, else approval.
1108
+ const waitingInput = exec.pending?.kind === 'budget' ? 'waiting_for_budget' : 'waiting_for_approval';
1109
+ const status = exec.status === 'completed' ? 'completed' : exec.status === 'failed' || exec.status === 'cancelled' ? 'failed' : exec.status === 'waiting_for_clarification' ? 'waiting_for_clarification' : exec.status === 'waiting_for_input' ? waitingInput : 'completed';
520
1110
  return {
521
1111
  ok,
522
1112
  runId,
523
1113
  mode: resolution,
524
1114
  status,
525
- response: { text: `execution ${exec.id} (${exec.status})` },
1115
+ response: { text: exec.pending?.kind === 'budget' && exec.pending.budget ? `execution ${exec.id}: ${exec.pending.budget.completedSteps}/${exec.pending.budget.totalSteps} step(s) done — raise the budget and resume` : `execution ${exec.id} (${exec.status})` },
526
1116
  ...(exec.plan ? { plan: exec.plan } : {}),
527
1117
  execution: { id: exec.id, observations: exec.observations, planHistory: exec.plan ? [exec.plan] : [] },
528
1118
  ...(exec.pending?.kind === 'clarification' && exec.pending.question ? { clarification: { question: exec.pending.question } } : {}),
@@ -612,21 +1202,37 @@ export class Runtime {
612
1202
  const checkpoint = exec.checkpoints[exec.checkpoints.length - 1];
613
1203
  const recon = checkpoint ? reconcile(checkpoint, this.workspaceRoot, this.skills()) : { drifted: true, reasons: ['no checkpoint'] };
614
1204
  const goal = exec.pending?.kind === 'clarification' && opts.clarificationAnswer ? `${exec.goal}\n\nClarification: ${opts.clarificationAnswer}` : exec.goal;
615
- const policy = resolvePolicy({ mode: 'orchestrate', overrides: { autonomy: 'autonomous', approval: 'none', ...(this.configPermissions ? { permissions: this.configPermissions } : {}) }, settings: this.settingsValue });
616
- // We only reach here for an approval-pending execution if it was explicitly approved.
1205
+ // Re-read the budget from env so raising AI_MAX_CALLS before resuming actually takes effect (Phase 22).
1206
+ const policy = resolvePolicy({ mode: 'orchestrate', overrides: { autonomy: 'autonomous', approval: 'none', ...(this.configPermissions ? { permissions: this.configPermissions } : {}) }, settings: this.settingsValue, env: { maxCostUsd: numFromEnv(this.env, 'AI_MAX_COST_USD'), maxCalls: numFromEnv(this.env, 'AI_MAX_CALLS') } });
1207
+ // A resumed run is never re-DERIVED — no surprise second model call on work already approved and
1208
+ // mid-flight — but it does get the FREE post-plan check: a grant may have changed since it started.
1209
+ const planning = this.settingsValue.capabilities?.planning
1210
+ ? { derived: [], gaps: [], block: '', permissions: this.resolvedPermissions({ permissions: policy.permissions }) }
1211
+ : undefined;
1212
+ // Continue (skip completed steps) for an approved plan, a budget pause, or partial progress.
617
1213
  const approvedNow = exec.pending?.kind === 'approval';
618
- const canContinue = !recon.drifted && !!exec.plan && (approvedNow || (exec.completedSteps.length > 0 && !exec.pending));
1214
+ const budgetPaused = exec.pending?.kind === 'budget';
1215
+ const canContinue = !recon.drifted && !!exec.plan && (approvedNow || budgetPaused || (exec.completedSteps.length > 0 && !exec.pending));
619
1216
  let outcome;
620
1217
  if (canContinue && exec.plan) {
621
- // No drift continue: execute the (approved / partially-done) plan, skipping completed steps.
622
- const exe = await this.withHeartbeat(exec.id, () => executePlan(exec.plan, { ...this.orchestrateRunners(), maxParallelSteps: policy.maxParallelSteps ?? 2, skip: new Set(exec.completedSteps), ...(opts.signal ? { signal: opts.signal } : {}) }));
623
- outcome = { status: exe.ok ? 'completed' : 'failed', plan: exe.plan, planHistory: [exe.plan], observations: exe.observations, summary: exe.ok ? `resumed and completed "${exec.goal}"` : `resume did not complete "${exec.goal}"` };
1218
+ // Continue the plan, skipping completed steps. The call budget is ALWAYS enforced on the continue
1219
+ // path (whether the pause was approval, budget, or partial-progress) so an approved-but-over-budget
1220
+ // plan pauses for budget rather than silently exceeding it; a raised budget re-applies here.
1221
+ const exe = await this.withHeartbeat(exec.id, () => executePlan(exec.plan, { ...this.orchestrateRunners(policy, opts.signal, { executionId: exec.id, planVersion: exec.planVersion }), maxParallelSteps: policy.maxParallelSteps ?? 2, ...(policy.limits ? { limits: policy.limits } : {}), skip: new Set(exec.completedSteps), ...(policy.maxCalls !== undefined ? { callBudget: policy.maxCalls } : {}), ...(opts.signal ? { signal: opts.signal } : {}) }));
1222
+ if (exe.stoppedForBudget) {
1223
+ const done = exe.plan.steps.filter((s) => s.status === 'succeeded').length;
1224
+ const total = exe.plan.steps.length;
1225
+ outcome = { status: 'waiting_for_budget', plan: exe.plan, planHistory: [exe.plan], observations: exe.observations, budget: { estCalls: foldCalls(exe.plan.steps, policy.maxCalls), maxCalls: policy.maxCalls, completedSteps: done, totalSteps: total }, summary: `Ran ${done} of ${total} step(s) within the ${policy.maxCalls}-call budget. Raise the budget (AI_MAX_CALLS) and resume to continue.` };
1226
+ }
1227
+ else {
1228
+ outcome = { status: exe.ok ? 'completed' : 'failed', plan: exe.plan, planHistory: [exe.plan], observations: exe.observations, summary: exe.ok ? `resumed and completed "${exec.goal}"` : `resume did not complete "${exec.goal}"` };
1229
+ }
624
1230
  }
625
1231
  else {
626
- // Drift, or a pending clarification answer, or no partial progress → replan from the goal.
627
- // Re-resolve routing so env/config excludes (and learned prefer) still apply to the replan's
628
- // planning-model call a hard exclude must not be dropped just because we're resuming.
629
- outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput('orchestrate', goal, policy, this.effectiveRouting())));
1232
+ // Drift, or a pending clarification answer, or no partial progress → replan from the goal. Re-resolve
1233
+ // routing so env/config excludes (and learned prefer) still apply. A budget-paused replan stays
1234
+ // partial so it keeps running-what-fits instead of reverting to notify-and-wait.
1235
+ outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput('orchestrate', goal, policy, this.effectiveRouting(), budgetPaused)));
630
1236
  }
631
1237
  exec.status = this.execStatus(outcome.status);
632
1238
  exec.observations = [...exec.observations, ...outcome.observations];
@@ -635,27 +1241,92 @@ export class Runtime {
635
1241
  exec.planVersion = outcome.plan.version;
636
1242
  exec.completedSteps = outcome.plan.steps.filter((s) => s.status === 'succeeded').map((s) => s.id);
637
1243
  }
638
- delete exec.pending;
1244
+ // Preserve a fresh budget pause; otherwise the pending state is resolved.
1245
+ if (outcome.status === 'waiting_for_budget' && outcome.budget)
1246
+ exec.pending = { kind: 'budget', budget: outcome.budget };
1247
+ else
1248
+ delete exec.pending;
639
1249
  exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps }));
640
1250
  this._executions.commit(exec);
641
- return this.mapOutcome(outcome, resolution, runId, exec.id);
1251
+ return this.mapOutcome(outcome, resolution, runId, exec.id, planning);
642
1252
  }
643
1253
  finally {
644
1254
  this._executions.release(id);
645
1255
  }
646
1256
  }
647
- orchestrateRunners() {
1257
+ /**
1258
+ * THE single source of step runners, used by both the fresh-run path and the resume-continue path.
1259
+ * They used to drift: resume built its own pair with no agent runner, so a persisted plan containing
1260
+ * an agent step would have failed every one of those steps.
1261
+ */
1262
+ orchestrateRunners(policy, signal, provenance) {
1263
+ const envelopes = policy ? this.agentEnvelopes(policy) : [];
1264
+ const byId = new Map(envelopes.map((e) => [e.agentId, e]));
648
1265
  return {
649
1266
  runSkill: (skillId, input) => this.runSkill(skillId, input).then((o) => ({ result: o.result, validation: o.validation })),
650
1267
  runTool: (toolId, input) => this.runTool(toolId, input),
1268
+ ...(envelopes.length
1269
+ ? {
1270
+ agents: envelopes,
1271
+ reserve: (step) => (step.agent ? byId.get(step.agent)?.reservation ?? 1 : 0),
1272
+ runAgent: async (step, ctx) => {
1273
+ const envelope = byId.get(step.agent ?? '');
1274
+ const definition = this.agentDefs.get(step.agent ?? '');
1275
+ if (!envelope || !definition)
1276
+ return { stepId: step.id, ok: false, code: 'agent-not-enabled', error: `no agent definition '${step.agent ?? ''}'` };
1277
+ const innerSkills = this._skills.list().filter((sk) => envelope.skills.includes(sk.id));
1278
+ const out = await runAgentTask(step, envelope, definition, {
1279
+ ai: this._ai,
1280
+ clock: this.clock,
1281
+ skills: innerSkills,
1282
+ runSkill: (id, i, o) => this.runSkill(id, i, o).then((r) => ({ result: r.result, validation: r.validation })),
1283
+ runTool: (id, i, o) => this.runTool(id, i, o),
1284
+ putArtifact: (content, source) => {
1285
+ // The `enabled` check must precede `put`: in stateless mode `put` still returns a
1286
+ // well-formed ref with a real checksum whose `resolve()` is permanently undefined —
1287
+ // a dangling evidence ref threaded as if it were real.
1288
+ if (!this._artifacts.enabled)
1289
+ return { unavailable: true };
1290
+ try {
1291
+ return { ref: this._artifacts.put({ type: 'agent-evidence', source, content }), unavailable: false };
1292
+ }
1293
+ catch {
1294
+ return { unavailable: true };
1295
+ }
1296
+ },
1297
+ emit: (e) => this.emitter.emit({ type: e.type, agentTaskId: e.record.agentTaskId, agentId: e.record.agentId, stepId: e.record.stepId, state: e.record.state, innerSteps: e.record.innerSteps, callsUsed: e.record.callsUsed, toolCallsUsed: e.record.toolCallsUsed, findings: e.record.findings.length }),
1298
+ ...(ctx.signal ?? signal ? { parentSignal: ctx.signal ?? signal } : {}),
1299
+ ...(provenance ? { provenance } : { provenance: { planVersion: 1 } }),
1300
+ abortReason: () => {
1301
+ for (const live of this.liveRuns.values())
1302
+ if (live.controller.signal.aborted && live.reason)
1303
+ return live.reason;
1304
+ return undefined;
1305
+ },
1306
+ });
1307
+ return out.observation;
1308
+ },
1309
+ }
1310
+ : {}),
651
1311
  };
652
1312
  }
1313
+ /** Abort a run that is in flight, recording WHY so a task can tell a pause from a cancellation. */
1314
+ abortLiveRun(id, reason) {
1315
+ const live = this.liveRuns.get(id);
1316
+ if (!live)
1317
+ return;
1318
+ live.reason = reason;
1319
+ live.controller.abort();
1320
+ }
653
1321
  /** Mark an execution paused (it can be resumed later). */
654
1322
  pauseExecution(id) {
655
1323
  const exec = this._executions.get(id);
656
1324
  if (!exec || TERMINAL.has(exec.status))
657
1325
  return false;
658
1326
  exec.status = 'paused';
1327
+ // Phase 3.4: abort whatever is actually in flight, so a pause stops live agent work instead of only
1328
+ // flipping a stored status. A no-op with agents disabled — `liveRuns` is empty then.
1329
+ this.abortLiveRun(id, 'pause');
659
1330
  this._executions.save(exec);
660
1331
  this._executions.release(id);
661
1332
  return true;
@@ -666,6 +1337,7 @@ export class Runtime {
666
1337
  if (!exec || TERMINAL.has(exec.status))
667
1338
  return false;
668
1339
  exec.status = 'cancelled';
1340
+ this.abortLiveRun(id, 'parent-cancel'); // parent cancelled ⇒ every live agent controller aborted
669
1341
  this._executions.save(exec);
670
1342
  this._executions.release(id);
671
1343
  return true;
@@ -692,7 +1364,7 @@ export class Runtime {
692
1364
  * `capture` is false on a dry run — retrieval is read-only, but writing a fact is a mutation the dry
693
1365
  * run must not perform.
694
1366
  */
695
- applyMemory(text, capture = true) {
1367
+ async applyMemory(text, capture = true) {
696
1368
  if (!this.memoryEnabled())
697
1369
  return undefined;
698
1370
  try {
@@ -704,7 +1376,8 @@ export class Runtime {
704
1376
  trace.captured = { id: rec.id, scope: rec.scope };
705
1377
  }
706
1378
  }
707
- trace.retrieved = this._memory.search(text, { limit: 3 }).map((h) => h.text);
1379
+ // Semantic retrieval when an embedder is configured, else BM25 (searchSemantic degrades internally).
1380
+ trace.retrieved = (await this._memory.searchSemantic(text, { limit: 3 })).map((h) => h.text);
708
1381
  if (trace.retrieved.length === 0 && !trace.captured)
709
1382
  return undefined;
710
1383
  return trace;