@dotdrelle/wiki-manager 0.15.54 → 0.15.57
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/mcp.endpoints.example.json +1 -1
- package/package.json +2 -2
- package/src/agent/graph.js +21 -7
- package/src/agent/graph.test.js +21 -0
- package/src/cli/wiki-manager.js +24 -1
- package/src/commands/slash.js +46 -7
- package/src/commands/slash.test.js +57 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/currentArtifact.js +49 -0
- package/src/core/currentArtifact.test.js +62 -0
- package/src/core/env.js +1 -0
- package/src/core/mcp.js +19 -4
- package/src/core/mcp.test.js +64 -2
- package/src/orchestrator/agentRegistry.js +25 -6
- package/src/orchestrator/agentRegistry.test.js +41 -0
- package/src/orchestrator/objectiveResolver.js +72 -16
- package/src/orchestrator/objectiveResolver.test.js +142 -44
- package/src/orchestrator/scheduler.test.js +76 -0
- package/src/runtime/runner.js +89 -4
- package/src/runtime/server.test.js +9 -0
- package/src/shell/repl.js +4 -0
- package/src/shell/repl.test.js +19 -0
|
@@ -62,6 +62,11 @@ export function createAgentRegistry({
|
|
|
62
62
|
} = {}) {
|
|
63
63
|
const agentsByInstance = new Map();
|
|
64
64
|
const instanceByServer = new Map();
|
|
65
|
+
// Whether the LAST probe of an instance failed. The "did not answer
|
|
66
|
+
// agent_describe" log is edge-triggered: it is emitted once when an instance
|
|
67
|
+
// stops answering, not on every re-scan while it stays down. A stopped agent
|
|
68
|
+
// is not an error to repeat every minute.
|
|
69
|
+
const lastProbeFailed = new Map();
|
|
65
70
|
|
|
66
71
|
return {
|
|
67
72
|
async discover(session, { signal = null } = {}) {
|
|
@@ -70,13 +75,18 @@ export function createAgentRegistry({
|
|
|
70
75
|
const activeServers = new Set(endpoints.map(([serverName]) => serverName));
|
|
71
76
|
for (const [serverName, endpoint] of endpoints) {
|
|
72
77
|
const agent = await discoverServerAgent(session, serverName, endpoint, { callTool, signal, now });
|
|
73
|
-
discovered.push(registerAgent(session, agent, { agentsByInstance, instanceByServer }));
|
|
78
|
+
discovered.push(registerAgent(session, agent, { agentsByInstance, instanceByServer, lastProbeFailed }));
|
|
74
79
|
}
|
|
75
80
|
for (const [serverName, instanceId] of instanceByServer) {
|
|
76
81
|
if (activeServers.has(serverName)) continue;
|
|
77
82
|
const previous = agentsByInstance.get(instanceId);
|
|
78
83
|
instanceByServer.delete(serverName);
|
|
79
84
|
agentsByInstance.delete(instanceId);
|
|
85
|
+
// Same cleanup as the two maps above: without it, a long-running
|
|
86
|
+
// process that sees many renamed/reconnected connectors (the
|
|
87
|
+
// Connectors panel supports exactly this) accumulates one stale
|
|
88
|
+
// entry per retired instance for the process lifetime.
|
|
89
|
+
lastProbeFailed.delete(instanceId);
|
|
80
90
|
if (previous) dispatchRegistryEvent(session, 'agent.unregistered', {
|
|
81
91
|
agentInstanceId: instanceId,
|
|
82
92
|
serverName,
|
|
@@ -140,7 +150,7 @@ async function discoverServerAgent(session, serverName, endpoint = {}, { callToo
|
|
|
140
150
|
}
|
|
141
151
|
}
|
|
142
152
|
|
|
143
|
-
function registerAgent(session, agent, { agentsByInstance, instanceByServer }) {
|
|
153
|
+
function registerAgent(session, agent, { agentsByInstance, instanceByServer, lastProbeFailed }) {
|
|
144
154
|
const previousInstanceId = instanceByServer.get(agent.serverName);
|
|
145
155
|
const previous = previousInstanceId ? agentsByInstance.get(previousInstanceId) : null;
|
|
146
156
|
|
|
@@ -166,13 +176,21 @@ function registerAgent(session, agent, { agentsByInstance, instanceByServer }) {
|
|
|
166
176
|
the blindness — a probe that failed is a fact worth stating, once, where
|
|
167
177
|
the panels and the shell already read.
|
|
168
178
|
|
|
179
|
+
"Once" is the operative word: the re-scan runs every minute, and a stopped
|
|
180
|
+
agent is not an error to repeat each time it is scanned. The log is
|
|
181
|
+
edge-triggered on the transition from answering to not answering.
|
|
182
|
+
|
|
169
183
|
Deliberately NOT a health change: the endpoint is down but the agent stays
|
|
170
184
|
usable by design here, and moving `health` would make `capabilityResolver`
|
|
171
185
|
refuse it — trading a silent loss for a silent refusal.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
186
|
+
*/
|
|
187
|
+
const wasAnswering = lastProbeFailed.get(previous.agentInstanceId) !== true;
|
|
188
|
+
lastProbeFailed.set(previous.agentInstanceId, true);
|
|
189
|
+
if (wasAnswering) {
|
|
190
|
+
dispatchRuntimeLog(session, `agent-registry: ${agent.serverName} did not answer agent_describe`
|
|
191
|
+
+ `${agent.error ? ` (${agent.error})` : ''}; keeping its known capabilities`
|
|
192
|
+
+ ` (${(previous.description?.capabilities ?? []).map((capability) => capability.id).join(', ') || 'none'}).`);
|
|
193
|
+
}
|
|
176
194
|
return cloneAgent(previous);
|
|
177
195
|
}
|
|
178
196
|
|
|
@@ -187,6 +205,7 @@ function registerAgent(session, agent, { agentsByInstance, instanceByServer }) {
|
|
|
187
205
|
}
|
|
188
206
|
agentsByInstance.set(next.agentInstanceId, next);
|
|
189
207
|
instanceByServer.set(next.serverName, next.agentInstanceId);
|
|
208
|
+
if (lastProbeFailed) lastProbeFailed.set(next.agentInstanceId, false);
|
|
190
209
|
|
|
191
210
|
if (!previous || previous.agentInstanceId !== next.agentInstanceId) {
|
|
192
211
|
dispatchRegistryEvent(session, 'agent.registered', { agent: next });
|
|
@@ -209,6 +209,47 @@ test('a failed re-discovery keeps a degraded orchestrator agent too', async () =
|
|
|
209
209
|
assert.equal(agent.description.capabilities.length, 1);
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
+
test('a stopped agent is reported once, not on every re-scan', async () => {
|
|
213
|
+
// The re-scan runs every minute; a deliberately stopped agent must not flood
|
|
214
|
+
// the log with the same "did not answer" line. The message is edge-triggered:
|
|
215
|
+
// emitted on the transition from answering to not answering, then silent
|
|
216
|
+
// until it answers again.
|
|
217
|
+
const events = [];
|
|
218
|
+
const session = {
|
|
219
|
+
workspace: 'acpi',
|
|
220
|
+
mcp: { production: { status: 'connected', tools: [{ name: 'agent_describe' }] } },
|
|
221
|
+
_onAgentEvent: (event) => events.push(event),
|
|
222
|
+
};
|
|
223
|
+
let down = false;
|
|
224
|
+
const registry = createAgentRegistry({
|
|
225
|
+
callTool: async () => {
|
|
226
|
+
if (down) throw new Error('fetch failed');
|
|
227
|
+
return { content: [{ type: 'text', text: JSON.stringify(description()) }] };
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await registry.discover(session);
|
|
232
|
+
down = true;
|
|
233
|
+
await registry.discover(session);
|
|
234
|
+
await registry.discover(session);
|
|
235
|
+
await registry.discover(session);
|
|
236
|
+
|
|
237
|
+
const reports = events.filter((event) => event.type === 'runtime_log'
|
|
238
|
+
&& String(event.payload?.message ?? '').includes('agent-registry:'));
|
|
239
|
+
assert.equal(reports.length, 1, 'the down agent is reported once, not once per scan');
|
|
240
|
+
|
|
241
|
+
// It answers again, then drops again: the next failure is reported again.
|
|
242
|
+
down = false;
|
|
243
|
+
await registry.discover(session);
|
|
244
|
+
down = true;
|
|
245
|
+
await registry.discover(session);
|
|
246
|
+
assert.equal(
|
|
247
|
+
events.filter((event) => event.type === 'runtime_log'
|
|
248
|
+
&& String(event.payload?.message ?? '').includes('agent-registry:')).length,
|
|
249
|
+
2,
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
|
|
212
253
|
test('discovery sends the workspace only to agents whose schema declares it', async () => {
|
|
213
254
|
const seen = {};
|
|
214
255
|
const registry = createAgentRegistry({
|
|
@@ -16,7 +16,10 @@ export class ObjectiveNotOrchestrableError extends Error {
|
|
|
16
16
|
export async function resolveObjective(objective, session) {
|
|
17
17
|
const candidates = capabilityCandidates(session);
|
|
18
18
|
if (candidates.length === 0) throw new Error('No orchestrable capability is currently available.');
|
|
19
|
-
|
|
19
|
+
// Resolution sees the primary intention only (notification + guardrails
|
|
20
|
+
// stripped). The delegated agent still receives the full objective.
|
|
21
|
+
const clean = objectiveForResolution(objective);
|
|
22
|
+
const deterministic = resolveMentionedRegistryOperation(clean, candidates);
|
|
20
23
|
if (deterministic) return selectionWithProvider(session, deterministic, candidates);
|
|
21
24
|
const llm = session?.llm;
|
|
22
25
|
if (!llm?.completeWithTools) throw new Error('Objective resolution requires the configured workspace LLM.');
|
|
@@ -25,6 +28,7 @@ export async function resolveObjective(objective, session) {
|
|
|
25
28
|
system: [
|
|
26
29
|
'You resolve one user objective against a closed capability registry.',
|
|
27
30
|
'Select exactly one listed capability and one of its supported operations.',
|
|
31
|
+
'The aliases of a capability are the strongest signal: match them before the generic description.',
|
|
28
32
|
// Without an explicit way out, the model has to pick SOMETHING: an
|
|
29
33
|
// objective no listed capability covers ("authorize Gmail") came back as
|
|
30
34
|
// workspace.diagnose/doctor and launched an unrelated job. Declining is
|
|
@@ -36,7 +40,7 @@ export async function resolveObjective(objective, session) {
|
|
|
36
40
|
tools: [],
|
|
37
41
|
messages: [{
|
|
38
42
|
role: 'user',
|
|
39
|
-
content: `Objective:\n${
|
|
43
|
+
content: `Objective:\n${clean}\n\nRegistry:\n${JSON.stringify(candidates, null, 2)}`,
|
|
40
44
|
}],
|
|
41
45
|
signal: session?._abortSignal,
|
|
42
46
|
});
|
|
@@ -54,21 +58,72 @@ export async function resolveObjective(objective, session) {
|
|
|
54
58
|
return selectionWithProvider(session, { capability, operation }, candidates);
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// The best-effort notification sentence and the negative guardrails
|
|
62
|
+
// ("Do not …", "Never …") are execution constraints, not the thing being
|
|
63
|
+
// resolved. They are kept intact for the delegated agent (prepareDelegation
|
|
64
|
+
// passes the original objective), but stripped here so they cannot poison the
|
|
65
|
+
// lexical matcher or the LLM prompt.
|
|
66
|
+
const NOTIFICATION_RE = /\s*[^.!?]*\bnotification\b[^.!?]*[.!?]/g;
|
|
67
|
+
const GUARDRAIL_RE = /\s*\b(?:Do not|do not|Never|never)\b[^.]*\./g;
|
|
68
|
+
|
|
69
|
+
export function objectiveForResolution(objective) {
|
|
70
|
+
return String(objective ?? '')
|
|
71
|
+
.replace(NOTIFICATION_RE, ' ')
|
|
72
|
+
.replace(GUARDRAIL_RE, ' ')
|
|
73
|
+
.replace(/\s+/g, ' ')
|
|
74
|
+
.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeText(text) {
|
|
78
|
+
return String(text ?? '')
|
|
63
79
|
.normalize('NFKD')
|
|
64
80
|
.replace(/\p{Diacritic}/gu, '')
|
|
65
|
-
.toLowerCase()
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
81
|
+
.toLowerCase();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizePhrase(value) {
|
|
85
|
+
return normalizeText(value).replace(/[._-]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function phraseIn(phrase, words, text) {
|
|
89
|
+
if (!phrase) return false;
|
|
90
|
+
if (!phrase.includes(' ')) return words.includes(phrase);
|
|
91
|
+
const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
92
|
+
return new RegExp(`(?:^|\\s)${escaped}(?:\\s|$)`).test(text);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Deterministic fast path, safe by construction:
|
|
96
|
+
// - whole-word/phrase matching only — no sub-token split (so `ingest_plan`
|
|
97
|
+
// never matches the generic word "plan") and no prefix stemming (so
|
|
98
|
+
// "exported"/"builds" never match "export"/"build");
|
|
99
|
+
// - aliases (declared by each agent in agent_describe) are authoritative and
|
|
100
|
+
// disambiguate overloaded verbs ("export" CME vs publish);
|
|
101
|
+
// - it fires only when exactly one capability is named, otherwise the LLM
|
|
102
|
+
// resolver decides. A new external agent registers simply by declaring its
|
|
103
|
+
// aliases; nothing here is hardcoded.
|
|
104
|
+
function resolveMentionedRegistryOperation(objective, candidates) {
|
|
105
|
+
const words = normalizeText(objective).match(/[a-z0-9]+/g) ?? [];
|
|
106
|
+
const text = normalizeText(objective);
|
|
107
|
+
|
|
108
|
+
const aliasHits = candidates
|
|
109
|
+
.filter((candidate) => (candidate.aliases ?? []).some((alias) =>
|
|
110
|
+
phraseIn(normalizePhrase(alias), words, text)))
|
|
111
|
+
.map((candidate) => ({ capability: candidate.id, operation: candidate.operations[0] }));
|
|
112
|
+
if (aliasHits.length === 1) return aliasHits[0];
|
|
113
|
+
if (aliasHits.length > 1) return null;
|
|
114
|
+
|
|
115
|
+
const opHits = [];
|
|
116
|
+
for (const candidate of candidates) {
|
|
117
|
+
const matched = candidate.operations.filter((operation) =>
|
|
118
|
+
phraseIn(normalizePhrase(operation), words, text));
|
|
119
|
+
if (matched.length === 1) opHits.push({ capability: candidate.id, operation: matched[0] });
|
|
120
|
+
else if (matched.length > 1) opHits.push({ capability: candidate.id, operation: matched[0], ambiguous: true });
|
|
121
|
+
}
|
|
122
|
+
if (opHits.length === 1 && !opHits[0].ambiguous) {
|
|
123
|
+
const { capability, operation } = opHits[0];
|
|
124
|
+
return { capability, operation };
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
72
127
|
}
|
|
73
128
|
|
|
74
129
|
function selectionWithProvider(session, selection, candidates) {
|
|
@@ -86,8 +141,9 @@ export function capabilityCandidates(session) {
|
|
|
86
141
|
for (const [versionedId, providers] of Object.entries(snapshot)) {
|
|
87
142
|
const id = versionedId.includes('@') ? versionedId.slice(0, versionedId.lastIndexOf('@')) : versionedId;
|
|
88
143
|
const operations = [...new Set((providers ?? []).flatMap((provider) => provider?.capability?.supportedOperations ?? []))].sort();
|
|
144
|
+
const aliases = [...new Set((providers ?? []).flatMap((provider) => provider?.capability?.aliases ?? []))].sort();
|
|
89
145
|
const description = (providers ?? []).map((provider) => provider?.capability?.description).find(Boolean) ?? '';
|
|
90
|
-
byId.set(id, { id, description, operations });
|
|
146
|
+
byId.set(id, { id, description, operations, aliases });
|
|
91
147
|
}
|
|
92
148
|
return [...byId.values()].filter((item) => item.operations.length > 0).sort((a, b) => a.id.localeCompare(b.id));
|
|
93
149
|
}
|
|
@@ -1,73 +1,171 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
capabilityCandidates,
|
|
5
|
+
objectiveForResolution,
|
|
6
|
+
resolveObjective,
|
|
7
|
+
ObjectiveNotOrchestrableError,
|
|
8
|
+
} from './objectiveResolver.js';
|
|
4
9
|
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
function makeCapability(id, { operations = [], aliases = [], description = '' } = {}) {
|
|
11
|
+
return { id, version: '1', description, supportedOperations: operations, aliases };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function provider(agentInstanceId, capability) {
|
|
15
|
+
return { agentInstanceId, serverName: agentInstanceId.split('-')[0], capability };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sessionWith(providers, llmSelection = {}) {
|
|
19
|
+
const snapshot = {};
|
|
20
|
+
for (const entry of providers) {
|
|
21
|
+
const key = `${entry.capability.id}@${entry.capability.version ?? '1'}`;
|
|
22
|
+
(snapshot[key] ??= []).push(entry);
|
|
23
|
+
}
|
|
16
24
|
return {
|
|
17
25
|
capabilityRegistry: {
|
|
18
|
-
snapshot: () =>
|
|
19
|
-
providersFor: () =>
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
completeWithTools: async () => ({ content: JSON.stringify(selection) }),
|
|
26
|
+
snapshot: () => snapshot,
|
|
27
|
+
providersFor: (capability) => Object.entries(snapshot)
|
|
28
|
+
.filter(([key]) => key === capability || key.startsWith(`${capability}@`))
|
|
29
|
+
.flatMap(([, ps]) => ps),
|
|
23
30
|
},
|
|
31
|
+
llm: { completeWithTools: async () => ({ content: JSON.stringify(llmSelection) }) },
|
|
24
32
|
};
|
|
25
33
|
}
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
const knowledge = makeCapability('knowledge.update', {
|
|
36
|
+
operations: ['ingest', 'ingest_plan', 'ingest_apply'],
|
|
37
|
+
aliases: ['ingest', 'ingestion'],
|
|
38
|
+
description: 'Update knowledge from pending sources.',
|
|
39
|
+
});
|
|
40
|
+
const cme = makeCapability('external-source.export', {
|
|
41
|
+
operations: ['export'],
|
|
42
|
+
aliases: ['confluence', 'confluence export', 'source export'],
|
|
43
|
+
description: 'Export configured Confluence sources.',
|
|
44
|
+
});
|
|
45
|
+
const publish = makeCapability('document.publish', {
|
|
46
|
+
operations: ['export', 'polish'],
|
|
47
|
+
aliases: ['publish', 'export deliverable'],
|
|
48
|
+
description: 'Export or polish existing deliverables.',
|
|
49
|
+
});
|
|
50
|
+
const diagnose = makeCapability('workspace.diagnose', {
|
|
51
|
+
operations: ['doctor'],
|
|
52
|
+
aliases: ['diagnose', 'diagnostic', 'doctor'],
|
|
53
|
+
description: 'Diagnose workspace configuration.',
|
|
54
|
+
});
|
|
55
|
+
const sendEmail = makeCapability('communication.send-email', {
|
|
56
|
+
operations: ['send'],
|
|
57
|
+
description: 'Send an email.',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('capabilityCandidates exposes aliases from the closed live registry', () => {
|
|
61
|
+
assert.deepEqual(capabilityCandidates(sessionWith([provider('production-1', knowledge)])), [{
|
|
29
62
|
id: 'knowledge.update',
|
|
30
63
|
description: 'Update knowledge from pending sources.',
|
|
31
|
-
operations: ['ingest'],
|
|
64
|
+
operations: ['ingest', 'ingest_apply', 'ingest_plan'],
|
|
65
|
+
aliases: ['ingest', 'ingestion'],
|
|
32
66
|
}]);
|
|
33
67
|
});
|
|
34
68
|
|
|
35
69
|
test('resolveObjective selects and validates one real provider', async () => {
|
|
36
|
-
const result = await resolveObjective('Ingère tous les fichiers en attente',
|
|
37
|
-
|
|
38
|
-
operation: 'ingest',
|
|
39
|
-
|
|
70
|
+
const result = await resolveObjective('Ingère tous les fichiers en attente', sessionWith(
|
|
71
|
+
[provider('production-1', knowledge)],
|
|
72
|
+
{ capability: 'knowledge.update', operation: 'ingest' },
|
|
73
|
+
));
|
|
40
74
|
assert.equal(result.capability, 'knowledge.update');
|
|
41
75
|
assert.equal(result.operation, 'ingest');
|
|
42
76
|
assert.equal(result.provider.agentInstanceId, 'production-1');
|
|
43
77
|
});
|
|
44
78
|
|
|
45
|
-
test('resolveObjective
|
|
46
|
-
const session =
|
|
47
|
-
|
|
48
|
-
'
|
|
49
|
-
'
|
|
50
|
-
|
|
51
|
-
serverName: 'cme',
|
|
52
|
-
capability: { id: 'external-source.export', version: '1', supportedOperations: ['export'] },
|
|
53
|
-
}],
|
|
54
|
-
});
|
|
55
|
-
session.capabilityRegistry.providersFor = (capability) =>
|
|
56
|
-
session.capabilityRegistry.snapshot()[`${capability}@1`] ?? [];
|
|
79
|
+
test('resolveObjective disambiguates "export" of a Confluence source via alias, without the LLM', async () => {
|
|
80
|
+
const session = sessionWith([
|
|
81
|
+
provider('production-1', knowledge),
|
|
82
|
+
provider('production-2', publish),
|
|
83
|
+
provider('cme-1', cme),
|
|
84
|
+
]);
|
|
57
85
|
session.llm.completeWithTools = async () => {
|
|
58
|
-
throw new Error('the explicit
|
|
86
|
+
throw new Error('the explicit alias must not depend on LLM selection');
|
|
59
87
|
};
|
|
88
|
+
const result = await resolveObjective('Export the requested Confluence source', session);
|
|
89
|
+
assert.equal(result.capability, 'external-source.export');
|
|
90
|
+
assert.equal(result.operation, 'export');
|
|
91
|
+
assert.equal(result.provider.agentInstanceId, 'cme-1');
|
|
92
|
+
});
|
|
60
93
|
|
|
61
|
-
|
|
94
|
+
test('resolveObjective resolves the ingest step of wiki-sync deterministically despite notification and guardrails', async () => {
|
|
95
|
+
const session = sessionWith([
|
|
96
|
+
provider('production-1', knowledge),
|
|
97
|
+
provider('production-2', publish),
|
|
98
|
+
provider('cme-1', cme),
|
|
99
|
+
provider('connectors-1', sendEmail),
|
|
100
|
+
]);
|
|
101
|
+
session.llm.completeWithTools = async () => {
|
|
102
|
+
throw new Error('the aliased intention must not depend on LLM selection');
|
|
103
|
+
};
|
|
104
|
+
const objective = 'Ingest the newly exported Markdown into the wiki. Do not build or publish deliverables. If a messaging connector and a notification recipient are available, send a short best-effort summary; otherwise skip notification silently.';
|
|
105
|
+
const result = await resolveObjective(objective, session);
|
|
62
106
|
assert.equal(result.capability, 'knowledge.update');
|
|
63
107
|
assert.equal(result.operation, 'ingest');
|
|
64
|
-
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('resolveObjective resolves diagnose via alias despite the notification "send"', async () => {
|
|
111
|
+
const session = sessionWith([
|
|
112
|
+
provider('production-1', diagnose),
|
|
113
|
+
provider('connectors-1', sendEmail),
|
|
114
|
+
]);
|
|
115
|
+
session.llm.completeWithTools = async () => {
|
|
116
|
+
throw new Error('the alias must resolve without the LLM');
|
|
117
|
+
};
|
|
118
|
+
const objective = 'Run a complete read-only diagnostic. If a messaging connector is available, send a short summary; otherwise skip notification silently.';
|
|
119
|
+
const result = await resolveObjective(objective, session);
|
|
120
|
+
assert.equal(result.capability, 'workspace.diagnose');
|
|
121
|
+
assert.equal(result.operation, 'doctor');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('objectiveForResolution strips notification and negative guardrails', () => {
|
|
125
|
+
const clean = objectiveForResolution(
|
|
126
|
+
'Ingest files. Do not build or publish deliverables. If a messaging connector is available, send a summary; otherwise skip notification silently.',
|
|
127
|
+
);
|
|
128
|
+
assert.ok(!/\bsend\b/.test(clean), 'notification "send" must be stripped');
|
|
129
|
+
assert.ok(!/\bbuild\b/.test(clean), 'guardrail "build" must be stripped');
|
|
130
|
+
assert.ok(!/\bnotification\b/.test(clean), 'the word "notification" must be stripped');
|
|
131
|
+
assert.match(clean, /Ingest files/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('resolveObjective never binds the generic word "plan" to ingest_plan', async () => {
|
|
135
|
+
const session = sessionWith(
|
|
136
|
+
[provider('production-1', knowledge)],
|
|
137
|
+
{ capability: null, reason: 'no capability' },
|
|
138
|
+
);
|
|
139
|
+
await assert.rejects(
|
|
140
|
+
resolveObjective('Preserve the delivery capability internal execution plan', session),
|
|
141
|
+
(err) => {
|
|
142
|
+
assert.equal(err.name, 'ObjectiveNotOrchestrableError');
|
|
143
|
+
return true;
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('resolveObjective never binds the past participle "exported" to an export operation', async () => {
|
|
149
|
+
const session = sessionWith([
|
|
150
|
+
provider('production-1', publish),
|
|
151
|
+
provider('cme-1', cme),
|
|
152
|
+
]);
|
|
153
|
+
session.llm.completeWithTools = async () => ({ content: JSON.stringify({ capability: null, reason: 'no capability' }) });
|
|
154
|
+
// "newly exported Markdown" describes state, not the action to run.
|
|
155
|
+
await assert.rejects(
|
|
156
|
+
resolveObjective('Review the newly exported Markdown', session),
|
|
157
|
+
(err) => {
|
|
158
|
+
assert.equal(err.name, 'ObjectiveNotOrchestrableError');
|
|
159
|
+
return true;
|
|
160
|
+
},
|
|
161
|
+
);
|
|
65
162
|
});
|
|
66
163
|
|
|
67
164
|
test('resolveObjective declines an objective no listed capability covers', async () => {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
165
|
+
const session = sessionWith(
|
|
166
|
+
[provider('production-1', knowledge)],
|
|
167
|
+
{ capability: null, reason: 'Gmail authorization is not an orchestrable capability.' },
|
|
168
|
+
);
|
|
71
169
|
await assert.rejects(
|
|
72
170
|
resolveObjective("cree l'auth pour le gmail", session),
|
|
73
171
|
(err) => {
|
|
@@ -82,7 +180,7 @@ test('resolveObjective declines an objective no listed capability covers', async
|
|
|
82
180
|
|
|
83
181
|
test('resolveObjective treats a malformed selection as a resolution defect, not a decline', async () => {
|
|
84
182
|
await assert.rejects(
|
|
85
|
-
resolveObjective('Traite tout',
|
|
183
|
+
resolveObjective('Traite tout', sessionWith([provider('production-1', knowledge)], { reason: 'missing capability' })),
|
|
86
184
|
(err) => {
|
|
87
185
|
assert.notEqual(err.name, 'ObjectiveNotOrchestrableError');
|
|
88
186
|
assert.match(err.message, /unknown capability/);
|
|
@@ -93,7 +191,7 @@ test('resolveObjective treats a malformed selection as a resolution defect, not
|
|
|
93
191
|
|
|
94
192
|
test('resolveObjective rejects invented capability and operation', async () => {
|
|
95
193
|
await assert.rejects(
|
|
96
|
-
resolveObjective('Traite tout',
|
|
194
|
+
resolveObjective('Traite tout', sessionWith([provider('production-1', knowledge)], { capability: 'ingest', operation: 'ingest_all_pending' })),
|
|
97
195
|
/unknown capability "ingest"/,
|
|
98
196
|
);
|
|
99
197
|
});
|
|
@@ -245,6 +245,82 @@ function task(id, overrides = {}) {
|
|
|
245
245
|
};
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
/*
|
|
249
|
+
Cas observé le 2026-08-22 (workspace acpi) : `/wiki-ingest` planifie 13
|
|
250
|
+
ingest_plan (groupe `ingest`) + 13 ingest_apply (groupe `apply`, sérialisés
|
|
251
|
+
sur le lock `workspace-write`, derrière la barrière `ingest`) + 1 taxonomy
|
|
252
|
+
(barrière `apply`). Le grant run-scope émis par le bouton Approve est « nu » :
|
|
253
|
+
`approvalClasses: []`, `planRevision: null`. Après la fin des 13 ingest_plan,
|
|
254
|
+
le scheduler a déclaré `no_ready_plan_task` au lieu de démarrer les apply.
|
|
255
|
+
|
|
256
|
+
Ce test verrouille la couverture du grant nu : les apply `waiting_approval`
|
|
257
|
+
DOIVENT redevenir ready quand le grant run-scope (même sans classes ni
|
|
258
|
+
révision) les couvre.
|
|
259
|
+
*/
|
|
260
|
+
test('un grant run-scope « nu » (sans classes ni révision) débloque les apply derrière une barrière', () => {
|
|
261
|
+
const plan = {
|
|
262
|
+
runId: 'run-1',
|
|
263
|
+
workspace: 'acpi',
|
|
264
|
+
planRevision: 1,
|
|
265
|
+
tasks: [
|
|
266
|
+
// 13 ingest_plan du groupe ingest, tous done.
|
|
267
|
+
...Array.from({ length: 13 }, (_, i) => task(`ingest-plan-${i}`, {
|
|
268
|
+
groupId: 'ingest',
|
|
269
|
+
status: 'done',
|
|
270
|
+
requiredCapability: 'knowledge.update',
|
|
271
|
+
operation: 'ingest_plan',
|
|
272
|
+
})),
|
|
273
|
+
// 13 apply : groupe apply, barrière ingest, lock workspace-write.
|
|
274
|
+
...Array.from({ length: 13 }, (_, i) => task(`ingest-apply-${i}`, {
|
|
275
|
+
groupId: 'apply',
|
|
276
|
+
dependsOnGroup: 'ingest',
|
|
277
|
+
barrier: true,
|
|
278
|
+
dependsOn: [`ingest-plan-${i}`],
|
|
279
|
+
status: 'waiting_approval',
|
|
280
|
+
requiredCapability: 'knowledge.update',
|
|
281
|
+
operation: 'ingest_apply',
|
|
282
|
+
locks: ['workspace-write'],
|
|
283
|
+
parallelizable: false,
|
|
284
|
+
requiresApproval: true,
|
|
285
|
+
approvalClass: 'mutation',
|
|
286
|
+
priority: i + 1,
|
|
287
|
+
})),
|
|
288
|
+
task('taxonomy', {
|
|
289
|
+
dependsOnGroup: 'apply',
|
|
290
|
+
barrier: true,
|
|
291
|
+
status: 'waiting_approval',
|
|
292
|
+
requiredCapability: 'knowledge.update',
|
|
293
|
+
operation: 'taxonomy',
|
|
294
|
+
requiresApproval: true,
|
|
295
|
+
approvalClass: 'mutation',
|
|
296
|
+
}),
|
|
297
|
+
],
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Sans grant : rien n'est ready.
|
|
301
|
+
assert.deepEqual(readyTasks(plan).map((item) => item.id), []);
|
|
302
|
+
|
|
303
|
+
// Grant run-scope « nu » — exactement ce que le bouton Approve émet.
|
|
304
|
+
// La couverture ne bloque pas : les 13 apply sont tous prêts.
|
|
305
|
+
const ready = readyTasks(plan, {
|
|
306
|
+
approvals: [{
|
|
307
|
+
status: 'approved',
|
|
308
|
+
scope: 'run',
|
|
309
|
+
runId: 'run-1',
|
|
310
|
+
workspaceId: 'acpi',
|
|
311
|
+
planRevision: null,
|
|
312
|
+
approvalClasses: [],
|
|
313
|
+
}],
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
assert.deepEqual(ready.map((item) => item.id), [
|
|
317
|
+
'ingest-apply-0', 'ingest-apply-1', 'ingest-apply-2', 'ingest-apply-3',
|
|
318
|
+
'ingest-apply-4', 'ingest-apply-5', 'ingest-apply-6', 'ingest-apply-7',
|
|
319
|
+
'ingest-apply-8', 'ingest-apply-9', 'ingest-apply-10', 'ingest-apply-11',
|
|
320
|
+
'ingest-apply-12',
|
|
321
|
+
]);
|
|
322
|
+
});
|
|
323
|
+
|
|
248
324
|
/*
|
|
249
325
|
Cas observé le 2026-08-04 (workspace juno) : une ingestion de dix fichiers,
|
|
250
326
|
neuf réussis, le dixième en échec sur du JSON malformé. La barrière de groupe
|