@dotdrelle/wiki-manager 0.15.66 → 0.15.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.env.example +10 -3
  2. package/README.md +54 -0
  3. package/agent-runtimes.example.json +68 -0
  4. package/agents.docker-compose.yml +35 -1
  5. package/docker-compose.yml +3 -3
  6. package/package.json +3 -2
  7. package/src/agent/graph.js +12 -11
  8. package/src/agent/skillRecursion.test.js +13 -12
  9. package/src/cli/wiki-manager.js +124 -36
  10. package/src/commands/slash.js +38 -3
  11. package/src/contracts/schemas.js +67 -0
  12. package/src/core/activity.js +5 -0
  13. package/src/core/agentEvents.js +18 -1
  14. package/src/core/buildInfo.json +2 -2
  15. package/src/core/dockerCompose.test.js +8 -40
  16. package/src/core/env.js +14 -0
  17. package/src/core/env.test.js +19 -0
  18. package/src/core/googleGrants.test.js +1 -1
  19. package/src/core/mcp.js +1 -1
  20. package/src/core/runtimeEventAdapter.js +81 -0
  21. package/src/core/runtimeEventAdapter.test.js +61 -0
  22. package/src/core/skillChainView.test.js +2 -2
  23. package/src/core/skillCompiler.test.js +1 -1
  24. package/src/core/skillInvocation.js +13 -8
  25. package/src/core/startupCheck.js +58 -0
  26. package/src/core/startupCheck.test.js +29 -1
  27. package/src/orchestrator/agentRegistry.js +1 -22
  28. package/src/orchestrator/assignmentManager.js +16 -4
  29. package/src/orchestrator/capabilityRegistry.js +8 -1
  30. package/src/orchestrator/dispatcher.js +361 -2
  31. package/src/orchestrator/dispatcher.test.js +112 -1
  32. package/src/orchestrator/objectiveResolver.js +10 -6
  33. package/src/orchestrator/objectiveResolver.test.js +26 -27
  34. package/src/orchestrator/providers/deepAgentsProvider.js +168 -0
  35. package/src/orchestrator/providers/deepAgentsProvider.test.js +178 -0
  36. package/src/orchestrator/providers/dispatcherExternalRuntime.test.js +409 -0
  37. package/src/orchestrator/providers/fakeRuntimeProvider.js +164 -0
  38. package/src/orchestrator/providers/fakeRuntimeProvider.test.js +201 -0
  39. package/src/orchestrator/providers/runtimeProvider.js +101 -0
  40. package/src/orchestrator/providers/runtimeProviders.js +325 -0
  41. package/src/orchestrator/providers/runtimeProviders.test.js +361 -0
  42. package/src/orchestrator/resultAggregator.js +35 -2
  43. package/src/orchestrator/resultAggregator.test.js +62 -0
  44. package/src/runtime/recoveryManager.js +70 -5
  45. package/src/runtime/skillChain.e2e.test.js +2 -2
  46. package/src/runtime/supervisor.js +5 -10
  47. package/src/shell/RightPane.tsx +9 -1
  48. package/src/shell/StartupScreen.tsx +44 -7
  49. package/src/shell/repl.test.js +13 -0
  50. package/wiki-workspace +19 -3
@@ -0,0 +1,361 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { capabilityRegistryForSession, createCapabilityRegistry } from '../capabilityRegistry.js';
7
+ import { CapabilityUnavailableError, resolve } from '../capabilityResolver.js';
8
+ import { createAssignmentManager } from '../assignmentManager.js';
9
+ import { resolveObjective } from '../objectiveResolver.js';
10
+ import { createFakeRuntimeProvider } from './fakeRuntimeProvider.js';
11
+ import {
12
+ discoverRuntimeProviderAgents,
13
+ discoverRuntimeProvidersOnce,
14
+ loadAgentRuntimesConfig,
15
+ resolveRuntimeProviders,
16
+ } from './runtimeProviders.js';
17
+
18
+ function mcpAgent(agentInstanceId, capabilityId) {
19
+ return {
20
+ agentInstanceId,
21
+ health: 'available',
22
+ description: {
23
+ contractVersion: '1',
24
+ agentType: agentInstanceId.split('-')[0],
25
+ agentInstanceId,
26
+ displayName: agentInstanceId,
27
+ capabilities: [{
28
+ id: capabilityId,
29
+ version: '1',
30
+ description: capabilityId,
31
+ inputSchema: {},
32
+ outputSchema: {},
33
+ supportedOperations: ['run'],
34
+ }],
35
+ },
36
+ };
37
+ }
38
+
39
+ test('discovery projects a live runtime into synthetic agents', async () => {
40
+ const provider = createFakeRuntimeProvider({
41
+ runtime: 'deepagents',
42
+ capabilities: [{ name: 'agent.review', operations: ['run'] }],
43
+ });
44
+
45
+ const { agents, unavailable } = await discoverRuntimeProviderAgents([
46
+ { id: 'deepagents', provider },
47
+ ]);
48
+
49
+ assert.equal(unavailable.length, 0);
50
+ assert.equal(agents.length, 1);
51
+ const agent = agents[0];
52
+ assert.equal(agent.agentInstanceId, 'deepagents::agent.review');
53
+ assert.equal(agent.providerKind, 'external-runtime');
54
+ assert.equal(agent.health, 'available');
55
+ assert.equal(agent.serverName, null);
56
+ assert.equal(agent.runtimeProvider, provider);
57
+ assert.equal(agent.description.capabilities[0].id, 'agent.review');
58
+ });
59
+
60
+ test('a down runtime yields no agents and is reported', async () => {
61
+ const provider = createFakeRuntimeProvider({ available: false });
62
+
63
+ const { agents, unavailable } = await discoverRuntimeProviderAgents([
64
+ { id: 'deepagents', provider },
65
+ ]);
66
+
67
+ assert.equal(agents.length, 0);
68
+ assert.equal(unavailable.length, 1);
69
+ assert.equal(unavailable[0].runtimeId, 'deepagents');
70
+ });
71
+
72
+ test('the capability registry carries providerKind and runtimeProvider for external entries', async () => {
73
+ const provider = createFakeRuntimeProvider({ capabilities: [{ name: 'agent.review', operations: ['run'] }] });
74
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
75
+
76
+ const registry = createCapabilityRegistry({ agents });
77
+
78
+ const providers = registry.providersFor('agent.review');
79
+ assert.equal(providers.length, 1);
80
+ assert.equal(providers[0].providerKind, 'external-runtime');
81
+ assert.equal(providers[0].runtimeProvider, provider);
82
+ });
83
+
84
+ test('resolve routes agent.* to the external runtime agent', async () => {
85
+ const provider = createFakeRuntimeProvider({ capabilities: [{ name: 'agent.review', operations: ['run'] }] });
86
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
87
+ const registry = createCapabilityRegistry({ agents });
88
+
89
+ const assignment = resolve('agent.review', { workspaceConfig: {}, registry });
90
+
91
+ assert.equal(assignment.agentInstanceId, 'deepagents::agent.review');
92
+ });
93
+
94
+ test('assignmentManager produces an external-runtime assignment with a null serverName', async () => {
95
+ const provider = createFakeRuntimeProvider({ capabilities: [{ name: 'agent.review', operations: ['run'] }] });
96
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
97
+ const session = {
98
+ capabilityRegistry: createCapabilityRegistry({ agents }),
99
+ agentRegistrySnapshot: [],
100
+ agents: [],
101
+ };
102
+ const assign = createAssignmentManager({ session });
103
+
104
+ const assignment = await assign.assign({
105
+ id: 'review-a',
106
+ requiredCapability: 'agent.review',
107
+ operation: 'run',
108
+ arguments: {},
109
+ });
110
+
111
+ assert.equal(assignment.agentInstanceId, 'deepagents::agent.review');
112
+ assert.equal(assignment.serverName, null);
113
+ assert.equal(assignment.runtimeProvider, provider);
114
+ assert.equal(assignment.providerKind, 'external-runtime');
115
+ });
116
+
117
+ test('a down runtime is isolated: agent.* resolves nowhere while MCP capabilities still do', async () => {
118
+ const { agents } = await discoverRuntimeProviderAgents([
119
+ { id: 'deepagents', provider: createFakeRuntimeProvider({ available: false }) },
120
+ ]);
121
+ assert.equal(agents.length, 0);
122
+
123
+ const session = {
124
+ agentRegistry: { snapshot: () => [mcpAgent('production-main', 'knowledge.update')] },
125
+ agentRegistrySnapshot: [],
126
+ };
127
+ const registry = capabilityRegistryForSession(session);
128
+
129
+ // The external capability is absent because its runtime is down.
130
+ assert.throws(
131
+ () => resolve('agent.review', { workspaceConfig: {}, registry }),
132
+ (error) => error instanceof CapabilityUnavailableError && error.reason === 'capability_not_found',
133
+ );
134
+ // The MCP capability is unaffected.
135
+ const mcpAssignment = resolve('knowledge.update', { workspaceConfig: {}, registry });
136
+ assert.equal(mcpAssignment.agentInstanceId, 'production-main');
137
+ });
138
+
139
+ function sessionWithEvents() {
140
+ return { workspace: 'test', mcp: {}, agentEvents: [], activities: {} };
141
+ }
142
+
143
+ test('resolveRuntimeProviders maps enabled entries and skips unknown types', () => {
144
+ const { providers, skipped } = resolveRuntimeProviders([
145
+ { id: 'fake-1', type: 'fake', capabilities: [{ name: 'agent.echo', operations: ['run'] }] },
146
+ { id: 'mystery', type: 'mystery-runtime' },
147
+ { id: 'off', type: 'fake', enabled: false },
148
+ ]);
149
+
150
+ assert.equal(providers.length, 1);
151
+ assert.equal(providers[0].id, 'fake-1');
152
+ assert.equal(providers[0].type, 'fake');
153
+ assert.equal(skipped.length, 1);
154
+ assert.equal(skipped[0].id, 'mystery');
155
+ });
156
+
157
+ test('loadAgentRuntimesConfig reads both array and object forms', () => {
158
+ const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
159
+ try {
160
+ writeFileSync(join(dir, 'agent-runtimes.json'), JSON.stringify({ runtimes: [{ id: 'a', type: 'fake' }] }));
161
+ assert.deepEqual(loadAgentRuntimesConfig({ stateDir: dir }), [{ id: 'a', type: 'fake' }]);
162
+ } finally {
163
+ rmSync(dir, { recursive: true, force: true });
164
+ }
165
+ });
166
+
167
+ test('loadAgentRuntimesConfig returns [] for a missing or unreadable file', () => {
168
+ const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
169
+ try {
170
+ assert.deepEqual(loadAgentRuntimesConfig({ stateDir: dir, env: {} }), []);
171
+ writeFileSync(join(dir, 'agent-runtimes.json'), '{not json');
172
+ const logs = [];
173
+ assert.deepEqual(loadAgentRuntimesConfig({ stateDir: dir, log: (message) => logs.push(message), env: {} }), []);
174
+ assert.equal(logs.length, 1);
175
+ } finally {
176
+ rmSync(dir, { recursive: true, force: true });
177
+ }
178
+ });
179
+
180
+ test('GATEWAY_ENABLED implies the deepagents runtime with the bearer token (one switch)', () => {
181
+ const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
182
+ try {
183
+ const config = loadAgentRuntimesConfig({
184
+ stateDir: dir,
185
+ env: { GATEWAY_ENABLED: 'true', GATEWAY_PORT: '7789', GATEWAY_AUTH_TOKEN: 'tok-1' },
186
+ });
187
+ const implied = config.find((entry) => entry.type === 'deepagents');
188
+ assert.ok(implied, 'the gateway is declared when GATEWAY_ENABLED is set');
189
+ assert.equal(implied.enabled, true);
190
+ assert.equal(implied.endpoint, 'http://localhost:7789');
191
+ assert.deepEqual(implied.headers, { Authorization: 'Bearer tok-1' });
192
+ } finally {
193
+ rmSync(dir, { recursive: true, force: true });
194
+ }
195
+ });
196
+
197
+ test('an explicit enabled deepagents entry wins over the implied one', () => {
198
+ const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
199
+ try {
200
+ writeFileSync(join(dir, 'agent-runtimes.json'), JSON.stringify({
201
+ runtimes: [{ id: 'deepagents', type: 'deepagents', endpoint: 'http://custom:9000', enabled: true }],
202
+ }));
203
+ const config = loadAgentRuntimesConfig({ stateDir: dir, env: { GATEWAY_ENABLED: 'true' } });
204
+ assert.equal(config.length, 1);
205
+ assert.equal(config[0].endpoint, 'http://custom:9000');
206
+ } finally {
207
+ rmSync(dir, { recursive: true, force: true });
208
+ }
209
+ });
210
+
211
+ test('discoverRuntimeProvidersOnce populates session.runtimeProviderAgents and announces down runtimes', async () => {
212
+ const session = sessionWithEvents();
213
+
214
+ const agents = await discoverRuntimeProvidersOnce(session, {
215
+ config: [
216
+ { id: 'fake', type: 'fake', capabilities: [{ name: 'agent.echo', operations: ['run'] }] },
217
+ { id: 'down', type: 'fake', available: false },
218
+ ],
219
+ });
220
+
221
+ assert.equal(agents.length, 1);
222
+ assert.equal(session.runtimeProviderAgents.length, 1);
223
+ assert.equal(session.runtimeProviderAgents[0].agentInstanceId, 'fake::agent.echo');
224
+
225
+ const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log');
226
+ assert.ok(
227
+ logs.some((event) => String(event.payload?.message ?? '').includes('down')
228
+ && String(event.payload?.message ?? '').includes('unavailable')),
229
+ 'a down runtime is announced in the journal',
230
+ );
231
+ });
232
+
233
+ test('discoverRuntimeProvidersOnce announces a down runtime only once across re-scans', async () => {
234
+ const session = sessionWithEvents();
235
+ const config = [{ id: 'down', type: 'fake', available: false }];
236
+
237
+ await discoverRuntimeProvidersOnce(session, { config });
238
+ await discoverRuntimeProvidersOnce(session, { config });
239
+
240
+ const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
241
+ && String(event.payload?.message ?? '').includes('unavailable'));
242
+ assert.equal(logs.length, 1, 'the degradation is announced once, not on every re-scan');
243
+ });
244
+
245
+ test('a transient probe failure keeps the last-known capability set (a failed probe is not a lost agent)', async () => {
246
+ const session = sessionWithEvents();
247
+ const up = [{ id: 'gw', type: 'fake', capabilities: [{ name: 'agent.review', operations: ['run'] }] }];
248
+ const down = [{ id: 'gw', type: 'fake', available: false }];
249
+
250
+ await discoverRuntimeProvidersOnce(session, { config: up });
251
+ assert.equal(session.runtimeProviderAgents.length, 1);
252
+
253
+ // Network blip during a periodic re-scan: discovery returns nothing.
254
+ await discoverRuntimeProvidersOnce(session, { config: down });
255
+ assert.equal(session.runtimeProviderAgents.length, 1, 'the capability survives the blip');
256
+ assert.equal(session.runtimeProviderAgents[0].agentInstanceId, 'gw::agent.review');
257
+
258
+ const logs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log'
259
+ && String(event.payload?.message ?? '').includes('keeping'));
260
+ assert.equal(logs.length, 1, 'the preservation is announced');
261
+
262
+ // Recovery: a fresh answer is authoritative again.
263
+ await discoverRuntimeProvidersOnce(session, { config: up });
264
+ assert.equal(session.runtimeProviderAgents.length, 1);
265
+ });
266
+
267
+ test('a healthy runtime that answers is authoritative — its set replaces, never merges with, the last-known one', async () => {
268
+ const session = sessionWithEvents();
269
+ await discoverRuntimeProvidersOnce(session, {
270
+ config: [{ id: 'gw', type: 'fake', capabilities: [{ name: 'agent.review', operations: ['run'] }] }],
271
+ });
272
+ assert.deepEqual(session.runtimeProviderAgents.map((a) => a.agentInstanceId), ['gw::agent.review']);
273
+
274
+ await discoverRuntimeProvidersOnce(session, {
275
+ config: [{ id: 'gw', type: 'fake', capabilities: [{ name: 'agent.consistency', operations: ['run'] }] }],
276
+ });
277
+ assert.deepEqual(
278
+ session.runtimeProviderAgents.map((a) => a.agentInstanceId),
279
+ ['gw::agent.consistency'],
280
+ 'the stale agent.review is gone, not kept alongside',
281
+ );
282
+ });
283
+
284
+ test('GATEWAY_ENABLED inherits the disabled entry\'s declared capabilities', () => {
285
+ const dir = mkdtempSync(join(tmpdir(), 'agent-runtimes-'));
286
+ try {
287
+ writeFileSync(join(dir, 'agent-runtimes.json'), JSON.stringify({
288
+ runtimes: [{
289
+ id: 'deepagents',
290
+ type: 'deepagents',
291
+ endpoint: 'http://agent-runtime:7789',
292
+ enabled: false,
293
+ capabilities: [
294
+ { name: 'agent.notify', operations: ['run'], defaultRequiresApproval: true },
295
+ ],
296
+ }],
297
+ }));
298
+ const config = loadAgentRuntimesConfig({ stateDir: dir, env: { GATEWAY_ENABLED: 'true' } });
299
+ const implied = config.find((entry) => entry.type === 'deepagents');
300
+ assert.ok(implied);
301
+ assert.equal(implied.enabled, true);
302
+ assert.equal(implied.endpoint, 'http://localhost:7789', 'the endpoint stays host-local');
303
+ assert.deepEqual(implied.capabilities, [
304
+ { name: 'agent.notify', operations: ['run'], defaultRequiresApproval: true },
305
+ ], 'the shipped approval metadata is not discarded');
306
+ assert.equal(config.filter((entry) => entry.type === 'deepagents').length, 1, 'no duplicate entry');
307
+ } finally {
308
+ rmSync(dir, { recursive: true, force: true });
309
+ }
310
+ });
311
+
312
+ test('external runtime capabilities carry aliases through to the registry', async () => {
313
+ const provider = createFakeRuntimeProvider({
314
+ capabilities: [{ name: 'agent.review', operations: ['run'], aliases: ['audit', 'review', 'analyze'] }],
315
+ });
316
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
317
+
318
+ assert.deepEqual(agents[0].description.capabilities[0].aliases, ['audit', 'review', 'analyze']);
319
+ assert.equal(agents[0].description.orchestration.canPlan, false);
320
+ assert.equal(agents[0].description.orchestration.singleTaskOnly, true, 'external runtimes are executor-only single-task');
321
+ });
322
+
323
+ test('external runtime capabilities carry mutationClass and defaultRequiresApproval through', async () => {
324
+ const provider = createFakeRuntimeProvider({
325
+ capabilities: [
326
+ { name: 'agent.research', operations: ['run'], mutationClass: 'ingest' },
327
+ { name: 'agent.notify', operations: ['run'], defaultRequiresApproval: true },
328
+ ],
329
+ });
330
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
331
+
332
+ const byName = new Map(
333
+ agents.map((agent) => [agent.description.capabilities[0].id, agent.description.capabilities[0]]),
334
+ );
335
+ assert.equal(byName.get('agent.research').mutationClass, 'ingest');
336
+ assert.equal(byName.get('agent.research').defaultRequiresApproval, undefined);
337
+ assert.equal(byName.get('agent.notify').defaultRequiresApproval, true);
338
+ assert.equal(byName.get('agent.notify').mutationClass, undefined);
339
+ });
340
+
341
+ test('external runtime capabilities carry aliasOperations through', async () => {
342
+ const provider = createFakeRuntimeProvider({
343
+ capabilities: [{ name: 'agent.plan', operations: ['plan', 'run'], aliasOperations: { plan: 'plan', apply: 'run' } }],
344
+ });
345
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
346
+
347
+ assert.deepEqual(agents[0].description.capabilities[0].aliasOperations, { plan: 'plan', apply: 'run' });
348
+ });
349
+
350
+ test('resolveObjective deterministically routes "audit" to agent.review via aliases (no LLM)', async () => {
351
+ const provider = createFakeRuntimeProvider({
352
+ capabilities: [{ name: 'agent.review', operations: ['run'], aliases: ['audit', 'review', 'analyze'] }],
353
+ });
354
+ const { agents } = await discoverRuntimeProviderAgents([{ id: 'deepagents', provider }]);
355
+ const session = { runtimeProviderAgents: agents };
356
+
357
+ const selection = await resolveObjective('audit la couverture conceptuelle', session);
358
+
359
+ assert.equal(selection.capability, 'agent.review');
360
+ assert.equal(selection.operation, 'run');
361
+ });
@@ -2,6 +2,7 @@ import { validateContract } from '../contracts/schemas.js';
2
2
  import { parseJsonText } from '../core/activity.js';
3
3
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
4
4
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
5
+ import { capabilityRegistryForSession } from './capabilityRegistry.js';
5
6
  import { resolve as resolveCapability } from './capabilityResolver.js';
6
7
  import { integrate } from './planIntegrator.js';
7
8
  import { validateFragment } from './planValidator.js';
@@ -112,7 +113,11 @@ async function maybeExpandPlan(result, {
112
113
  return rejectExpansion({ session, runId, taskId, store, errors: requestValidation.errors.map((message) => ({ code: 'invalid_plan_expansion_request', message })) });
113
114
  }
114
115
 
115
- const effectiveRegistry = registry ?? session.capabilityRegistry;
116
+ // session.capabilityRegistry is never assigned anywhere in production;
117
+ // falling back to it here meant every planExpansionRequest resolved
118
+ // against `undefined` and was unconditionally rejected as
119
+ // capability_unavailable, regardless of whether the capability existed.
120
+ const effectiveRegistry = registry ?? capabilityRegistryForSession(session);
116
121
  let resolved;
117
122
  try {
118
123
  resolved = resolveCapability(request.capability, {
@@ -151,6 +156,24 @@ async function maybeExpandPlan(result, {
151
156
  const toolName = toolNameFor(session, serverName, 'agent_plan');
152
157
  const planRequest = agentPlanRequest(request, session);
153
158
  const fragment = parseToolPayload(await callTool(session.mcp, serverName, toolName, planRequest));
159
+ // A planner that REFUSES answers { ok: false, error } (the production agent
160
+ // does, e.g. "knowledge.update cannot plan operation: doctor"). Feeding that
161
+ // envelope to validateFragment turned the planner's actual sentence into
162
+ // contract noise ("taskGraphFragment.ok is not allowed") — the human saw a
163
+ // schema violation instead of the reason. Surface the planner's words.
164
+ if (fragment && typeof fragment === 'object' && !Array.isArray(fragment) && fragment.ok === false) {
165
+ return rejectExpansion({
166
+ session,
167
+ runId,
168
+ taskId,
169
+ store,
170
+ errors: [{
171
+ code: 'planner_rejected',
172
+ message: String(fragment.error ?? 'the planner rejected the objective without a reason'),
173
+ details: { capability: request.capability, operation: request.operation ?? null },
174
+ }],
175
+ });
176
+ }
154
177
  const validation = validateFragment(fragment, {
155
178
  registry: effectiveRegistry,
156
179
  run: { plannerAgentInstanceId: resolved.agentInstanceId },
@@ -211,7 +234,17 @@ function agentPlanRequest(request, session) {
211
234
  objective: request.objective ?? request.reason ?? undefined,
212
235
  workspace: request.workspace ?? workspaceRequest(session),
213
236
  arguments: request.arguments && typeof request.arguments === 'object' ? request.arguments : {},
214
- constraints: request.constraints && typeof request.constraints === 'object' ? request.constraints : {},
237
+ constraints: {
238
+ ...(request.constraints && typeof request.constraints === 'object' ? request.constraints : {}),
239
+ // Default governance, same as prepareDelegation: mutations wait for a
240
+ // human grant. Omitting it here let the planner answer mutating tasks
241
+ // with requiresApproval:false — the scheduler dispatched an ingest
242
+ // plan with no gate, and the production agent's confirmation guard
243
+ // then rejected every job ("requires confirm=true").
244
+ // requireApprovalForMutations: false from the proposal itself remains
245
+ // an explicit opt-out (a runtime declaring its own policy).
246
+ requireApprovalForMutations: request.constraints?.requireApprovalForMutations !== false,
247
+ },
215
248
  };
216
249
  }
217
250
 
@@ -209,3 +209,65 @@ function expansionRegistry() {
209
209
  },
210
210
  };
211
211
  }
212
+
213
+ test('resultAggregator surfaces the planner rejection instead of contract noise', async () => {
214
+ const session = sessionWithPlan();
215
+ session.approvals = [{ id: 'approval-run', scope: 'run', status: 'approved', runId: 'run-expansion' }];
216
+
217
+ const result = await accept(taskResultWithExpansion(), {
218
+ session,
219
+ runId: 'run-expansion',
220
+ task: session.headlessPlan[0],
221
+ assignment: { agentInstanceId: 'production-main', serverName: 'production' },
222
+ registry: expansionRegistry(),
223
+ callTool: async () => ({ ok: false, error: 'knowledge.update cannot plan operation: doctor' }),
224
+ });
225
+
226
+ assert.equal(result.ok, true, 'the producing task itself completed');
227
+ assert.equal(result.expansion.ok, false);
228
+ assert.equal(result.expansion.errors[0].code, 'planner_rejected');
229
+ assert.match(result.expansion.errors[0].message, /cannot plan operation: doctor/);
230
+ });
231
+
232
+ test('resultAggregator defaults requireApprovalForMutations to true on the plan request', async () => {
233
+ const session = sessionWithPlan();
234
+ session.approvals = [{ id: 'approval-run', scope: 'run', status: 'approved', runId: 'run-expansion' }];
235
+ const calls = [];
236
+
237
+ await accept(taskResultWithExpansion(), {
238
+ session,
239
+ runId: 'run-expansion',
240
+ task: session.headlessPlan[0],
241
+ assignment: { agentInstanceId: 'production-main', serverName: 'production' },
242
+ registry: expansionRegistry(),
243
+ callTool: async (_mcp, serverName, toolName, args) => {
244
+ calls.push(args);
245
+ return expansionFragment();
246
+ },
247
+ });
248
+
249
+ assert.equal(calls[0].constraints.requireApprovalForMutations, true);
250
+ });
251
+
252
+ test('resultAggregator honours an explicit opt-out from the proposal constraints', async () => {
253
+ const session = sessionWithPlan();
254
+ session.approvals = [{ id: 'approval-run', scope: 'run', status: 'approved', runId: 'run-expansion' }];
255
+ const calls = [];
256
+ const expansion = taskResultWithExpansion();
257
+ expansion.planExpansionRequest.constraints = { requireApprovalForMutations: false, maxTasks: 2 };
258
+
259
+ await accept(expansion, {
260
+ session,
261
+ runId: 'run-expansion',
262
+ task: session.headlessPlan[0],
263
+ assignment: { agentInstanceId: 'production-main', serverName: 'production' },
264
+ registry: expansionRegistry(),
265
+ callTool: async (_mcp, serverName, toolName, args) => {
266
+ calls.push(args);
267
+ return expansionFragment();
268
+ },
269
+ });
270
+
271
+ assert.equal(calls[0].constraints.requireApprovalForMutations, false);
272
+ assert.equal(calls[0].constraints.maxTasks, 2);
273
+ });
@@ -1,7 +1,7 @@
1
1
  import { parseJsonText } from '../core/activity.js';
2
2
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
3
  import { formatMcpToolResult, callMcpTool as defaultCallMcpTool } from '../core/mcp.js';
4
- import { createCapabilityRegistry } from '../orchestrator/capabilityRegistry.js';
4
+ import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
5
5
  import { accept as acceptResult } from '../orchestrator/resultAggregator.js';
6
6
  import { isSuccessful, isTerminal } from '../orchestrator/taskStatuses.js';
7
7
 
@@ -100,6 +100,9 @@ async function recoverTask({ store, session, run, task, callTool, resultAggregat
100
100
  }
101
101
 
102
102
  const agent = agentFor(session, assignment.agentInstanceId);
103
+ if (agent?.providerKind === 'external-runtime' && typeof agent?.runtimeProvider?.status === 'function') {
104
+ return recoverExternalRuntimeTask({ store, session, run, task, attempt, assignment, agent, resultAggregator });
105
+ }
103
106
  const serverName = agent?.serverName ?? assignment.agentId ?? assignment.agentInstanceId;
104
107
  const statusTool = toolNameFor(session, serverName, 'agent_status');
105
108
  const status = parseToolPayload(await callTool(session.mcp, serverName, statusTool, { jobId: attempt.jobId }));
@@ -146,6 +149,70 @@ async function recoverTask({ store, session, run, task, callTool, resultAggregat
146
149
  return interruptTask({ store, session, run, task, reason: 'active job is non-terminal and task has no idempotencyKey' });
147
150
  }
148
151
 
152
+ // An MCP job survives a manager restart on the agent's own side and reports
153
+ // its status through agent_status. An external-runtime job has no such
154
+ // side-channel here: the only way to check on it, or to give it up, is the
155
+ // same RuntimeProvider the dispatcher used to start it, re-resolved by
156
+ // agentInstanceId from the live registry rather than replayed from storage.
157
+ async function recoverExternalRuntimeTask({ store, session, run, task, attempt, assignment, agent, resultAggregator }) {
158
+ const runtimeProvider = agent.runtimeProvider;
159
+ let status;
160
+ try {
161
+ status = await runtimeProvider.status(attempt.jobId);
162
+ } catch (error) {
163
+ return interruptTask({
164
+ store,
165
+ session,
166
+ run,
167
+ task,
168
+ reason: `external runtime status check failed: ${error instanceof Error ? error.message : String(error)}`,
169
+ });
170
+ }
171
+
172
+ if (isTerminal(status?.status)) {
173
+ const result = {
174
+ ok: isSuccessful(String(status?.status ?? '').toLowerCase()),
175
+ taskId: task.id,
176
+ attemptId: attempt.attemptId ?? null,
177
+ jobId: attempt.jobId,
178
+ agentInstanceId: assignment.agentInstanceId,
179
+ status: status?.status,
180
+ outputRefs: Array.isArray(status?.result?.outputRefs) ? status.result.outputRefs : [],
181
+ metrics: status?.result?.metrics ?? {},
182
+ // The gateway reports its failure at the TOP level of the status
183
+ // payload ({ runId, status, error }), not inside `result` — same fix
184
+ // already applied in dispatcher.js's taskResultFromStatus and
185
+ // deepAgentsProvider.js's status().
186
+ error: status?.result?.error ?? status?.error ?? null,
187
+ rawStatus: status,
188
+ };
189
+ await resultAggregator(result, {
190
+ session,
191
+ runId: run.id,
192
+ task,
193
+ assignment: { agentInstanceId: assignment.agentInstanceId, serverName: null, agent },
194
+ store,
195
+ registry: capabilityRegistryForSession(session),
196
+ workspaceConfig: session.wikircConfig ?? session.wikirc?.config ?? {},
197
+ });
198
+ return { status: 'recovered', runId: run.id, taskId: task.id, jobId: attempt.jobId };
199
+ }
200
+
201
+ // The runtime declares supportsIdempotency: false (runtimeProviders.js), so
202
+ // a fresh invocation cannot be deduped against the one still running on the
203
+ // external side. Requeuing it to `pending` like the MCP path below would
204
+ // start a duplicate while the orphaned original keeps running/billing.
205
+ // Cancel it explicitly instead of leaving it to run unattended.
206
+ await runtimeProvider.cancel(attempt.jobId).catch(() => null);
207
+ return interruptTask({
208
+ store,
209
+ session,
210
+ run,
211
+ task,
212
+ reason: 'active external-runtime job cancelled on recovery (no idempotency support)',
213
+ });
214
+ }
215
+
149
216
  function interruptTask({ store, session, run, task, reason }) {
150
217
  dispatch(session, store, 'runtime_log', {
151
218
  origin: 'recovery_manager',
@@ -171,10 +238,7 @@ function latestAssignment(assignments, attemptId) {
171
238
 
172
239
 
173
240
  function capabilityResolvable(session, capability) {
174
- const registry = session.capabilityRegistry
175
- ?? ((session.agentRegistrySnapshot ?? []).length > 0
176
- ? createCapabilityRegistry({ agents: session.agentRegistrySnapshot })
177
- : null);
241
+ const registry = capabilityRegistryForSession(session);
178
242
  if (!registry || typeof registry.providersFor !== 'function') return true;
179
243
  // Only trust a registry that actually knows about capabilities. An empty
180
244
  // one (discovery not finished, or agents described without capability
@@ -211,6 +275,7 @@ function agentFor(session, agentInstanceId) {
211
275
  return [
212
276
  ...(session.agentRegistrySnapshot ?? []),
213
277
  ...(session.agents ?? []),
278
+ ...(session.runtimeProviderAgents ?? []),
214
279
  ].find((agent) => agent?.agentInstanceId === agentInstanceId) ?? null;
215
280
  }
216
281
 
@@ -142,7 +142,7 @@ test('E2E-002 wiki-sync: two objectives, two ordered runs, one chainId', async (
142
142
  assert.equal(body.objectives, 2);
143
143
  assert.equal(env.runs.length, 2, 'the second objective must run after the first');
144
144
  assert.match(env.runs[0].input, /^Export the requested Confluence source/);
145
- assert.match(env.runs[1].input, /^Run the production pipeline over the newly exported Markdown/);
145
+ assert.match(env.runs[1].input, /^Run the production pipeline step ingest over the newly exported Markdown/);
146
146
  // CME first, Production second — and the parameter reaches the step that
147
147
  // consumes it, not only the last objective.
148
148
  for (const run of env.runs) assert.match(run.input, /User parameters:\nsource: docs/);
@@ -202,7 +202,7 @@ test('E2E-003 cancel: the running step and its chain stop, unrelated queue survi
202
202
  // that silently fragments would show up as extra runs, not as extra objectives.
203
203
  const PERFORMANCE_TABLE = {
204
204
  pipeline: 1,
205
- 'wiki-ingest': 2,
205
+ 'wiki-ingest': 1,
206
206
  'wiki-build': 1,
207
207
  deliver: 1,
208
208
  diagnose: 1,
@@ -1,11 +1,11 @@
1
1
  import { openSync, readSync, closeSync, fstatSync } from 'node:fs';
2
2
  import { isAbsolute, join, normalize, resolve } from 'node:path';
3
- import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
+ import { createAgentEvent, dispatchAgentEvent, dispatchRuntimeLog } from '../core/agentEvents.js';
4
4
  import { extractActivity, mergePolledActivity, parseJsonText, sessionActivities } from '../core/activity.js';
5
5
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
6
- import { normalizeRuntimeLog } from '../core/runtimeLog.js';
7
6
  import { startNextQueuedJob, syncQueueWithActivity } from '../core/jobQueue.js';
8
7
  import { createAgentRegistry } from '../orchestrator/agentRegistry.js';
8
+ import { discoverRuntimeProvidersOnce } from '../orchestrator/providers/runtimeProviders.js';
9
9
 
10
10
  export function startActivitySupervisor(session, {
11
11
  intervalMs = 1000,
@@ -52,11 +52,13 @@ export function startActivitySupervisor(session, {
52
52
  }
53
53
  }
54
54
  void discoverAgentsOnce(session, { registry, signal: runSignal });
55
+ void discoverRuntimeProvidersOnce(session, { signal: runSignal });
55
56
  }, agentRegistryIntervalMs)
56
57
  : null;
57
58
 
58
59
  void pollActivitiesOnce(session, { pollBusy, callTool, signal: runSignal });
59
60
  void discoverAgentsOnce(session, { registry, signal: runSignal });
61
+ void discoverRuntimeProvidersOnce(session, { signal: runSignal });
60
62
 
61
63
  return {
62
64
  pollBusy,
@@ -199,14 +201,7 @@ export async function pollActivitiesOnce(session, {
199
201
  }
200
202
 
201
203
  export function emitRuntimeLog(session, message) {
202
- const payload = normalizeRuntimeLog(message, { session });
203
- dispatchAgentEvent(session, createAgentEvent('runtime_log', {
204
- origin: 'runtime',
205
- runId: payload.runId ?? null,
206
- taskId: payload.taskId ?? null,
207
- workspace: payload.workspaceId ?? null,
208
- payload,
209
- }));
204
+ dispatchRuntimeLog(session, message);
210
205
  }
211
206
 
212
207
  function registryIntervalFromEnv() {