@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
@@ -1,7 +1,10 @@
1
1
  import { normalizeActivity, parseJsonText } from '../core/activity.js';
2
2
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
3
3
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
4
+ import { loadWorkspaceProfile } from '../core/profile.js';
5
+ import { mapRuntimeEvent } from '../core/runtimeEventAdapter.js';
4
6
  import { emitRuntimeLog, pollActivitiesOnce } from '../runtime/supervisor.js';
7
+ import { APPROVAL_DEFAULT_CLASS, approvalCovered } from './approvalPolicy.js';
5
8
  import { isSuccessful, isTerminal } from './taskStatuses.js';
6
9
 
7
10
 
@@ -32,6 +35,16 @@ export async function execute(task, assignment, {
32
35
  pollBusy = new Set(),
33
36
  pollIntervalMs = 2500,
34
37
  } = {}) {
38
+ if (isExternalRuntimeAssignment(assignment)) {
39
+ return executeExternalRuntime(task, assignment, {
40
+ session,
41
+ signal,
42
+ runId,
43
+ attempt,
44
+ timeoutMs,
45
+ pollIntervalMs,
46
+ });
47
+ }
35
48
  if (!session) throw new Error('dispatcher.execute requires session.');
36
49
  if (!assignment?.serverName) throw new Error(`No MCP server found for agent ${assignment?.agentInstanceId ?? '(unknown)'}.`);
37
50
  const serverName = assignment.serverName;
@@ -157,6 +170,243 @@ export async function execute(task, assignment, {
157
170
  }
158
171
  }
159
172
 
173
+ function isExternalRuntimeAssignment(assignment) {
174
+ return assignment?.providerKind === 'external-runtime'
175
+ && typeof assignment?.runtimeProvider?.execute === 'function';
176
+ }
177
+
178
+ // A proposal without mutations still waits for a human grant: the request is
179
+ // treated as the default approval class, covered by a run-scope grant like
180
+ // any other. Every announced class must be covered — the "global" approval is
181
+ // bounded by the proposal.
182
+ function pendingApprovalCovered(classes, approvals, context) {
183
+ const list = Array.isArray(classes) && classes.length > 0
184
+ ? classes
185
+ : [APPROVAL_DEFAULT_CLASS];
186
+ return list.every((approvalClass) => approvalCovered(
187
+ {
188
+ // Without an id, grantCoversTask's task/tool-scope match (which keys
189
+ // off task.id/localId/taskId) can never succeed, so a per-task
190
+ // `/approve item <id>` grant silently fails to cover this task and only
191
+ // a run-scope "approve all" can ever unblock it.
192
+ id: context?.taskId ?? null,
193
+ taskId: context?.taskId ?? null,
194
+ groupId: context?.groupId ?? null,
195
+ requiresApproval: true,
196
+ approvalClass: String(approvalClass),
197
+ },
198
+ approvals,
199
+ context,
200
+ ));
201
+ }
202
+
203
+ async function executeExternalRuntime(task, assignment, {
204
+ session,
205
+ signal = null,
206
+ runId = null,
207
+ attempt = null,
208
+ timeoutMs = null,
209
+ pollIntervalMs = 2500,
210
+ } = {}) {
211
+ if (!session) throw new Error('dispatcher.execute requires session.');
212
+ const runtimeProvider = assignment.runtimeProvider;
213
+ const taskId = String(task.id ?? task.step);
214
+ const taskTimeoutMs = resolvedTimeoutMs(assignment, timeoutMs);
215
+ let deadline = Date.now() + taskTimeoutMs;
216
+ let runtimeRunId = null;
217
+ let unsubscribe = null;
218
+ let pendingApproval = null;
219
+ try {
220
+ emitRuntimeLog(session, taskLogPayload('runtime.execute', task, assignment, {
221
+ runId,
222
+ attempt,
223
+ detail: 'external-runtime',
224
+ }));
225
+ const mcpPool = activeProfileMcp(session);
226
+ if (!mcpPool) {
227
+ // A degradation must announce itself. The runtime's whole value is its
228
+ // eyes: dispatched without the workspace wiki MCP, the Deep Agent
229
+ // improvises with its built-in backend (it ran `ls /` on the gateway
230
+ // container and answered "the workspace is empty" — a plausible,
231
+ // confident, entirely wrong answer). Say it in the journal, keep the
232
+ // run (the objective may still be answerable from the model alone),
233
+ // but never let blindness pass silently.
234
+ emitRuntimeLog(session, taskLogPayload('runtime.blind', task, assignment, {
235
+ runId,
236
+ attempt,
237
+ detail: 'no workspace wiki MCP pool — the endpoint is down or declares no read tools; the runtime will run without its eyes',
238
+ }));
239
+ }
240
+ const accepted = await runtimeProvider.execute({
241
+ objective: task.label ?? task.description ?? taskId,
242
+ operation: task.operation ?? null,
243
+ capability: task.requiredCapability ?? null,
244
+ arguments: task.arguments && typeof task.arguments === 'object' ? task.arguments : {},
245
+ workspace: workspaceRequest(session),
246
+ model: activeProfileModel(session),
247
+ language: session?.language ?? session?.wikircConfig?.language ?? null,
248
+ mcp: mcpPool,
249
+ systemPrompt: activeRuntimeSystemPrompt(session, task, assignment),
250
+ });
251
+ runtimeRunId = String(accepted?.runId ?? '');
252
+ if (!runtimeRunId) throw new Error('runtime.execute did not return runId.');
253
+ emitRuntimeLog(session, taskLogPayload('runtime.accepted', task, assignment, {
254
+ runId,
255
+ attempt,
256
+ jobId: runtimeRunId,
257
+ detail: String(accepted?.status ?? 'running'),
258
+ }));
259
+ dispatchAgentEvent(session, createAgentEvent('task.started', {
260
+ origin: 'dispatcher',
261
+ runId,
262
+ taskId,
263
+ payload: {
264
+ runId,
265
+ taskId,
266
+ attemptId: attempt?.attemptId ?? null,
267
+ agentInstanceId: assignment.agentInstanceId,
268
+ jobId: runtimeRunId,
269
+ startedAt: new Date().toISOString(),
270
+ },
271
+ }));
272
+ dispatchExternalRuntimeActivity(session, task, assignment, runtimeRunId, 'running', runId);
273
+ if (typeof runtimeProvider.subscribe === 'function') {
274
+ unsubscribe = runtimeProvider.subscribe(runtimeRunId, (event) => {
275
+ for (const mapped of mapRuntimeEvent(event)) {
276
+ dispatchAgentEvent(session, createAgentEvent(mapped.type, {
277
+ origin: 'runtime_provider',
278
+ runId,
279
+ taskId,
280
+ payload: mapped.payload,
281
+ }));
282
+ if (mapped.type === 'approval.requested') {
283
+ pendingApproval = {
284
+ approvalId: mapped.payload?.approvalId ?? null,
285
+ approvalClasses: Array.isArray(mapped.payload?.approvalClasses)
286
+ ? mapped.payload.approvalClasses
287
+ : [],
288
+ };
289
+ const summary = String(mapped.payload?.proposal?.summary ?? mapped.payload?.reason ?? '').trim();
290
+ dispatchAgentEvent(session, createAgentEvent('assistant_message', {
291
+ origin: 'runtime_provider',
292
+ runId,
293
+ payload: {
294
+ content: [
295
+ '⏸ Approval required before execution:',
296
+ ...(summary ? [` ${summary}`] : []),
297
+ 'Type /approve (or click "Approve") to proceed, "cancel" to abandon.',
298
+ ].filter(Boolean).join('\n'),
299
+ },
300
+ }));
301
+ }
302
+ }
303
+ });
304
+ }
305
+ let approvalWaitStartedAt = null;
306
+ while (true) {
307
+ throwIfAborted(signal);
308
+ // While a proposal waits for a human decision, the task timeout does
309
+ // not tick — the human decides, not the clock (the scheduler applies
310
+ // the same rule to its approval waits). The elapsed wait is credited
311
+ // back onto the deadline once the gate clears (below), rather than
312
+ // merely skipped from this check, so a late approval does not
313
+ // immediately expire the task on the very next iteration.
314
+ if (!pendingApproval && Date.now() > deadline) {
315
+ await runtimeProvider.cancel(runtimeRunId).catch(() => null);
316
+ throw new Error(`Task timed out after ${taskTimeoutMs}ms.`);
317
+ }
318
+ if (pendingApproval) {
319
+ approvalWaitStartedAt ??= Date.now();
320
+ const approvalBeingChecked = pendingApproval;
321
+ const approvals = session.agentProjection?.approvals ?? session.approvals ?? [];
322
+ const covered = pendingApprovalCovered(approvalBeingChecked.approvalClasses, approvals, {
323
+ runId,
324
+ taskId,
325
+ groupId: task.groupId ?? null,
326
+ workspaceId: session.workspace ?? null,
327
+ planRevision: session.planRevision ?? session.agentProjection?.planRevision ?? null,
328
+ });
329
+ if (covered && typeof runtimeProvider.approve === 'function') {
330
+ emitRuntimeLog(session, taskLogPayload('runtime.approval_granted', task, assignment, {
331
+ runId,
332
+ attempt,
333
+ jobId: runtimeRunId,
334
+ detail: 'unblocking runtime HITL',
335
+ }));
336
+ await runtimeProvider.approve(runtimeRunId, { approved: true, scope: approvalBeingChecked.approvalClasses });
337
+ // Only clear the approval just resolved: the runtime's event stream
338
+ // may have raised a second, unrelated one while the approve() round
339
+ // trip above was in flight.
340
+ if (pendingApproval === approvalBeingChecked) {
341
+ pendingApproval = null;
342
+ deadline += Date.now() - approvalWaitStartedAt;
343
+ approvalWaitStartedAt = null;
344
+ }
345
+ } else {
346
+ await delay(pollIntervalMs, signal);
347
+ continue;
348
+ }
349
+ }
350
+ const lastStatus = await runtimeProvider.status(runtimeRunId);
351
+ if (isTerminal(lastStatus?.status)) {
352
+ const refusedParams = Array.isArray(lastStatus?.result?.refusedParams)
353
+ ? lastStatus.result.refusedParams
354
+ : [];
355
+ if (refusedParams.length > 0) {
356
+ emitRuntimeLog(session, taskLogPayload('runtime.params_refused', task, assignment, {
357
+ runId,
358
+ attempt,
359
+ jobId: runtimeRunId,
360
+ detail: `model refused sampling parameters (${refusedParams.join(', ')}) — remove them from the workspace .wikirc (llm.<key>) so they are no longer sent`,
361
+ }));
362
+ }
363
+ emitRuntimeLog(session, taskLogPayload('runtime.result_returned', task, assignment, {
364
+ runId,
365
+ attempt,
366
+ jobId: runtimeRunId,
367
+ status: lastStatus.status,
368
+ detail: 'terminal status',
369
+ }));
370
+ dispatchExternalRuntimeActivity(session, task, assignment, runtimeRunId, lastStatus.status, runId);
371
+ return taskResultFromStatus(task, assignment, runtimeRunId, lastStatus, attempt);
372
+ }
373
+ await delay(pollIntervalMs, signal);
374
+ }
375
+ } catch (error) {
376
+ if (isAbortError(error) && runtimeRunId) {
377
+ await runtimeProvider.cancel(runtimeRunId).catch(() => null);
378
+ }
379
+ throw error;
380
+ } finally {
381
+ unsubscribe?.();
382
+ emitRuntimeLog(session, taskLogPayload('lock.released', task, assignment, {
383
+ runId,
384
+ attempt,
385
+ jobId: runtimeRunId,
386
+ detail: attempt?.locks?.join(',') || 'no locks',
387
+ }));
388
+ attempt?.release?.();
389
+ }
390
+ }
391
+
392
+ function dispatchExternalRuntimeActivity(session, task, assignment, runtimeRunId, status, runId) {
393
+ const activity = normalizeActivity({
394
+ id: runtimeRunId,
395
+ source: assignment.runtimeId ?? 'external-runtime',
396
+ kind: task.operation ?? task.requiredCapability ?? 'task',
397
+ label: task.label ?? task.description ?? String(task.id ?? task.step),
398
+ status,
399
+ progress: { percent: isTerminal(status) ? 100 : 0, stepId: String(task.id ?? task.step) },
400
+ outputRefs: [],
401
+ });
402
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
403
+ origin: 'dispatcher',
404
+ runId,
405
+ taskId: String(task.id ?? task.step),
406
+ payload: { activity },
407
+ }));
408
+ }
409
+
160
410
  function executeRequest(task, session, runId) {
161
411
  return {
162
412
  taskId: String(task.id ?? task.step),
@@ -165,7 +415,20 @@ function executeRequest(task, session, runId) {
165
415
  idempotencyKey: task.idempotencyKey ?? undefined,
166
416
  operation: task.operation,
167
417
  workspace: workspaceRequest(session),
168
- arguments: task.arguments && typeof task.arguments === 'object' ? task.arguments : {},
418
+ arguments: {
419
+ ...(task.arguments && typeof task.arguments === 'object' ? task.arguments : {}),
420
+ // The production agent's confirmation guard
421
+ // (PRODUCTION_REQUIRE_CONFIRMATION=true) has one contract: "only an
422
+ // approved task may run a mutating operation — Donna passes
423
+ // confirm=true after the run-scope approval". The scheduler never
424
+ // dispatches a requiresApproval task before coverage
425
+ // (dependencyResolver.readyTasks), so reaching dispatch IS the
426
+ // approval. Without this, an operator who enables the guard sees every
427
+ // mutating production job fail with "requires confirm=true" — the
428
+ // first E2E ingest plan dispatched by the deep agent's
429
+ // planExpansionRequest failed exactly that way, 19 tasks in one batch.
430
+ ...(task.requiresApproval === true ? { confirm: true } : {}),
431
+ },
169
432
  constraints: {
170
433
  requireApprovalForMutations: task.requiresApproval === true,
171
434
  },
@@ -178,6 +441,93 @@ function workspaceRequest(session) {
178
441
  return { name: String(workspace ?? 'workspace') };
179
442
  }
180
443
 
444
+ // The model travels WITH the run: the runtime is workspace-agnostic and must
445
+ // follow the active profile — default or `/config use` — without a config
446
+ // sync. Numeric LLM parameters declared by the profile (temperature, …) ride
447
+ // along too: nothing is hardcoded here, the workspace config is the source.
448
+ function activeProfileModel(session) {
449
+ const llm = session?.wikircConfig?.llm ?? {};
450
+ const model = {
451
+ ...(llm.baseUrl ? { baseUrl: String(llm.baseUrl) } : {}),
452
+ ...(llm.model ? { model: String(llm.model) } : {}),
453
+ ...(llm.apiKey ? { apiKey: String(llm.apiKey) } : {}),
454
+ };
455
+ for (const key of ['temperature', 'maxTokens', 'topP', 'seed']) {
456
+ const value = Number(llm[key]);
457
+ if (Number.isFinite(value)) model[key] = value;
458
+ }
459
+ return Object.keys(model).length > 0 ? model : null;
460
+ }
461
+
462
+ // The read-only wiki MCP tools the runtime is allowed to see. An explicit
463
+ // allow-list, not a denylist: the runtime has eyes (read tools) and a mouth
464
+ // (gated side-effects through the DAG), never hands on the workspace. A denylist
465
+ // keyed on "write_page|add_source|…" already silently let build_context_write
466
+ // and template_write through, and would leak any future wiki_delete_page /
467
+ // wiki_move_page / wiki_rename the moment it is added upstream.
468
+ const READ_ONLY_WIKI_TOOLS = new Set([
469
+ 'help_list', 'help_read', 'help_search',
470
+ 'profile_read', 'template_read',
471
+ 'wiki_collect_context', 'wiki_list_ingested_sources', 'wiki_list_pages',
472
+ 'wiki_outline', 'wiki_read_deliverable', 'wiki_read_ingested_source',
473
+ 'wiki_read_page', 'wiki_read_pages', 'wiki_search_context',
474
+ 'wiki_workspace_status',
475
+ ]);
476
+
477
+ // The runtime's EYES, per run: the active workspace's wiki MCP, read tools
478
+ // only. Workspace-scoped endpoints are per-run by nature — they cannot live
479
+ // in a static gateway file. The allow-list here is the authority: nothing
480
+ // else reaches the runtime.
481
+ export function activeProfileMcp(session) {
482
+ const wiki = session?.mcp?.wiki;
483
+ if (!wiki?.url || wiki.status !== 'connected') return null;
484
+ const tools = (wiki.tools ?? [])
485
+ .map((tool) => String(tool.name ?? ''))
486
+ .filter((name) => {
487
+ if (!name) return false;
488
+ const base = name.includes('__') ? name.slice(name.lastIndexOf('__') + 2) : name;
489
+ return READ_ONLY_WIKI_TOOLS.has(base);
490
+ });
491
+ if (tools.length === 0) return null;
492
+ // Same credential contract as the manager's own MCP client (mcp.js
493
+ // `authorization: Bearer ${endpoint.token}`): the wiki detail carries
494
+ // `token`, not `headers` — without it the gateway's MCP connection is
495
+ // rejected by the workspace MCP server ("invalid or missing bearer token")
496
+ // and the Deep Agent runs blind.
497
+ const headers = {
498
+ ...(wiki.headers && typeof wiki.headers === 'object' ? wiki.headers : {}),
499
+ ...(wiki.token ? { Authorization: `Bearer ${wiki.token}` } : {}),
500
+ };
501
+ return [{
502
+ name: 'wiki',
503
+ url: String(wiki.url),
504
+ ...(Object.keys(headers).length > 0 ? { headers } : {}),
505
+ tools,
506
+ }];
507
+ }
508
+
509
+ // The Deep Agent's system prompt, built per run from the same ingredients
510
+ // Donna uses: role, the ONE capability being executed (with its declared
511
+ // description), the eyes/bouche/mains boundary, the workspace profile and the
512
+ // reply language. Without it, the runtime falls back to deepagents' generic
513
+ // assistant prompt — which is exactly the "upload your project" hallucination.
514
+ function activeRuntimeSystemPrompt(session, task, assignment) {
515
+ const capability = assignment?.capability ?? null;
516
+ const description = String(capability?.description ?? '').trim();
517
+ const language = session?.language ?? session?.wikircConfig?.language ?? null;
518
+ const profile = loadWorkspaceProfile(session?.workspacePath);
519
+ return [
520
+ 'You are the agentic analysis engine of a knowledge workspace (wikiLLM), executed behind the manager Donna.',
521
+ `Execute exactly ONE capability: ${task?.requiredCapability ?? 'unknown'}${description ? ` — ${description}` : ''}.`,
522
+ `Operation: ${task?.operation ?? 'run'}.`,
523
+ 'Boundary: you have READ tools only (the workspace wiki). You never modify the workspace — structural changes are proposals you return in your final answer (a planExpansionRequest), the manager integrates them under human approval. Side-effects on the outside world are gated by approval.',
524
+ 'Ground every claim in what the read tools return. Never invent pages, names, facts, jobs or results.',
525
+ 'Tool discipline: discover real page paths with the list/search tools BEFORE reading. Never guess a path — a read refused for "path not allowed" means the path was invented, so list/search first, then read exactly what exists.',
526
+ ...(language ? [`Reply in the workspace language: ${language}.`] : []),
527
+ ...(profile ? [`Workspace preferences — apply them to every reply:\n${profile}`] : []),
528
+ ].join('\n');
529
+ }
530
+
181
531
  function dispatchTaskActivity(session, task, assignment, jobId, statusTool, runId) {
182
532
  const activity = normalizeActivity({
183
533
  id: jobId,
@@ -215,7 +565,16 @@ function taskResultFromStatus(task, assignment, jobId, statusPayload, attempt =
215
565
  status: result.status ?? statusPayload?.status,
216
566
  outputRefs: Array.isArray(result.outputRefs) ? result.outputRefs : [],
217
567
  metrics: result.metrics ?? {},
218
- error: normalizeTaskError(result.error),
568
+ // The gateway reports its failure at the TOP level of the status payload
569
+ // ({ runId, status, error }), not inside `result`. Reading only
570
+ // `result.error` dropped the only actionable sentence ("no model…") and
571
+ // left Donna to invent a cause.
572
+ error: normalizeTaskError(result.error ?? statusPayload.error),
573
+ // Agent -> DAG (RFC § 30/31/41): a result may request the execution of a
574
+ // deterministic capability. Propagated here so resultAggregator.maybeExpandPlan
575
+ // picks it up and routes it through the manager's own resolution + approval
576
+ // — never a direct scheduler call by the runtime.
577
+ ...(result.planExpansionRequest ? { planExpansionRequest: result.planExpansionRequest } : {}),
219
578
  rawStatus: statusPayload,
220
579
  };
221
580
  }
@@ -1,6 +1,52 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { createDispatcher, normalizeTaskError } from './dispatcher.js';
3
+ import { activeProfileMcp, createDispatcher, normalizeTaskError } from './dispatcher.js';
4
+
5
+ test('activeProfileMcp forwards only the read-only wiki tools to the external runtime', () => {
6
+ const session = {
7
+ mcp: {
8
+ wiki: {
9
+ url: 'http://wiki:3000/mcp',
10
+ status: 'connected',
11
+ token: 't',
12
+ tools: [
13
+ { name: 'wiki_read_page' },
14
+ { name: 'wiki_search_context' },
15
+ { name: 'wiki_workspace_status' },
16
+ { name: 'help_read' },
17
+ // every mutation tool must be dropped, including the ones the old
18
+ // substring denylist ("write_page|add_source|…") let through:
19
+ { name: 'wiki_write_page' },
20
+ { name: 'wiki_add_source' },
21
+ { name: 'profile_update' },
22
+ { name: 'template_write' },
23
+ { name: 'build_context_write' },
24
+ // and a hypothetical future mutation tool the denylist could not know:
25
+ { name: 'wiki_delete_page' },
26
+ { name: 'wiki_move_page' },
27
+ ],
28
+ },
29
+ },
30
+ };
31
+ const [pool] = activeProfileMcp(session);
32
+ assert.deepEqual(
33
+ [...pool.tools].sort(),
34
+ ['help_read', 'wiki_read_page', 'wiki_search_context', 'wiki_workspace_status'],
35
+ );
36
+ });
37
+
38
+ test('activeProfileMcp tolerates namespaced tool names', () => {
39
+ const session = {
40
+ mcp: {
41
+ wiki: {
42
+ url: 'http://wiki:3000/mcp',
43
+ status: 'connected',
44
+ tools: [{ name: 'wiki__wiki_read_page' }, { name: 'wiki__wiki_write_page' }],
45
+ },
46
+ },
47
+ };
48
+ assert.deepEqual(activeProfileMcp(session)[0].tools, ['wiki__wiki_read_page']);
49
+ });
4
50
 
5
51
  test('dispatcher returns a retryable logical failure when agent_execute reports workspace_busy', async () => {
6
52
  const session = {
@@ -215,3 +261,68 @@ test('normalizeTaskError falls back only when the agent reports no reason at all
215
261
  assert.equal(error.code, 'execution_rejected');
216
262
  assert.equal(error.message, 'agent_execute rejected task');
217
263
  });
264
+
265
+ test('dispatcher passes confirm=true to agent_execute for an approval-gated task', async () => {
266
+ let executeArgs;
267
+ const session = {
268
+ workspace: 'test',
269
+ mcp: {
270
+ production: {
271
+ tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
272
+ },
273
+ },
274
+ activities: {},
275
+ };
276
+ const dispatcher = createDispatcher({
277
+ session,
278
+ pollIntervalMs: 1,
279
+ callTool: async (_mcp, _server, tool, args) => {
280
+ if (tool === 'agent_execute') {
281
+ executeArgs = args;
282
+ return { accepted: true, jobId: 'job-ingest', status: 'queued' };
283
+ }
284
+ return { jobId: 'job-ingest', status: 'succeeded', terminal: true, result: { status: 'succeeded' } };
285
+ },
286
+ });
287
+
288
+ await dispatcher.execute(
289
+ { id: 'ingest-a', requiredCapability: 'knowledge.update', operation: 'ingest_apply', arguments: {}, requiresApproval: true },
290
+ { serverName: 'production', agentInstanceId: 'production-main' },
291
+ { runId: 'run-donna-confirm', attempt: { attemptId: 'ingest-a:attempt-1', locks: [], release() {} } },
292
+ );
293
+
294
+ assert.equal(executeArgs.arguments.confirm, true, 'covered mutating task must carry confirm=true');
295
+ assert.equal(executeArgs.constraints.requireApprovalForMutations, true);
296
+ });
297
+
298
+ test('dispatcher does not invent confirm=true for a non-gated task', async () => {
299
+ let executeArgs;
300
+ const session = {
301
+ workspace: 'test',
302
+ mcp: {
303
+ production: {
304
+ tools: [{ name: 'agent_execute' }, { name: 'agent_status' }, { name: 'agent_cancel' }],
305
+ },
306
+ },
307
+ activities: {},
308
+ };
309
+ const dispatcher = createDispatcher({
310
+ session,
311
+ pollIntervalMs: 1,
312
+ callTool: async (_mcp, _server, tool, args) => {
313
+ if (tool === 'agent_execute') {
314
+ executeArgs = args;
315
+ return { accepted: true, jobId: 'job-doctor', status: 'queued' };
316
+ }
317
+ return { jobId: 'job-doctor', status: 'succeeded', terminal: true, result: { status: 'succeeded' } };
318
+ },
319
+ });
320
+
321
+ await dispatcher.execute(
322
+ { id: 'doctor-a', requiredCapability: 'workspace.diagnose', operation: 'doctor', arguments: {} },
323
+ { serverName: 'production', agentInstanceId: 'production-main' },
324
+ { runId: 'run-donna-doctor', attempt: { attemptId: 'doctor-a:attempt-1', locks: [], release() {} } },
325
+ );
326
+
327
+ assert.equal(executeArgs.arguments.confirm, undefined);
328
+ });
@@ -113,11 +113,10 @@ function resolveMentionedRegistryOperation(objective, candidates) {
113
113
  // A capability with more than one operation may declare
114
114
  // aliasOperations, mapping the specific alias phrase that matched to
115
115
  // the operation it actually names. Without it, operations[0]
116
- // (alphabetical) is a silent guess: for knowledge.concepts this always
117
- // picked the destructive grid-rebuild "concepts" operation, even when
118
- // the matched alias ("reclassify concepts", "file unclassified
119
- // concepts") named the safe, mechanical "reclassify-concepts" one —
120
- // making that operation structurally unreachable from natural language.
116
+ // (alphabetical) is a silent guess — a multi-operation capability's
117
+ // other operations become structurally unreachable from natural
118
+ // language, and the alphabetical first one may well be the destructive
119
+ // one.
121
120
  const operation = candidate.aliasOperations?.[matchedAlias] ?? candidate.operations[0];
122
121
  return { capability: candidate.id, operation };
123
122
  })
@@ -176,7 +175,10 @@ function registrySnapshot(session) {
176
175
  const registry = session?.capabilityRegistry;
177
176
  if (registry?.snapshot) return registry.snapshot();
178
177
  if (registry && typeof registry === 'object') return registry;
179
- const agents = session?.agentRegistry?.snapshot?.() ?? session?.agentRegistrySnapshot ?? [];
178
+ const agents = [
179
+ ...(session?.agentRegistry?.snapshot?.() ?? session?.agentRegistrySnapshot ?? []),
180
+ ...(session?.runtimeProviderAgents ?? []),
181
+ ];
180
182
  const snapshot = {};
181
183
  for (const agent of agents) {
182
184
  for (const capability of agent?.description?.capabilities ?? []) {
@@ -187,6 +189,8 @@ function registrySnapshot(session) {
187
189
  capability,
188
190
  description: agent.description,
189
191
  health: agent.health,
192
+ providerKind: agent.providerKind ?? null,
193
+ runtimeProvider: agent.runtimeProvider ?? null,
190
194
  });
191
195
  }
192
196
  }
@@ -122,53 +122,52 @@ test('resolveObjective resolves diagnose via alias despite the notification "sen
122
122
  assert.equal(result.operation, 'doctor');
123
123
  });
124
124
 
125
- const concepts = makeCapability('knowledge.concepts', {
126
- operations: ['concepts', 'reclassify-concepts'],
127
- aliases: ['concept grid', 'reclassify concepts', 'file unclassified concepts'],
125
+ const snapshot = makeCapability('data.snapshot', {
126
+ operations: ['snapshot', 'prune'],
127
+ aliases: ['take a snapshot', 'prune snapshots', 'clean old snapshots'],
128
128
  aliasOperations: {
129
- 'concept grid': 'concepts',
130
- 'reclassify concepts': 'reclassify-concepts',
131
- 'file unclassified concepts': 'reclassify-concepts',
129
+ 'take a snapshot': 'snapshot',
130
+ 'prune snapshots': 'prune',
131
+ 'clean old snapshots': 'prune',
132
132
  },
133
- description: 'Synthesize the concept grid or file unclassified pages into it.',
133
+ description: 'Take a snapshot of the workspace or prune old snapshots.',
134
134
  });
135
135
 
136
136
  /*
137
137
  Regression: with two operations, [...new Set(supportedOperations)].sort()
138
- alphabetizes to ["concepts", "reclassify-concepts"], so a naive alias hit
139
- defaulting to operations[0] would ALWAYS resolve to "concepts" — the
140
- destructive grid rebuild — even for aliases explicitly authored to reach the
141
- safe "reclassify-concepts" operation. aliasOperations must be consulted
142
- first.
138
+ alphabetizes to ["prune", "snapshot"], so a naive alias hit defaulting to
139
+ operations[0] would ALWAYS resolve to "prune" — the destructive operation —
140
+ even for aliases explicitly authored to reach the safe "snapshot" one.
141
+ aliasOperations must be consulted first.
143
142
  */
144
- test('resolveObjective routes "reclassify concepts" to reclassify-concepts, not operations[0]', async () => {
145
- const session = sessionWith([provider('production-1', concepts)]);
143
+ test('resolveObjective routes "prune snapshots" to prune, not operations[0]', async () => {
144
+ const session = sessionWith([provider('production-1', snapshot)]);
146
145
  session.llm.completeWithTools = async () => {
147
146
  throw new Error('the aliased operation must not depend on LLM selection');
148
147
  };
149
- const result = await resolveObjective('Please reclassify concepts in the workspace', session);
150
- assert.equal(result.capability, 'knowledge.concepts');
151
- assert.equal(result.operation, 'reclassify-concepts');
148
+ const result = await resolveObjective('Please prune the old snapshots', session);
149
+ assert.equal(result.capability, 'data.snapshot');
150
+ assert.equal(result.operation, 'prune');
152
151
  });
153
152
 
154
- test('resolveObjective routes "file unclassified concepts" to reclassify-concepts', async () => {
155
- const session = sessionWith([provider('production-1', concepts)]);
153
+ test('resolveObjective routes "clean old snapshots" to prune', async () => {
154
+ const session = sessionWith([provider('production-1', snapshot)]);
156
155
  session.llm.completeWithTools = async () => {
157
156
  throw new Error('the aliased operation must not depend on LLM selection');
158
157
  };
159
- const result = await resolveObjective('File unclassified concepts into the grid', session);
160
- assert.equal(result.capability, 'knowledge.concepts');
161
- assert.equal(result.operation, 'reclassify-concepts');
158
+ const result = await resolveObjective('Clean old snapshots of the workspace', session);
159
+ assert.equal(result.capability, 'data.snapshot');
160
+ assert.equal(result.operation, 'prune');
162
161
  });
163
162
 
164
- test('resolveObjective routes "concept grid" to the concepts operation', async () => {
165
- const session = sessionWith([provider('production-1', concepts)]);
163
+ test('resolveObjective routes "take a snapshot" to the snapshot operation', async () => {
164
+ const session = sessionWith([provider('production-1', snapshot)]);
166
165
  session.llm.completeWithTools = async () => {
167
166
  throw new Error('the aliased operation must not depend on LLM selection');
168
167
  };
169
- const result = await resolveObjective('Rebuild the concept grid', session);
170
- assert.equal(result.capability, 'knowledge.concepts');
171
- assert.equal(result.operation, 'concepts');
168
+ const result = await resolveObjective('Take a snapshot of the workspace', session);
169
+ assert.equal(result.capability, 'data.snapshot');
170
+ assert.equal(result.operation, 'snapshot');
172
171
  });
173
172
 
174
173
  test('resolveObjective falls back to operations[0] when a matched alias has no aliasOperations entry', async () => {