@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.
- package/.env.example +6 -0
- package/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/src/agent/graph.js +377 -142
- package/src/agent/graph.test.js +576 -34
- package/src/agent/llm.js +5 -5
- package/src/cli/wiki-manager.js +294 -9
- package/src/cli/wiki-manager.test.js +28 -0
- package/src/commands/slash.js +80 -13
- package/src/commands/slash.test.js +9 -1
- package/src/contracts/schemas.js +33 -0
- package/src/contracts/schemas.test.js +14 -0
- package/src/core/agentEvents.js +6 -0
- package/src/core/agentEvents.test.js +26 -0
- package/src/core/agentLoop.js +15 -16
- package/src/core/agentLoop.test.js +9 -7
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +13 -6
- package/src/core/mcp.test.js +0 -12
- package/src/core/skills.js +0 -28
- package/src/orchestrator/capabilityRegistry.js +14 -0
- package/src/orchestrator/capabilityRegistry.test.js +12 -1
- package/src/orchestrator/dependencyResolver.js +10 -1
- package/src/orchestrator/dispatcher.js +34 -3
- package/src/orchestrator/dispatcher.test.js +34 -0
- package/src/orchestrator/objectiveResolver.js +79 -0
- package/src/orchestrator/objectiveResolver.test.js +50 -0
- package/src/orchestrator/scheduler.js +24 -0
- package/src/orchestrator/scheduler.test.js +65 -1
- package/src/runtime/client.js +34 -2
- package/src/runtime/lifecycle.js +1 -1
- package/src/runtime/recoveryManager.js +14 -7
- package/src/runtime/runner.js +214 -14
- package/src/runtime/runner.test.js +100 -2
- package/src/runtime/server.js +43 -3
- package/src/runtime/supervisor.js +65 -1
- package/src/runtime/supervisor.test.js +80 -0
- package/src/shell/LeftPane.tsx +9 -2
- package/src/shell/repl.js +57 -42
- package/src/shell/repl.test.js +81 -12
- package/src/shell/tui.tsx +26 -3
- 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
|
+
});
|
package/src/core/agentLoop.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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,
|
|
148
|
-
assert.equal(
|
|
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
|
});
|
package/src/core/buildInfo.json
CHANGED
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.
|
|
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
|
-
|
|
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
|
|
488
|
-
if (
|
|
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),
|
package/src/core/mcp.test.js
CHANGED
|
@@ -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
|
-
});
|
package/src/core/skills.js
CHANGED
|
@@ -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) =>
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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();
|