@dotdrelle/wiki-manager 0.15.28 → 0.15.32
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 +5 -2
- package/README.md +5 -4
- package/agents.docker-compose.override.example.yml +1 -1
- package/agents.docker-compose.yml +10 -0
- package/bin/wiki-manager.js +1 -1
- package/docker-compose.override.example.yml +3 -2
- package/mcp.endpoints.example.json +1 -1
- package/package.json +2 -2
- package/src/agent/graph.js +77 -13
- package/src/agent/graph.test.js +165 -8
- package/src/cli/runtimeStartup.test.js +14 -0
- package/src/cli/wiki-manager.js +118 -19
- package/src/cli/wiki-manager.test.js +192 -0
- package/src/commands/slash.js +168 -39
- package/src/commands/slash.test.js +100 -1
- package/src/core/agentsCompose.js +79 -0
- package/src/core/agentsCompose.test.js +99 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/composeOverrides.test.js +17 -3
- package/src/core/env.js +27 -10
- package/src/core/mcp.js +113 -38
- package/src/core/mcp.test.js +181 -20
- package/src/core/startupCheck.js +64 -27
- package/src/core/startupCheck.test.js +49 -1
- package/src/core/wikiSetup.js +57 -22
- package/src/core/wikiSetup.test.js +87 -0
- package/src/core/wikiWorkspace.test.js +16 -0
- package/src/orchestrator/agentRegistry.js +35 -7
- package/src/orchestrator/agentRegistry.test.js +73 -0
- package/src/orchestrator/dispatcher.js +6 -1
- package/src/orchestrator/dispatcher.test.js +24 -1
- package/src/orchestrator/objectiveResolver.js +24 -0
- package/src/orchestrator/objectiveResolver.test.js +27 -0
- package/src/runtime/auth.test.js +1 -65
- package/src/runtime/donna-contract.test.js +2 -1
- package/src/runtime/lifecycle.js +0 -33
- package/src/runtime/runner.test.js +10 -1
- package/src/runtime/supervisor.test.js +11 -1
- package/src/shell/LeftPane.tsx +49 -24
- package/src/shell/repl.js +114 -21
- package/src/shell/repl.test.js +88 -17
- package/src/shell/tui.tsx +10 -19
- package/wiki-workspace +98 -12
package/src/cli/wiki-manager.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
2
3
|
import { readFileSync } from 'node:fs';
|
|
3
4
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
5
|
import { dirname, join, resolve } from 'node:path';
|
|
@@ -25,6 +26,7 @@ import { listWorkspaces } from '../core/workspaces.js';
|
|
|
25
26
|
|
|
26
27
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
27
28
|
const packageJsonPath = resolve(__dirname, '../../package.json');
|
|
29
|
+
const workspaceCliPath = resolve(__dirname, '../../wiki-workspace');
|
|
28
30
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
29
31
|
const SHELL_COMMANDS = ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'connector', 'wiki', 'skills', 'clear', 'chat', 'agent', 'approve'];
|
|
30
32
|
|
|
@@ -106,7 +108,13 @@ export function buildExecutorOnlyFragment({ objective, workspace, selection }) {
|
|
|
106
108
|
* then a JSON-text completion, then no arguments — the executor uses its own
|
|
107
109
|
* defaults. It never throws and never invents identifiers.
|
|
108
110
|
*/
|
|
109
|
-
export async function resolveExecutorArguments({
|
|
111
|
+
export async function resolveExecutorArguments({
|
|
112
|
+
llm,
|
|
113
|
+
objective,
|
|
114
|
+
capability,
|
|
115
|
+
workspace,
|
|
116
|
+
signal,
|
|
117
|
+
} = {}) {
|
|
110
118
|
const schema = capability?.inputSchema;
|
|
111
119
|
const objectiveText = String(objective ?? '').trim();
|
|
112
120
|
if (
|
|
@@ -121,10 +129,22 @@ export async function resolveExecutorArguments({ llm, objective, capability, sig
|
|
|
121
129
|
) {
|
|
122
130
|
return {};
|
|
123
131
|
}
|
|
132
|
+
const workspaceName = String(workspace ?? '').trim();
|
|
124
133
|
const system = [
|
|
125
134
|
'Extract structured arguments for a task from the user objective.',
|
|
126
135
|
'Only fill a field when the objective explicitly states or clearly implies its value.',
|
|
127
136
|
'Omit every field that is not stated. Never invent identifiers, queries, filters or counts.',
|
|
137
|
+
// The objective almost always names the workspace ("export the pages of
|
|
138
|
+
// workspace acpi"), and the orchestrator already binds it out of band. With
|
|
139
|
+
// one free-text field in the schema and no field for the workspace, a model
|
|
140
|
+
// reliably misbinds the two — that is how a workspace name ended up as a
|
|
141
|
+
// source name and failed the task.
|
|
142
|
+
...(workspaceName
|
|
143
|
+
? [
|
|
144
|
+
`The task already runs against workspace "${workspaceName}"; the orchestrator supplies it separately.`,
|
|
145
|
+
`Never use "${workspaceName}" as the value of any field. If the objective only names the workspace, return {}.`,
|
|
146
|
+
]
|
|
147
|
+
: []),
|
|
128
148
|
'Return the arguments object only.',
|
|
129
149
|
].join('\n');
|
|
130
150
|
const tool = {
|
|
@@ -150,9 +170,9 @@ export async function resolveExecutorArguments({ llm, objective, capability, sig
|
|
|
150
170
|
});
|
|
151
171
|
const call = (result?.tool_calls ?? []).find((item) => item?.function?.name === 'set_task_arguments');
|
|
152
172
|
const fromCall = call ? safeParseArgumentObject(call.function?.arguments) : null;
|
|
153
|
-
if (fromCall) return pruneArgumentsToSchema(fromCall, schema);
|
|
173
|
+
if (fromCall) return pruneArgumentsToSchema(fromCall, schema, workspaceName);
|
|
154
174
|
const fromText = safeParseArgumentObject(result?.content);
|
|
155
|
-
if (fromText) return pruneArgumentsToSchema(fromText, schema);
|
|
175
|
+
if (fromText) return pruneArgumentsToSchema(fromText, schema, workspaceName);
|
|
156
176
|
} catch {
|
|
157
177
|
// Fall through to the tool-less path.
|
|
158
178
|
}
|
|
@@ -164,13 +184,48 @@ export async function resolveExecutorArguments({ llm, objective, capability, sig
|
|
|
164
184
|
signal,
|
|
165
185
|
});
|
|
166
186
|
const fromText = safeParseArgumentObject(result?.content);
|
|
167
|
-
if (fromText) return pruneArgumentsToSchema(fromText, schema);
|
|
187
|
+
if (fromText) return pruneArgumentsToSchema(fromText, schema, workspaceName);
|
|
168
188
|
} catch {
|
|
169
189
|
// Give up: the executor will use its own defaults.
|
|
170
190
|
}
|
|
171
191
|
return {};
|
|
172
192
|
}
|
|
173
193
|
|
|
194
|
+
// The system prompt above is advice a model may ignore; this is not. A value
|
|
195
|
+
// that merely echoes the workspace name carries no information the orchestrator
|
|
196
|
+
// does not already hold, so dropping it can only widen the task to the
|
|
197
|
+
// executor's own default — never narrow it to something wrong. (A source
|
|
198
|
+
// genuinely named after its workspace degrades to "process everything", which
|
|
199
|
+
// still includes it.)
|
|
200
|
+
function echoesWorkspace(key, value, workspaceName) {
|
|
201
|
+
if (!workspaceName || typeof value !== 'string') return false;
|
|
202
|
+
if (/workspace/i.test(key)) return false;
|
|
203
|
+
return value.trim().toLowerCase() === String(workspaceName).trim().toLowerCase();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// A field with an `enum` declares a closed vocabulary, so an extracted value
|
|
207
|
+
// outside it is checkably wrong — not a judgement call about meaning. This is
|
|
208
|
+
// the only class of hallucinated identifier the orchestrator can reject
|
|
209
|
+
// without knowing anything about the business domain, which is exactly why
|
|
210
|
+
// agents must publish the vocabulary instead of a bare string.
|
|
211
|
+
function violatesEnum(schemaEntry, value) {
|
|
212
|
+
const values = schemaEntry?.enum;
|
|
213
|
+
if (!Array.isArray(values) || values.length === 0) return false;
|
|
214
|
+
return !values.some((allowed) => allowed === value);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function missingRequiredArguments(schema, args) {
|
|
218
|
+
const required = Array.isArray(schema?.required) ? schema.required.map(String) : [];
|
|
219
|
+
const values = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
|
|
220
|
+
return required.filter((key) => {
|
|
221
|
+
const value = values[key];
|
|
222
|
+
if (value === undefined || value === null) return true;
|
|
223
|
+
if (typeof value === 'string') return value.trim() === '';
|
|
224
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
225
|
+
return false;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
174
229
|
function safeParseArgumentObject(text) {
|
|
175
230
|
const cleaned = String(text ?? '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
|
176
231
|
if (!cleaned) return null;
|
|
@@ -182,12 +237,14 @@ function safeParseArgumentObject(text) {
|
|
|
182
237
|
}
|
|
183
238
|
}
|
|
184
239
|
|
|
185
|
-
function pruneArgumentsToSchema(value, schema) {
|
|
240
|
+
function pruneArgumentsToSchema(value, schema, workspaceName = '') {
|
|
186
241
|
const allowed = schema.properties ?? {};
|
|
187
242
|
const acceptsExtra = schema.additionalProperties !== false;
|
|
188
243
|
const out = {};
|
|
189
244
|
for (const [key, entry] of Object.entries(value)) {
|
|
190
245
|
if (entry === undefined || entry === null) continue;
|
|
246
|
+
if (echoesWorkspace(key, entry, workspaceName)) continue;
|
|
247
|
+
if (violatesEnum(allowed[key], entry)) continue;
|
|
191
248
|
if (Object.hasOwn(allowed, key) || acceptsExtra) out[key] = entry;
|
|
192
249
|
}
|
|
193
250
|
return out;
|
|
@@ -248,6 +305,12 @@ export function ensureInteractiveAssistantMessage(session, response, { turnId, w
|
|
|
248
305
|
return true;
|
|
249
306
|
}
|
|
250
307
|
|
|
308
|
+
export function mcpStatusNeedsRefresh(mcpStatus) {
|
|
309
|
+
return Object.values(mcpStatus ?? {}).some(
|
|
310
|
+
(endpoint) => endpoint?.url && endpoint.status !== 'connected',
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
251
314
|
export async function forwardRuntimeApproval(getWorkspaceContext, request = {}) {
|
|
252
315
|
const context = await getWorkspaceContext(request.workspace ?? null);
|
|
253
316
|
return context.approvalManager?.approve(request) ?? { approved: false };
|
|
@@ -1011,8 +1074,16 @@ async function runRuntime(argv, agent) {
|
|
|
1011
1074
|
llm: session.llm,
|
|
1012
1075
|
objective,
|
|
1013
1076
|
capability: provider.capability,
|
|
1077
|
+
workspace: session.workspace ?? context.workspace ?? '',
|
|
1014
1078
|
signal: session._abortSignal,
|
|
1015
1079
|
});
|
|
1080
|
+
const missingArguments = missingRequiredArguments(
|
|
1081
|
+
provider.capability?.inputSchema,
|
|
1082
|
+
extractedArguments,
|
|
1083
|
+
);
|
|
1084
|
+
if (missingArguments.length > 0) {
|
|
1085
|
+
throw new Error(`Delegation requires input: ${missingArguments.join(', ')}`);
|
|
1086
|
+
}
|
|
1016
1087
|
fragment = buildExecutorOnlyFragment({
|
|
1017
1088
|
objective,
|
|
1018
1089
|
workspace: session.workspace ?? context.workspace ?? 'workspace',
|
|
@@ -1236,6 +1307,14 @@ async function runRuntime(argv, agent) {
|
|
|
1236
1307
|
async function executeInteractiveTurn(context, body, { signal, turnId } = {}) {
|
|
1237
1308
|
const input = String(body.input ?? body.prompt ?? '').trim();
|
|
1238
1309
|
if (!input) throw new Error('Missing input.');
|
|
1310
|
+
// The runtime may start while optional agents are still stopped. `/start
|
|
1311
|
+
// agents` happens in the shell process, so its refreshed MCP snapshot does
|
|
1312
|
+
// not mutate this long-lived runtime context. Re-probe only while at least
|
|
1313
|
+
// one configured endpoint is disconnected; once connected, mcpRequest's
|
|
1314
|
+
// stale-session recovery handles later server restarts cheaply.
|
|
1315
|
+
if (mcpStatusNeedsRefresh(context.session.mcp)) {
|
|
1316
|
+
await refreshMcpRuntimeStatus(context.session);
|
|
1317
|
+
}
|
|
1239
1318
|
const ephemeral = createInteractiveSession(context, { runtimeUrl: selfRuntimeUrl, turnId, signal });
|
|
1240
1319
|
// Seed from a freshly reduced COPY of persisted events. Interactive turn
|
|
1241
1320
|
// events deliberately do not mutate the canonical run projection, so the
|
|
@@ -1368,6 +1447,18 @@ function logImageRefreshErrors(imageRefresh) {
|
|
|
1368
1447
|
}
|
|
1369
1448
|
|
|
1370
1449
|
export async function runCli(argv) {
|
|
1450
|
+
if (argv.includes('--refresh')) {
|
|
1451
|
+
if (argv.length !== 1) throw new Error('--refresh does not accept other options.');
|
|
1452
|
+
const result = spawnSync(workspaceCliPath, ['refresh'], {
|
|
1453
|
+
cwd: process.cwd(),
|
|
1454
|
+
env: process.env,
|
|
1455
|
+
stdio: 'inherit',
|
|
1456
|
+
});
|
|
1457
|
+
if (result.error) throw result.error;
|
|
1458
|
+
if (result.status !== 0) throw new Error(`Refresh failed with exit code ${result.status ?? 1}.`);
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1371
1462
|
if (argv[0] === 'runtime') {
|
|
1372
1463
|
const scaffolded = ensureManagerScaffold({ log: (message) => console.log(`[wiki-manager] ${message}`) });
|
|
1373
1464
|
if (scaffolded.length > 0) loadManagerEnv();
|
|
@@ -1434,12 +1525,17 @@ export async function runCli(argv) {
|
|
|
1434
1525
|
// in a random cwd must not litter files.
|
|
1435
1526
|
const scaffolded = ensureManagerScaffold({ log: (message) => console.log(`[wiki-manager] ${message}`) });
|
|
1436
1527
|
if (scaffolded.length > 0) loadManagerEnv();
|
|
1437
|
-
const reportCheck = ({ kind, ok, detail, context, skipped, pending }) => {
|
|
1528
|
+
const reportCheck = ({ kind, ok, detail, context, skipped, pending, requested }) => {
|
|
1438
1529
|
// "waiting" checks (containers not up yet, MCP not connected yet) are the
|
|
1439
1530
|
// normal state at launch — the shell starts them and reports the real
|
|
1440
1531
|
// status afterwards, so echoing them here is pure noise. Only what is
|
|
1441
1532
|
// ready or actually broken is printed.
|
|
1442
|
-
|
|
1533
|
+
//
|
|
1534
|
+
// `pending` alone cannot express this: a service the operator explicitly
|
|
1535
|
+
// asked for (`requested`) is still "pending" right after /start, and that
|
|
1536
|
+
// one MUST be shown — silence there is how a failed start reads as a
|
|
1537
|
+
// success. Boot silence applies to unrequested checks only.
|
|
1538
|
+
if (!ok && (pending || skipped) && !requested) return;
|
|
1443
1539
|
const labels = { docker: 'Docker', internet: 'Internet', agents: 'Agent containers', workspace: 'Workspaces', containers: 'Workspace containers', mcp: 'MCP' };
|
|
1444
1540
|
const label = labels[kind] ?? kind;
|
|
1445
1541
|
const instruction = !ok && context?.command ? ` — command: ${context.command}` : '';
|
|
@@ -1452,10 +1548,12 @@ export async function runCli(argv) {
|
|
|
1452
1548
|
console.log(`${color}${icon} configuration: ${label} ${state}${suffix}${instruction}\x1b[0m`);
|
|
1453
1549
|
};
|
|
1454
1550
|
let preflight = await runPreflightChecks({ onCheck: reportCheck });
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
//
|
|
1551
|
+
const wizardGaps = startupWizardGaps(preflight.gaps);
|
|
1552
|
+
if (wizardGaps.length > 0) {
|
|
1553
|
+
await runStartupWizard(wizardGaps);
|
|
1554
|
+
// The wizard may have created a workspace or repaired configuration.
|
|
1555
|
+
// Agents are deliberately excluded: starting them is an explicit console
|
|
1556
|
+
// action through /start agents or /start all.
|
|
1459
1557
|
preflight = await runPreflightChecks();
|
|
1460
1558
|
}
|
|
1461
1559
|
const dockerReady = preflight.checks.some((check) => check.kind === 'docker' && check.ok);
|
|
@@ -1480,10 +1578,8 @@ export async function runCli(argv) {
|
|
|
1480
1578
|
console.error(`Runtime unavailable: ${runtime.error}`);
|
|
1481
1579
|
}
|
|
1482
1580
|
preflight = withRuntimePreflight(preflight, runtime);
|
|
1483
|
-
//
|
|
1484
|
-
//
|
|
1485
|
-
// after this await would run while the shell is still on screen —
|
|
1486
|
-
// 0.12.9 shipped exactly that bug and killed the runtime under the user.
|
|
1581
|
+
// render() resolves at mount; the TUI owns renderer teardown. The shared
|
|
1582
|
+
// runtime deliberately survives shell exit because `serve` may use it.
|
|
1487
1583
|
await runOpenTuiShell({
|
|
1488
1584
|
agent,
|
|
1489
1585
|
packageJson,
|
|
@@ -1505,8 +1601,11 @@ export async function runCli(argv) {
|
|
|
1505
1601
|
}
|
|
1506
1602
|
}
|
|
1507
1603
|
await runShell({ agent, packageJson, runtime });
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// Missing/stopped agents are status information, never a reason to interrupt
|
|
1607
|
+
// startup with a confirmation screen. The operator owns their lifecycle from
|
|
1608
|
+
// the console (`/start agents`, `/start all`, `/stop agents`).
|
|
1609
|
+
export function startupWizardGaps(gaps = []) {
|
|
1610
|
+
return gaps.filter((gap) => gap?.kind !== 'agents');
|
|
1512
1611
|
}
|
|
@@ -3,8 +3,11 @@ import test from 'node:test';
|
|
|
3
3
|
import {
|
|
4
4
|
buildExecutorOnlyFragment,
|
|
5
5
|
forwardRuntimeApproval,
|
|
6
|
+
mcpStatusNeedsRefresh,
|
|
7
|
+
missingRequiredArguments,
|
|
6
8
|
resolveExecutorArguments,
|
|
7
9
|
resolvePreparedDelegationApproval,
|
|
10
|
+
startupWizardGaps,
|
|
8
11
|
} from './wiki-manager.js';
|
|
9
12
|
|
|
10
13
|
const COLLECT_CAPABILITY = {
|
|
@@ -19,6 +22,32 @@ const COLLECT_CAPABILITY = {
|
|
|
19
22
|
},
|
|
20
23
|
};
|
|
21
24
|
|
|
25
|
+
test('startup never opens the setup wizard just because agents are stopped', () => {
|
|
26
|
+
const workspace = { kind: 'workspace', context: {} };
|
|
27
|
+
assert.deepEqual(
|
|
28
|
+
startupWizardGaps([
|
|
29
|
+
{ kind: 'agents', context: { downServices: ['cme', 'documents'] } },
|
|
30
|
+
workspace,
|
|
31
|
+
]),
|
|
32
|
+
[workspace],
|
|
33
|
+
);
|
|
34
|
+
assert.deepEqual(startupWizardGaps([{ kind: 'agents' }]), []);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('interactive runtime refreshes configured MCP endpoints that started late', () => {
|
|
38
|
+
assert.equal(mcpStatusNeedsRefresh({
|
|
39
|
+
wiki: { url: 'http://127.0.0.1:3201/mcp', status: 'connected' },
|
|
40
|
+
cme: { url: 'http://127.0.0.1:3336/mcp/', status: 'configured' },
|
|
41
|
+
}), true);
|
|
42
|
+
assert.equal(mcpStatusNeedsRefresh({
|
|
43
|
+
wiki: { url: 'http://127.0.0.1:3201/mcp', status: 'connected' },
|
|
44
|
+
cme: { url: 'http://127.0.0.1:3336/mcp/', status: 'connected' },
|
|
45
|
+
}), false);
|
|
46
|
+
assert.equal(mcpStatusNeedsRefresh({
|
|
47
|
+
disabled: { url: null, status: 'missing' },
|
|
48
|
+
}), false);
|
|
49
|
+
});
|
|
50
|
+
|
|
22
51
|
test('executor-only capabilities receive one manager-authored executable task', () => {
|
|
23
52
|
const fragment = buildExecutorOnlyFragment({
|
|
24
53
|
objective: 'donne-moi mes derniers mails',
|
|
@@ -96,6 +125,27 @@ test('argument extraction stays agnostic and safe when it cannot extract', async
|
|
|
96
125
|
);
|
|
97
126
|
});
|
|
98
127
|
|
|
128
|
+
test('required executor arguments become a conversational blocker before plan validation', () => {
|
|
129
|
+
const schema = {
|
|
130
|
+
type: 'object',
|
|
131
|
+
required: ['to', 'subject', 'body'],
|
|
132
|
+
properties: {
|
|
133
|
+
to: { type: 'string' },
|
|
134
|
+
subject: { type: 'string' },
|
|
135
|
+
body: { type: 'string' },
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
assert.deepEqual(missingRequiredArguments(schema, {}), ['to', 'subject', 'body']);
|
|
139
|
+
assert.deepEqual(
|
|
140
|
+
missingRequiredArguments(schema, { to: 'a@example.test', subject: 'Hello', body: 'Message' }),
|
|
141
|
+
[],
|
|
142
|
+
);
|
|
143
|
+
assert.deepEqual(
|
|
144
|
+
missingRequiredArguments(schema, { to: [], subject: ' ', body: 'Message' }),
|
|
145
|
+
['to', 'subject'],
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
99
149
|
test('runtime approval bridge preserves the complete run-scoped grant', async () => {
|
|
100
150
|
let forwarded = null;
|
|
101
151
|
const request = {
|
|
@@ -156,3 +206,145 @@ test('prepared delegation only approves when autoApprove is explicitly true', ()
|
|
|
156
206
|
result: { approved: true },
|
|
157
207
|
});
|
|
158
208
|
});
|
|
209
|
+
|
|
210
|
+
const EXPORT_CAPABILITY = {
|
|
211
|
+
description: 'Export configured sources to workspace markdown files.',
|
|
212
|
+
inputSchema: {
|
|
213
|
+
type: 'object',
|
|
214
|
+
additionalProperties: true,
|
|
215
|
+
properties: { source_name: { type: 'string' } },
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
test('argument extraction drops a value that only echoes the active workspace', async () => {
|
|
220
|
+
// Regression: "exporter les pages Confluence du workspace acpi" against a
|
|
221
|
+
// schema whose single free-text field is source_name. The model binds the
|
|
222
|
+
// workspace name to it, and the executor fails with "source 'acpi' not
|
|
223
|
+
// found". The workspace is already bound out of band, so the echo is noise.
|
|
224
|
+
const llm = {
|
|
225
|
+
completeWithTools: async () => ({
|
|
226
|
+
tool_calls: [{
|
|
227
|
+
function: { name: 'set_task_arguments', arguments: JSON.stringify({ source_name: 'acpi' }) },
|
|
228
|
+
}],
|
|
229
|
+
}),
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
assert.deepEqual(
|
|
233
|
+
await resolveExecutorArguments({
|
|
234
|
+
llm,
|
|
235
|
+
objective: 'exporter les pages Confluence du workspace acpi',
|
|
236
|
+
capability: EXPORT_CAPABILITY,
|
|
237
|
+
workspace: 'acpi',
|
|
238
|
+
}),
|
|
239
|
+
{},
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('argument extraction keeps a real value that is not the workspace name', async () => {
|
|
244
|
+
const llm = {
|
|
245
|
+
completeWithTools: async () => ({
|
|
246
|
+
tool_calls: [{
|
|
247
|
+
function: {
|
|
248
|
+
name: 'set_task_arguments',
|
|
249
|
+
arguments: JSON.stringify({ source_name: 'EAS_Avant_projet_ACPI' }),
|
|
250
|
+
},
|
|
251
|
+
}],
|
|
252
|
+
}),
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
assert.deepEqual(
|
|
256
|
+
await resolveExecutorArguments({
|
|
257
|
+
llm,
|
|
258
|
+
objective: 'exporter la source EAS_Avant_projet_ACPI',
|
|
259
|
+
capability: EXPORT_CAPABILITY,
|
|
260
|
+
workspace: 'acpi',
|
|
261
|
+
}),
|
|
262
|
+
{ source_name: 'EAS_Avant_projet_ACPI' },
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('argument extraction tells the model the workspace is already bound', async () => {
|
|
267
|
+
let seenSystem = '';
|
|
268
|
+
const llm = {
|
|
269
|
+
completeWithTools: async ({ system }) => {
|
|
270
|
+
seenSystem = system;
|
|
271
|
+
return { tool_calls: [] };
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
await resolveExecutorArguments({
|
|
276
|
+
llm,
|
|
277
|
+
objective: 'exporter les pages du workspace acpi',
|
|
278
|
+
capability: EXPORT_CAPABILITY,
|
|
279
|
+
workspace: 'acpi',
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
assert.match(seenSystem, /already runs against workspace "acpi"/);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('argument extraction rejects a value outside the closed vocabulary', async () => {
|
|
286
|
+
// Regression: "exporter les pages Confluence du workspace juno" — the
|
|
287
|
+
// workspace guard removes "juno", so the model reaches for the next noun and
|
|
288
|
+
// emits "Confluence". Only the agent knows the valid names; once it publishes
|
|
289
|
+
// them as an enum, the orchestrator can check without guessing at meaning.
|
|
290
|
+
const capability = {
|
|
291
|
+
description: 'Export configured sources.',
|
|
292
|
+
inputSchema: {
|
|
293
|
+
type: 'object',
|
|
294
|
+
additionalProperties: true,
|
|
295
|
+
properties: {
|
|
296
|
+
source_name: { type: 'string', enum: ['EAS_Avant_projet_ACPI'] },
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
const llm = {
|
|
301
|
+
completeWithTools: async () => ({
|
|
302
|
+
tool_calls: [{
|
|
303
|
+
function: { name: 'set_task_arguments', arguments: JSON.stringify({ source_name: 'Confluence' }) },
|
|
304
|
+
}],
|
|
305
|
+
}),
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
assert.deepEqual(
|
|
309
|
+
await resolveExecutorArguments({
|
|
310
|
+
llm,
|
|
311
|
+
objective: 'exporter les pages Confluence du workspace juno',
|
|
312
|
+
capability,
|
|
313
|
+
workspace: 'juno',
|
|
314
|
+
}),
|
|
315
|
+
{},
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('argument extraction keeps a value the vocabulary allows', async () => {
|
|
320
|
+
const capability = {
|
|
321
|
+
description: 'Export configured sources.',
|
|
322
|
+
inputSchema: {
|
|
323
|
+
type: 'object',
|
|
324
|
+
additionalProperties: true,
|
|
325
|
+
properties: {
|
|
326
|
+
source_name: { type: 'string', enum: ['EAS_Avant_projet_ACPI', 'autre'] },
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
const llm = {
|
|
331
|
+
completeWithTools: async () => ({
|
|
332
|
+
tool_calls: [{
|
|
333
|
+
function: {
|
|
334
|
+
name: 'set_task_arguments',
|
|
335
|
+
arguments: JSON.stringify({ source_name: 'EAS_Avant_projet_ACPI' }),
|
|
336
|
+
},
|
|
337
|
+
}],
|
|
338
|
+
}),
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
assert.deepEqual(
|
|
342
|
+
await resolveExecutorArguments({
|
|
343
|
+
llm,
|
|
344
|
+
objective: 'exporter la source EAS_Avant_projet_ACPI',
|
|
345
|
+
capability,
|
|
346
|
+
workspace: 'acpi',
|
|
347
|
+
}),
|
|
348
|
+
{ source_name: 'EAS_Avant_projet_ACPI' },
|
|
349
|
+
);
|
|
350
|
+
});
|