@dotdrelle/wiki-manager 0.15.66 → 0.15.71

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 (72) hide show
  1. package/.env.example +10 -3
  2. package/README.md +57 -0
  3. package/agent-runtimes.example.json +68 -0
  4. package/agents.docker-compose.yml +39 -1
  5. package/docker-compose.yml +3 -3
  6. package/package.json +3 -2
  7. package/src/activity/activityAggregator.test.js +2 -2
  8. package/src/agent/graph.js +13 -11
  9. package/src/agent/skillRecursion.test.js +13 -12
  10. package/src/cli/wiki-manager.js +125 -37
  11. package/src/cli/wiki-manager.test.js +16 -16
  12. package/src/commands/slash.js +59 -5
  13. package/src/contracts/schemas.js +67 -0
  14. package/src/core/activity.js +5 -0
  15. package/src/core/agentEvents.js +139 -25
  16. package/src/core/agentEvents.test.js +26 -1
  17. package/src/core/buildInfo.json +2 -2
  18. package/src/core/commandFailure.test.js +2 -2
  19. package/src/core/currentArtifact.test.js +5 -5
  20. package/src/core/dockerCompose.test.js +8 -40
  21. package/src/core/env.js +14 -0
  22. package/src/core/env.test.js +19 -0
  23. package/src/core/googleGrants.test.js +1 -1
  24. package/src/core/mcp.js +1 -1
  25. package/src/core/mcp.test.js +1 -1
  26. package/src/core/otherWorkspacesRunning.test.js +6 -6
  27. package/src/core/runtimeEventAdapter.js +81 -0
  28. package/src/core/runtimeEventAdapter.test.js +61 -0
  29. package/src/core/runtimeLog.js +35 -1
  30. package/src/core/runtimeLog.test.js +27 -2
  31. package/src/core/skillChainView.test.js +2 -2
  32. package/src/core/skillCompiler.test.js +1 -1
  33. package/src/core/skillInvocation.js +13 -8
  34. package/src/core/skillInvocation.test.js +1 -1
  35. package/src/core/startupCheck.js +58 -0
  36. package/src/core/startupCheck.test.js +29 -1
  37. package/src/core/wikiSetup.js +25 -0
  38. package/src/core/wikiSetup.test.js +35 -0
  39. package/src/core/wikirc.test.js +6 -6
  40. package/src/core/workspaceInherit.test.js +14 -14
  41. package/src/orchestrator/agentRegistry.js +1 -22
  42. package/src/orchestrator/agentRegistry.test.js +6 -6
  43. package/src/orchestrator/assignmentManager.js +16 -4
  44. package/src/orchestrator/capabilityRegistry.js +8 -1
  45. package/src/orchestrator/dispatcher.js +405 -2
  46. package/src/orchestrator/dispatcher.test.js +158 -4
  47. package/src/orchestrator/objectiveResolver.js +10 -6
  48. package/src/orchestrator/objectiveResolver.test.js +26 -27
  49. package/src/orchestrator/providers/deepAgentsProvider.js +168 -0
  50. package/src/orchestrator/providers/deepAgentsProvider.test.js +178 -0
  51. package/src/orchestrator/providers/dispatcherExternalRuntime.test.js +409 -0
  52. package/src/orchestrator/providers/fakeRuntimeProvider.js +164 -0
  53. package/src/orchestrator/providers/fakeRuntimeProvider.test.js +201 -0
  54. package/src/orchestrator/providers/runtimeProvider.js +101 -0
  55. package/src/orchestrator/providers/runtimeProviders.js +378 -0
  56. package/src/orchestrator/providers/runtimeProviders.test.js +384 -0
  57. package/src/orchestrator/resultAggregator.js +35 -2
  58. package/src/orchestrator/resultAggregator.test.js +62 -0
  59. package/src/orchestrator/scheduler.test.js +4 -4
  60. package/src/runtime/delegation.test.js +11 -11
  61. package/src/runtime/recoveryManager.js +70 -5
  62. package/src/runtime/runner.test.js +1 -1
  63. package/src/runtime/server.test.js +2 -2
  64. package/src/runtime/skillChain.e2e.test.js +2 -2
  65. package/src/runtime/store.test.js +8 -5
  66. package/src/runtime/supervisor.js +5 -10
  67. package/src/runtime/workspaceIsolation.test.js +26 -26
  68. package/src/shell/RightPane.tsx +23 -3
  69. package/src/shell/StartupScreen.tsx +44 -7
  70. package/src/shell/repl.js +24 -2
  71. package/src/shell/repl.test.js +13 -0
  72. package/wiki-workspace +53 -3
@@ -30,6 +30,7 @@ import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core
30
30
  import { runAgentTurn, runAgenticLoop } from '../core/agentLoop.js';
31
31
  import { resolveCapabilityConcurrency } from '../orchestrator/scheduler.js';
32
32
  import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
33
+ import { CapabilityUnavailableError, resolve as resolveCapability } from '../orchestrator/capabilityResolver.js';
33
34
  import { listWorkspaces } from '../core/workspaces.js';
34
35
  import { findSkill } from '../core/skills.js';
35
36
  import { rememberArtifact } from '../core/currentArtifact.js';
@@ -69,6 +70,20 @@ function unavailableRuntime(err) {
69
70
  return { url: null, error: reason };
70
71
  }
71
72
 
73
+ // The user-facing reason a capability cannot start — one sentence, the likely
74
+ // cause, and the fix. The raw registry goes to the logs, never to the chat.
75
+ function capabilityUnavailableMessage(capabilityId, seen = []) {
76
+ const agentic = String(capabilityId).startsWith('agent.');
77
+ const servicesDown = seen.some((entry) => /\[none\]$/.test(entry));
78
+ if (agentic) {
79
+ return `No agent provides capability ${capabilityId} — the agentic runtime is not enabled or not reachable. Enable it (GATEWAY_ENABLED=true + wiki-workspace agents up) and check /status.`;
80
+ }
81
+ if (servicesDown) {
82
+ return `No agent provides capability ${capabilityId} — the workspace services are not running. Start them (wiki-workspace up <workspace>), then retry.`;
83
+ }
84
+ return `No agent provides capability ${capabilityId} — no connected agent declares it. Check /status.`;
85
+ }
86
+
72
87
  export function buildExecutorOnlyFragment({ objective, workspace, selection }) {
73
88
  const provider = selection.provider;
74
89
  const capability = provider.capability ?? {};
@@ -155,7 +170,7 @@ export async function resolveExecutorArguments({
155
170
  'Only fill a field when the objective explicitly states or clearly implies its value.',
156
171
  'Omit every field that is not stated. Never invent identifiers, queries, filters or counts.',
157
172
  // The objective almost always names the workspace ("export the pages of
158
- // workspace acpi"), and the orchestrator already binds it out of band. With
173
+ // workspace acme"), and the orchestrator already binds it out of band. With
159
174
  // one free-text field in the schema and no field for the workspace, a model
160
175
  // reliably misbinds the two — that is how a workspace name ended up as a
161
176
  // source name and failed the task.
@@ -923,6 +938,7 @@ async function runRuntime(argv, agent) {
923
938
  const { startRuntimeServer } = await import('../runtime/server.js');
924
939
  const { recoverActiveRuns } = await import('../runtime/recoveryManager.js');
925
940
  const { emitRuntimeLog, startActivitySupervisor, cancelActiveActivityJobs, discoverAgentsOnce } = await import('../runtime/supervisor.js');
941
+ const { discoverRuntimeProvidersOnce } = await import('../orchestrator/providers/runtimeProviders.js');
926
942
  const { resolveRuntimeAuthToken } = await import('../runtime/auth.js');
927
943
  const { createSqliteQueueStore } = await import('../runtime/queueStore.js');
928
944
  const { createApprovalManager } = await import('../runtime/approvals.js');
@@ -950,6 +966,7 @@ async function runRuntime(argv, agent) {
950
966
  await Promise.all(resolved.map(async (context) => {
951
967
  await refreshMcpRuntimeStatus(context.session);
952
968
  await discoverAgentsOnce(context.session);
969
+ await discoverRuntimeProvidersOnce(context.session);
953
970
  }));
954
971
  }
955
972
 
@@ -1137,6 +1154,14 @@ async function runRuntime(argv, agent) {
1137
1154
  try {
1138
1155
  const context = await getWorkspaceContext(workspace);
1139
1156
  await refreshMcpRuntimeStatus(context.session);
1157
+ // The supervisor kicks off agent + runtime-provider discovery fire-and-
1158
+ // forget, so at boot `session.runtimeProviderAgents` is usually still
1159
+ // undefined here. recoverActiveRuns resolves an external-runtime task by
1160
+ // finding its synthetic agent in that list; without it the task falls to
1161
+ // the MCP path, calls a non-existent server named after the capability,
1162
+ // throws, and the run is interrupted instead of recovered. Await the same
1163
+ // discovery the /run path already awaits (wiki-manager.js's turn setup).
1164
+ await discoverRuntimeProvidersOnce(context.session);
1140
1165
  const gaps = recoveryMcpGaps(context);
1141
1166
  if (gaps.length > 0) {
1142
1167
  const interrupted = store.interruptRuns({ workspace: context.workspace });
@@ -1243,6 +1268,7 @@ async function runRuntime(argv, agent) {
1243
1268
  // live endpoints and await one discovery pass before resolving.
1244
1269
  await refreshMcpRuntimeStatus(session);
1245
1270
  await discoverAgentsOnce(session, { registry: session.agentRegistry });
1271
+ await discoverRuntimeProvidersOnce(session);
1246
1272
  let selection;
1247
1273
  try {
1248
1274
  selection = await resolveObjective(objective, session);
@@ -1457,11 +1483,37 @@ async function runRuntime(argv, agent) {
1457
1483
  if (body.capabilityPlan?.capability) {
1458
1484
  const { validateFragment } = await import('../orchestrator/planValidator.js');
1459
1485
  const { integrate } = await import('../orchestrator/planIntegrator.js');
1486
+ // Same boot-race guard as prepareDelegation below: discovery is
1487
+ // asynchronous at startup, so a structured capability run submitted
1488
+ // right after boot must not observe the transient empty registry and
1489
+ // fail while the provider is already healthy (first E2E run of the
1490
+ // day did exactly that: "No agent provides capability agent.review"
1491
+ // while the gateway answered /health).
1492
+ await refreshMcpRuntimeStatus(session);
1493
+ await discoverAgentsOnce(session, { registry: session.agentRegistry });
1494
+ await discoverRuntimeProvidersOnce(session);
1460
1495
  const registry = capabilityRegistryForSession(session);
1461
- const agents = session.agentRegistry?.snapshot?.() ?? session.agentRegistrySnapshot ?? [];
1462
- const provider = agents.find((item) => (item.description?.capabilities ?? [])
1463
- .some((capability) => capability.id === body.capabilityPlan.capability));
1464
- if (!provider?.serverName) {
1496
+ const capabilityId = String(body.capabilityPlan.capability);
1497
+ const candidates = registry.providersFor(capabilityId) ?? [];
1498
+ // Route through the same resolver every other task assignment uses
1499
+ // (assignmentManager.js, resultAggregator.js), instead of a bespoke
1500
+ // "prefer external-runtime, else any with serverName" chain that
1501
+ // ignored the workspace's own capabilityRouting config (preferred/
1502
+ // allowed/fallback agents) and never checked health/availability at
1503
+ // all — this could hand a direct /run capability request to a
1504
+ // disallowed or unhealthy agent while every other entry point honors
1505
+ // the routing config.
1506
+ const workspaceConfig = session?.wikircConfig ?? session?.wikirc?.config ?? {};
1507
+ let provider = null;
1508
+ let resolutionReason = null;
1509
+ try {
1510
+ const resolved = resolveCapability(capabilityId, { workspaceConfig, registry });
1511
+ provider = candidates.find((item) => item.agentInstanceId === resolved.agentInstanceId) ?? null;
1512
+ } catch (error) {
1513
+ if (!(error instanceof CapabilityUnavailableError)) throw error;
1514
+ resolutionReason = error.reason;
1515
+ }
1516
+ if (!provider) {
1465
1517
  /*
1466
1518
  Say what WAS seen, not only what was missing.
1467
1519
 
@@ -1471,38 +1523,60 @@ async function runRuntime(argv, agent) {
1471
1523
  PRODUCTION_ALLOWED_STEPS drops its whole capability from
1472
1524
  agent_describe, silently), or a name mismatch. Listing the registry
1473
1525
  turns the next occurrence into its own diagnosis instead of a guess.
1474
- */
1475
- const seen = agents.map((item) => {
1476
- const ids = (item.description?.capabilities ?? []).map((capability) => capability.id);
1477
- return `${item.serverName ?? '?'}[${ids.join(', ') || 'none'}]`;
1478
- });
1479
- throw new Error(
1480
- `No agent provides capability ${body.capabilityPlan.capability}. `
1481
- + (seen.length
1482
- ? `Registered agents: ${seen.join('; ')}.`
1483
- : 'No agent is registered: none answered agent_describe.'),
1484
- );
1526
+ */
1527
+ const seen = [
1528
+ ...(session.agentRegistry?.snapshot?.() ?? session.agentRegistrySnapshot ?? []),
1529
+ ...(session.runtimeProviderAgents ?? []),
1530
+ ].map((item) => {
1531
+ const ids = (item.description?.capabilities ?? []).map((capability) => capability.id);
1532
+ return `${item.serverName ?? item.runtimeId ?? '?'}[${ids.join(', ') || 'none'}]`;
1533
+ });
1534
+ // The raw registry stays for diagnosis, in the LOGS — never in the
1535
+ // user-facing message, which explains the likely cause instead.
1536
+ emitRuntimeLog(session, `capability-plan: ${capabilityId} unresolvable (${resolutionReason ?? 'no_candidate'}) — registry: ${seen.join('; ') || 'no agent answered agent_describe'}`);
1537
+ const error = new Error(capabilityUnavailableMessage(capabilityId, seen));
1538
+ error.code = 'capability_unavailable';
1539
+ throw error;
1485
1540
  }
1486
- const fragment = parseJsonText(formatMcpToolResult(await callMcpTool(session.mcp, provider.serverName, 'agent_plan', {
1487
- capability: body.capabilityPlan.capability,
1488
- operation: body.capabilityPlan.operation ?? undefined,
1489
- workspace: { revision: String(Date.now()) },
1490
- constraints: {
1491
- // The agent declares its capacity. Request/env values are only
1492
- // constraints: they may lower that capacity, never raise it.
1493
- maxConcurrency: resolveCapabilityConcurrency(
1494
- provider,
1495
- body.capabilityPlan.maxConcurrency,
1496
- process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY,
1497
- ),
1498
- requireApprovalForMutations: body.capabilityPlan.requireApproval !== false,
1499
- },
1500
- ...(body.capabilityPlan.arguments && typeof body.capabilityPlan.arguments === 'object'
1501
- ? { arguments: body.capabilityPlan.arguments }
1502
- : Array.isArray(body.capabilityPlan.inputs) && body.capabilityPlan.inputs.length > 0
1503
- ? { arguments: { inputs: body.capabilityPlan.inputs } }
1504
- : {}),
1505
- })));
1541
+ const operation = body.capabilityPlan.operation
1542
+ ?? provider.capability?.supportedOperations?.[0]
1543
+ ?? 'run';
1544
+ const canPlan = provider.description?.orchestration?.canPlan !== false;
1545
+ const fragment = canPlan
1546
+ ? parseJsonText(formatMcpToolResult(await callMcpTool(session.mcp, provider.serverName, 'agent_plan', {
1547
+ capability: capabilityId,
1548
+ operation,
1549
+ workspace: { revision: String(Date.now()) },
1550
+ constraints: {
1551
+ // The agent declares its capacity. Request/env values are only
1552
+ // constraints: they may lower that capacity, never raise it.
1553
+ maxConcurrency: resolveCapabilityConcurrency(
1554
+ provider,
1555
+ body.capabilityPlan.maxConcurrency,
1556
+ process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY,
1557
+ ),
1558
+ requireApprovalForMutations: body.capabilityPlan.requireApproval !== false,
1559
+ },
1560
+ ...(body.capabilityPlan.arguments && typeof body.capabilityPlan.arguments === 'object'
1561
+ ? { arguments: body.capabilityPlan.arguments }
1562
+ : Array.isArray(body.capabilityPlan.inputs) && body.capabilityPlan.inputs.length > 0
1563
+ ? { arguments: { inputs: body.capabilityPlan.inputs } }
1564
+ : {}),
1565
+ })))
1566
+ : buildExecutorOnlyFragment({
1567
+ objective: `Capability run ${capabilityId}`,
1568
+ workspace: session.workspace ?? 'workspace',
1569
+ selection: {
1570
+ capability: capabilityId,
1571
+ operation,
1572
+ provider,
1573
+ arguments: body.capabilityPlan.arguments && typeof body.capabilityPlan.arguments === 'object'
1574
+ ? body.capabilityPlan.arguments
1575
+ : Array.isArray(body.capabilityPlan.inputs) && body.capabilityPlan.inputs.length > 0
1576
+ ? { inputs: body.capabilityPlan.inputs }
1577
+ : {},
1578
+ },
1579
+ });
1506
1580
  if (!Array.isArray(fragment?.tasks) || fragment.tasks.length === 0) {
1507
1581
  dispatchAgentEvent(session, createAgentEvent('assistant_message', {
1508
1582
  origin: 'runtime',
@@ -1534,7 +1608,18 @@ async function runRuntime(argv, agent) {
1534
1608
  if (!integrated.ok) {
1535
1609
  throw new Error(`Capability plan integration failed: ${(integrated.errors ?? []).map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
1536
1610
  }
1537
- emitRuntimeLog(session, `capability-plan: ${fragment.tasks.length} task(s) integrated from ${provider.serverName}.agent_plan (${body.capabilityPlan.capability}); approvals: ${(session.agentProjection?.approvals ?? []).filter((approval) => approval.status === 'pending_approval').length} pending`);
1611
+ // The structured path integrates the plan directly, so it must honor
1612
+ // the same explicit opt-in as the delegation path (delegation.js
1613
+ // `resolvePreparedDelegationApproval`): autoApprove grants one
1614
+ // run-scoped approval right after integration. Without it, a
1615
+ // headless/CI caller with autoApprove:true deadlocks on the
1616
+ // scheduler's approval gate until the timeout — first E2E research
1617
+ // run did exactly that.
1618
+ if (body.autoApprove === true) {
1619
+ const { resolvePreparedDelegationApproval } = await import('../runtime/delegation.js');
1620
+ resolvePreparedDelegationApproval({ autoApprove: true, approvalManager: context.approvalManager, runId });
1621
+ }
1622
+ emitRuntimeLog(session, `capability-plan: ${fragment.tasks.length} task(s) integrated${provider.serverName ? ` from ${provider.serverName}.agent_plan` : ''} (${body.capabilityPlan.capability}); approvals: ${(session.agentProjection?.approvals ?? []).filter((approval) => approval.status === 'pending_approval').length} pending`);
1538
1623
  }
1539
1624
  await runRuntimeAgenticWorkflow(agent, session, input, {
1540
1625
  signal,
@@ -1568,6 +1653,9 @@ async function runRuntime(argv, agent) {
1568
1653
  payload: {
1569
1654
  runId,
1570
1655
  message: err instanceof Error ? err.message : String(err),
1656
+ // UIs map this to amber ("service not available") instead of red
1657
+ // ("the run failed"): the run could not START, it did not crash.
1658
+ ...(err?.code === 'capability_unavailable' ? { kind: 'capability_unavailable' } : {}),
1571
1659
  },
1572
1660
  }));
1573
1661
  } finally {
@@ -66,7 +66,7 @@ test('interactive runtime refreshes configured MCP endpoints that started late',
66
66
  test('executor-only capabilities receive one manager-authored executable task', () => {
67
67
  const fragment = buildExecutorOnlyFragment({
68
68
  objective: 'donne-moi mes derniers mails',
69
- workspace: 'juno',
69
+ workspace: 'demo',
70
70
  selection: {
71
71
  capability: 'external-source.collect',
72
72
  operation: 'collect',
@@ -91,7 +91,7 @@ test('executor-only capabilities receive one manager-authored executable task',
91
91
  assert.equal(fragment.tasks[0].requiredCapability, 'external-source.collect');
92
92
  assert.equal(fragment.tasks[0].operation, 'collect');
93
93
  assert.deepEqual(fragment.tasks[0].arguments, { maxMessages: 10 });
94
- assert.deepEqual(fragment.tasks[0].locks, ['external-source.collect:juno']);
94
+ assert.deepEqual(fragment.tasks[0].locks, ['external-source.collect:demo']);
95
95
  assert.equal(fragment.tasks[0].requiresApproval, true);
96
96
  assert.equal(fragment.tasks[0].approvalClass, 'external-source');
97
97
  assert.match(fragment.tasks[0].idempotencyKey, /^[0-9a-f-]{36}$/);
@@ -263,14 +263,14 @@ const EXPORT_CAPABILITY = {
263
263
  };
264
264
 
265
265
  test('argument extraction drops a value that only echoes the active workspace', async () => {
266
- // Regression: "exporter les pages Confluence du workspace acpi" against a
266
+ // Regression: "export the Confluence pages of the acme workspace" against a
267
267
  // schema whose single free-text field is source_name. The model binds the
268
- // workspace name to it, and the executor fails with "source 'acpi' not
268
+ // workspace name to it, and the executor fails with "source 'acme' not
269
269
  // found". The workspace is already bound out of band, so the echo is noise.
270
270
  const llm = {
271
271
  completeWithTools: async () => ({
272
272
  tool_calls: [{
273
- function: { name: 'set_task_arguments', arguments: JSON.stringify({ source_name: 'acpi' }) },
273
+ function: { name: 'set_task_arguments', arguments: JSON.stringify({ source_name: 'acme' }) },
274
274
  }],
275
275
  }),
276
276
  };
@@ -278,9 +278,9 @@ test('argument extraction drops a value that only echoes the active workspace',
278
278
  assert.deepEqual(
279
279
  await resolveExecutorArguments({
280
280
  llm,
281
- objective: 'exporter les pages Confluence du workspace acpi',
281
+ objective: 'export the Confluence pages of the acme workspace',
282
282
  capability: EXPORT_CAPABILITY,
283
- workspace: 'acpi',
283
+ workspace: 'acme',
284
284
  }),
285
285
  {},
286
286
  );
@@ -303,7 +303,7 @@ test('argument extraction keeps a real value that is not the workspace name', as
303
303
  llm,
304
304
  objective: 'exporter la source EAS_Avant_projet_ACPI',
305
305
  capability: EXPORT_CAPABILITY,
306
- workspace: 'acpi',
306
+ workspace: 'acme',
307
307
  }),
308
308
  { source_name: 'EAS_Avant_projet_ACPI' },
309
309
  );
@@ -320,17 +320,17 @@ test('argument extraction tells the model the workspace is already bound', async
320
320
 
321
321
  await resolveExecutorArguments({
322
322
  llm,
323
- objective: 'exporter les pages du workspace acpi',
323
+ objective: 'export the pages of the acme workspace',
324
324
  capability: EXPORT_CAPABILITY,
325
- workspace: 'acpi',
325
+ workspace: 'acme',
326
326
  });
327
327
 
328
- assert.match(seenSystem, /already runs against workspace "acpi"/);
328
+ assert.match(seenSystem, /already runs against workspace "acme"/);
329
329
  });
330
330
 
331
331
  test('argument extraction rejects a value outside the closed vocabulary', async () => {
332
- // Regression: "exporter les pages Confluence du workspace juno" — the
333
- // workspace guard removes "juno", so the model reaches for the next noun and
332
+ // Regression: "export the Confluence pages of the demo workspace" — the
333
+ // workspace guard removes "demo", so the model reaches for the next noun and
334
334
  // emits "Confluence". Only the agent knows the valid names; once it publishes
335
335
  // them as an enum, the orchestrator can check without guessing at meaning.
336
336
  const capability = {
@@ -354,9 +354,9 @@ test('argument extraction rejects a value outside the closed vocabulary', async
354
354
  assert.deepEqual(
355
355
  await resolveExecutorArguments({
356
356
  llm,
357
- objective: 'exporter les pages Confluence du workspace juno',
357
+ objective: 'export the Confluence pages of the demo workspace',
358
358
  capability,
359
- workspace: 'juno',
359
+ workspace: 'demo',
360
360
  }),
361
361
  {},
362
362
  );
@@ -389,7 +389,7 @@ test('argument extraction keeps a value the vocabulary allows', async () => {
389
389
  llm,
390
390
  objective: 'exporter la source EAS_Avant_projet_ACPI',
391
391
  capability,
392
- workspace: 'acpi',
392
+ workspace: 'acme',
393
393
  }),
394
394
  { source_name: 'EAS_Avant_projet_ACPI' },
395
395
  );
@@ -30,6 +30,7 @@ import { findSkill, inspectSkills, listSkills } from '../core/skills.js';
30
30
  import { extractActivity, formatActivityError, formatActivityLine, formatActivitySummary, parseJsonText } from '../core/activity.js';
31
31
  import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
32
32
  import { emitRuntimeLog } from '../runtime/supervisor.js';
33
+ import { discoverRuntimeProvidersOnce } from '../orchestrator/providers/runtimeProviders.js';
33
34
  import {
34
35
  cancelQueueItem,
35
36
  clearFinishedQueueItems,
@@ -660,8 +661,34 @@ function reportNewlyDegradedMcp(session, previousMcp) {
660
661
  }
661
662
  }
662
663
 
664
+ function runtimeProvidersSection(session) {
665
+ const agents = session.runtimeProviderAgents ?? [];
666
+ if (agents.length === 0) return null;
667
+ const byRuntime = new Map();
668
+ for (const agent of agents) {
669
+ const runtimeId = agent.runtimeId ?? 'external';
670
+ if (!byRuntime.has(runtimeId)) byRuntime.set(runtimeId, []);
671
+ byRuntime.get(runtimeId).push(agent);
672
+ }
673
+ const lines = [];
674
+ for (const [runtimeId, list] of byRuntime) {
675
+ const health = list[0]?.health ?? 'unknown';
676
+ const capabilities = list
677
+ .map((agent) => agent.description?.capabilities?.[0]?.id ?? agent.agentInstanceId);
678
+ lines.push(`${runtimeId}: ${health}`);
679
+ // One capability per line: the status pane is a fixed-width box that
680
+ // TRUNCATES long lines, and a joined list ended mid-word
681
+ // ("capabilities: agent.review, agent.research, agent"). A per-line list
682
+ // never truncates and stays scannable.
683
+ lines.push('capabilities:');
684
+ for (const capability of capabilities) lines.push(` ${capability}`);
685
+ }
686
+ return sectionBlock('Agentic runtime', lines);
687
+ }
688
+
663
689
  async function statusText(session) {
664
690
  const states = await refreshMcpRuntimeStatus(session);
691
+ await discoverRuntimeProvidersOnce(session);
665
692
  const workspaceStats = collectWorkspaceStats(session);
666
693
  const workspaceColumn = sectionBlock(`Workspace · ${session.workspace ?? '-'}`, [
667
694
  `path: ${compactPath(session.workspacePath ?? '-')}`,
@@ -678,11 +705,20 @@ async function statusText(session) {
678
705
  `baseUrl: ${compactBaseUrl(session.wikircConfig?.llm?.baseUrl)}`,
679
706
  ...(skillDiagnosticCount ? [`skill diagnostics: ${skillDiagnostics.rejected.length} rejected, ${skillDiagnostics.warnings.length} warning(s) (/skills)`] : []),
680
707
  ]);
681
- const runtimeColumn = sectionBlock('Runtime', (states ? serviceStatesText(states) : 'Docker runtime not available or no workspace loaded.').split('\n'));
708
+ // The workspace stack (serve / mcp-http / production-mcp) only means
709
+ // something once a workspace is loaded AND services are actually declared;
710
+ // an empty list is noise either way. Named "Services" (not "Runtime") to
711
+ // avoid colliding with the "Agentic runtime" section that lists the
712
+ // external engines.
713
+ const hasServices = Boolean(states && Object.keys(states).length > 0);
714
+ const runtimeColumn = hasServices
715
+ ? sectionBlock('Services', serviceStatesText(states).split('\n'))
716
+ : null;
682
717
  const mcpColumn = sectionBlock('MCP', compactMcpStatus(session.mcp).split('\n'));
718
+ const runtimesColumn = runtimeProvidersSection(session);
683
719
  const stats = workspaceStatsColumns(workspaceStats, session);
684
720
 
685
- const leftColumn = [workspaceColumn, stats.left, runtimeColumn, mcpColumn].filter(Boolean).join('\n\n');
721
+ const leftColumn = [workspaceColumn, stats.left, runtimeColumn, mcpColumn, runtimesColumn].filter(Boolean).join('\n\n');
686
722
  const rightColumn = [configColumn, stats.right].filter(Boolean).join('\n\n');
687
723
 
688
724
  // Leading/trailing blank row so the boxed pair doesn't butt directly against
@@ -850,12 +886,13 @@ function componentInstallAction(missingImages) {
850
886
  return missingImages.length > 0 ? 'downloaded-and-installed-missing-components' : null;
851
887
  }
852
888
 
853
- export function localizedOperationResult({ operation, target, status = 'succeeded', componentAction = null, images = [] }) {
889
+ export function localizedOperationResult({ operation, target, status = 'succeeded', componentAction = null, images = [], detail = null }) {
854
890
  const facts = JSON.stringify({
855
891
  operation,
856
892
  target,
857
893
  status,
858
894
  ...(componentAction ? { componentAction, images } : {}),
895
+ ...(detail ? { detail } : {}),
859
896
  });
860
897
  return {
861
898
  output: facts,
@@ -937,7 +974,10 @@ export async function handleSlashCommand(line, context) {
937
974
  const runAgentCommand = async (fn, verb) => {
938
975
  try {
939
976
  step(`Agents: ${verb}ing external agents…`);
940
- const missingImages = await collectMissingImages(step, fn);
977
+ let outcome = null;
978
+ const missingImages = await collectMissingImages(step, async (options) => {
979
+ outcome = await fn(options);
980
+ });
941
981
  if (verb === 'start') {
942
982
  // `wiki-workspace agents up` generates the agent tokens into the
943
983
  // manager .env and adds the profiled entries to mcp.endpoints.json.
@@ -951,6 +991,17 @@ export async function handleSlashCommand(line, context) {
951
991
  loadManagerEnv({ override: true });
952
992
  await refreshMcpRuntimeStatus(context.session);
953
993
  }
994
+ if (outcome?.degraded) {
995
+ step('Agents: stack started degraded — one optional agent failed; the base agents are up.');
996
+ return localizedOperationResult({
997
+ operation: verb,
998
+ target: 'agents',
999
+ status: 'degraded',
1000
+ componentAction: componentInstallAction(missingImages),
1001
+ images: missingImages,
1002
+ detail: rawFailureText(outcome.degradedError),
1003
+ });
1004
+ }
954
1005
  return localizedOperationResult({
955
1006
  operation: verb,
956
1007
  target: 'agents',
@@ -1396,7 +1447,10 @@ export async function handleSlashCommand(line, context) {
1396
1447
  },
1397
1448
  });
1398
1449
  if (result?.runId) {
1399
- return { output: `▶ Capability run accepted (${String(result.runId).slice(0, 8)}) — the agent's plan will be integrated and dispatched in parallel; approval requested before mutations (/approve).` };
1450
+ // Honest and generic: the runtime decides, per task, whether a
1451
+ // mutation needs approval. Claiming "awaiting approval" here would
1452
+ // be false for read-only capabilities like agent.review.
1453
+ return { output: `▶ Capability run accepted (${String(result.runId).slice(0, 8)}) — the plan will be integrated and dispatched in parallel; any mutating step will wait for /approve.` };
1400
1454
  }
1401
1455
  return { output: `Run not started: ${result?.explanation ?? result?.error ?? JSON.stringify(result)}` };
1402
1456
  }
@@ -330,6 +330,71 @@ const patchTaskSchema = {
330
330
  },
331
331
  };
332
332
 
333
+ const runtimeCapabilitySchema = {
334
+ type: 'object',
335
+ required: ['name'],
336
+ additionalProperties: true,
337
+ properties: {
338
+ name: { type: 'string', minLength: 1 },
339
+ description: { type: 'string' },
340
+ operations: stringArraySchema,
341
+ aliases: stringArraySchema,
342
+ aliasOperations: { type: 'object', additionalProperties: true },
343
+ mutationClass: { type: 'string' },
344
+ defaultRequiresApproval: { type: 'boolean' },
345
+ },
346
+ };
347
+
348
+ const runtimeDescriptionSchema = {
349
+ $id: 'https://dotdrelle.dev/wiki-manager/contracts/runtime-description/v1',
350
+ title: 'RuntimeDescription',
351
+ schemaVersion: '1',
352
+ type: 'object',
353
+ required: ['runtime', 'version', 'protocolVersion'],
354
+ additionalProperties: true,
355
+ properties: {
356
+ runtime: { type: 'string', minLength: 1 },
357
+ version: { type: 'string', minLength: 1 },
358
+ protocolVersion: { type: 'string', minLength: 1 },
359
+ health: { type: 'string' },
360
+ capabilities: { type: 'array', items: runtimeCapabilitySchema },
361
+ },
362
+ };
363
+
364
+ const runtimeEventSchema = {
365
+ $id: 'https://dotdrelle.dev/wiki-manager/contracts/runtime-event/v1',
366
+ title: 'RuntimeEvent',
367
+ schemaVersion: '1',
368
+ type: 'object',
369
+ required: ['type'],
370
+ additionalProperties: true,
371
+ properties: {
372
+ type: {
373
+ type: 'string',
374
+ enum: [
375
+ 'run_created',
376
+ 'run_started',
377
+ 'agent_thinking',
378
+ 'tool_started',
379
+ 'tool_finished',
380
+ 'subagent_started',
381
+ 'subagent_finished',
382
+ 'message',
383
+ 'approval_required',
384
+ 'run_completed',
385
+ 'run_failed',
386
+ 'run_cancelled',
387
+ ],
388
+ },
389
+ runId: { type: 'string' },
390
+ tool: { type: 'string' },
391
+ durationMs: { type: 'number' },
392
+ resultSummary: { type: 'string' },
393
+ error: nullableString,
394
+ args: nullableObject,
395
+ },
396
+ };
397
+
333
398
  export const contractSchemas = {
334
399
  activity: {
335
400
  $id: 'https://dotdrelle.dev/wiki-manager/contracts/activity/v1',
@@ -462,6 +527,8 @@ export const contractSchemas = {
462
527
  plannedTask: plannedTaskSchema,
463
528
  taskGraphFragment: taskGraphFragmentSchema,
464
529
  planExpansionRequest: planExpansionRequestSchema,
530
+ runtimeDescription: runtimeDescriptionSchema,
531
+ runtimeEvent: runtimeEventSchema,
465
532
  };
466
533
 
467
534
  export function validateContract(name, value) {
@@ -214,6 +214,11 @@ export function mergePolledActivity(tracked, polled) {
214
214
  // Identity: keep the tracked source/id so activityKey() stays stable.
215
215
  id: tracked.id ?? polled.id,
216
216
  source: tracked.source ?? polled.source,
217
+ // A later poll often reports only {status, progress} with no phase/type,
218
+ // and activityFromStatusPayload then falls back to the generic 'job' —
219
+ // spreading that over the tracked value degraded a real kind (e.g.
220
+ // 'knowledge.update') to 'job' on the very next poll tick.
221
+ kind: tracked.kind ?? polled.kind,
217
222
  // Keep polling with the descriptor that worked when the agent's answer
218
223
  // does not carry one of its own.
219
224
  poll: polled.poll ?? tracked.poll,