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