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.
- package/CHANGELOG.md +638 -0
- package/dist/agents/admit.d.ts +69 -0
- package/dist/agents/admit.js +129 -0
- package/dist/agents/definition.d.ts +36 -0
- package/dist/agents/definition.js +9 -0
- package/dist/agents/envelope.d.ts +53 -0
- package/dist/agents/envelope.js +68 -0
- package/dist/agents/finding.d.ts +79 -0
- package/dist/agents/finding.js +80 -0
- package/dist/agents/roles.d.ts +36 -0
- package/dist/agents/roles.js +44 -0
- package/dist/agents/synthesize.d.ts +44 -0
- package/dist/agents/synthesize.js +60 -0
- package/dist/agents/task.d.ts +112 -0
- package/dist/agents/task.js +48 -0
- package/dist/agents/worker.d.ts +91 -0
- package/dist/agents/worker.js +377 -0
- package/dist/capabilities/capability.d.ts +117 -0
- package/dist/capabilities/capability.js +66 -0
- package/dist/capabilities/registry.d.ts +139 -0
- package/dist/capabilities/registry.js +413 -0
- package/dist/capabilities/vocabulary.d.ts +32 -0
- package/dist/capabilities/vocabulary.js +34 -0
- package/dist/cli/cli.js +55 -4
- package/dist/cli/commands/cleanup.js +29 -27
- package/dist/cli/commands/doctor.d.ts +14 -0
- package/dist/cli/commands/doctor.js +38 -8
- package/dist/cli/commands/executions.js +34 -25
- package/dist/cli/commands/info.d.ts +1 -0
- package/dist/cli/commands/info.js +11 -9
- package/dist/cli/commands/init.js +19 -0
- package/dist/cli/commands/inspect.d.ts +40 -1
- package/dist/cli/commands/inspect.js +157 -2
- package/dist/cli/commands/mcp.d.ts +45 -0
- package/dist/cli/commands/mcp.js +148 -0
- package/dist/cli/commands/route.js +21 -0
- package/dist/cli/commands/run.d.ts +1 -0
- package/dist/cli/commands/run.js +21 -2
- package/dist/cli/commands/skills.d.ts +2 -0
- package/dist/cli/commands/skills.js +29 -7
- package/dist/cli/interactive/ansi.d.ts +41 -0
- package/dist/cli/interactive/ansi.js +43 -0
- package/dist/cli/interactive/complete.d.ts +10 -0
- package/dist/cli/interactive/complete.js +19 -0
- package/dist/cli/interactive/lanes.d.ts +69 -0
- package/dist/cli/interactive/lanes.js +181 -0
- package/dist/cli/interactive/repl.d.ts +3 -0
- package/dist/cli/interactive/repl.js +91 -13
- package/dist/cli/interactive/session.d.ts +8 -0
- package/dist/cli/interactive/session.js +73 -2
- package/dist/cli/render.d.ts +7 -0
- package/dist/cli/render.js +10 -0
- package/dist/cli/runtimeSession.d.ts +11 -0
- package/dist/cli/runtimeSession.js +17 -0
- package/dist/config/defaults.d.ts +3 -1
- package/dist/config/defaults.js +2 -0
- package/dist/config/schema.d.ts +1 -0
- package/dist/config/schema.js +2 -2
- package/dist/context/lossVerifier.d.ts +24 -0
- package/dist/context/lossVerifier.js +45 -0
- package/dist/context/summarize.d.ts +19 -0
- package/dist/context/summarize.js +53 -0
- package/dist/core/fallback/fallback.d.ts +5 -0
- package/dist/core/fallback/fallback.js +3 -1
- package/dist/core/router/router.d.ts +3 -0
- package/dist/core/router/router.js +1 -0
- package/dist/executions/agentTasks.d.ts +627 -0
- package/dist/executions/agentTasks.js +149 -0
- package/dist/executions/checkpoint.d.ts +5 -1
- package/dist/executions/checkpoint.js +13 -1
- package/dist/executions/execution.d.ts +36 -2
- package/dist/executions/store.d.ts +37 -0
- package/dist/executions/store.js +33 -0
- package/dist/generation/generateAdapter.d.ts +14 -0
- package/dist/generation/generateAdapter.js +38 -0
- package/dist/generation/generateSkill.d.ts +26 -0
- package/dist/generation/generateSkill.js +51 -0
- package/dist/index.d.ts +47 -4
- package/dist/index.js +33 -2
- package/dist/mcp/client.d.ts +70 -0
- package/dist/mcp/client.js +221 -0
- package/dist/mcp/manager.d.ts +151 -0
- package/dist/mcp/manager.js +493 -0
- package/dist/mcp/protocol.d.ts +216 -0
- package/dist/mcp/protocol.js +149 -0
- package/dist/mcp/toolAdapter.d.ts +44 -0
- package/dist/mcp/toolAdapter.js +94 -0
- package/dist/mcp/transport.d.ts +109 -0
- package/dist/mcp/transport.js +383 -0
- package/dist/memory/embedders/hash.d.ts +12 -0
- package/dist/memory/embedders/hash.js +31 -0
- package/dist/memory/embedders/http.d.ts +25 -0
- package/dist/memory/embedders/http.js +48 -0
- package/dist/memory/memory.d.ts +19 -2
- package/dist/memory/memory.js +75 -11
- package/dist/memory/semantic.d.ts +17 -0
- package/dist/memory/semantic.js +29 -0
- package/dist/orchestration/budget.d.ts +30 -0
- package/dist/orchestration/budget.js +40 -0
- package/dist/orchestration/executor.d.ts +64 -1
- package/dist/orchestration/executor.js +104 -7
- package/dist/orchestration/orchestrator.d.ts +35 -1
- package/dist/orchestration/orchestrator.js +106 -8
- package/dist/orchestration/plan.d.ts +15 -1
- package/dist/orchestration/plan.js +23 -4
- package/dist/orchestration/planner.d.ts +19 -1
- package/dist/orchestration/planner.js +25 -5
- package/dist/plugin/ai.d.ts +4 -0
- package/dist/plugin/ai.js +9 -0
- package/dist/runtime/config.js +50 -6
- package/dist/runtime/intent/aiClassifier.d.ts +19 -0
- package/dist/runtime/intent/aiClassifier.js +74 -0
- package/dist/runtime/models/modelProfile.d.ts +61 -0
- package/dist/runtime/models/modelProfile.js +139 -0
- package/dist/runtime/planning/deriveCapabilities.d.ts +95 -0
- package/dist/runtime/planning/deriveCapabilities.js +146 -0
- package/dist/runtime/policy.d.ts +10 -0
- package/dist/runtime/policy.js +9 -2
- package/dist/runtime/runtime.d.ts +233 -0
- package/dist/runtime/runtime.js +1042 -60
- package/dist/runtime/types.d.ts +88 -2
- package/dist/security/redact.js +22 -10
- package/dist/skills/manifest.d.ts +3 -0
- package/dist/skills/manifest.js +24 -0
- package/dist/skills/registry.d.ts +16 -1
- package/dist/skills/registry.js +21 -1
- package/dist/skills/skill.d.ts +6 -1
- package/dist/store/area.d.ts +15 -1
- package/dist/store/area.js +19 -8
- package/dist/store/crypto.d.ts +21 -0
- package/dist/store/crypto.js +49 -0
- package/dist/store/paths.d.ts +5 -1
- package/dist/store/paths.js +6 -0
- package/dist/store/store.d.ts +15 -3
- package/dist/store/store.js +28 -7
- package/dist/telemetry/sinks/otlp.d.ts +31 -0
- package/dist/telemetry/sinks/otlp.js +76 -0
- package/dist/tools/builtins/filesystem.js +1 -0
- package/dist/tools/builtins/git.js +1 -0
- package/dist/tools/builtins/shell.js +1 -0
- package/dist/tools/permissions.d.ts +28 -0
- package/dist/tools/permissions.js +72 -0
- package/dist/tools/registry.d.ts +18 -2
- package/dist/tools/registry.js +22 -2
- package/dist/tools/tool.d.ts +4 -0
- package/dist/types.d.ts +5 -1
- package/dist/util/flatten.d.ts +11 -0
- package/dist/util/flatten.js +18 -0
- package/dist/util/hash.d.ts +19 -0
- package/dist/util/hash.js +39 -0
- package/dist/util/semaphore.d.ts +19 -0
- package/dist/util/semaphore.js +60 -0
- package/package.json +24 -9
package/dist/runtime/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
434
|
-
this
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
498
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
|
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
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
//
|
|
646
|
-
//
|
|
647
|
-
|
|
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 = [...
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|