@dotdrelle/wiki-manager 0.12.12 → 0.14.1
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/docker-compose.yml +1 -1
- package/mcp.endpoints.example.json +7 -0
- package/package.json +2 -2
- package/src/agent/graph.js +354 -143
- package/src/agent/graph.test.js +516 -54
- package/src/agent/llm.js +5 -5
- package/src/cli/wiki-manager.js +234 -6
- package/src/cli/wiki-manager.test.js +28 -0
- package/src/commands/slash.js +32 -11
- package/src/commands/slash.test.js +9 -1
- package/src/core/agentEvents.js +7 -1
- package/src/core/agentEvents.test.js +13 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +46 -4
- package/src/core/skills.js +0 -28
- package/src/core/toolLoop.js +56 -0
- package/src/core/toolLoop.test.js +88 -0
- 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.test.js +25 -0
- package/src/runtime/client.js +32 -1
- package/src/runtime/lifecycle.js +32 -2
- package/src/runtime/recoveryManager.js +14 -7
- package/src/runtime/runner.js +112 -13
- package/src/runtime/runner.test.js +64 -1
- package/src/runtime/server.js +47 -3
- package/src/runtime/supervisor.js +4 -1
- package/src/runtime/supervisor.test.js +49 -0
- package/src/shell/repl.js +134 -55
- package/src/shell/repl.test.js +151 -12
- package/src/shell/useSession.ts +15 -3
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
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Minimal, side-effect-free bounded tool-use loop.
|
|
2
|
+
//
|
|
3
|
+
// This is the shared mechanic of "ask the LLM with a tool set, run the tool
|
|
4
|
+
// calls it emits, feed results back, repeat up to a cap". The caller injects
|
|
5
|
+
// the ONLY policy that varies: `executeCall(call) -> string` decides whether a
|
|
6
|
+
// requested tool is allowed and produces its textual result (allow-list check,
|
|
7
|
+
// MCP dispatch, error formatting). The loop itself owns no plan, no delegation,
|
|
8
|
+
// no run identity and no agent events — deliberately unlike the /agent
|
|
9
|
+
// orchestration loop in createAgentGraph, which is a stateful LangGraph node
|
|
10
|
+
// graph and stays separate. Use this for stateless tool-answer turns (e.g.
|
|
11
|
+
// /chat read-only questions).
|
|
12
|
+
//
|
|
13
|
+
// `executeCall` may throw to abort the whole loop (e.g. an AbortError on
|
|
14
|
+
// cancel); anything it returns is treated as the tool result for that call.
|
|
15
|
+
export async function runBoundedToolLoop({
|
|
16
|
+
llm,
|
|
17
|
+
system,
|
|
18
|
+
messages,
|
|
19
|
+
tools,
|
|
20
|
+
executeCall,
|
|
21
|
+
maxIterations = 4,
|
|
22
|
+
signal,
|
|
23
|
+
onStep,
|
|
24
|
+
} = {}) {
|
|
25
|
+
const cap = Math.max(1, Math.floor(maxIterations) || 1);
|
|
26
|
+
const convo = [...(messages ?? [])];
|
|
27
|
+
for (let i = 0; i < cap; i += 1) {
|
|
28
|
+
onStep?.(i + 1, cap);
|
|
29
|
+
const result = await llm.completeWithTools({
|
|
30
|
+
system,
|
|
31
|
+
tools,
|
|
32
|
+
messages: convo,
|
|
33
|
+
toolChoice: 'auto',
|
|
34
|
+
signal,
|
|
35
|
+
});
|
|
36
|
+
const calls = result?.tool_calls ?? [];
|
|
37
|
+
if (calls.length === 0) {
|
|
38
|
+
return {
|
|
39
|
+
content: result?.content ?? result?.message?.content ?? '',
|
|
40
|
+
iterations: i + 1,
|
|
41
|
+
capped: false,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
convo.push(result.message ?? { role: 'assistant', content: result.content ?? '', tool_calls: calls });
|
|
45
|
+
// Tool calls within one turn are independent: dispatch concurrently, then
|
|
46
|
+
// replay results in the model's call order so the transcript stays stable.
|
|
47
|
+
const outcomes = await Promise.all(calls.map(async (call) => ({
|
|
48
|
+
tool_call_id: call.id,
|
|
49
|
+
content: await executeCall(call),
|
|
50
|
+
})));
|
|
51
|
+
for (const outcome of outcomes) {
|
|
52
|
+
convo.push({ role: 'tool', tool_call_id: outcome.tool_call_id, content: outcome.content });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { content: '', iterations: cap, capped: true };
|
|
56
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { runBoundedToolLoop } from './toolLoop.js';
|
|
4
|
+
|
|
5
|
+
function toolCall(id, name, args = '{}') {
|
|
6
|
+
return { id, function: { name, arguments: args } };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
test('returns the model answer directly when no tool is called', async () => {
|
|
10
|
+
const llm = {
|
|
11
|
+
async completeWithTools() {
|
|
12
|
+
return { content: 'plain answer', tool_calls: [] };
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
const out = await runBoundedToolLoop({ llm, tools: [], executeCall: async () => 'unused' });
|
|
16
|
+
assert.deepEqual(out, { content: 'plain answer', iterations: 1, capped: false });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('dispatches a tool call, feeds the result back, then returns the final answer', async () => {
|
|
20
|
+
let round = 0;
|
|
21
|
+
const seen = [];
|
|
22
|
+
const llm = {
|
|
23
|
+
async completeWithTools({ messages }) {
|
|
24
|
+
round += 1;
|
|
25
|
+
if (round === 1) return { message: { role: 'assistant', content: '', tool_calls: [toolCall('c1', 'cme__cme_status')] }, tool_calls: [toolCall('c1', 'cme__cme_status')] };
|
|
26
|
+
seen.push(messages.find((m) => m.role === 'tool')?.content);
|
|
27
|
+
return { content: 'configured', tool_calls: [] };
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
const out = await runBoundedToolLoop({
|
|
31
|
+
llm,
|
|
32
|
+
tools: [{ function: { name: 'cme__cme_status' } }],
|
|
33
|
+
executeCall: async (call) => `RESULT(${call.function.name})`,
|
|
34
|
+
});
|
|
35
|
+
assert.equal(out.content, 'configured');
|
|
36
|
+
assert.equal(out.iterations, 2);
|
|
37
|
+
assert.equal(out.capped, false);
|
|
38
|
+
assert.deepEqual(seen, ['RESULT(cme__cme_status)']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('runs concurrent tool calls and replays results in call order', async () => {
|
|
42
|
+
let round = 0;
|
|
43
|
+
const order = [];
|
|
44
|
+
const llm = {
|
|
45
|
+
async completeWithTools({ messages }) {
|
|
46
|
+
round += 1;
|
|
47
|
+
if (round === 1) {
|
|
48
|
+
const calls = [toolCall('a', 's__list'), toolCall('b', 's__status')];
|
|
49
|
+
return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
|
|
50
|
+
}
|
|
51
|
+
order.push(...messages.filter((m) => m.role === 'tool').map((m) => m.tool_call_id));
|
|
52
|
+
return { content: 'done', tool_calls: [] };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
const out = await runBoundedToolLoop({
|
|
56
|
+
llm,
|
|
57
|
+
tools: [],
|
|
58
|
+
executeCall: async (call) => call.id,
|
|
59
|
+
});
|
|
60
|
+
assert.equal(out.content, 'done');
|
|
61
|
+
assert.deepEqual(order, ['a', 'b']); // preserved model call order
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('reports capped when the model keeps calling tools past the cap', async () => {
|
|
65
|
+
const llm = {
|
|
66
|
+
async completeWithTools() {
|
|
67
|
+
const calls = [toolCall('x', 's__status')];
|
|
68
|
+
return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
const out = await runBoundedToolLoop({ llm, tools: [], executeCall: async () => 'r', maxIterations: 3 });
|
|
72
|
+
assert.equal(out.capped, true);
|
|
73
|
+
assert.equal(out.iterations, 3);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('propagates an abort thrown by executeCall', async () => {
|
|
77
|
+
const llm = {
|
|
78
|
+
async completeWithTools() {
|
|
79
|
+
const calls = [toolCall('x', 's__status')];
|
|
80
|
+
return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
const abort = Object.assign(new Error('aborted'), { name: 'AbortError' });
|
|
84
|
+
await assert.rejects(
|
|
85
|
+
runBoundedToolLoop({ llm, tools: [], executeCall: async () => { throw abort; } }),
|
|
86
|
+
/aborted/,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -35,6 +35,31 @@ test('dependencyResolver orders ready tasks by priority before step', () => {
|
|
|
35
35
|
assert.deepEqual(readyTasks(plan).map((item) => item.id), ['high', 'low', 'none']);
|
|
36
36
|
});
|
|
37
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
|
+
|
|
38
63
|
test('dependencyResolver skips tasks whose locks are not free and keeps other ready work moving', () => {
|
|
39
64
|
const lockManager = createLockManager();
|
|
40
65
|
const held = lockManager.acquire(['deliverable:a.md']);
|
package/src/runtime/client.js
CHANGED
|
@@ -70,6 +70,25 @@ export async function postRuntimeRun(input, {
|
|
|
70
70
|
return response.json();
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
export async function postRuntimeDelegate(objective, {
|
|
74
|
+
url = runtimeUrlFromEnv(),
|
|
75
|
+
token = runtimeToken(),
|
|
76
|
+
workspace = null,
|
|
77
|
+
} = {}) {
|
|
78
|
+
const response = await fetch(runtimeEndpoint(url, '/delegate', workspace), {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { ...runtimeHeaders(token), 'Content-Type': 'application/json' },
|
|
81
|
+
body: JSON.stringify({ objective, workspace }),
|
|
82
|
+
});
|
|
83
|
+
const payload = await response.json().catch(() => ({}));
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
const err = new Error(payload.error ?? `Runtime delegation failed: HTTP ${response.status}`);
|
|
86
|
+
err.status = response.status;
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
return payload;
|
|
90
|
+
}
|
|
91
|
+
|
|
73
92
|
export async function postRuntimeControl(action, {
|
|
74
93
|
url = runtimeUrlFromEnv(),
|
|
75
94
|
token = runtimeToken(),
|
|
@@ -152,6 +171,9 @@ export async function postRuntimeApprove({
|
|
|
152
171
|
runId = null,
|
|
153
172
|
itemId = null,
|
|
154
173
|
approvalId = null,
|
|
174
|
+
scope = null,
|
|
175
|
+
planRevision = null,
|
|
176
|
+
approvalClasses = null,
|
|
155
177
|
} = {}) {
|
|
156
178
|
const endpoint = runtimeEndpoint(url, '/approve', workspace);
|
|
157
179
|
const parsed = new URL(endpoint);
|
|
@@ -160,7 +182,16 @@ export async function postRuntimeApprove({
|
|
|
160
182
|
if (approvalId) parsed.searchParams.set('approvalId', approvalId);
|
|
161
183
|
const response = await fetch(parsed.toString(), {
|
|
162
184
|
method: 'POST',
|
|
163
|
-
headers: runtimeHeaders(token),
|
|
185
|
+
headers: { ...runtimeHeaders(token), 'Content-Type': 'application/json' },
|
|
186
|
+
body: JSON.stringify({
|
|
187
|
+
workspace,
|
|
188
|
+
runId,
|
|
189
|
+
itemId,
|
|
190
|
+
approvalId,
|
|
191
|
+
scope,
|
|
192
|
+
planRevision,
|
|
193
|
+
approvalClasses,
|
|
194
|
+
}),
|
|
164
195
|
});
|
|
165
196
|
if (!response.ok) throw new Error(`Runtime approve failed: HTTP ${response.status}`);
|
|
166
197
|
return response.json();
|
package/src/runtime/lifecycle.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile, spawn } from 'node:child_process';
|
|
2
|
-
import {
|
|
2
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { activeCacertPath } from '../core/cacert.js';
|
|
5
6
|
import { checkRuntimeHealth, postRuntimeShutdown, runtimeUrlFromEnv } from './client.js';
|
|
@@ -10,6 +11,26 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
10
11
|
const managerRoot = resolve(__dirname, '../..');
|
|
11
12
|
const binPath = resolve(managerRoot, 'bin/wiki-manager.js');
|
|
12
13
|
|
|
14
|
+
// Newest mtime (ms) of the manager's own source tree. Used to detect that the
|
|
15
|
+
// code was edited after a reused runtime started, so ensureRuntime can restart
|
|
16
|
+
// it instead of serving stale code. Returns 0 if the source tree is unreadable
|
|
17
|
+
// (e.g. running from a packed install) — in that case staleness is not checked.
|
|
18
|
+
function newestManagerSourceMtimeMs() {
|
|
19
|
+
const srcDir = join(managerRoot, 'src');
|
|
20
|
+
let newest = 0;
|
|
21
|
+
try {
|
|
22
|
+
for (const entry of readdirSync(srcDir, { recursive: true })) {
|
|
23
|
+
const name = String(entry);
|
|
24
|
+
if (!(name.endsWith('.js') || name.endsWith('.ts') || name.endsWith('.tsx'))) continue;
|
|
25
|
+
try {
|
|
26
|
+
const mtime = statSync(join(srcDir, name)).mtimeMs;
|
|
27
|
+
if (mtime > newest) newest = mtime;
|
|
28
|
+
} catch { /* file vanished mid-scan */ }
|
|
29
|
+
}
|
|
30
|
+
} catch { return 0; }
|
|
31
|
+
return newest;
|
|
32
|
+
}
|
|
33
|
+
|
|
13
34
|
export function runtimeNodeExecutable() {
|
|
14
35
|
return process.versions.bun
|
|
15
36
|
? (process.env.WIKI_MANAGER_NODE_BIN ?? 'node')
|
|
@@ -47,13 +68,22 @@ export async function ensureRuntime({
|
|
|
47
68
|
if (existing) {
|
|
48
69
|
const expectedCacertPath = activeCacertPath();
|
|
49
70
|
const actualCacertPath = existing.cacertPath ? resolve(existing.cacertPath) : null;
|
|
71
|
+
// Dev staleness: if the manager source was edited after this runtime
|
|
72
|
+
// started, the reused process would keep serving old code (the recurring
|
|
73
|
+
// "my change is not taking effect" trap). Treat it as stale and restart.
|
|
74
|
+
// Packed installs report mtime 0 (unreadable src) → never flagged stale.
|
|
75
|
+
// Opt out with WIKI_MANAGER_RUNTIME_NO_STALE_CHECK=1.
|
|
76
|
+
const startedAtMs = Number(existing.startedAtMs) || 0;
|
|
77
|
+
const sourceMtimeMs = process.env.WIKI_MANAGER_RUNTIME_NO_STALE_CHECK === '1' ? 0 : newestManagerSourceMtimeMs();
|
|
78
|
+
const stale = startedAtMs > 0 && sourceMtimeMs > startedAtMs;
|
|
50
79
|
// forceRestart: the caller knows the manager configuration just changed
|
|
51
80
|
// (e.g. mcp.endpoints.json scaffolded on first run) — a runtime started
|
|
52
81
|
// BEFORE that only knows the old endpoints and would keep answering
|
|
53
82
|
// without the agents until manually restarted.
|
|
54
|
-
if (!forceRestart && actualCacertPath === expectedCacertPath) {
|
|
83
|
+
if (!forceRestart && !stale && actualCacertPath === expectedCacertPath) {
|
|
55
84
|
return { url, started: false, health: existing, token: auth.token, tokenPath: auth.tokenPath };
|
|
56
85
|
}
|
|
86
|
+
if (stale) console.error('runtime: source changed since start — restarting for fresh code.');
|
|
57
87
|
await postRuntimeShutdown({ url, token: auth.token });
|
|
58
88
|
await waitForRuntimeShutdown(url, auth.token, 2500);
|
|
59
89
|
}
|
|
@@ -37,18 +37,25 @@ export async function recoverActiveRuns({
|
|
|
37
37
|
errors.push({ runId: run.id, taskId: task.id, error: error instanceof Error ? error.message : String(error) });
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
-
// A run
|
|
41
|
-
//
|
|
42
|
-
// every
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
// A run that recovery cannot move forward must be closed for good, or it
|
|
41
|
+
// re-attaches as a blocking "a runtime run is already active" zombie on
|
|
42
|
+
// every boot. This covers BOTH cases that can never resume on a fresh boot:
|
|
43
|
+
// - runs whose active tasks were all interrupted, and
|
|
44
|
+
// - runs with no active task to recover at all (e.g. left waiting for
|
|
45
|
+
// approval, or with only un-started pending tasks). Nothing here will
|
|
46
|
+
// ever progress, so finalize it now.
|
|
47
|
+
const progressed = runOutcomes.some((outcome) => outcome?.status === 'recovered' || outcome?.status === 'rescheduled');
|
|
48
|
+
if (!progressed) {
|
|
49
|
+
const reason = activeTasks.length > 0
|
|
50
|
+
? 'Recovery found no recoverable task.'
|
|
51
|
+
: 'Recovery found no active task to resume.';
|
|
52
|
+
const changed = store.interruptRuns?.({ workspace: run.workspace ?? null, runId: run.id, reason }) ?? 0;
|
|
46
53
|
if (changed > 0) {
|
|
47
54
|
dispatch(session, store, 'runtime_log', {
|
|
48
55
|
origin: 'recovery_manager',
|
|
49
56
|
runId: run.id,
|
|
50
57
|
workspace: run.workspace ?? workspaceFromSession(session),
|
|
51
|
-
payload: { message: `recovery: run ${run.id} interrupted (
|
|
58
|
+
payload: { message: `recovery: run ${run.id} interrupted (${reason})` },
|
|
52
59
|
});
|
|
53
60
|
}
|
|
54
61
|
}
|