@dotdrelle/wiki-manager 0.12.11 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.env.example +6 -0
  2. package/docker-compose.yml +1 -1
  3. package/package.json +1 -1
  4. package/src/agent/graph.js +377 -142
  5. package/src/agent/graph.test.js +576 -34
  6. package/src/agent/llm.js +5 -5
  7. package/src/cli/wiki-manager.js +294 -9
  8. package/src/cli/wiki-manager.test.js +28 -0
  9. package/src/commands/slash.js +80 -13
  10. package/src/commands/slash.test.js +9 -1
  11. package/src/contracts/schemas.js +33 -0
  12. package/src/contracts/schemas.test.js +14 -0
  13. package/src/core/agentEvents.js +6 -0
  14. package/src/core/agentEvents.test.js +26 -0
  15. package/src/core/agentLoop.js +15 -16
  16. package/src/core/agentLoop.test.js +9 -7
  17. package/src/core/buildInfo.json +2 -2
  18. package/src/core/mcp.js +13 -6
  19. package/src/core/mcp.test.js +0 -12
  20. package/src/core/skills.js +0 -28
  21. package/src/orchestrator/capabilityRegistry.js +14 -0
  22. package/src/orchestrator/capabilityRegistry.test.js +12 -1
  23. package/src/orchestrator/dependencyResolver.js +10 -1
  24. package/src/orchestrator/dispatcher.js +34 -3
  25. package/src/orchestrator/dispatcher.test.js +34 -0
  26. package/src/orchestrator/objectiveResolver.js +79 -0
  27. package/src/orchestrator/objectiveResolver.test.js +50 -0
  28. package/src/orchestrator/scheduler.js +24 -0
  29. package/src/orchestrator/scheduler.test.js +65 -1
  30. package/src/runtime/client.js +34 -2
  31. package/src/runtime/lifecycle.js +1 -1
  32. package/src/runtime/recoveryManager.js +14 -7
  33. package/src/runtime/runner.js +214 -14
  34. package/src/runtime/runner.test.js +100 -2
  35. package/src/runtime/server.js +43 -3
  36. package/src/runtime/supervisor.js +65 -1
  37. package/src/runtime/supervisor.test.js +80 -0
  38. package/src/shell/LeftPane.tsx +9 -2
  39. package/src/shell/repl.js +57 -42
  40. package/src/shell/repl.test.js +81 -12
  41. package/src/shell/tui.tsx +26 -3
  42. package/src/shell/useSession.ts +15 -3
@@ -440,3 +440,29 @@ test('empty assistant_message finalize is a no-op without a streaming entry', ()
440
440
  dispatchAgentEvent(session, createAgentEvent('assistant_message', { origin: 'llm', payload: { content: '' } }));
441
441
  assert.equal(session.agentProjection.conversation.length, 1);
442
442
  });
443
+
444
+ test('run_error cancels pending plan steps and active activities (no ghosts at relaunch)', () => {
445
+ const session = {};
446
+ dispatchAgentEvent(session, createAgentEvent('plan_set', {
447
+ origin: 'runtime',
448
+ payload: { steps: [
449
+ { id: 'a', description: 'Ingest a.md', status: 'pending', requiredCapability: 'knowledge.update', operation: 'ingest_plan' },
450
+ { id: 'b', description: 'Ingest b.md', status: 'done' },
451
+ ] },
452
+ }));
453
+ dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
454
+ origin: 'runtime_poll',
455
+ payload: { activity: { key: 'production:j1', id: 'j1', label: 'Ingest', status: 'running', terminal: false } },
456
+ }));
457
+ dispatchAgentEvent(session, createAgentEvent('run_error', {
458
+ origin: 'runtime',
459
+ payload: { message: 'Plan is stalled: no_ready_plan_task' },
460
+ }));
461
+
462
+ const plan = session.agentProjection.plan;
463
+ assert.equal(plan.find((step) => step.id === 'a').status, 'cancelled');
464
+ assert.equal(plan.find((step) => step.id === 'b').status, 'done', 'completed work stays done');
465
+ const activity = session.agentProjection.activities.find((item) => item.id === 'j1');
466
+ assert.equal(activity.status, 'cancelled');
467
+ assert.equal(activity.terminal, true);
468
+ });
@@ -1,7 +1,7 @@
1
1
  import { buildAgentSystemPrompt, formatLlmUnavailableMessage } from '../agent/graph.js';
2
2
  import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
3
3
  import { activitySnapshot, newNonTerminalActivities } from './activity.js';
4
- import { extractHeadlessPlan, formatCompletedActivities, formatPlanStatus } from './plan.js';
4
+ import { formatCompletedActivities, formatPlanStatus } from './plan.js';
5
5
  import { formatReadyTaskPrompt, nextReadyPlanTask, readyPlanTasks, sanitizePlanForExecution } from './planPatch.js';
6
6
 
7
7
  export function abortError(message = 'Agent run cancelled.') {
@@ -70,9 +70,13 @@ export async function runAgenticLoop(agent, session, initialInput, {
70
70
  onMaxTurns = null,
71
71
  abortMessage = 'Agent run cancelled.',
72
72
  parallelHandoff = false,
73
+ initialMessages = [],
73
74
  } = {}) {
74
75
  if (!waitForActivities) throw new Error('runAgenticLoop requires waitForActivities.');
75
- const conversationHistory = [];
76
+ // Seeded with the chat that led to this run: a run that starts amnesiac
77
+ // receives an orphan sentence ("lance l'ingestion") and the model invents
78
+ // the missing context — the root of most "Donna répond sans savoir".
79
+ const conversationHistory = [...initialMessages];
76
80
  let currentInput = initialInput;
77
81
 
78
82
  for (let turn = 1; turn <= maxTurns; turn += 1) {
@@ -95,21 +99,16 @@ export async function runAgenticLoop(agent, session, initialInput, {
95
99
  { role: 'assistant', content: response },
96
100
  );
97
101
 
98
- if (turn === 1) {
99
- if (session.headlessPlan === null) {
100
- const extractedPlan = extractHeadlessPlan(response);
101
- if (extractedPlan) {
102
- dispatchAgentEvent(session, createAgentEvent('plan_set', {
103
- origin: planOrigin,
104
- runId,
105
- payload: { steps: extractedPlan },
106
- }));
107
- onPlanExtracted?.({ steps: session.headlessPlan ?? extractedPlan, fallback: true });
108
- }
109
- } else {
110
- onPlanAlreadySet?.({ steps: session.headlessPlan });
111
- }
102
+ if (turn === 1 && session.headlessPlan !== null) {
103
+ onPlanAlreadySet?.({ steps: session.headlessPlan });
112
104
  }
105
+ // NOTE: the deprecated text-plan extraction is gone. It converted any
106
+ // numbered list in a chatty LLM answer into an executable plan — the
107
+ // model's own questions ("Souhaitez-vous que je vous guide ?") became
108
+ // pending tasks, each step re-invoked the LLM, which produced another
109
+ // list… an infinite work-inventing loop. Plans now come ONLY from
110
+ // explicit channels: wiki__plan_set, _activity.plan.steps, or an
111
+ // integrated agent_plan fragment. Prose stays prose.
113
112
  sanitizeSessionPlan(session, { runId });
114
113
 
115
114
  const newPending = newNonTerminalActivities(snapshot, session);
@@ -129,23 +129,25 @@ test('runAgenticLoop can finish from terminal activity facts without another LLM
129
129
  assert.match(result.summary, /output: deliverables\/result\.md/);
130
130
  });
131
131
 
132
- test('runAgenticLoop extracts a fallback numbered plan from first response', async () => {
132
+ test('runAgenticLoop never turns a chatty numbered answer into a plan', async () => {
133
+ // Regression guard for the removed text-plan extraction: the model's own
134
+ // numbered prose ("1. … 2. … Souhaitez-vous… ?") used to become pending
135
+ // tasks and re-invoke the LLM in an infinite work-inventing loop. A chatty
136
+ // answer with no declared plan and no activity is simply a COMPLETE reply.
133
137
  const session = {
134
138
  activities: {},
135
139
  headlessPlan: null,
136
140
  };
137
141
  const result = await runAgenticLoop({
138
142
  async invoke() {
139
- return { response: '1. Collect sources\n2. Build page' };
143
+ return { response: '1. Collect sources\n2. Build page\nSouhaitez-vous que je vous guide ?' };
140
144
  },
141
145
  }, session, 'Plan task', {
142
- maxTurns: 1,
146
+ maxTurns: 3,
143
147
  timeoutMs: 1000,
144
148
  waitForActivities: async () => assert.fail('No activities should be waited for.'),
145
149
  });
146
150
 
147
- assert.equal(result.ok, false);
148
- assert.equal(result.maxTurns, true);
149
- assert.equal(session.headlessPlan.length, 2);
150
- assert.equal(session.headlessPlan[0].description, 'Collect sources');
151
+ assert.equal(result.ok, true, 'the run completes with the reply instead of inventing steps');
152
+ assert.equal(session.headlessPlan, null);
151
153
  });
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.12.11",
3
- "commit": "d8eae0b"
2
+ "version": "0.14.0",
3
+ "commit": "fb0b915"
4
4
  }
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.12.11';
4
+ const WIKI_MANAGER_VERSION = '0.14.0';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
@@ -305,8 +305,7 @@ export function formatMcpToolResult(result) {
305
305
  const DEFAULT_TOOL_RESULT_MAX_CHARS = 16000;
306
306
 
307
307
  function toolResultMaxChars() {
308
- const parsed = Number(process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS);
309
- return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : DEFAULT_TOOL_RESULT_MAX_CHARS;
308
+ return DEFAULT_TOOL_RESULT_MAX_CHARS;
310
309
  }
311
310
 
312
311
  // Bound what a tool result injects into the LLM context and the conversation
@@ -480,15 +479,22 @@ export function formatMcpToolSummary(mcpStatus) {
480
479
  return lines.length > 0 ? lines.join('\n') : 'No connected MCP tools discovered.';
481
480
  }
482
481
 
483
- export function formatMcpToolsForAgent(mcpStatus) {
482
+ export function formatMcpToolsForAgent(mcpStatus, { include } = {}) {
484
483
  const sections = [];
485
484
  for (const [name, value] of Object.entries(mcpStatus ?? {})) {
486
485
  if (value.status !== 'connected') continue;
487
- const tools = value.tools ?? [];
488
- if (tools.length === 0) {
486
+ const allTools = value.tools ?? [];
487
+ if (allTools.length === 0) {
489
488
  sections.push(`${name}: connected, tools not discovered yet`);
490
489
  continue;
491
490
  }
491
+ // Optional filter: callers (e.g. the interactive prompt) advertise only
492
+ // the tools Donna is actually allowed to call, so a capable model is not
493
+ // tempted to invoke a mutating provider tool directly instead of delegating.
494
+ const tools = typeof include === 'function'
495
+ ? allTools.filter((tool) => include(`${name}__${tool.name}`, tool, name))
496
+ : allTools;
497
+ if (tools.length === 0) continue;
492
498
  // Always advertise the qualified call name (server__tool): showing bare
493
499
  // tool names here is what teaches the model to emit unqualified calls.
494
500
  sections.push(`${name}: ${tools.map((tool) => `${name}__${tool.name}`).join(', ')}`);
@@ -503,6 +509,7 @@ export function buildLlmTools(mcpStatus) {
503
509
  for (const tool of value.tools ?? []) {
504
510
  tools.push({
505
511
  type: 'function',
512
+ readOnly: tool.annotations?.readOnlyHint === true,
506
513
  function: {
507
514
  name: `${serverName}__${tool.name}`,
508
515
  description: clarifyToolDescription(serverName, tool.name, tool.description),
@@ -529,15 +529,3 @@ test('truncateToolResult keeps short results intact and bounds long ones head+ta
529
529
  assert.match(bounded, /caractères tronqués/);
530
530
  });
531
531
 
532
- test('truncateToolResult honours WIKI_MANAGER_TOOL_RESULT_MAX_CHARS', () => {
533
- const previous = process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS;
534
- process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS = '500';
535
- try {
536
- const bounded = truncateToolResult('y'.repeat(5000));
537
- assert.ok(bounded.length < 700);
538
- assert.match(bounded, /caractères tronqués/);
539
- } finally {
540
- if (previous === undefined) delete process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS;
541
- else process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS = previous;
542
- }
543
- });
@@ -69,32 +69,6 @@ function readWorkspaceManifest(workspacePath) {
69
69
  }
70
70
  }
71
71
 
72
- function readWorkspaceManifestSkill(workspacePath, loadedManifest = null) {
73
- const loaded = loadedManifest ?? readWorkspaceManifest(workspacePath);
74
- if (!loaded) return null;
75
- const { manifest, manifestPath } = loaded;
76
- const name = String(manifest.name || basename(workspacePath)).trim();
77
- if (!SKILL_NAME_RE.test(name)) return null;
78
- const entrypoints = manifest.entrypoints && typeof manifest.entrypoints === 'object'
79
- ? manifest.entrypoints
80
- : {};
81
- const claude = safeRelativeEntry(entrypoints.claude, 'CLAUDE.md');
82
- const body = readOptionalText(join(workspacePath, claude));
83
- return {
84
- name,
85
- title: String(manifest.title || name).trim(),
86
- description: String(manifest.description || '').trim(),
87
- params: [],
88
- body,
89
- scope: 'workspace',
90
- path: manifestPath,
91
- manifest,
92
- entrypoints,
93
- version: manifest.version ? String(manifest.version) : null,
94
- language: manifest.language ? String(manifest.language) : null,
95
- };
96
- }
97
-
98
72
  function workspaceUiSkillDir(loadedManifest = null) {
99
73
  if (!loadedManifest) return DEFAULT_UI_SKILL_DIR;
100
74
  const { manifest } = loadedManifest;
@@ -119,8 +93,6 @@ export function listSkills(session = {}) {
119
93
  const skills = [];
120
94
  if (session.workspacePath) {
121
95
  const loadedManifest = readWorkspaceManifest(session.workspacePath);
122
- const manifestSkill = readWorkspaceManifestSkill(session.workspacePath, loadedManifest);
123
- if (manifestSkill) skills.push(manifestSkill);
124
96
  skills.push(...collectDirectorySkills(join(session.workspacePath, workspaceUiSkillDir(loadedManifest)), 'workspace'));
125
97
  }
126
98
 
@@ -45,6 +45,20 @@ export function createCapabilityRegistry({ agents = [], compatibleContractVersio
45
45
  };
46
46
  }
47
47
 
48
+ // Discovery and registry construction are asynchronous and are not always
49
+ // completed in the same order. Consumers must nevertheless validate against
50
+ // the live discovered agents instead of treating a temporarily absent cached
51
+ // registry as an empty registry.
52
+ export function capabilityRegistryForSession(session) {
53
+ const agents = session?.agentRegistry?.snapshot?.()
54
+ ?? session?.agentRegistrySnapshot
55
+ ?? session?.agents
56
+ ?? [];
57
+ if (agents.length > 0) return createCapabilityRegistry({ agents });
58
+ if (session?.capabilityRegistry?.providersFor) return session.capabilityRegistry;
59
+ return createCapabilityRegistry();
60
+ }
61
+
48
62
  function isProviderAgent(agent, compatible) {
49
63
  if (!agent || agent.legacy || agent.orchestrable === false) return false;
50
64
  if (!compatible.has(String(agent.description?.contractVersion ?? ''))) return false;
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { createCapabilityRegistry } from './capabilityRegistry.js';
3
+ import { capabilityRegistryForSession, createCapabilityRegistry } from './capabilityRegistry.js';
4
4
 
5
5
  function agent(agentInstanceId, capabilityId, { contractVersion = '1', health = 'available', version = '1' } = {}) {
6
6
  return {
@@ -23,6 +23,17 @@ function agent(agentInstanceId, capabilityId, { contractVersion = '1', health =
23
23
  };
24
24
  }
25
25
 
26
+ test('capabilityRegistryForSession rebuilds the registry from live discovery when the cache is absent', () => {
27
+ const discoveredAgent = agent('production-main', 'knowledge.update');
28
+ const registry = capabilityRegistryForSession({
29
+ capabilityRegistry: createCapabilityRegistry(),
30
+ agentRegistry: { snapshot: () => [discoveredAgent] },
31
+ agentRegistrySnapshot: [],
32
+ });
33
+
34
+ assert.equal(registry.providersFor('knowledge.update').length, 1);
35
+ });
36
+
26
37
  test('capabilityRegistry indexes two agents for the same capability', () => {
27
38
  const registry = createCapabilityRegistry({
28
39
  agents: [
@@ -15,7 +15,16 @@ export function readyTasks(dag, {
15
15
  const done = new Set(tasks.filter((task) => DONE_STATUSES.has(statusOf(task))).map(taskId));
16
16
  const active = new Set([...activeTaskIds].map(String));
17
17
  return tasks
18
- .filter((task) => statusOf(task) === 'pending')
18
+ .filter((task) => {
19
+ const status = statusOf(task);
20
+ return status === 'pending'
21
+ || ((status === 'waiting_approval' || status === 'pending_approval')
22
+ && approvalCovered(task, approvals, {
23
+ runId: task?.runId ?? dag?.runId ?? null,
24
+ workspaceId: dag?.workspace ?? null,
25
+ planRevision: dag?.planRevision ?? null,
26
+ }));
27
+ })
19
28
  .filter((task) => !active.has(taskId(task)))
20
29
  .filter((task) => dependenciesDone(task, done))
21
30
  .filter((task) => groupBarrierSatisfied(task, tasks))
@@ -8,7 +8,7 @@ const TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'canceled', 'c
8
8
  export function createDispatcher({
9
9
  session = null,
10
10
  callTool = callMcpTool,
11
- pollIntervalMs = 250,
11
+ pollIntervalMs = 2500,
12
12
  } = {}) {
13
13
  return {
14
14
  execute(task, assignment, options = {}) {
@@ -30,7 +30,7 @@ export async function execute(task, assignment, {
30
30
  attempt = null,
31
31
  timeoutMs = null,
32
32
  pollBusy = new Set(),
33
- pollIntervalMs = 250,
33
+ pollIntervalMs = 2500,
34
34
  } = {}) {
35
35
  if (!session) throw new Error('dispatcher.execute requires session.');
36
36
  if (!assignment?.serverName) throw new Error(`No MCP server found for agent ${assignment?.agentInstanceId ?? '(unknown)'}.`);
@@ -57,7 +57,7 @@ export async function execute(task, assignment, {
57
57
  signal,
58
58
  ));
59
59
  if (accepted?.accepted === false || accepted?.ok === false) {
60
- throw new Error(String(accepted.error ?? 'agent_execute rejected task'));
60
+ return rejectedTaskResult(task, assignment, accepted, attempt);
61
61
  }
62
62
  jobId = String(accepted.jobId ?? '');
63
63
  if (!jobId) throw new Error('agent_execute did not return jobId.');
@@ -218,6 +218,37 @@ function taskResultFromStatus(task, assignment, jobId, statusPayload, attempt =
218
218
  };
219
219
  }
220
220
 
221
+ function rejectedTaskResult(task, assignment, payload, attempt = null) {
222
+ const rawError = payload?.error;
223
+ const error = rawError && typeof rawError === 'object'
224
+ ? { ...rawError }
225
+ : {
226
+ code: String(rawError ?? 'execution_rejected'),
227
+ message: String(payload?.message ?? rawError ?? 'agent_execute rejected task'),
228
+ retryable: transientError(rawError ?? payload?.message),
229
+ };
230
+ return {
231
+ ok: false,
232
+ taskId: String(task.id ?? task.step),
233
+ attemptId: attempt?.attemptId ?? null,
234
+ jobId: payload?.activeJobId ?? null,
235
+ agentInstanceId: assignment.agentInstanceId,
236
+ status: 'failed',
237
+ outputRefs: [],
238
+ metrics: {},
239
+ error: {
240
+ code: String(error.code ?? 'execution_rejected'),
241
+ message: String(error.message ?? error.code ?? 'agent_execute rejected task'),
242
+ retryable: error.retryable === true || transientError(error.code) || transientError(error.message),
243
+ },
244
+ rawStatus: payload,
245
+ };
246
+ }
247
+
248
+ function transientError(value) {
249
+ return /(?:429|timeout|temporar|throttl|rate.?limit|quota|busy|unavailable)/i.test(String(value ?? ''));
250
+ }
251
+
221
252
  function toolNameFor(session, serverName, baseName) {
222
253
  const tools = session.mcp?.[serverName]?.tools ?? [];
223
254
  const names = tools.map((tool) => String(tool.name ?? '')).filter(Boolean);
@@ -0,0 +1,34 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { createDispatcher } from './dispatcher.js';
4
+
5
+ test('dispatcher returns a retryable logical failure when agent_execute reports workspace_busy', async () => {
6
+ const session = {
7
+ workspace: 'test',
8
+ mcp: {
9
+ production: {
10
+ tools: [
11
+ { name: 'agent_execute' },
12
+ { name: 'agent_status' },
13
+ { name: 'agent_cancel' },
14
+ ],
15
+ },
16
+ },
17
+ };
18
+ const dispatcher = createDispatcher({
19
+ session,
20
+ callTool: async () => ({ accepted: false, error: 'workspace_busy', activeJobId: 'job-old' }),
21
+ });
22
+
23
+ const result = await dispatcher.execute(
24
+ { id: 'ingest-a', requiredCapability: 'knowledge.update', operation: 'ingest_plan', arguments: {} },
25
+ { serverName: 'production', agentInstanceId: 'production-main' },
26
+ { attempt: { attemptId: 'ingest-a:attempt-1', locks: [], release() {} } },
27
+ );
28
+
29
+ assert.equal(result.ok, false);
30
+ assert.equal(result.taskId, 'ingest-a');
31
+ assert.equal(result.attemptId, 'ingest-a:attempt-1');
32
+ assert.equal(result.error.code, 'workspace_busy');
33
+ assert.equal(result.error.retryable, true);
34
+ });
@@ -0,0 +1,79 @@
1
+ export async function resolveObjective(objective, session) {
2
+ const candidates = capabilityCandidates(session);
3
+ if (candidates.length === 0) throw new Error('No orchestrable capability is currently available.');
4
+ const llm = session?.llm;
5
+ if (!llm?.completeWithTools) throw new Error('Objective resolution requires the configured workspace LLM.');
6
+
7
+ const result = await llm.completeWithTools({
8
+ system: [
9
+ 'You resolve one user objective against a closed capability registry.',
10
+ 'Select exactly one listed capability and one of its supported operations.',
11
+ 'Never invent identifiers. Return JSON only: {"capability":"...","operation":"..."}.',
12
+ ].join('\n'),
13
+ tools: [],
14
+ messages: [{
15
+ role: 'user',
16
+ content: `Objective:\n${String(objective)}\n\nRegistry:\n${JSON.stringify(candidates, null, 2)}`,
17
+ }],
18
+ signal: session?._abortSignal,
19
+ });
20
+ const selection = parseJson(result?.content);
21
+ const capability = String(selection?.capability ?? '');
22
+ const operation = String(selection?.operation ?? '');
23
+ const candidate = candidates.find((item) => item.id === capability);
24
+ if (!candidate) throw new Error(`Objective resolver selected unknown capability "${capability}".`);
25
+ if (!candidate.operations.includes(operation)) {
26
+ throw new Error(`Objective resolver selected unsupported operation "${operation}" for ${capability}.`);
27
+ }
28
+ const providers = providersFor(session, capability)
29
+ .filter((provider) => !operation || (provider.capability?.supportedOperations ?? []).includes(operation))
30
+ .sort((a, b) => String(a.agentInstanceId).localeCompare(String(b.agentInstanceId)));
31
+ if (providers.length === 0) throw new Error(`No healthy agent provides ${capability}/${operation}.`);
32
+ return { capability, operation, provider: providers[0], candidates };
33
+ }
34
+
35
+ export function capabilityCandidates(session) {
36
+ const snapshot = registrySnapshot(session);
37
+ const byId = new Map();
38
+ for (const [versionedId, providers] of Object.entries(snapshot)) {
39
+ const id = versionedId.includes('@') ? versionedId.slice(0, versionedId.lastIndexOf('@')) : versionedId;
40
+ const operations = [...new Set((providers ?? []).flatMap((provider) => provider?.capability?.supportedOperations ?? []))].sort();
41
+ const description = (providers ?? []).map((provider) => provider?.capability?.description).find(Boolean) ?? '';
42
+ byId.set(id, { id, description, operations });
43
+ }
44
+ return [...byId.values()].filter((item) => item.operations.length > 0).sort((a, b) => a.id.localeCompare(b.id));
45
+ }
46
+
47
+ function providersFor(session, capability) {
48
+ if (session?.capabilityRegistry?.providersFor) return session.capabilityRegistry.providersFor(capability) ?? [];
49
+ return Object.entries(registrySnapshot(session))
50
+ .filter(([key]) => key === capability || key.startsWith(`${capability}@`))
51
+ .flatMap(([, providers]) => providers ?? []);
52
+ }
53
+
54
+ function registrySnapshot(session) {
55
+ const registry = session?.capabilityRegistry;
56
+ if (registry?.snapshot) return registry.snapshot();
57
+ if (registry && typeof registry === 'object') return registry;
58
+ const agents = session?.agentRegistry?.snapshot?.() ?? session?.agentRegistrySnapshot ?? [];
59
+ const snapshot = {};
60
+ for (const agent of agents) {
61
+ for (const capability of agent?.description?.capabilities ?? []) {
62
+ const key = `${capability.id}@${capability.version ?? '1'}`;
63
+ (snapshot[key] ??= []).push({
64
+ agentInstanceId: agent.agentInstanceId,
65
+ serverName: agent.serverName,
66
+ capability,
67
+ description: agent.description,
68
+ health: agent.health,
69
+ });
70
+ }
71
+ }
72
+ return snapshot;
73
+ }
74
+
75
+ function parseJson(content) {
76
+ const text = String(content ?? '').trim();
77
+ const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
78
+ return JSON.parse(fenced ? fenced[1] : text);
79
+ }
@@ -0,0 +1,50 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { capabilityCandidates, resolveObjective } from './objectiveResolver.js';
4
+
5
+ function sessionWithSelection(selection) {
6
+ const provider = {
7
+ agentInstanceId: 'production-1',
8
+ serverName: 'production',
9
+ capability: {
10
+ id: 'knowledge.update',
11
+ version: '1',
12
+ description: 'Update knowledge from pending sources.',
13
+ supportedOperations: ['ingest'],
14
+ },
15
+ };
16
+ return {
17
+ capabilityRegistry: {
18
+ snapshot: () => ({ 'knowledge.update@1': [provider] }),
19
+ providersFor: () => [provider],
20
+ },
21
+ llm: {
22
+ completeWithTools: async () => ({ content: JSON.stringify(selection) }),
23
+ },
24
+ };
25
+ }
26
+
27
+ test('capabilityCandidates exposes only the closed live registry', () => {
28
+ assert.deepEqual(capabilityCandidates(sessionWithSelection({})), [{
29
+ id: 'knowledge.update',
30
+ description: 'Update knowledge from pending sources.',
31
+ operations: ['ingest'],
32
+ }]);
33
+ });
34
+
35
+ test('resolveObjective selects and validates one real provider', async () => {
36
+ const result = await resolveObjective('Ingère tous les fichiers en attente', sessionWithSelection({
37
+ capability: 'knowledge.update',
38
+ operation: 'ingest',
39
+ }));
40
+ assert.equal(result.capability, 'knowledge.update');
41
+ assert.equal(result.operation, 'ingest');
42
+ assert.equal(result.provider.agentInstanceId, 'production-1');
43
+ });
44
+
45
+ test('resolveObjective rejects invented capability and operation', async () => {
46
+ await assert.rejects(
47
+ resolveObjective('Ingère tout', sessionWithSelection({ capability: 'ingest', operation: 'ingest_all_pending' })),
48
+ /unknown capability "ingest"/,
49
+ );
50
+ });
@@ -9,6 +9,30 @@ export function resolveSchedulerConcurrency(value = process.env.WIKI_MANAGER_SCH
9
9
  : DEFAULT_SCHEDULER_CONCURRENCY;
10
10
  }
11
11
 
12
+ export function resolvePlanConcurrency({ plan = [], agents = [], configured = null } = {}) {
13
+ const capabilities = new Set(plan.map((task) => task?.requiredCapability).filter(Boolean).map(String));
14
+ const assignedAgents = new Set(plan.map((task) => task?.agentInstanceId).filter(Boolean).map(String));
15
+ const relevantAgents = agents.filter((agent) => {
16
+ const id = String(agent?.agentInstanceId ?? agent?.description?.agentInstanceId ?? '');
17
+ if (id && assignedAgents.has(id)) return true;
18
+ return (agent?.description?.capabilities ?? []).some((capability) => capabilities.has(String(capability?.id ?? '')));
19
+ });
20
+ const values = [
21
+ positiveInteger(configured),
22
+ ...plan.flatMap(concurrencyValues),
23
+ ...relevantAgents.flatMap(concurrencyValues),
24
+ ].filter(Boolean);
25
+ return values.length > 0 ? Math.max(1, Math.min(...values)) : DEFAULT_SCHEDULER_CONCURRENCY;
26
+ }
27
+
28
+ export function resolveCapabilityConcurrency(agent = null, ...constraints) {
29
+ const values = [
30
+ ...concurrencyValues(agent),
31
+ ...constraints.map(positiveInteger),
32
+ ].filter(Boolean);
33
+ return values.length > 0 ? Math.max(1, Math.min(...values)) : DEFAULT_SCHEDULER_CONCURRENCY;
34
+ }
35
+
12
36
  export function effectiveConcurrency(group = null, agent = null, donna = null, provider = null) {
13
37
  const limits = [
14
38
  ...concurrencyValues(donna),
@@ -6,7 +6,12 @@ import test from 'node:test';
6
6
  import { createBudgetManager } from './budgetManager.js';
7
7
  import { readyTasks } from './dependencyResolver.js';
8
8
  import { createLockManager } from './lockManager.js';
9
- import { effectiveConcurrency, startReadyTasks } from './scheduler.js';
9
+ import {
10
+ effectiveConcurrency,
11
+ resolveCapabilityConcurrency,
12
+ resolvePlanConcurrency,
13
+ startReadyTasks,
14
+ } from './scheduler.js';
10
15
 
11
16
  test('dependencyResolver holds a barrier task until its dependsOnGroup is done', () => {
12
17
  const plan = [
@@ -30,6 +35,31 @@ test('dependencyResolver orders ready tasks by priority before step', () => {
30
35
  assert.deepEqual(readyTasks(plan).map((item) => item.id), ['high', 'low', 'none']);
31
36
  });
32
37
 
38
+ test('dependencyResolver releases a waiting task when a run grant covers it', () => {
39
+ const plan = {
40
+ runId: 'run-1',
41
+ workspace: 'test4',
42
+ planRevision: 1,
43
+ tasks: [task('ingest', {
44
+ status: 'waiting_approval',
45
+ requiresApproval: true,
46
+ approvalClass: 'mutation',
47
+ })],
48
+ };
49
+
50
+ assert.deepEqual(readyTasks(plan), []);
51
+ assert.deepEqual(readyTasks(plan, {
52
+ approvals: [{
53
+ status: 'approved',
54
+ scope: 'run',
55
+ runId: 'run-1',
56
+ workspaceId: 'test4',
57
+ planRevision: 1,
58
+ approvalClasses: ['mutation'],
59
+ }],
60
+ }).map((item) => item.id), ['ingest']);
61
+ });
62
+
33
63
  test('dependencyResolver skips tasks whose locks are not free and keeps other ready work moving', () => {
34
64
  const lockManager = createLockManager();
35
65
  const held = lockManager.acquire(['deliverable:a.md']);
@@ -76,6 +106,40 @@ test('scheduler.effectiveConcurrency returns the minimum effective concurrency',
76
106
  assert.equal(effectiveConcurrency(group, agent, donna, provider), 2);
77
107
  });
78
108
 
109
+ test('scheduler uses the relevant agent declaration instead of hard-capping plans at three', () => {
110
+ const plan = [{ id: 'task-1', requiredCapability: 'ingest' }];
111
+ const agents = [{
112
+ description: {
113
+ capabilities: [{ id: 'ingest' }],
114
+ limits: { recommendedConcurrency: 10, maxConcurrency: 12 },
115
+ },
116
+ }];
117
+
118
+ assert.equal(resolvePlanConcurrency({ plan, agents }), 10);
119
+ assert.equal(resolvePlanConcurrency({ plan, agents, configured: 3 }), 3);
120
+ assert.equal(resolvePlanConcurrency({ plan, agents, configured: 20 }), 10);
121
+ });
122
+
123
+ test('scheduler ignores unrelated agents and falls back to three without declarations', () => {
124
+ const plan = [{ id: 'task-1', requiredCapability: 'ingest' }];
125
+ const agents = [{
126
+ description: {
127
+ capabilities: [{ id: 'production' }],
128
+ limits: { recommendedConcurrency: 1 },
129
+ },
130
+ }];
131
+
132
+ assert.equal(resolvePlanConcurrency({ plan, agents }), 3);
133
+ });
134
+
135
+ test('capability constraints can lower but never raise an agent declaration', () => {
136
+ const agent = { description: { limits: { recommendedConcurrency: 6, maxConcurrency: 10 } } };
137
+
138
+ assert.equal(resolveCapabilityConcurrency(agent), 6);
139
+ assert.equal(resolveCapabilityConcurrency(agent, 2), 2);
140
+ assert.equal(resolveCapabilityConcurrency(agent, 20), 6);
141
+ });
142
+
79
143
  test('startReadyTasks starts only ready tasks and respects lock starvation', () => {
80
144
  const active = new Map();
81
145
  const lockManager = createLockManager();