ai-runtime-engine 1.3.0 → 2.8.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 +638 -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/roles.d.ts +36 -0
  11. package/dist/agents/roles.js +44 -0
  12. package/dist/agents/synthesize.d.ts +44 -0
  13. package/dist/agents/synthesize.js +60 -0
  14. package/dist/agents/task.d.ts +112 -0
  15. package/dist/agents/task.js +48 -0
  16. package/dist/agents/worker.d.ts +91 -0
  17. package/dist/agents/worker.js +377 -0
  18. package/dist/capabilities/capability.d.ts +117 -0
  19. package/dist/capabilities/capability.js +66 -0
  20. package/dist/capabilities/registry.d.ts +139 -0
  21. package/dist/capabilities/registry.js +413 -0
  22. package/dist/capabilities/vocabulary.d.ts +32 -0
  23. package/dist/capabilities/vocabulary.js +34 -0
  24. package/dist/cli/cli.js +55 -4
  25. package/dist/cli/commands/cleanup.js +29 -27
  26. package/dist/cli/commands/doctor.d.ts +14 -0
  27. package/dist/cli/commands/doctor.js +38 -8
  28. package/dist/cli/commands/executions.js +34 -25
  29. package/dist/cli/commands/info.d.ts +1 -0
  30. package/dist/cli/commands/info.js +11 -9
  31. package/dist/cli/commands/init.js +19 -0
  32. package/dist/cli/commands/inspect.d.ts +40 -1
  33. package/dist/cli/commands/inspect.js +157 -2
  34. package/dist/cli/commands/mcp.d.ts +45 -0
  35. package/dist/cli/commands/mcp.js +148 -0
  36. package/dist/cli/commands/route.js +21 -0
  37. package/dist/cli/commands/run.d.ts +1 -0
  38. package/dist/cli/commands/run.js +21 -2
  39. package/dist/cli/commands/skills.d.ts +2 -0
  40. package/dist/cli/commands/skills.js +29 -7
  41. package/dist/cli/interactive/ansi.d.ts +41 -0
  42. package/dist/cli/interactive/ansi.js +43 -0
  43. package/dist/cli/interactive/complete.d.ts +10 -0
  44. package/dist/cli/interactive/complete.js +19 -0
  45. package/dist/cli/interactive/lanes.d.ts +69 -0
  46. package/dist/cli/interactive/lanes.js +181 -0
  47. package/dist/cli/interactive/repl.d.ts +3 -0
  48. package/dist/cli/interactive/repl.js +91 -13
  49. package/dist/cli/interactive/session.d.ts +8 -0
  50. package/dist/cli/interactive/session.js +73 -2
  51. package/dist/cli/render.d.ts +7 -0
  52. package/dist/cli/render.js +10 -0
  53. package/dist/cli/runtimeSession.d.ts +11 -0
  54. package/dist/cli/runtimeSession.js +17 -0
  55. package/dist/config/defaults.d.ts +3 -1
  56. package/dist/config/defaults.js +2 -0
  57. package/dist/config/schema.d.ts +1 -0
  58. package/dist/config/schema.js +2 -2
  59. package/dist/context/lossVerifier.d.ts +24 -0
  60. package/dist/context/lossVerifier.js +45 -0
  61. package/dist/context/summarize.d.ts +19 -0
  62. package/dist/context/summarize.js +53 -0
  63. package/dist/core/fallback/fallback.d.ts +5 -0
  64. package/dist/core/fallback/fallback.js +3 -1
  65. package/dist/core/router/router.d.ts +3 -0
  66. package/dist/core/router/router.js +1 -0
  67. package/dist/executions/agentTasks.d.ts +627 -0
  68. package/dist/executions/agentTasks.js +149 -0
  69. package/dist/executions/checkpoint.d.ts +5 -1
  70. package/dist/executions/checkpoint.js +13 -1
  71. package/dist/executions/execution.d.ts +36 -2
  72. package/dist/executions/store.d.ts +37 -0
  73. package/dist/executions/store.js +33 -0
  74. package/dist/generation/generateAdapter.d.ts +14 -0
  75. package/dist/generation/generateAdapter.js +38 -0
  76. package/dist/generation/generateSkill.d.ts +26 -0
  77. package/dist/generation/generateSkill.js +51 -0
  78. package/dist/index.d.ts +47 -4
  79. package/dist/index.js +33 -2
  80. package/dist/mcp/client.d.ts +70 -0
  81. package/dist/mcp/client.js +221 -0
  82. package/dist/mcp/manager.d.ts +151 -0
  83. package/dist/mcp/manager.js +493 -0
  84. package/dist/mcp/protocol.d.ts +216 -0
  85. package/dist/mcp/protocol.js +149 -0
  86. package/dist/mcp/toolAdapter.d.ts +44 -0
  87. package/dist/mcp/toolAdapter.js +94 -0
  88. package/dist/mcp/transport.d.ts +109 -0
  89. package/dist/mcp/transport.js +383 -0
  90. package/dist/memory/embedders/hash.d.ts +12 -0
  91. package/dist/memory/embedders/hash.js +31 -0
  92. package/dist/memory/embedders/http.d.ts +25 -0
  93. package/dist/memory/embedders/http.js +48 -0
  94. package/dist/memory/memory.d.ts +19 -2
  95. package/dist/memory/memory.js +75 -11
  96. package/dist/memory/semantic.d.ts +17 -0
  97. package/dist/memory/semantic.js +29 -0
  98. package/dist/orchestration/budget.d.ts +30 -0
  99. package/dist/orchestration/budget.js +40 -0
  100. package/dist/orchestration/executor.d.ts +64 -1
  101. package/dist/orchestration/executor.js +104 -7
  102. package/dist/orchestration/orchestrator.d.ts +35 -1
  103. package/dist/orchestration/orchestrator.js +106 -8
  104. package/dist/orchestration/plan.d.ts +15 -1
  105. package/dist/orchestration/plan.js +23 -4
  106. package/dist/orchestration/planner.d.ts +19 -1
  107. package/dist/orchestration/planner.js +25 -5
  108. package/dist/plugin/ai.d.ts +4 -0
  109. package/dist/plugin/ai.js +9 -0
  110. package/dist/runtime/config.js +50 -6
  111. package/dist/runtime/intent/aiClassifier.d.ts +19 -0
  112. package/dist/runtime/intent/aiClassifier.js +74 -0
  113. package/dist/runtime/models/modelProfile.d.ts +61 -0
  114. package/dist/runtime/models/modelProfile.js +139 -0
  115. package/dist/runtime/planning/deriveCapabilities.d.ts +95 -0
  116. package/dist/runtime/planning/deriveCapabilities.js +146 -0
  117. package/dist/runtime/policy.d.ts +10 -0
  118. package/dist/runtime/policy.js +9 -2
  119. package/dist/runtime/runtime.d.ts +233 -0
  120. package/dist/runtime/runtime.js +1042 -60
  121. package/dist/runtime/types.d.ts +88 -2
  122. package/dist/security/redact.js +22 -10
  123. package/dist/skills/manifest.d.ts +3 -0
  124. package/dist/skills/manifest.js +24 -0
  125. package/dist/skills/registry.d.ts +16 -1
  126. package/dist/skills/registry.js +21 -1
  127. package/dist/skills/skill.d.ts +6 -1
  128. package/dist/store/area.d.ts +15 -1
  129. package/dist/store/area.js +19 -8
  130. package/dist/store/crypto.d.ts +21 -0
  131. package/dist/store/crypto.js +49 -0
  132. package/dist/store/paths.d.ts +5 -1
  133. package/dist/store/paths.js +6 -0
  134. package/dist/store/store.d.ts +15 -3
  135. package/dist/store/store.js +28 -7
  136. package/dist/telemetry/sinks/otlp.d.ts +31 -0
  137. package/dist/telemetry/sinks/otlp.js +76 -0
  138. package/dist/tools/builtins/filesystem.js +1 -0
  139. package/dist/tools/builtins/git.js +1 -0
  140. package/dist/tools/builtins/shell.js +1 -0
  141. package/dist/tools/permissions.d.ts +28 -0
  142. package/dist/tools/permissions.js +72 -0
  143. package/dist/tools/registry.d.ts +18 -2
  144. package/dist/tools/registry.js +22 -2
  145. package/dist/tools/tool.d.ts +4 -0
  146. package/dist/types.d.ts +5 -1
  147. package/dist/util/flatten.d.ts +11 -0
  148. package/dist/util/flatten.js +18 -0
  149. package/dist/util/hash.d.ts +19 -0
  150. package/dist/util/hash.js +39 -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,21 @@ 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 { wrapUntrusted } from '../tools/untrusted.js';
35
+ import { redact } from '../security/redact.js';
36
+ import { McpManager } from '../mcp/manager.js';
37
+ import { mcpTool, mcpToolId } from '../mcp/toolAdapter.js';
38
+ import { StaticMcpSource } from '../mcp/mcp.js';
39
+ import { ActionCapabilityRegistry, capabilityReportFrom } from '../capabilities/registry.js';
40
+ import { namespacedId } from '../capabilities/capability.js';
41
+ import { candidatesFrom, deriveCapabilities } from './planning/deriveCapabilities.js';
27
42
  import { filesystemTool } from '../tools/builtins/filesystem.js';
28
43
  import { shellTool } from '../tools/builtins/shell.js';
29
44
  import { gitTool } from '../tools/builtins/git.js';
@@ -32,15 +47,52 @@ import { fileAnalyzerSkill } from '../skills/builtins/fileAnalyzer.js';
32
47
  import { repositoryAnalyzerSkill } from '../skills/builtins/repositoryAnalyzer.js';
33
48
  import { discoverSkills, scanRepoForSkills, loadSkillSource, loadSkillPackage } from '../skills/discovery.js';
34
49
  import { orchestrate } from '../orchestration/orchestrator.js';
50
+ import { foldCalls } from '../orchestration/budget.js';
51
+ import { narrowEnvelope } from '../agents/envelope.js';
52
+ import { runAgentTask } from '../agents/worker.js';
35
53
  import { executePlan } from '../orchestration/executor.js';
36
54
  import { ExecutionStore } from '../executions/store.js';
37
55
  import { RESUMABLE, TERMINAL } from '../executions/execution.js';
38
56
  import { captureCheckpoint, reconcile } from '../executions/checkpoint.js';
57
+ import { AGENT_TERMINAL, AGENT_RESUMABLE } from '../agents/task.js';
58
+ import { parseAgentTasks } from '../executions/agentTasks.js';
59
+ import { stepIdentity } from '../agents/worker.js';
60
+ import { hashOf } from '../util/hash.js';
61
+ /** Checkpoints kept per execution. Only the newest is ever read; the rest are audit trail, and the
62
+ * record is rewritten on every commit, so this is a file-size bound (Phase 3.5). */
63
+ const CHECKPOINTS_MAX = 5;
64
+ /** Findings carried into a replan prompt. Bounded because they are rendered into a model call. */
65
+ const FINDINGS_BRIEF_MAX = 10;
66
+ /**
67
+ * What an observation looks like ON DISK (Phase 3.5).
68
+ *
69
+ * Two jobs, both at the persistence boundary. REDACTION: an observation carries tool output, which is
70
+ * arbitrary text from outside the process — a tool that prints a token would otherwise write it into
71
+ * the execution file verbatim, where it survives every later read. Every other egress (logs, telemetry,
72
+ * CLI) already redacts; the store did not, and Phase 3.5 writes far more of this content far more often.
73
+ * SLIMMING: findings already live on the agent task record in the same file, so keeping only their ids
74
+ * here stops every finding being stored twice and rewritten on every commit.
75
+ *
76
+ * The live observation is untouched — callers still receive full findings from the run itself.
77
+ */
78
+ function persistableObservation(obs) {
79
+ const data = obs.data;
80
+ const slimmed = data && Array.isArray(data.findings)
81
+ ? { ...obs, data: { ...(({ findings: _drop, ...rest }) => rest)(data), findingIds: data.findings.map((f) => f?.id).filter((id) => typeof id === 'string') } }
82
+ : obs;
83
+ return redact(slimmed);
84
+ }
39
85
  import { ArtifactStore } from '../artifacts/artifacts.js';
40
86
  import { compare } from '../comparison/comparator.js';
41
87
  import { renderComparison } from '../comparison/render.js';
42
88
  import { LearningStore } from '../learning/learningStore.js';
43
- import { resolveRoutingPrefs, withPreferredProviders } from './routing.js';
89
+ import { resolveRoutingPrefs, withPreferredProviders, mergeRouting } from './routing.js';
90
+ import { loadModelProfile, resolveModelDirective, directiveToOverrides } from './models/modelProfile.js';
91
+ import { HashEmbedder } from '../memory/embedders/hash.js';
92
+ import { HttpEmbedder } from '../memory/embedders/http.js';
93
+ import { Credential } from '../security/credentials.js';
94
+ import { makeCodec, deriveKey } from '../store/crypto.js';
95
+ import { AIError } from '../core/fallback/errors.js';
44
96
  import { tokenize } from '../memory/bm25.js';
45
97
  let runCounter = 0;
46
98
  function nextRunId() {
@@ -54,6 +106,23 @@ function numFromEnv(env, key) {
54
106
  const n = Number(raw);
55
107
  return Number.isFinite(n) ? n : undefined;
56
108
  }
109
+ /**
110
+ * Clamp a source-controlled string before it is rendered into a MODEL PROMPT (invariant 15/16). Ids,
111
+ * provider ids, and tool lists come from tools/skills/packs — and from MCP servers in Phase 3.2 — so a
112
+ * rendered catalog row must never carry newlines, control characters, or unbounded text that could forge
113
+ * additional rows or smuggle instructions into the planner prompt.
114
+ */
115
+ function promptSafe(raw, max = 80) {
116
+ return flattenClamp(raw, max);
117
+ }
118
+ /** Merge gap lists first-wins by capabilityId: pre-pass, then post-plan check, then plan validation. */
119
+ function mergeGapsById(list) {
120
+ const out = new Map();
121
+ for (const g of list)
122
+ if (!out.has(g.capabilityId))
123
+ out.set(g.capabilityId, g);
124
+ return [...out.values()];
125
+ }
57
126
  export class Runtime {
58
127
  _ai;
59
128
  settingsValue;
@@ -65,20 +134,46 @@ export class Runtime {
65
134
  _memory;
66
135
  _conversations;
67
136
  estimator = new TokenEstimator();
137
+ /** ACTION-capability registry (Phase 3.1). Declared BEFORE the tool/skill registries so its
138
+ * ingest listeners are attached in time to capture the builtin registrations below. */
139
+ _capabilities = new ActionCapabilityRegistry();
140
+ /** MCP server lifecycle (Phase 3.2). Connection is LAZY: nothing is contacted until first use. */
141
+ _mcp;
142
+ mcpConnected = false;
143
+ mcpToolIds = new Set();
144
+ mcpWarnings = [];
68
145
  _tools = new ToolRegistry();
69
146
  _skills = new SkillRegistry();
70
147
  _executions;
71
148
  _artifacts;
72
149
  _learning;
73
150
  workspaceRoot;
151
+ _modelProfile;
74
152
  configPermissions;
153
+ /** The INJECTED clock. Agent deadlines are measured against it, never a real timer, so an offline
154
+ * test with a fake clock stays deterministic. */
155
+ clock;
156
+ /** Multi-agent core (Phase 3.4). False ⇒ every agent path is inert. */
157
+ agentsEnabled;
158
+ agentDefs = new Map();
159
+ /** Live runs, so a pause/cancel can abort the agent tasks actually in flight. Only ever populated
160
+ * when agents are enabled, so pause/cancel are unchanged with the flag off. */
161
+ liveRuns = new Map();
162
+ /** The config file's `budget:` ceilings, kept only so the 3.3 pre-pass can decline a model call. */
163
+ _configBudget;
75
164
  approval;
76
165
  _configFile;
77
166
  _loadedSkillSources = [];
78
167
  constructor(config = { router: { providers: [] } }, options = {}, workspace) {
79
- this._ai = new AI(config.router, options.ai);
168
+ // Phase 19: fold the config's per-provider concurrency caps into the AI (shared limiter across runs).
169
+ const perProvider = config.runtime?.concurrency?.perProvider;
170
+ const aiOptions = { ...options.ai, ...(perProvider ? { concurrency: { ...(options.ai?.concurrency ?? {}), perProvider } } : {}) };
171
+ this._ai = new AI(config.router, aiOptions);
80
172
  this.settingsValue = config.runtime ?? {};
81
- this.classifier = options.classifier ?? new HeuristicIntentClassifier();
173
+ // Base classifier: a caller-supplied one wins, else the deterministic heuristic. When
174
+ // `runtime.intent.aiFallback` is on, wrap it so an *ambiguous* heuristic result consults one model call.
175
+ const baseClassifier = options.classifier ?? new HeuristicIntentClassifier();
176
+ this.classifier = this.settingsValue.intent?.aiFallback ? new AIIntentClassifier(this._ai, baseClassifier) : baseClassifier;
82
177
  this.env = options.ai?.env ?? process.env;
83
178
  if (workspace)
84
179
  this.workspace = workspace;
@@ -87,19 +182,64 @@ export class Runtime {
87
182
  this.approval = options.host.approval;
88
183
  if (options.configFile)
89
184
  this._configFile = options.configFile;
185
+ const cfgBudget = config.router.budget?.maxCalls ?? config.router.budget?.maxCostUsd;
186
+ if (cfgBudget !== undefined)
187
+ this._configBudget = cfgBudget;
90
188
  // Local store (conversations + memory). Disabled → NullAreas (stateless mode).
91
189
  const clock = options.ai?.clock ?? systemClock;
190
+ this.clock = clock;
191
+ this.agentsEnabled = config.runtime?.agents?.enabled === true;
192
+ for (const [id, def] of Object.entries(config.runtime?.agents?.definitions ?? {}))
193
+ this.agentDefs.set(id, def);
92
194
  const root = workspace?.root ?? resolveCwd(options.workspace ?? options.host?.workspace);
93
195
  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);
196
+ const persistenceEnabled = options.persistence !== 'disabled';
197
+ const cipher = persistenceEnabled ? this.buildCipher() : undefined; // throws a hard CONFIG error if encrypt:true but no key
198
+ 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 } : {}) });
199
+ // MCP (Phase 3.2): construct always (cheap, connects nothing); a discovered tool becomes an ordinary
200
+ // Runtime Tool so runTool's permission/approval/signal machinery applies with no parallel path.
201
+ this._mcp = new McpManager({
202
+ ...(config.mcp?.servers ? { servers: config.mcp.servers } : {}),
203
+ ...(persistenceEnabled ? { store: this._store.mcp() } : {}),
204
+ env: this.env,
205
+ clock,
206
+ ...(options.ai?.fetchImpl ? { fetchImpl: options.ai.fetchImpl } : {}),
207
+ onToolsDiscovered: (serverId, tools) => this.registerMcpTools(serverId, tools),
208
+ onServerRemoved: (serverId) => this.deregisterMcpTools(serverId),
209
+ });
210
+ this._modelProfile = loadModelProfile(root); // models.md (per-mode/task routing); undefined if absent
211
+ this._memory = new MemoryStore(this._store, clock, this.buildEmbedder(options.ai?.fetchImpl));
96
212
  this._conversations = new ConversationStore(this._store.conversations(), clock);
97
213
  this._executions = new ExecutionStore(this._store.executions(), { clock });
98
214
  this._artifacts = new ArtifactStore(this._store.artifacts(), clock);
99
215
  this._learning = new LearningStore(this._store.learning());
100
216
  // Built-in tools + generic skills (QA-neutral). Users add more via registerTool/registerSkill.
101
- this._tools.register(filesystemTool).register(shellTool).register(gitTool);
217
+ // Action capabilities: wire live accessors + ingest listeners BEFORE any registration, then
218
+ // register the builtins as TRUSTED (the only code allowed to claim bare curated ids).
219
+ this._capabilities
220
+ .attachAccessors({
221
+ toolIds: () => this._tools.ids(),
222
+ skills: () => this._skills.list().map((s) => ({ id: s.id, ...(s.tools ? { tools: s.tools } : {}) })),
223
+ mcpAvailability: (serverId) => this.mcpAvailability(serverId),
224
+ })
225
+ .configure(this.settingsValue.capabilities ?? {});
226
+ // An MCP-backed tool ingests under the `mcp:` namespace (its source kind drives the resolution
227
+ // tie-break and `removeBySourcePrefix`); `mcpToolIds` is populated BEFORE register for exactly this.
228
+ this._tools.attach({
229
+ onRegister: (t, trusted) => (this.mcpToolIds.has(t.id) ? this._capabilities.ingestMcpTool(t) : this._capabilities.ingestTool(t, trusted)),
230
+ onUnregister: (id) => this._capabilities.removeSource(this.mcpToolIds.has(id) ? 'mcp' : 'tool', id),
231
+ });
232
+ this._skills.attach({ onRegister: (sk) => this._capabilities.ingestSkill(sk), onUnregister: (id) => this._capabilities.removeSource('skill', id) });
233
+ this._tools.register(filesystemTool, true).register(shellTool, true).register(gitTool, true);
102
234
  this._skills.register(fileAnalyzerSkill).register(repositoryAnalyzerSkill);
235
+ // Cache-seeded MCP catalog: a previously discovered tool is plan-referenceable BEFORE (or entirely
236
+ // without) a connection — at availability `unknown`, denying honestly at call time. No I/O beyond
237
+ // the store read the manager already did, so lazy connect is preserved.
238
+ for (const serverId of this._mcp.ids()) {
239
+ const cached = this._mcp.tools(serverId);
240
+ if (cached.length)
241
+ this.registerMcpTools(serverId, cached, { cached: true });
242
+ }
103
243
  // Host integration: attach an event sink and register a credential resolver if supplied.
104
244
  const host = options.host;
105
245
  this.emitter = new RuntimeEmitter({
@@ -113,6 +253,41 @@ export class Runtime {
113
253
  if (!hasChat)
114
254
  this._ai.registerTask(CHAT_TASK);
115
255
  }
256
+ /**
257
+ * Build the memory embedder from `runtime.embedding` (Phase 14). Absent → undefined → BM25 keyword
258
+ * retrieval (the offline default). `local` = the zero-dep deterministic HashEmbedder; `openai-compatible`
259
+ * = the HTTP adapter (key by env NAME via `Credential`; a missing `baseUrl` yields no embedder → BM25).
260
+ */
261
+ buildEmbedder(fetchImpl) {
262
+ const cfg = this.settingsValue.embedding;
263
+ if (!cfg)
264
+ return undefined;
265
+ if (cfg.provider === 'local')
266
+ return new HashEmbedder();
267
+ if (!cfg.baseUrl)
268
+ return undefined; // openai-compatible needs an endpoint; no URL → stay on BM25
269
+ return new HttpEmbedder({
270
+ baseUrl: cfg.baseUrl,
271
+ model: cfg.model ?? 'text-embedding-3-small',
272
+ credential: new Credential(cfg.apiKeyEnv, this.env),
273
+ ...(fetchImpl ? { fetchImpl } : {}),
274
+ });
275
+ }
276
+ /**
277
+ * Build the store cipher from `runtime.storage` (Phase 18). Absent / `encrypt:false` → undefined → a
278
+ * plaintext store (the default). `encrypt:true` resolves the key from the env var NAMED by `keyEnv` via
279
+ * `Credential` (never a value) and derives an AES-256 key; a missing key is a hard CONFIG error (silent
280
+ * plaintext would be a lie). The key lives only inside the returned codec — never logged or serialized.
281
+ */
282
+ buildCipher() {
283
+ const cfg = this.settingsValue.storage;
284
+ if (!cfg?.encrypt)
285
+ return undefined;
286
+ const key = new Credential(cfg.keyEnv, this.env).use();
287
+ if (!key)
288
+ throw new AIError(`storage encryption is enabled but the key env var ${cfg.keyEnv} is not set`, { category: 'CONFIG' });
289
+ return makeCodec(deriveKey(key));
290
+ }
116
291
  /** Build a Runtime from a workspace: load config (.ai-runtime/config.yaml > root fallback), detect workspace. */
117
292
  static async load(options = {}) {
118
293
  const workspaceRoot = resolveCwd(options.workspace ?? options.host?.workspace);
@@ -210,6 +385,36 @@ export class Runtime {
210
385
  return base;
211
386
  return withPreferredProviders(base, this._learning.preferredProviders());
212
387
  }
388
+ /** The loaded `models.md` routing profile (per-mode/per-task model directives), or undefined if none. */
389
+ modelProfile() {
390
+ return this._modelProfile;
391
+ }
392
+ /**
393
+ * Fold the `models.md` directive for this (mode, task) into the run — a soft prefer (universal, merged
394
+ * into routing), a strategy, or a hard pin (chat path, via the request escape hatch). Precedence is
395
+ * explicit per-run > models.md: the caller's own strategy/pin win, and prefer is unioned (never
396
+ * re-admitting an exclusion). No profile / no matching directive ⇒ the request is returned unchanged.
397
+ */
398
+ applyModelProfile(req, mode) {
399
+ if (!this._modelProfile)
400
+ return req;
401
+ const directive = resolveModelDirective(this._modelProfile, { mode, ...(mode === 'chat' ? { task: 'chat' } : {}) });
402
+ if (!directive)
403
+ return req;
404
+ const o = directiveToOverrides(directive);
405
+ const next = { ...req };
406
+ if (o.strategy && next.strategy === undefined)
407
+ next.strategy = o.strategy;
408
+ if ((o.provider || o.model) && next.request?.provider === undefined && next.request?.model === undefined) {
409
+ next.request = { ...(next.request ?? {}), ...(o.provider ? { provider: o.provider } : {}), ...(o.model ? { model: o.model } : {}) };
410
+ }
411
+ if (o.routing) {
412
+ const merged = mergeRouting(next.routing, o.routing);
413
+ if (merged)
414
+ next.routing = merged;
415
+ }
416
+ return next;
417
+ }
213
418
  /** Apply free-text feedback (e.g. "that worked" / "wrong root cause") to the most recent outcome. */
214
419
  feedback(text, opts) {
215
420
  return this._learning.feedback(text, opts ?? {});
@@ -246,18 +451,206 @@ export class Runtime {
246
451
  this._skills.register(skill);
247
452
  return this;
248
453
  }
454
+ /**
455
+ * Register an agent definition (Phase 3.4). Chainable, and inert unless `runtime.agents.enabled` is
456
+ * set — registering a definition grants nothing on its own, exactly like adding an MCP server.
457
+ */
458
+ registerAgent(id, def) {
459
+ if (!/^[a-z0-9][a-z0-9_-]{0,32}$/.test(id))
460
+ throw new AIError(`invalid agent id '${id}' — use lowercase letters, digits, '_' or '-' (max 33 chars)`, { category: 'CONFIG' });
461
+ this.agentDefs.set(id, def);
462
+ return this;
463
+ }
464
+ /** The agent definition ids this runtime knows (registered or configured). */
465
+ agents() {
466
+ return [...this.agentDefs.keys()].sort();
467
+ }
468
+ /**
469
+ * This run's agent envelopes. THE ONLY call site of `narrowEnvelope` — never re-derive an inner
470
+ * catalog, a permission clamp, or a reservation anywhere else (see the header of agents/envelope.ts).
471
+ */
472
+ agentEnvelopes(policy) {
473
+ if (!this.agentsEnabled || this.agentDefs.size === 0)
474
+ return [];
475
+ const routing = this.effectiveRouting();
476
+ return [...this.agentDefs].map(([id, definition]) => narrowEnvelope({
477
+ agentId: id,
478
+ definition,
479
+ parentTools: this._tools.ids(),
480
+ parentSkills: this.skills().map((sk) => ({ id: sk.id, ...(sk.tools ? { tools: sk.tools } : {}) })),
481
+ parentPermissions: policy.permissions,
482
+ ...(routing ? { parentRouting: routing } : {}),
483
+ defaults: {
484
+ maxToolCalls: this.settingsValue.agents?.maxToolCalls ?? 25,
485
+ maxDurationMs: this.settingsValue.agents?.maxDurationMs ?? 120_000,
486
+ maxInnerCalls: this.settingsValue.agents?.maxInnerCalls ?? 3,
487
+ },
488
+ }));
489
+ }
490
+ // ── MCP (Phase 3.2) ────────────────────────────────────────────────────────
491
+ /** The MCP server manager: `list()`, `status(id)`, `test(id)`, `addServer`, `removeServer`, `setEnabled`. */
492
+ mcp() {
493
+ return this._mcp;
494
+ }
495
+ /**
496
+ * Connect every enabled MCP server, discover their tools, and register them. LAZY BY DESIGN: a one-shot
497
+ * CLI command that never touches MCP pays nothing, and a failing server records its state instead of
498
+ * breaking construction. Idempotent — the second call is a no-op.
499
+ */
500
+ async connectMcp() {
501
+ if (this.mcpConnected)
502
+ return this._mcp.list();
503
+ this.mcpConnected = true;
504
+ return this._mcp.connectAll();
505
+ }
506
+ /** Whether any MCP server is configured at all (absent ⇒ MCP is entirely inert). */
507
+ hasMcpServers() {
508
+ return this._mcp.ids().length > 0;
509
+ }
510
+ /**
511
+ * Register one server's discovered tools as ordinary Runtime Tools (+ their namespaced action
512
+ * capabilities, via the tool registry's ingest listener). A generated id NEVER shadows an existing
513
+ * non-MCP tool: the collision is skipped and reported, because silently replacing `filesystem` would be
514
+ * a privilege swap.
515
+ */
516
+ registerMcpTools(serverId, tools, opts = {}) {
517
+ const fresh = new Set();
518
+ for (const decl of tools) {
519
+ const id = mcpToolId(serverId, decl.name);
520
+ if (this._tools.get(id) && !this.mcpToolIds.has(id)) {
521
+ this.mcpWarnings.push(`MCP tool '${id}' collides with a registered tool and was skipped`);
522
+ continue;
523
+ }
524
+ // A CACHED annotation must never RELAX a gate: `readOnlyHint` would lower the permission gate, and
525
+ // `destructiveHint: false` would remove the confirmation. Until a live handshake says otherwise the
526
+ // tool is write-like AND destructive. (The adapter also re-reads live annotations at call time —
527
+ // this is the registration-side half of the same rule.)
528
+ const gated = opts.cached ? { ...decl, readOnly: false, destructive: true } : decl;
529
+ this.mcpToolIds.add(id); // before register: the capability ingest hook routes on this set
530
+ this._tools.register(mcpTool({
531
+ call: (sid, t, a, o) => this._mcp.call(sid, t, a, o),
532
+ usable: (sid) => this._mcp.usable(sid),
533
+ liveTool: (sid, t) => this._mcp.liveTool(sid, t),
534
+ }, serverId, gated));
535
+ fresh.add(id);
536
+ }
537
+ // A live discovery is the AUTHORITY on what this server offers: anything we still have registered
538
+ // that the server no longer lists (a stale cache entry, a withdrawn tool) must go, or it would keep
539
+ // reporting itself as available and get planned against.
540
+ if (!opts.cached) {
541
+ for (const id of [...this.mcpToolIds]) {
542
+ if (!id.startsWith(`${serverId}.`) || fresh.has(id))
543
+ continue;
544
+ this._tools.unregister(id);
545
+ this.mcpToolIds.delete(id);
546
+ this._capabilities.removeSource('mcp', id);
547
+ }
548
+ }
549
+ this.publishMcpSource(serverId, tools);
550
+ }
551
+ /**
552
+ * The legacy declaration path (`AI.run({ mcp: true })`) — tools DECLARED to a model, never executed
553
+ * through here. It honors the same read/full split as execution (declaring a tool the executor would
554
+ * refuse just invites the model to plan around it) and is keyed by server id, so re-publishing with an
555
+ * empty list REVOKES it.
556
+ */
557
+ publishMcpSource(serverId, tools) {
558
+ const servers = this.permissions().mcp.servers;
559
+ const grant = Object.prototype.hasOwnProperty.call(servers, serverId) ? servers[serverId] : 'off';
560
+ const offered = grant === 'full' ? tools : grant === 'read' ? tools.filter((t) => t.readOnly) : [];
561
+ // Names are emitted ALREADY namespaced: McpRegistry only prefixes a name without a dot, so a server
562
+ // tool called `other.thing` would otherwise appear to the model inside another source's namespace.
563
+ const specs = offered.map((t) => ({ name: mcpToolId(serverId, t.name), description: `${t.description} (MCP server: ${serverId})`, ...(t.inputSchema ? { parameters: t.inputSchema } : {}) }));
564
+ this._ai.registerMcpSource(new StaticMcpSource(serverId, specs));
565
+ }
566
+ /** Drop a server's tools (removal / disable). */
567
+ deregisterMcpTools(serverId) {
568
+ for (const id of [...this.mcpToolIds]) {
569
+ if (!id.startsWith(`${serverId}.`))
570
+ continue;
571
+ this._tools.unregister(id);
572
+ this.mcpToolIds.delete(id);
573
+ }
574
+ this._capabilities.removeMcpServer(serverId);
575
+ this.publishMcpSource(serverId, []); // revoke the model-facing declarations too
576
+ }
577
+ /** Map a server's lifecycle state onto capability availability (the D7 derivation table). */
578
+ mcpAvailability(serverId) {
579
+ const st = this._mcp.status(serverId);
580
+ if (!st)
581
+ return 'unavailable';
582
+ if (!st.enabled || st.state === 'disabled')
583
+ return 'disabled';
584
+ if (st.state === 'connected' || st.state === 'degraded')
585
+ return 'available';
586
+ if (st.state === 'configured' || st.state === 'connecting')
587
+ return 'unknown'; // cache-seeded / in flight
588
+ return 'unavailable'; // auth_failed | unreachable
589
+ }
590
+ /** Warnings from MCP wiring (id collisions) plus the manager's own (invalid store files, etc.). */
591
+ mcpWarningsList() {
592
+ return [...this.mcpWarnings, ...this._mcp.warningsList()];
593
+ }
594
+ /**
595
+ * Release long-lived resources — today: MCP stdio child processes. A one-shot CLI command and the REPL
596
+ * both call this on completion/exit; without it a spawned server keeps the event loop alive.
597
+ */
598
+ async close() {
599
+ await this._mcp.close();
600
+ }
249
601
  /** Skills whose required tools are all registered. */
250
602
  skills() {
251
603
  return this._skills.match({ tools: this._tools.ids() });
252
604
  }
605
+ /**
606
+ * Scaffold a skill MANIFEST from a natural-language goal (Phase 21a). Drafts a `*.skill.yaml` via the
607
+ * model, validated by `parseManifest` + tool-membership + a dry compile. **Writes nothing** — pass the
608
+ * returned manifest to `saveScaffoldedSkill` on explicit confirm.
609
+ */
610
+ async scaffoldSkill(goal) {
611
+ return generateSkillManifest({ goal, tools: this._tools.ids(), ai: this._ai });
612
+ }
613
+ /**
614
+ * Write a scaffolded manifest into the consented `.ai-runtime/skills/` directory (Phase 21a). Only call
615
+ * this after the user has confirmed the drafted manifest. Returns the written path. The id was validated
616
+ * as kebab-case at generation; it is re-checked here so a hand-built manifest can't escape the skills dir.
617
+ */
618
+ saveScaffoldedSkill(manifest) {
619
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(manifest.id))
620
+ throw new AIError(`unsafe skill id: ${JSON.stringify(manifest.id)}`, { category: 'CONFIG' });
621
+ const dir = join(this.workspaceRoot, '.ai-runtime', 'skills');
622
+ mkdirSync(dir, { recursive: true });
623
+ const path = join(dir, `${manifest.id}.skill.yaml`);
624
+ writeFileSync(path, stringifyYaml(manifest));
625
+ return path;
626
+ }
627
+ /** The ACTION-capability registry (Phase 3.1): what this runtime can DO, and who provides it. */
628
+ capabilities() {
629
+ return this._capabilities;
630
+ }
253
631
  /** The resolved tool permissions for this runtime (config grants over deny-by-default defaults). */
254
632
  permissions() {
255
633
  return resolvePermissions(this.configPermissions);
256
634
  }
635
+ /**
636
+ * The ONE resolved-permission view for a run: config grants merged with a per-run override, with `mcp`
637
+ * merged by per-server MINIMUM so an override can only ever NARROW. Extracted from `toolContext`
638
+ * (Phase 3.3 / D9) so capability resolution and tool execution can never read different permissions.
639
+ * Never re-derive this merge at a call site: a plain `resolvePermissions({ ...config, ...override })`
640
+ * would let an override widen `read` to `full` and drop sibling servers.
641
+ */
642
+ resolvedPermissions(overrides) {
643
+ const permissions = resolvePermissions({ ...this.configPermissions, ...(overrides?.permissions ?? {}) });
644
+ // `mcp` is the first NESTED permission dimension, so a shallow spread would let an override REPLACE
645
+ // the configured grants — silently widening `read` to `full` and dropping sibling servers. It merges
646
+ // by per-server minimum instead: an override can only ever narrow (invariant: permissions never widen).
647
+ permissions.mcp = { servers: clampMcpPermissions(resolvePermissions(this.configPermissions).mcp.servers, overrides?.permissions?.mcp?.servers) };
648
+ return permissions;
649
+ }
257
650
  toolContext(overrides) {
258
651
  return {
259
652
  workspaceRoot: this.workspaceRoot,
260
- permissions: resolvePermissions({ ...this.configPermissions, ...(overrides?.permissions ?? {}) }),
653
+ permissions: this.resolvedPermissions(overrides),
261
654
  ...(this.approval ? { approval: this.approval } : {}),
262
655
  ...(overrides?.signal ? { signal: overrides.signal } : {}),
263
656
  };
@@ -277,7 +670,9 @@ export class Runtime {
277
670
  const toolCtx = this.toolContext(overrides);
278
671
  const ctx = {
279
672
  input,
280
- ai: this._ai,
673
+ // Phase 3.4: an agent worker passes its METERED facade here, so a skill's own model calls are
674
+ // counted against that agent's reservation. Absent ⇒ the real AI, byte-identical.
675
+ ai: overrides?.ai ?? this._ai,
281
676
  callTool: async (toolId, toolInput) => {
282
677
  const tool = this._tools.get(toolId);
283
678
  if (!tool)
@@ -306,7 +701,7 @@ export class Runtime {
306
701
  return this.emitter.recent();
307
702
  }
308
703
  async run(input) {
309
- const req = typeof input === 'string' ? { input } : input;
704
+ let req = typeof input === 'string' ? { input } : input;
310
705
  const runId = nextRunId();
311
706
  const text = inputText(req.input);
312
707
  this.emitter.emit({ type: 'runtime.started', runId, requested: req.mode ?? 'auto' });
@@ -319,6 +714,9 @@ export class Runtime {
319
714
  });
320
715
  const resolution = modeResult.resolution;
321
716
  this.emitter.emit({ type: 'mode.selected', runId, requested: resolution.requested, selected: resolution.selected, executed: resolution.executed, source: resolution.source, confidence: resolution.confidence });
717
+ // models.md (Phase 23): fold the per-mode/per-task model directive into this run's routing/strategy/pin
718
+ // — but never over an explicit per-run choice. Downstream strategy/routing/dispatch read the result.
719
+ req = this.applyModelProfile(req, resolution.executed);
322
720
  const strategy = resolveStrategy({
323
721
  ...(req.strategy !== undefined ? { explicit: req.strategy } : {}),
324
722
  ...(this.settingsValue.defaultStrategy !== undefined ? { configDefault: this.settingsValue.defaultStrategy } : {}),
@@ -339,7 +737,7 @@ export class Runtime {
339
737
  // Orchestration modes (plan/execute/orchestrate) plan + drive skills/tools; they do not use the
340
738
  // chat memory/context path. Clarification here comes from the planner, not mode ambiguity.
341
739
  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);
740
+ return this.runOrchestration(resolution.executed, text, resolution, runId, policy, req.requestId, routing, req.partial);
343
741
  }
344
742
  // Compare mode: fan out the same task across pins and analyze — read-only, no approval gate.
345
743
  if (resolution.executed === 'compare') {
@@ -352,14 +750,23 @@ export class Runtime {
352
750
  this.emitter.emit({ type: 'clarification.requested', runId, question: clarification.question });
353
751
  // Memory: retrieve relevant facts into context, and (unless this is a dry run) capture an explicit
354
752
  // "remember …". A dry run performs zero mutations, so it retrieves but never writes.
355
- const memTrace = this.applyMemory(text, !policy.dryRun);
753
+ const memTrace = await this.applyMemory(text, !policy.dryRun);
356
754
  // Compile the model context (workspace summary + memory facts + user system) under a token budget.
357
755
  const budget = resolveContextBudget({
358
756
  ...(req.context?.maxTokens !== undefined ? { perRun: req.context.maxTokens } : {}),
359
757
  ...(this.settingsValue.context?.maxTokens !== undefined ? { config: this.settingsValue.context.maxTokens } : {}),
360
758
  ...(this.env.AI_CONTEXT_MAX_TOKENS !== undefined ? { env: this.env.AI_CONTEXT_MAX_TOKENS } : {}),
361
759
  });
362
- const compiled = compileContext(this.contextBlocks(req, context.workspace, memTrace?.retrieved), { budgetTokens: budget, estimator: this.estimator });
760
+ // Optional abstractive summarization pre-pass (Phase 16, opt-in) shrinks over-budget non-critical
761
+ // blocks before the deterministic compile. Never on a dry run (it makes model calls). Failure → originals.
762
+ // `originalBlocks` is kept so loss verification can compare against the TRUE originals (pre-summary),
763
+ // catching loss introduced by summarization as well as by the compiler.
764
+ const originalBlocks = this.contextBlocks(req, context.workspace, memTrace?.retrieved);
765
+ let blocks = originalBlocks;
766
+ if (!policy.dryRun && this.settingsValue.context?.summarize) {
767
+ blocks = await summarizeOverBudget({ ai: this._ai, blocks, budgetTokens: budget, estimator: this.estimator });
768
+ }
769
+ const compiled = compileContext(blocks, { budgetTokens: budget, estimator: this.estimator });
363
770
  const contextReport = { metrics: compiled.metrics, validation: compiled.validation };
364
771
  // Dry-run: chat's only "action" is the model call itself, so a dry run makes NO call. It reports
365
772
  // what it would send (mode, compiled-context size) and performs zero mutations.
@@ -374,6 +781,11 @@ export class Runtime {
374
781
  result.memory = memTrace;
375
782
  return result;
376
783
  }
784
+ // Optional model-based context-loss verification (Phase 16, opt-in) — appends one advisory check to
785
+ // validation.checks; never blocks, never runs on a dry run (returned above). Failure → inconclusive.
786
+ if (this.settingsValue.context?.verifyLoss) {
787
+ compiled.validation.checks.push(await verifyContextLoss({ ai: this._ai, blocks: originalBlocks, compiled }));
788
+ }
377
789
  const runRequest = buildChatRequest(req, strategy, compiled.system || undefined, routing);
378
790
  // Streaming (Phase 13): text-only, chat-mode only, never on a dry run (handled above). Each chunk is
379
791
  // emitted as a `response.delta` lifecycle event (redacted by the emitter). We also accumulate the raw
@@ -426,13 +838,29 @@ export class Runtime {
426
838
  return this._executions.list();
427
839
  }
428
840
  /** plan/execute/orchestrate/agent/debug: run the orchestrator and persist a resumable Execution. */
429
- async runOrchestration(mode, goal, resolution, runId, policy, requestId, routing) {
841
+ async runOrchestration(mode, goal, resolution, runId, policy, requestId, routing, partial) {
430
842
  const effectiveGoal = mode === 'debug' ? `Investigate and diagnose the following, gathering evidence before concluding: ${goal}` : goal;
843
+ // Phase 3.4: ONE controller per run, registered under the run id (and the execution id below) so
844
+ // pauseExecution/cancelExecution can abort agent work that is actually in flight. With agents
845
+ // disabled nothing consumes the signal, so this is inert.
846
+ const controller = new AbortController();
847
+ if (this.agentsEnabled)
848
+ this.liveRuns.set(runId, { controller });
431
849
  // plan mode and dry-run produce no durable work → run without persisting an execution.
432
850
  if (mode === 'plan' || policy.dryRun || !this._executions.enabled) {
433
- const outcome = await orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, routing));
434
- this.recordOrchestration(mode, goal, outcome);
435
- return this.mapOutcome(outcome, resolution, runId, undefined);
851
+ // Phase 3.3 (opt-in): derive + resolve this goal's capabilities BEFORE planning. Done here rather
852
+ // than in `orchestrateInput` because this scope has the resolved `policy`, so plan/execute/
853
+ // orchestrate/agent/debug all behave identically. `undefined` when the flag is off, which is the
854
+ // entire flag-off delta on this path.
855
+ const planning = await this.capabilityPlanning(effectiveGoal, policy, routing);
856
+ try {
857
+ const outcome = await orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, routing, partial, planning?.block, this.agentsEnabled ? controller.signal : undefined, { planVersion: 1 }));
858
+ this.recordOrchestration(mode, goal, outcome);
859
+ return this.mapOutcome(outcome, resolution, runId, undefined, planning);
860
+ }
861
+ finally {
862
+ this.liveRuns.delete(runId);
863
+ }
436
864
  }
437
865
  // Reserve the execution BEFORE running (persist-in-progress + claim the requestId under the store
438
866
  // lock so a concurrent retry with the same requestId dedupes to it).
@@ -449,11 +877,19 @@ export class Runtime {
449
877
  return this.resultFromExecution(reserved.exec, resolution, runId);
450
878
  }
451
879
  const exec = reserved.exec;
880
+ // Derive only AFTER the requestId dedup has admitted this as fresh work: an idempotent retry returns
881
+ // the existing execution above, and paying for a model call whose result is then discarded would
882
+ // spend budget to answer a question nobody asked.
883
+ const planning = await this.capabilityPlanning(effectiveGoal, policy, routing);
452
884
  try {
453
885
  // Renew the lease while the (possibly long) run is in flight so it can't expire mid-run.
454
- const outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput(mode, effectiveGoal, policy, routing)));
886
+ if (this.agentsEnabled)
887
+ this.liveRuns.set(exec.id, { controller });
888
+ // Phase 3.5: only when there is a store to write to — a stateless run keeps the 2.7.0 shape.
889
+ const sink = this._executions.enabled ? this.runPersistence(exec, controller) : undefined;
890
+ 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 }, sink)));
455
891
  exec.status = this.execStatus(outcome.status);
456
- exec.observations = outcome.observations;
892
+ exec.observations = outcome.observations.map(persistableObservation);
457
893
  if (outcome.plan) {
458
894
  exec.plan = outcome.plan;
459
895
  exec.planVersion = outcome.plan.version;
@@ -461,17 +897,132 @@ export class Runtime {
461
897
  }
462
898
  if (outcome.status === 'waiting_for_approval')
463
899
  exec.pending = { kind: 'approval', action: goal };
464
- else if (outcome.status === 'waiting_for_clarification' && outcome.clarification)
465
- exec.pending = { kind: 'clarification', question: outcome.clarification };
466
- exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps }));
467
- this._executions.commit(exec); // ownership-checked: never clobber a newer owner
900
+ else if (outcome.status === 'waiting_for_clarification' && outcome.clarification) {
901
+ // First-wins: if an AGENT is what is waiting, the pending slot records which one, so the answer
902
+ // is routed into that task's inner resume instead of being appended to the outer goal.
903
+ const waiter = this.electWaitingAgent(exec);
904
+ exec.pending = { kind: 'clarification', question: outcome.clarification, ...(waiter ? { agentTaskId: waiter.agentTaskId } : {}) };
905
+ }
906
+ else if (outcome.status === 'waiting_for_budget' && outcome.budget)
907
+ exec.pending = { kind: 'budget', budget: outcome.budget };
908
+ exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps, mcpTools: this.mcpToolHashes(exec.plan) }));
909
+ if (sink)
910
+ sink.finalize();
911
+ else
912
+ this._executions.commit(exec); // ownership-checked: never clobber a newer owner
468
913
  this.recordOrchestration(mode, goal, outcome);
469
- return this.mapOutcome(outcome, resolution, runId, exec.id);
914
+ return this.mapOutcome(outcome, resolution, runId, exec.id, planning);
470
915
  }
471
916
  finally {
917
+ this.liveRuns.delete(runId);
918
+ this.liveRuns.delete(exec.id);
472
919
  this._executions.release(exec.id);
473
920
  }
474
921
  }
922
+ /**
923
+ * THE mid-run persistence sink (Phase 3.5) — the only thing that writes an execution while it runs.
924
+ *
925
+ * Invariant 18 says everything needed for resume is on disk before the next wave starts, and the
926
+ * non-obvious part is WHAT that includes. Committing the plan, the completed steps and the agent
927
+ * records is not enough: the resume gate is `!recon.drifted && !!exec.plan`, and `recon` defaults to
928
+ * DRIFTED whenever `checkpoints` is empty. Since checkpoints were captured only after orchestration
929
+ * returned, a crash mid-run always drifted, always replanned, and re-ran every completed agent task —
930
+ * the exact thing this phase exists to prevent. So the sink captures a checkpoint too.
931
+ *
932
+ * Every refusal from the funnel ABORTS the run. A refused commit means another owner now owns this
933
+ * execution's fate; carrying on would call the same tools and burn the same model calls twice while
934
+ * that owner re-runs the identical steps, and every result would be discarded at the end anyway.
935
+ */
936
+ runPersistence(exec, controller) {
937
+ // Fixed for each executePlan call: its `callsUsed` is cumulative for that call, so the pool must be
938
+ // `priorCalls + thisCall`, and `priorCalls` advances only when the call ends. Adding a per-fire delta
939
+ // instead would let the total go BACKWARDS across replan iterations and over-grant the resume pool.
940
+ let priorCalls = exec.callsUsed ?? 0;
941
+ let lastCheckpointed = '';
942
+ let stopped = false;
943
+ const commit = () => {
944
+ if (stopped)
945
+ return;
946
+ const outcome = this._executions.commitProgress(exec);
947
+ if (outcome === 'ok')
948
+ return;
949
+ // Stop trying to write, and stop the run itself.
950
+ stopped = true;
951
+ this.abortLiveRun(exec.id, outcome === 'paused' ? 'pause' : 'parent-cancel');
952
+ controller.abort();
953
+ };
954
+ /** Capture only when the completed set moved: `captureCheckpoint` hashes files and shells git. */
955
+ const checkpointIfMoved = () => {
956
+ const key = exec.completedSteps.join('\u0000');
957
+ if (key === lastCheckpointed)
958
+ return;
959
+ lastCheckpointed = key;
960
+ exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps, mcpTools: this.mcpToolHashes(exec.plan) }));
961
+ if (exec.checkpoints.length > CHECKPOINTS_MAX)
962
+ exec.checkpoints.splice(0, exec.checkpoints.length - CHECKPOINTS_MAX);
963
+ };
964
+ return {
965
+ /** The plan is settled and nothing has run: a crash in wave 1 must still resume against a plan. */
966
+ onPlan: (plan) => {
967
+ exec.plan = plan;
968
+ exec.planVersion = plan.version;
969
+ exec.completedSteps = plan.steps.filter((st) => st.status === 'succeeded').map((st) => st.id);
970
+ checkpointIfMoved();
971
+ commit();
972
+ },
973
+ onProgress: (snap) => {
974
+ exec.plan = snap.plan;
975
+ exec.completedSteps = snap.plan.steps.filter((st) => st.status === 'succeeded').map((st) => st.id);
976
+ if (snap.observations.length)
977
+ exec.observations = [...exec.observations, ...snap.observations.map(persistableObservation)];
978
+ exec.callsUsed = priorCalls + snap.callsUsed;
979
+ checkpointIfMoved();
980
+ commit();
981
+ // One executePlan call is over; its spend is now part of the floor for the next one.
982
+ if (snap.at === 'plan-end')
983
+ priorCalls = exec.callsUsed ?? priorCalls;
984
+ },
985
+ onRecord: (record) => {
986
+ const tasks = (exec.agentTasks ??= []);
987
+ const at = tasks.findIndex((t) => t.agentTaskId === record.agentTaskId);
988
+ // TERMINAL is sticky for a TASK too. A late write from an aborted worker must not reopen a task
989
+ // that already completed, failed or was cancelled.
990
+ if (at >= 0 && AGENT_TERMINAL.has(tasks[at].state) && !AGENT_TERMINAL.has(record.state))
991
+ return;
992
+ // Redacted at the boundary: findings, inner observations and diagnostics all carry text that
993
+ // came from tools and models, and this record is about to become a durable file.
994
+ // The inner workspace fingerprint. The OUTER checkpoint cannot stand in for it: `planPaths`
995
+ // reads the outer plan's step inputs, so files an agent touched through its own inner steps are
996
+ // invisible to it. Captured here because the worker has no workspace root — it is deliberately
997
+ // not given one.
998
+ if (record.innerPlan) {
999
+ record.innerCheckpoint = captureCheckpoint({ root: this.workspaceRoot, plan: record.innerPlan, skills: this.skills(), completedSteps: record.innerCompletedSteps });
1000
+ }
1001
+ const snapshot = redact({ ...record });
1002
+ if (at >= 0)
1003
+ tasks[at] = snapshot;
1004
+ else
1005
+ tasks.push(snapshot);
1006
+ // A task that FINISHED means its step succeeded. Waiting for the batch commit to record that
1007
+ // leaves a window where a crash finds a `completed` record — which is not resumable, so no
1008
+ // record is offered — and a step not in `completedSteps`, so the agent is simply re-run: a
1009
+ // second paid planning call, the tools fired twice, and two records for one step.
1010
+ if (record.state === 'completed' && !exec.completedSteps.includes(record.stepId)) {
1011
+ exec.completedSteps = [...exec.completedSteps, record.stepId];
1012
+ checkpointIfMoved();
1013
+ }
1014
+ commit();
1015
+ },
1016
+ /**
1017
+ * The terminal write. It goes through the SAME funnel as every mid-run commit, so a pause or a
1018
+ * cancel that landed while the run was finishing is not overwritten by its result: the plain
1019
+ * `commit()` only refuses a live FOREIGN lease, and pause/cancel release the lease as this very
1020
+ * owner — so nothing stopped the final write from resurrecting a cancelled run as `completed`.
1021
+ */
1022
+ finalize: () => (stopped ? 'terminal' : this._executions.commitProgress(exec)),
1023
+ stopped: () => stopped,
1024
+ };
1025
+ }
475
1026
  /** Run `fn` while heartbeating the execution lease so a long run never lets the lease expire. */
476
1027
  async withHeartbeat(id, fn) {
477
1028
  const timer = setInterval(() => this._executions.heartbeat(id), this._executions.heartbeatMs);
@@ -484,7 +1035,7 @@ export class Runtime {
484
1035
  clearInterval(timer);
485
1036
  }
486
1037
  }
487
- orchestrateInput(mode, goal, policy, routing) {
1038
+ orchestrateInput(mode, goal, policy, routing, partial, requiredCapabilities, signal, provenance, sink, agentResume) {
488
1039
  return {
489
1040
  goal,
490
1041
  mode,
@@ -493,9 +1044,22 @@ export class Runtime {
493
1044
  tools: this._tools.ids(),
494
1045
  policy,
495
1046
  ...(routing ? { routing } : {}),
1047
+ ...(partial ? { partial: true } : {}),
496
1048
  ...(this.approval ? { approval: this.approval } : {}),
497
- runSkill: (id, input) => this.runSkill(id, input).then((o) => ({ result: o.result, validation: o.validation })),
498
- runTool: (id, input) => this.runTool(id, input),
1049
+ // Phase 3.1: the capability snapshot is opt-in (`runtime.capabilities.catalog`); the gap resolver is
1050
+ // always on but only fires on a validation failure, adding metadata to an unchanged error.
1051
+ ...(this.settingsValue.capabilities?.catalog ? { capabilityCatalog: this.capabilityCatalogText() } : {}),
1052
+ // Phase 3.3: the derived-requirement block (opt-in, pre-rendered + clamped). Absent ⇒ the
1053
+ // OrchestrateInput/PlannerInput objects are key-identical to 2.5.1.
1054
+ ...(requiredCapabilities ? { requiredCapabilities } : {}),
1055
+ resolveGaps: (missing) => this.resolveMissingRefs(missing, policy),
1056
+ // Phase 3.4: ONE runner source. `agents`, `runAgent` and `reserve` ride along only when agents are
1057
+ // enabled AND a definition exists, so with the flag off this object is KEY-identical to 2.6.0.
1058
+ ...this.orchestrateRunners(policy, signal, provenance, sink?.onRecord, agentResume),
1059
+ // Phase 3.5: the commit points. Present ONLY when there is a store to commit to, so a stateless
1060
+ // Runtime builds an OrchestrateInput key-identical to 2.7.0.
1061
+ ...(sink ? { onPlan: sink.onPlan, onProgress: sink.onProgress } : {}),
1062
+ ...(signal ? { signal } : {}),
499
1063
  };
500
1064
  }
501
1065
  /** Record an EXECUTED orchestration outcome for learning. plan-only, dry-run, and waiting states are
@@ -507,19 +1071,168 @@ export class Runtime {
507
1071
  return;
508
1072
  this._learning.record({ goalType: this.goalType(goal), mode, ok: outcome.status === 'completed', skills: this.planSkillRefs(outcome.plan) });
509
1073
  }
1074
+ /**
1075
+ * A capped, FENCED action-capability snapshot for the planner prompt (Phase 3.1, opt-in). Untrusted
1076
+ * sources (anything not an in-tree builtin) have their descriptions fenced, and the block is bounded so
1077
+ * a large catalog can never dominate the prompt.
1078
+ */
1079
+ capabilityCatalogText(maxEntries = 40) {
1080
+ const caps = this._capabilities.list();
1081
+ if (caps.length === 0)
1082
+ return '';
1083
+ const lines = [];
1084
+ for (const c of caps.slice(0, maxEntries)) {
1085
+ const providers = this._capabilities
1086
+ .providersOf(c.id)
1087
+ .map((p) => `${promptSafe(p.providerId)}${p.availability === 'available' ? '' : ` (${p.availability})`}`)
1088
+ .join(', ');
1089
+ lines.push(` - capability "${promptSafe(c.id)}" [${c.effects.join('/')}] → ${promptSafe(providers, 200)}`);
1090
+ }
1091
+ const more = caps.length > maxEntries ? `\n …and ${caps.length - maxEntries} more (see /capabilities)` : '';
1092
+ // Skills hidden by a missing tool — the "why can't you do this" answer the planner needs.
1093
+ const usable = new Set(this.skills().map((sk) => sk.id));
1094
+ const blocked = this._skills
1095
+ .list()
1096
+ .filter((sk) => !usable.has(sk.id))
1097
+ .map((sk) => ` - skill "${promptSafe(sk.id)}" needs tool(s) ${promptSafe((sk.tools ?? []).filter((t) => !this._tools.ids().includes(t)).join(', '), 200)} (not registered)`);
1098
+ const unavailable = blocked.length ? `\nUnavailable (do not use):\n${blocked.join('\n')}` : '';
1099
+ return `Action capabilities:\n${lines.join('\n')}${more}${unavailable}`;
1100
+ }
1101
+ /** Any call/cost ceiling declared in the config file's `budget:` block (router-level, not policy). */
1102
+ configBudget() {
1103
+ return this._configBudget;
1104
+ }
1105
+ /** Deriver candidates: registry ids + one clamped label. Built by the SAME helper the CLI uses. */
1106
+ capabilityCandidates() {
1107
+ return candidatesFrom(this._capabilities.list());
1108
+ }
1109
+ /**
1110
+ * Derive the capabilities this goal needs and resolve them (Phase 3.3, opt-in). Offline BM25 first —
1111
+ * free and deterministic — with ONE model call only when the offline rung finds nothing. ADVISORY: it
1112
+ * never blocks a run and never grants anything; a gap is metadata plus a line in the planner prompt.
1113
+ */
1114
+ async capabilityPlanning(goal, policy, routing) {
1115
+ if (!this.settingsValue.capabilities?.planning)
1116
+ return undefined;
1117
+ const permissions = this.resolvedPermissions({ permissions: policy.permissions });
1118
+ const candidates = this.capabilityCandidates();
1119
+ if (candidates.length === 0)
1120
+ return { derived: [], gaps: [], block: '', permissions };
1121
+ // The model rung is skipped whenever ANY budget is set. `maxCalls` is denominated in plan skill steps
1122
+ // (orchestrator.estimateCalls), so it cannot express a pre-plan call — spending one would exceed a
1123
+ // limit only ExecutionPolicy is meant to author — and a COST ceiling is just as much a budget. The
1124
+ // router-config `budget.maxCalls` is consulted directly because it deliberately does NOT flow into
1125
+ // `policy.maxCalls`: threading it there would newly gate ORCHESTRATION for every existing config,
1126
+ // which is a flag-off behavior change this phase must not make. Skipped on a dry run too. The
1127
+ // offline rung still runs in every one of these cases and costs nothing.
1128
+ const budgeted = policy.maxCalls !== undefined || policy.maxCostUsd !== undefined || this.configBudget() !== undefined;
1129
+ const useModel = !policy.dryRun && !budgeted;
1130
+ const res = await deriveCapabilities({ goal, candidates, ...(useModel ? { ai: this._ai } : {}), ...(routing ? { routing } : {}) });
1131
+ if (res.required.length === 0)
1132
+ return { derived: [], gaps: [], block: '', permissions };
1133
+ const resolution = this._capabilities.resolve(res.required, { permissions });
1134
+ return { derived: res.required, gaps: resolution.gaps, block: this.requiredCapabilitiesText(resolution), permissions };
1135
+ }
1136
+ /** The pre-rendered "Required capabilities" planner block (Phase 3.3) — clamped like the 3.1 catalog. */
1137
+ requiredCapabilitiesText(res) {
1138
+ const rows = [
1139
+ ...res.satisfied.map((sat) => ` - "${promptSafe(sat.capabilityId)}" → use ${promptSafe(sat.chosen.providerId)}`),
1140
+ ...res.gaps.map((g) => ` - "${promptSafe(g.capabilityId)}" → NOT AVAILABLE (${g.reason}) — do not plan a step that needs it`),
1141
+ ];
1142
+ return rows.length ? `Required capabilities for this goal (derived):\n${rows.join('\n')}` : '';
1143
+ }
1144
+ /** A step's capability provider id. An MCP-backed tool ingests under `mcp:` (see the ingest hook). */
1145
+ providerIdForRef(ref) {
1146
+ if (ref.kind === 'skill')
1147
+ return namespacedId('skill', ref.id);
1148
+ return namespacedId(this.mcpToolIds.has(ref.id) ? 'mcp' : 'tool', ref.id);
1149
+ }
1150
+ /**
1151
+ * Resolve the capabilities the plan's OWN steps reference (Phase 3.3). ZERO model calls, purely
1152
+ * additive metadata, and the only path that can surface a real `permission` gap with a concrete
1153
+ * policyKey — the always-on validation path resolves namespaced MISSES, which have no providers and are
1154
+ * therefore always reason 'unknown'. It never blocks: a gap here is advice, and the tool's own
1155
+ * permission check remains the authority.
1156
+ */
1157
+ checkPlanCapabilities(plan, permissions, derived) {
1158
+ try {
1159
+ const ids = new Set();
1160
+ for (const step of plan.steps) {
1161
+ const ref = step.skill ? { kind: 'skill', id: step.skill } : step.tool ? { kind: 'tool', id: step.tool } : undefined;
1162
+ if (!ref)
1163
+ continue;
1164
+ for (const id of this._capabilities.capabilitiesOf(this.providerIdForRef(ref), ref.id))
1165
+ ids.add(id);
1166
+ }
1167
+ const all = [...ids].sort();
1168
+ const res = this._capabilities.resolve(all, { permissions });
1169
+ const reasonOf = new Map(res.gaps.map((g) => [g.capabilityId, g.reason]));
1170
+ /**
1171
+ * A provider can do more than the step will. `tool:filesystem` provides read_file AND write_file,
1172
+ * so a read-only step would otherwise be reported as needing write access — and the remedy would
1173
+ * tell the operator to GRANT it. Pushing an over-grant for work that may never happen is worse
1174
+ * than saying nothing, so a `permission` gap survives only when the GOAL derived that capability.
1175
+ * Every other reason (unknown / unavailable / disabled provider) is a fact about the plan whatever
1176
+ * the step does, and is always reported.
1177
+ */
1178
+ const keep = (id) => derived.has(id) || reasonOf.get(id) !== 'permission';
1179
+ return { required: all.filter(keep), gaps: res.gaps.filter((g) => keep(g.capabilityId)) };
1180
+ }
1181
+ catch {
1182
+ return { required: [], gaps: [] }; // a check bug can never fail a run the runtime could complete
1183
+ }
1184
+ }
1185
+ /**
1186
+ * Turn unregistered plan references into structured gaps (Phase 3.1's always-on upgrade), resolved
1187
+ * against the RUN's permissions rather than the raw config (Phase 3.3 / D9) — so what resolution
1188
+ * reports and what the executor will allow can never disagree.
1189
+ */
1190
+ resolveMissingRefs(missing, policy) {
1191
+ const ids = [...new Set(missing.map((m) => namespacedId(m.kind, m.id)))];
1192
+ return this._capabilities.resolve(ids, { permissions: this.resolvedPermissions({ permissions: policy.permissions }) }).gaps;
1193
+ }
1194
+ /**
1195
+ * The user-facing report for a set of required capability ids (Phase 3.1), against the runtime's
1196
+ * CONFIGURED posture. A specific run's gaps use that run's merged permissions instead (see
1197
+ * `resolveMissingRefs` and the capability-planning pre-pass); this entry point has no run in scope.
1198
+ */
1199
+ capabilityReport(required) {
1200
+ return this._capabilities.report(required, { permissions: this.permissions() });
1201
+ }
1202
+ /**
1203
+ * Project an orchestration status onto a persisted execution status. EXHAUSTIVE on purpose (Phase
1204
+ * 3.4): the previous catch-all silently persisted an unrecognized status as
1205
+ * `waiting_for_clarification` — resumable, answerable with a text answer, and wrong. The mapping of
1206
+ * the six existing members is unchanged.
1207
+ */
510
1208
  execStatus(s) {
511
- if (s === 'completed' || s === 'dry-run')
512
- return 'completed';
513
- if (s === 'failed')
514
- return 'failed';
515
- if (s === 'waiting_for_approval')
516
- return 'waiting_for_input';
517
- return 'waiting_for_clarification';
518
- }
519
- mapOutcome(outcome, resolution, runId, execId) {
1209
+ switch (s) {
1210
+ case 'completed':
1211
+ case 'dry-run':
1212
+ return 'completed';
1213
+ case 'failed':
1214
+ return 'failed';
1215
+ case 'waiting_for_approval':
1216
+ case 'waiting_for_budget':
1217
+ return 'waiting_for_input';
1218
+ case 'waiting_for_clarification':
1219
+ return 'waiting_for_clarification';
1220
+ default: {
1221
+ const never = s;
1222
+ return never;
1223
+ }
1224
+ }
1225
+ }
1226
+ mapOutcome(outcome, resolution, runId, execId, planning) {
520
1227
  const status = outcome.status === 'dry-run' ? 'completed' : outcome.status;
521
1228
  const ok = outcome.status === 'completed' || outcome.status === 'dry-run' || outcome.status.startsWith('waiting');
522
1229
  this.emitter.emit({ type: 'run.completed', runId, ok, status, confidence: ok ? 1 : 0 });
1230
+ // One report, three sources: the pre-pass (the goal), the post-plan check (the plan's own steps),
1231
+ // and the always-on validation upgrade (unregistered refs). The post-plan check is free — no model
1232
+ // call — so it runs wherever the flag is on, resumes included.
1233
+ const check = planning && outcome.plan ? this.checkPlanCapabilities(outcome.plan, planning.permissions, new Set(planning.derived)) : undefined;
1234
+ const gaps = mergeGapsById([...(planning?.gaps ?? []), ...(check?.gaps ?? []), ...(outcome.gaps ?? [])]);
1235
+ const required = [...new Set([...(planning?.derived ?? []), ...(check?.required ?? []), ...(outcome.gaps ?? []).map((g) => g.capabilityId)])];
523
1236
  return {
524
1237
  ok,
525
1238
  runId,
@@ -527,20 +1240,25 @@ export class Runtime {
527
1240
  status,
528
1241
  response: { text: outcome.summary },
529
1242
  ...(outcome.plan ? { plan: outcome.plan } : {}),
1243
+ ...(gaps.length ? { capabilityGaps: capabilityReportFrom(required, gaps, (id) => this._capabilities.providersOf(id)) } : {}),
530
1244
  execution: { ...(execId ? { id: execId } : {}), observations: outcome.observations, planHistory: outcome.planHistory },
531
1245
  ...(outcome.clarification ? { clarification: { question: outcome.clarification } } : {}),
532
- artifacts: [],
1246
+ // Phase 3.4: an agent step's admitted findings carry evidence artifacts. With agents disabled
1247
+ // there are no agent observations, so this is the same empty array as 2.6.0.
1248
+ artifacts: outcome.observations.flatMap((o) => o.artifacts ?? []),
533
1249
  };
534
1250
  }
535
1251
  resultFromExecution(exec, resolution, runId) {
536
1252
  const ok = exec.status !== 'failed' && exec.status !== 'cancelled';
537
- 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';
1253
+ // waiting_for_input carries a pending kind a budget pause maps to waiting_for_budget, else approval.
1254
+ const waitingInput = exec.pending?.kind === 'budget' ? 'waiting_for_budget' : 'waiting_for_approval';
1255
+ 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';
538
1256
  return {
539
1257
  ok,
540
1258
  runId,
541
1259
  mode: resolution,
542
1260
  status,
543
- response: { text: `execution ${exec.id} (${exec.status})` },
1261
+ 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})` },
544
1262
  ...(exec.plan ? { plan: exec.plan } : {}),
545
1263
  execution: { id: exec.id, observations: exec.observations, planHistory: exec.plan ? [exec.plan] : [] },
546
1264
  ...(exec.pending?.kind === 'clarification' && exec.pending.question ? { clarification: { question: exec.pending.question } } : {}),
@@ -608,6 +1326,13 @@ export class Runtime {
608
1326
  return { ok: false, runId, mode: resolution, status: 'failed', response: { text: `cannot resume ${id}: ${acq.reason ?? 'unavailable'}` }, artifacts: [] };
609
1327
  }
610
1328
  const exec = acq.execution;
1329
+ // A resumed run is a live run: pause/cancel must be able to abort it, and a refused commit must be
1330
+ // able to stop it — both of which need a controller registered under this execution's id.
1331
+ const controller = new AbortController();
1332
+ // What was on the record BEFORE this resume. The terminal write rebuilds from here rather than
1333
+ // appending: the sink has already been appending this run's observations as they happened, so
1334
+ // appending the outcome's copy too would store every step of a resumed run twice.
1335
+ const observationsBefore = [...exec.observations];
611
1336
  try {
612
1337
  if (TERMINAL.has(exec.status))
613
1338
  return this.resultFromExecution(exec, resolution, runId);
@@ -626,54 +1351,306 @@ export class Runtime {
626
1351
  return this.resultFromExecution(exec, resolution, runId);
627
1352
  }
628
1353
  }
1354
+ // An INNER wait needs its answer, exactly as an approval needs a decision. Without this gate the
1355
+ // ordinary `resume-execution <id>` (the CLI makes --answer optional) falls through to the replan
1356
+ // branch, which replaces the plan and orphans every sibling agent's completed work — destroying
1357
+ // progress in the one situation the wait exists to protect.
1358
+ if (exec.pending?.kind === 'clarification' && exec.pending.agentTaskId && !opts.clarificationAnswer) {
1359
+ return this.resultFromExecution(exec, resolution, runId);
1360
+ }
1361
+ // Phase 3.5: whoever was mid-flight when this execution stopped is gone. Put those records back in
1362
+ // `queued` with the reason BEFORE anything is scheduled against them.
1363
+ if (this.reconcileAgentTasks(exec, 'crash') > 0)
1364
+ this._executions.commit(exec);
629
1365
  // Reconcile against the checkpoint — drift forces a replan rather than a blind continue.
630
1366
  const checkpoint = exec.checkpoints[exec.checkpoints.length - 1];
631
- const recon = checkpoint ? reconcile(checkpoint, this.workspaceRoot, this.skills()) : { drifted: true, reasons: ['no checkpoint'] };
632
- const goal = exec.pending?.kind === 'clarification' && opts.clarificationAnswer ? `${exec.goal}\n\nClarification: ${opts.clarificationAnswer}` : exec.goal;
633
- const policy = resolvePolicy({ mode: 'orchestrate', overrides: { autonomy: 'autonomous', approval: 'none', ...(this.configPermissions ? { permissions: this.configPermissions } : {}) }, settings: this.settingsValue });
634
- // We only reach here for an approval-pending execution if it was explicitly approved.
1367
+ const recon = checkpoint ? reconcile(checkpoint, this.workspaceRoot, this.skills(), this.mcpToolHashes(exec.plan)) : { drifted: true, reasons: ['no checkpoint'] };
1368
+ // An INNER answer belongs to one agent task, never to the outer goal: appending it here would
1369
+ // replan the whole plan and discard every sibling agent's completed work.
1370
+ const innerAnswered = exec.pending?.kind === 'clarification' && !!exec.pending.agentTaskId && !!opts.clarificationAnswer;
1371
+ const goal = exec.pending?.kind === 'clarification' && opts.clarificationAnswer && !innerAnswered ? `${exec.goal}\n\nClarification: ${opts.clarificationAnswer}` : exec.goal;
1372
+ // Re-read the budget from env so raising AI_MAX_CALLS before resuming actually takes effect (Phase 22).
1373
+ 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') } });
1374
+ if (this.agentsEnabled)
1375
+ this.liveRuns.set(exec.id, { controller });
1376
+ // A resumed run is never re-DERIVED — no surprise second model call on work already approved and
1377
+ // mid-flight — but it does get the FREE post-plan check: a grant may have changed since it started.
1378
+ const planning = this.settingsValue.capabilities?.planning
1379
+ ? { derived: [], gaps: [], block: '', permissions: this.resolvedPermissions({ permissions: policy.permissions }) }
1380
+ : undefined;
1381
+ // Continue (skip completed steps) for an approved plan, a budget pause, or partial progress.
635
1382
  const approvedNow = exec.pending?.kind === 'approval';
636
- const canContinue = !recon.drifted && !!exec.plan && (approvedNow || (exec.completedSteps.length > 0 && !exec.pending));
1383
+ const budgetPaused = exec.pending?.kind === 'budget';
1384
+ // `innerAnswered` is its own arm: the plan is intact and one agent step needs re-running with its
1385
+ // answer. The partial-progress arm cannot cover it — a run where every agent asked before doing
1386
+ // anything has NO completed steps, which is precisely the two-waiting-agents case.
1387
+ const canContinue = !recon.drifted && !!exec.plan && (approvedNow || budgetPaused || innerAnswered || (exec.completedSteps.length > 0 && !exec.pending));
1388
+ const sink = this._executions.enabled ? this.runPersistence(exec, controller) : undefined;
637
1389
  let outcome;
638
1390
  if (canContinue && exec.plan) {
639
- // No drift continue: execute the (approved / partially-done) plan, skipping completed steps.
640
- 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 } : {}) }));
641
- 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}"` };
1391
+ // Continue the plan, skipping completed steps. The call budget is ALWAYS enforced on the continue
1392
+ // path (whether the pause was approval, budget, or partial-progress) so an approved-but-over-budget
1393
+ // plan pauses for budget rather than silently exceeding it; a raised budget re-applies here.
1394
+ const resumeLookup = this.agentResumeLookup(exec, opts.clarificationAnswer);
1395
+ const exe = await this.withHeartbeat(exec.id, () => executePlan(exec.plan, { ...this.orchestrateRunners(policy, opts.signal ?? controller.signal, { executionId: exec.id, planVersion: exec.planVersion }, sink?.onRecord, resumeLookup), ...(sink ? { onProgress: sink.onProgress } : {}), 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 } : {}) }));
1396
+ if (exe.stoppedForBudget) {
1397
+ const done = exe.plan.steps.filter((s) => s.status === 'succeeded').length;
1398
+ const total = exe.plan.steps.length;
1399
+ 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.` };
1400
+ }
1401
+ else if (exe.waiting?.length) {
1402
+ // Answering one agent can simply reveal the next one. Reporting `failed` here (the pre-3.5
1403
+ // shape, which read `ok` alone) would mark a perfectly resumable run as dead and lie in the
1404
+ // summary while doing it.
1405
+ const asked = exe.observations.find((o) => o.code === 'agent-waiting');
1406
+ outcome = {
1407
+ status: 'waiting_for_clarification',
1408
+ plan: exe.plan,
1409
+ planHistory: [exe.plan],
1410
+ observations: exe.observations,
1411
+ clarification: asked?.error ?? 'an agent needs more information to continue',
1412
+ summary: `Still waiting: ${exe.waiting.length} agent step(s) need an answer.`,
1413
+ };
1414
+ }
1415
+ else {
1416
+ 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}"` };
1417
+ }
642
1418
  }
643
1419
  else {
644
- // Drift, or a pending clarification answer, or no partial progress → replan from the goal.
645
- // Re-resolve routing so env/config excludes (and learned prefer) still apply to the replan's
646
- // planning-model call a hard exclude must not be dropped just because we're resuming.
647
- outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput('orchestrate', goal, policy, this.effectiveRouting())));
1420
+ // Drift, or a pending clarification answer, or no partial progress → replan from the goal. Re-resolve
1421
+ // routing so env/config excludes (and learned prefer) still apply. A budget-paused replan stays
1422
+ // partial so it keeps running-what-fits instead of reverting to notify-and-wait.
1423
+ // Drift replaces the PLAN, not the facts. What the agents proved before the drift is carried
1424
+ // into the new planning context rather than silently orphaned.
1425
+ const brief = this.findingsBrief(exec);
1426
+ // The full argument list. Passing five positionals to a ten-parameter function silently left
1427
+ // this arm with no signal, no provenance, no sink and no resume lookup — so a replan persisted
1428
+ // no agent record AT ALL (onRecord is the only writer of `agentTasks`), could not be paused or
1429
+ // cancelled, and elected `pending` from stale records.
1430
+ outcome = await this.withHeartbeat(exec.id, () => orchestrate(this.orchestrateInput('orchestrate', brief ? `${goal}\n\n${brief}` : goal, policy, this.effectiveRouting(), budgetPaused, undefined, this.agentsEnabled ? controller.signal : undefined, { executionId: exec.id, planVersion: exec.planVersion }, sink)));
648
1431
  }
649
1432
  exec.status = this.execStatus(outcome.status);
650
- exec.observations = [...exec.observations, ...outcome.observations];
1433
+ exec.observations = [...observationsBefore, ...outcome.observations.map(persistableObservation)];
651
1434
  if (outcome.plan) {
652
1435
  exec.plan = outcome.plan;
653
1436
  exec.planVersion = outcome.plan.version;
654
1437
  exec.completedSteps = outcome.plan.steps.filter((s) => s.status === 'succeeded').map((s) => s.id);
655
1438
  }
656
- delete exec.pending;
657
- exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps }));
658
- this._executions.commit(exec);
659
- return this.mapOutcome(outcome, resolution, runId, exec.id);
1439
+ // Preserve a fresh budget pause; otherwise the pending state is resolved — except that answering
1440
+ // one agent's question may simply have revealed the NEXT one (rediscovery: the records are the
1441
+ // source of truth, so a second waiting task is re-elected rather than queued somewhere).
1442
+ if (outcome.status === 'waiting_for_budget' && outcome.budget)
1443
+ exec.pending = { kind: 'budget', budget: outcome.budget };
1444
+ else if (outcome.status === 'waiting_for_clarification' && outcome.clarification) {
1445
+ const waiter = this.electWaitingAgent(exec);
1446
+ exec.pending = { kind: 'clarification', question: outcome.clarification, ...(waiter ? { agentTaskId: waiter.agentTaskId } : {}) };
1447
+ }
1448
+ else
1449
+ delete exec.pending;
1450
+ exec.checkpoints.push(captureCheckpoint({ root: this.workspaceRoot, ...(exec.plan ? { plan: exec.plan } : {}), skills: this.skills(), completedSteps: exec.completedSteps, mcpTools: this.mcpToolHashes(exec.plan) }));
1451
+ if (sink)
1452
+ sink.finalize();
1453
+ else
1454
+ this._executions.commit(exec);
1455
+ return this.mapOutcome(outcome, resolution, runId, exec.id, planning);
660
1456
  }
661
1457
  finally {
1458
+ this.liveRuns.delete(exec.id);
662
1459
  this._executions.release(id);
663
1460
  }
664
1461
  }
665
- orchestrateRunners() {
1462
+ /**
1463
+ * THE single source of step runners, used by both the fresh-run path and the resume-continue path.
1464
+ * They used to drift: resume built its own pair with no agent runner, so a persisted plan containing
1465
+ * an agent step would have failed every one of those steps.
1466
+ */
1467
+ orchestrateRunners(policy, signal, provenance, onRecord, agentResume) {
1468
+ const envelopes = policy ? this.agentEnvelopes(policy) : [];
1469
+ const byId = new Map(envelopes.map((e) => [e.agentId, e]));
666
1470
  return {
667
1471
  runSkill: (skillId, input) => this.runSkill(skillId, input).then((o) => ({ result: o.result, validation: o.validation })),
668
1472
  runTool: (toolId, input) => this.runTool(toolId, input),
1473
+ ...(envelopes.length
1474
+ ? {
1475
+ agents: envelopes,
1476
+ reserve: (step) => (step.agent ? byId.get(step.agent)?.reservation ?? 1 : 0),
1477
+ runAgent: async (step, ctx) => {
1478
+ const envelope = byId.get(step.agent ?? '');
1479
+ const definition = this.agentDefs.get(step.agent ?? '');
1480
+ if (!envelope || !definition)
1481
+ return { stepId: step.id, ok: false, code: 'agent-not-enabled', error: `no agent definition '${step.agent ?? ''}'` };
1482
+ const innerSkills = this._skills.list().filter((sk) => envelope.skills.includes(sk.id));
1483
+ // Phase 3.5: continue a persisted task for THIS step, if one exists.
1484
+ const prior = agentResume?.(step);
1485
+ const out = await runAgentTask(step, envelope, definition, {
1486
+ ...(prior?.record ? { resume: prior.record } : {}),
1487
+ ...(prior?.answer ? { resumeAnswer: prior.answer } : {}),
1488
+ ai: this._ai,
1489
+ clock: this.clock,
1490
+ skills: innerSkills,
1491
+ runSkill: (id, i, o) => this.runSkill(id, i, o).then((r) => ({ result: r.result, validation: r.validation })),
1492
+ runTool: (id, i, o) => this.runTool(id, i, o),
1493
+ putArtifact: (content, source) => {
1494
+ // The `enabled` check must precede `put`: in stateless mode `put` still returns a
1495
+ // well-formed ref with a real checksum whose `resolve()` is permanently undefined —
1496
+ // a dangling evidence ref threaded as if it were real.
1497
+ if (!this._artifacts.enabled)
1498
+ return { unavailable: true };
1499
+ try {
1500
+ return { ref: this._artifacts.put({ type: 'agent-evidence', source, content }), unavailable: false };
1501
+ }
1502
+ catch {
1503
+ return { unavailable: true };
1504
+ }
1505
+ },
1506
+ 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 }),
1507
+ ...(onRecord ? { onRecord } : {}),
1508
+ ...(ctx.signal ?? signal ? { parentSignal: ctx.signal ?? signal } : {}),
1509
+ ...(provenance ? { provenance } : { provenance: { planVersion: 1 } }),
1510
+ abortReason: () => {
1511
+ for (const live of this.liveRuns.values())
1512
+ if (live.controller.signal.aborted && live.reason)
1513
+ return live.reason;
1514
+ return undefined;
1515
+ },
1516
+ });
1517
+ return out.observation;
1518
+ },
1519
+ }
1520
+ : {}),
669
1521
  };
670
1522
  }
1523
+ /**
1524
+ * A bounded, fenced brief of what the agents have already established (Phase 3.5).
1525
+ *
1526
+ * On drift the plan is thrown away and the goal is replanned — but validated findings are facts about
1527
+ * the WORKSPACE, not about the plan's structure, so discarding them silently would make the run redo
1528
+ * work it had already proved. They are agent-authored text, so they cross a prompt boundary fenced,
1529
+ * exactly like every other untrusted string.
1530
+ */
1531
+ findingsBrief(exec) {
1532
+ const active = parseAgentTasks(exec)
1533
+ .tasks.flatMap((t) => t.findings)
1534
+ .filter((f) => f.status === 'active')
1535
+ .slice(0, FINDINGS_BRIEF_MAX);
1536
+ if (!active.length)
1537
+ return undefined;
1538
+ const lines = active.map((f) => `- [${flattenClamp(f.type, 40)}] ${flattenClamp(f.subject ?? '', 60)}: ${flattenClamp(f.claim, 160)}`);
1539
+ return wrapUntrusted('prior-findings', `Already established by earlier agent work:\n${lines.join('\n')}`);
1540
+ }
1541
+ /**
1542
+ * Fingerprint the MCP tools a plan actually references (Phase 3.5). An MCP server is remote and
1543
+ * mutable: while a plan sits paused it can change a tool's input schema, change what it does, or drop
1544
+ * it entirely — and the plan would then be resumed against a tool that is no longer the tool it was
1545
+ * planned for. Hashing the live declaration makes that visible as ordinary drift.
1546
+ *
1547
+ * Non-MCP tools are deliberately absent: they are in-tree code covered by the config/skill hashes.
1548
+ */
1549
+ mcpToolHashes(plan) {
1550
+ const out = {};
1551
+ if (!plan)
1552
+ return out;
1553
+ for (const step of plan.steps) {
1554
+ if (!step.tool || !step.tool.startsWith('mcp:') || out[step.tool])
1555
+ continue;
1556
+ const tool = this._tools.get(step.tool);
1557
+ // A missing tool hashes to a sentinel rather than being skipped — "gone" must be comparable, or a
1558
+ // removed server would silently look like no drift at all.
1559
+ out[step.tool] = tool ? hashOf({ description: tool.description, parameters: tool.parameters ?? null }) : 'absent';
1560
+ }
1561
+ return out;
1562
+ }
1563
+ /**
1564
+ * Crash / pause / cancel reconciliation (Phase 3.5). A record left `running` or `created` describes a
1565
+ * worker that no longer exists — the process died, or the parent stopped it — so it goes back to
1566
+ * `queued` with an auditable reason. An interruption is deliberately NOT a state: the lifecycle does
1567
+ * not grow, only the explanation does.
1568
+ *
1569
+ * This runs on the RESUME path and inside pause/cancel. Resume alone is not enough: `cancelExecution`
1570
+ * writes a terminal status and resume early-returns on terminal, so a cancelled execution's `running`
1571
+ * records would stay `running` on disk forever, unstamped and unexplained.
1572
+ *
1573
+ * Records that fail validation are preserved in place, never dropped — reconciling is not a licence to
1574
+ * delete what this version could not parse.
1575
+ */
1576
+ reconcileAgentTasks(exec, kind) {
1577
+ const raw = exec.agentTasks;
1578
+ if (!Array.isArray(raw) || raw.length === 0)
1579
+ return 0;
1580
+ const { tasks, dropped } = parseAgentTasks(exec);
1581
+ const now = this.clock.now();
1582
+ const fixed = new Map();
1583
+ for (const t of tasks) {
1584
+ if (t.state !== 'running' && t.state !== 'created')
1585
+ continue;
1586
+ fixed.set(t.agentTaskId, { ...t, state: 'queued', interruption: { kind, at: now }, updatedAt: now });
1587
+ }
1588
+ if (dropped)
1589
+ exec.agentTasksDropped = dropped;
1590
+ if (fixed.size === 0)
1591
+ return 0;
1592
+ exec.agentTasks = raw.map((entry) => fixed.get(entry.agentTaskId ?? '') ?? entry);
1593
+ return fixed.size;
1594
+ }
1595
+ /**
1596
+ * FIRST-WINS PENDING. `Execution.pending` is a single slot but two agents can be waiting at once, so
1597
+ * the earliest-created waiting task claims it. The others are not lost: once this one is answered and
1598
+ * the run continues, the next resume re-elects whichever task is still waiting — rediscovery, rather
1599
+ * than a queue that has to be kept in sync with the records that are already the source of truth.
1600
+ */
1601
+ electWaitingAgent(exec) {
1602
+ return parseAgentTasks(exec).tasks.find((t) => t.state === 'waiting_for_clarification' && t.pendingInner);
1603
+ }
1604
+ /**
1605
+ * Which persisted task, if any, a plan step should CONTINUE. Bound by step-input hash, never by step
1606
+ * id: plan step ids (`s1`, `auto1`) are model-authored and recur across replans, so an id match would
1607
+ * hand one step's completed inner work to a different step with the same id and a different input.
1608
+ */
1609
+ agentResumeLookup(exec, answer) {
1610
+ const tasks = parseAgentTasks(exec).tasks.filter((t) => AGENT_RESUMABLE.has(t.state));
1611
+ const answeringId = exec.pending?.agentTaskId;
1612
+ // A plan may legitimately contain two steps with identical identity (same agent, same input) — a
1613
+ // retry, or genuinely duplicated work. Without claiming, both would resolve to the SAME record and
1614
+ // the second would resume, and then overwrite, the first's inner work.
1615
+ const claimed = new Set();
1616
+ return (step) => {
1617
+ if (!step.agent)
1618
+ return undefined;
1619
+ const hash = stepIdentity(step);
1620
+ const record = tasks.find((t) => t.agentId === step.agent && t.stepInputHash === hash && !claimed.has(t.agentTaskId));
1621
+ if (!record)
1622
+ return undefined;
1623
+ claimed.add(record.agentTaskId);
1624
+ // If the files this agent worked on changed while the run was stopped, its completed inner steps
1625
+ // are no longer safe to skip — the same rule the outer plan already lives by.
1626
+ if (record.innerCheckpoint && reconcile(record.innerCheckpoint, this.workspaceRoot, this.skills()).drifted) {
1627
+ const { innerPlan: _drop, ...rest } = record;
1628
+ return { record: { ...rest, innerCompletedSteps: [], innerObservations: [], innerObservationsOmitted: 0 } };
1629
+ }
1630
+ // The answer belongs to exactly ONE task — the one that asked.
1631
+ return answer && record.agentTaskId === answeringId ? { record, answer } : { record };
1632
+ };
1633
+ }
1634
+ /** Abort a run that is in flight, recording WHY so a task can tell a pause from a cancellation. */
1635
+ abortLiveRun(id, reason) {
1636
+ const live = this.liveRuns.get(id);
1637
+ if (!live)
1638
+ return;
1639
+ live.reason = reason;
1640
+ live.controller.abort();
1641
+ }
671
1642
  /** Mark an execution paused (it can be resumed later). */
672
1643
  pauseExecution(id) {
673
1644
  const exec = this._executions.get(id);
674
1645
  if (!exec || TERMINAL.has(exec.status))
675
1646
  return false;
676
1647
  exec.status = 'paused';
1648
+ // Phase 3.4: abort whatever is actually in flight, so a pause stops live agent work instead of only
1649
+ // flipping a stored status. A no-op with agents disabled — `liveRuns` is empty then.
1650
+ this.abortLiveRun(id, 'pause');
1651
+ // Phase 3.5: stamp whatever was mid-flight. Doing it here rather than only on resume is what stops a
1652
+ // record sitting at `running` with no worker behind it.
1653
+ this.reconcileAgentTasks(exec, 'pause');
677
1654
  this._executions.save(exec);
678
1655
  this._executions.release(id);
679
1656
  return true;
@@ -684,6 +1661,10 @@ export class Runtime {
684
1661
  if (!exec || TERMINAL.has(exec.status))
685
1662
  return false;
686
1663
  exec.status = 'cancelled';
1664
+ this.abortLiveRun(id, 'parent-cancel'); // parent cancelled ⇒ every live agent controller aborted
1665
+ // Cancel is TERMINAL and resume early-returns on terminal, so if this did not reconcile here those
1666
+ // `running` records would stay `running` on disk forever, unstamped and unexplained.
1667
+ this.reconcileAgentTasks(exec, 'parent-cancel');
687
1668
  this._executions.save(exec);
688
1669
  this._executions.release(id);
689
1670
  return true;
@@ -710,7 +1691,7 @@ export class Runtime {
710
1691
  * `capture` is false on a dry run — retrieval is read-only, but writing a fact is a mutation the dry
711
1692
  * run must not perform.
712
1693
  */
713
- applyMemory(text, capture = true) {
1694
+ async applyMemory(text, capture = true) {
714
1695
  if (!this.memoryEnabled())
715
1696
  return undefined;
716
1697
  try {
@@ -722,7 +1703,8 @@ export class Runtime {
722
1703
  trace.captured = { id: rec.id, scope: rec.scope };
723
1704
  }
724
1705
  }
725
- trace.retrieved = this._memory.search(text, { limit: 3 }).map((h) => h.text);
1706
+ // Semantic retrieval when an embedder is configured, else BM25 (searchSemantic degrades internally).
1707
+ trace.retrieved = (await this._memory.searchSemantic(text, { limit: 3 })).map((h) => h.text);
726
1708
  if (trace.retrieved.length === 0 && !trace.captured)
727
1709
  return undefined;
728
1710
  return trace;