@dotdrelle/wiki-manager 0.14.16 → 0.14.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.env.example +46 -4
  2. package/README.md +116 -11
  3. package/agents.docker-compose.yml +41 -0
  4. package/docker-compose.yml +16 -1
  5. package/mcp.endpoints.example.json +3 -3
  6. package/package.json +1 -2
  7. package/src/activity/activityAggregator.js +7 -3
  8. package/src/activity/activityAggregator.test.js +16 -2
  9. package/src/agent/graph.js +91 -11
  10. package/src/agent/graph.test.js +108 -6
  11. package/src/cli/runtimeStartup.test.js +14 -0
  12. package/src/cli/wiki-manager.js +220 -37
  13. package/src/cli/wiki-manager.test.js +131 -1
  14. package/src/commands/slash.js +58 -0
  15. package/src/commands/slash.test.js +18 -0
  16. package/src/core/activity.js +1 -1
  17. package/src/core/activity.test.js +5 -0
  18. package/src/core/buildInfo.json +2 -2
  19. package/src/core/compose.js +7 -1
  20. package/src/core/dockerCompose.test.js +27 -3
  21. package/src/core/env.js +16 -3
  22. package/src/core/env.test.js +8 -3
  23. package/src/core/mcp.js +7 -2
  24. package/src/core/mcp.test.js +55 -0
  25. package/src/core/wikiSetup.js +26 -2
  26. package/src/core/wikiWorkspace.test.js +25 -0
  27. package/src/core/workflow.js +72 -0
  28. package/src/core/workflow.test.js +57 -0
  29. package/src/orchestrator/dispatcher.js +1 -1
  30. package/src/orchestrator/dispatcher.test.js +48 -0
  31. package/src/orchestrator/scheduler.js +27 -7
  32. package/src/orchestrator/scheduler.test.js +23 -0
  33. package/src/runtime/client.js +23 -0
  34. package/src/runtime/runner.js +30 -3
  35. package/src/runtime/store.js +54 -0
  36. package/src/runtime/store.test.js +42 -0
  37. package/src/shell/LeftPane.tsx +20 -1
  38. package/src/shell/RightPane.tsx +22 -3
  39. package/src/shell/repl.js +86 -9
  40. package/src/shell/repl.test.js +103 -2
  41. package/src/shell/tui.tsx +6 -1
  42. package/src/shell/useAgent.ts +8 -12
  43. package/src/shell/useSession.ts +47 -0
  44. package/tsconfig.json +2 -1
  45. package/wiki-workspace +110 -18
  46. package/agents.docker-compose.mailer.example.yml +0 -36
@@ -403,7 +403,8 @@ test('Donna refuses to delegate connector authentication to an export capability
403
403
  globalThis.fetch = async (url, options = {}) => {
404
404
  fetchedUrls.push(String(url));
405
405
  const body = JSON.parse(String(options.body ?? '{}'));
406
- assert.equal(body.params?.name, 'start_google_auth');
406
+ assert.equal(body.params?.name, 'connectors_google_oauth_start');
407
+ assert.deepEqual(body.params?.arguments, { workspace: 'docs' });
407
408
  return {
408
409
  ok: true,
409
410
  status: 200,
@@ -415,16 +416,16 @@ test('Donna refuses to delegate connector authentication to an export capability
415
416
  const session = sessionBase({
416
417
  runtime: { url: 'http://runtime.test' },
417
418
  mcp: {
418
- 'google-workspace': {
419
+ connectors: {
419
420
  status: 'connected',
420
421
  url: 'http://google.test/mcp',
421
422
  tools: [
422
423
  {
423
- name: 'start_google_auth',
424
+ name: 'connectors_google_oauth_start',
424
425
  description: 'Manually initiate Google OAuth authentication flow.',
425
426
  inputSchema: { type: 'object', additionalProperties: true },
426
427
  },
427
- { name: 'search_gmail_messages', inputSchema: { type: 'object', additionalProperties: true } },
428
+ { name: 'connectors_google_status', inputSchema: { type: 'object', additionalProperties: true } },
428
429
  ],
429
430
  },
430
431
  },
@@ -439,7 +440,7 @@ test('Donna refuses to delegate connector authentication to an export capability
439
440
  if (turn === 2) return {
440
441
  content: null,
441
442
  message: { role: 'assistant', content: null },
442
- tool_calls: [{ id: 'google-auth', type: 'function', function: { name: 'google-workspace__start_google_auth', arguments: '{}' } }],
443
+ tool_calls: [{ id: 'google-auth', type: 'function', function: { name: 'connectors__connectors_google_oauth_start', arguments: '{}' } }],
443
444
  };
444
445
  return {
445
446
  content: 'J’ai lancé l’authentification. Ouvre le lien fourni.',
@@ -1146,7 +1147,8 @@ test('agent graph accepts plan steps with known capabilities and null capability
1146
1147
  test('buildAgentSystemPrompt assigns capability resolution exclusively to the runtime', () => {
1147
1148
  const withAgents = buildAgentSystemPrompt({ session: sessionBase({ agentRegistrySnapshot: orchestrableAgentSnapshot() }) });
1148
1149
  assert.match(withAgents, /call runtime__delegate with the user objective only/);
1149
- assert.match(withAgents, /Never choose a capability, operation, agent, plan, or implementation yourself/);
1150
+ assert.match(withAgents, /executor-only single-task agents/);
1151
+ assert.match(withAgents, /Never choose those identifiers yourself/);
1150
1152
  assert.doesNotMatch(withAgents, /ONLY values allowed in requiredCapability/);
1151
1153
  });
1152
1154
 
@@ -1310,6 +1312,106 @@ test('runtime action retries a text-only hallucination and requires a real tool
1310
1312
  }
1311
1313
  });
1312
1314
 
1315
+ test('interactive action delegates after a connector status check did not execute the requested collection', async () => {
1316
+ const originalFetch = globalThis.fetch;
1317
+ let delegatedObjective = null;
1318
+ globalThis.fetch = async (url, options = {}) => {
1319
+ if (String(url).includes('/delegate')) {
1320
+ const body = JSON.parse(String(options.body ?? '{}'));
1321
+ delegatedObjective = body.objective;
1322
+ return {
1323
+ ok: true,
1324
+ status: 202,
1325
+ json: async () => ({
1326
+ accepted: true,
1327
+ runId: 'collect-run',
1328
+ delegation: { tasks: 1, agent: 'connectors' },
1329
+ }),
1330
+ };
1331
+ }
1332
+ return {
1333
+ ok: true,
1334
+ status: 200,
1335
+ headers: { get: () => null },
1336
+ text: async () => JSON.stringify({
1337
+ result: { content: [{ type: 'text', text: '{"ok":true,"status":"configured"}' }] },
1338
+ }),
1339
+ };
1340
+ };
1341
+ let modelCalls = 0;
1342
+ const session = sessionBase({
1343
+ runtime: { url: 'http://runtime.test' },
1344
+ mcp: {
1345
+ connectors: {
1346
+ status: 'connected',
1347
+ url: 'http://connectors.test/mcp/',
1348
+ tools: [{
1349
+ name: 'connectors_google_status',
1350
+ description: 'Read Google authorization status.',
1351
+ inputSchema: { type: 'object', properties: { workspace: { type: 'string' } } },
1352
+ }],
1353
+ },
1354
+ },
1355
+ llm: {
1356
+ async completeWithTools({ tools }) {
1357
+ if (tools.some((tool) => tool.function.name === 'classify_action_request')) {
1358
+ return {
1359
+ content: null,
1360
+ tool_calls: [{
1361
+ id: 'classification',
1362
+ type: 'function',
1363
+ function: { name: 'classify_action_request', arguments: '{"action":true}' },
1364
+ }],
1365
+ };
1366
+ }
1367
+ modelCalls += 1;
1368
+ if (modelCalls === 1) {
1369
+ return {
1370
+ content: null,
1371
+ message: { role: 'assistant', content: null },
1372
+ tool_calls: [{
1373
+ id: 'status',
1374
+ type: 'function',
1375
+ function: { name: 'connectors__connectors_google_status', arguments: '{"workspace":"docs"}' },
1376
+ }],
1377
+ };
1378
+ }
1379
+ if (modelCalls === 2) {
1380
+ return {
1381
+ content: 'Le statut est configuré mais je ne peux pas collecter.',
1382
+ message: { role: 'assistant', content: 'Le statut est configuré mais je ne peux pas collecter.' },
1383
+ tool_calls: null,
1384
+ };
1385
+ }
1386
+ if (modelCalls === 3) {
1387
+ return {
1388
+ content: null,
1389
+ message: { role: 'assistant', content: null },
1390
+ tool_calls: [{
1391
+ id: 'delegate',
1392
+ type: 'function',
1393
+ function: { name: 'runtime__delegate', arguments: '{"objective":"charge les 10 derniers mails"}' },
1394
+ }],
1395
+ };
1396
+ }
1397
+ return {
1398
+ content: 'Collecte lancée.',
1399
+ message: { role: 'assistant', content: 'Collecte lancée.' },
1400
+ tool_calls: null,
1401
+ };
1402
+ },
1403
+ },
1404
+ });
1405
+
1406
+ try {
1407
+ const result = await createAgentGraph().invoke({ input: 'charge les 10 derniers mails', session });
1408
+ assert.equal(delegatedObjective, 'charge les 10 derniers mails');
1409
+ assert.equal(result.response, 'Collecte lancée.');
1410
+ } finally {
1411
+ globalThis.fetch = originalFetch;
1412
+ }
1413
+ });
1414
+
1313
1415
  test('agent graph auto-declares the plan from an agent_plan task-graph fragment', async () => {
1314
1416
  // The bridge that makes parallel ingestion real: when the LLM calls
1315
1417
  // production__agent_plan, the shell integrates the fragment as the plan
@@ -0,0 +1,14 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import assert from 'node:assert/strict';
3
+ import test from 'node:test';
4
+
5
+ test('runtime startup is not blocked by optional Docker image maintenance', async () => {
6
+ const source = await readFile(new URL('./wiki-manager.js', import.meta.url), 'utf8');
7
+ const runtimeBranch = source.slice(
8
+ source.indexOf("if (argv[0] === 'runtime')"),
9
+ source.indexOf("if (argv.includes('--setup-wizard'))"),
10
+ );
11
+
12
+ assert.match(runtimeBranch, /await runRuntime\(argv\.slice\(1\), agent\)/);
13
+ assert.doesNotMatch(runtimeBranch, /refreshRunningContainers/);
14
+ });
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { readFileSync } from 'node:fs';
2
3
  import { mkdir, writeFile } from 'node:fs/promises';
3
4
  import { dirname, join, resolve } from 'node:path';
@@ -25,7 +26,7 @@ import { listWorkspaces } from '../core/workspaces.js';
25
26
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
27
  const packageJsonPath = resolve(__dirname, '../../package.json');
27
28
  const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
28
- const SHELL_COMMANDS = ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'wiki', 'skills', 'clear', 'chat', 'agent', 'approve'];
29
+ const SHELL_COMMANDS = ['help', 'version', 'exit', 'workspace', 'new', 'use', 'config', 'status', 'services', 'start', 'stop', 'logs', 'mcp', 'connector', 'wiki', 'skills', 'clear', 'chat', 'agent', 'approve'];
29
30
 
30
31
  function valueAfter(argv, flag) {
31
32
  const index = argv.indexOf(flag);
@@ -54,6 +55,144 @@ function unavailableRuntime(err) {
54
55
  return { url: null, error: reason };
55
56
  }
56
57
 
58
+ export function buildExecutorOnlyFragment({ objective, workspace, selection }) {
59
+ const provider = selection.provider;
60
+ const capability = provider.capability ?? {};
61
+ const agentInstanceId = String(provider.agentInstanceId ?? provider.serverName);
62
+ const label = String(objective ?? '').trim() || `${selection.operation} ${selection.capability}`;
63
+ const mutationClass = typeof capability.mutationClass === 'string'
64
+ ? capability.mutationClass
65
+ : null;
66
+ const requiresApproval = capability.defaultRequiresApproval === true || Boolean(mutationClass);
67
+ return {
68
+ contractVersion: String(provider.description?.contractVersion ?? '1'),
69
+ agentInstanceId,
70
+ capability: selection.capability,
71
+ summary: {
72
+ label,
73
+ initialSynthesis: [],
74
+ estimatedTasks: 1,
75
+ },
76
+ groups: [],
77
+ tasks: [{
78
+ id: `${selection.operation}-${randomUUID()}`,
79
+ label,
80
+ requiredCapability: selection.capability,
81
+ operation: selection.operation,
82
+ arguments: selection.arguments ?? {},
83
+ dependsOn: [],
84
+ parallelizable: false,
85
+ inputRefs: [],
86
+ expectedOutputRefs: [],
87
+ locks: [`${selection.capability}:${String(workspace)}`],
88
+ requiresApproval,
89
+ ...(mutationClass ? { approvalClass: mutationClass } : {}),
90
+ ...(requiresApproval ? { approvalSummary: label } : {}),
91
+ idempotencyKey: randomUUID(),
92
+ progressWeight: 1,
93
+ }],
94
+ expectedOutputs: [],
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Fill a single-task executor's arguments from the natural-language objective,
100
+ * generically — against the capability's own declared `inputSchema`, with no
101
+ * per-agent or per-provider knowledge in the manager. This lets Donna honour
102
+ * stated constraints ("les 10 derniers mails", "de LinkedIn") while keeping
103
+ * `runtime__delegate` agnostic (it still only carries the objective).
104
+ *
105
+ * Degrades gracefully (cf. provider compatibility): forced tool_choice first,
106
+ * then a JSON-text completion, then no arguments — the executor uses its own
107
+ * defaults. It never throws and never invents identifiers.
108
+ */
109
+ export async function resolveExecutorArguments({ llm, objective, capability, signal } = {}) {
110
+ const schema = capability?.inputSchema;
111
+ const objectiveText = String(objective ?? '').trim();
112
+ if (
113
+ !objectiveText
114
+ || !llm
115
+ || typeof llm.completeWithTools !== 'function'
116
+ || !schema
117
+ || typeof schema !== 'object'
118
+ || !schema.properties
119
+ || typeof schema.properties !== 'object'
120
+ || Object.keys(schema.properties).length === 0
121
+ ) {
122
+ return {};
123
+ }
124
+ const system = [
125
+ 'Extract structured arguments for a task from the user objective.',
126
+ 'Only fill a field when the objective explicitly states or clearly implies its value.',
127
+ 'Omit every field that is not stated. Never invent identifiers, queries, filters or counts.',
128
+ 'Return the arguments object only.',
129
+ ].join('\n');
130
+ const tool = {
131
+ type: 'function',
132
+ function: {
133
+ name: 'set_task_arguments',
134
+ description: String(capability.description ?? 'Task arguments for the selected capability.'),
135
+ parameters: {
136
+ type: 'object',
137
+ additionalProperties: schema.additionalProperties ?? false,
138
+ properties: schema.properties,
139
+ },
140
+ },
141
+ };
142
+ const messages = [{ role: 'user', content: objectiveText }];
143
+ try {
144
+ const result = await llm.completeWithTools({
145
+ system,
146
+ tools: [tool],
147
+ toolChoice: { type: 'function', function: { name: 'set_task_arguments' } },
148
+ messages,
149
+ signal,
150
+ });
151
+ const call = (result?.tool_calls ?? []).find((item) => item?.function?.name === 'set_task_arguments');
152
+ const fromCall = call ? safeParseArgumentObject(call.function?.arguments) : null;
153
+ if (fromCall) return pruneArgumentsToSchema(fromCall, schema);
154
+ const fromText = safeParseArgumentObject(result?.content);
155
+ if (fromText) return pruneArgumentsToSchema(fromText, schema);
156
+ } catch {
157
+ // Fall through to the tool-less path.
158
+ }
159
+ try {
160
+ const result = await llm.completeWithTools({
161
+ system: `${system}\nReturn a single JSON object only.`,
162
+ tools: [],
163
+ messages,
164
+ signal,
165
+ });
166
+ const fromText = safeParseArgumentObject(result?.content);
167
+ if (fromText) return pruneArgumentsToSchema(fromText, schema);
168
+ } catch {
169
+ // Give up: the executor will use its own defaults.
170
+ }
171
+ return {};
172
+ }
173
+
174
+ function safeParseArgumentObject(text) {
175
+ const cleaned = String(text ?? '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
176
+ if (!cleaned) return null;
177
+ try {
178
+ const value = JSON.parse(cleaned);
179
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+
185
+ function pruneArgumentsToSchema(value, schema) {
186
+ const allowed = schema.properties ?? {};
187
+ const acceptsExtra = schema.additionalProperties !== false;
188
+ const out = {};
189
+ for (const [key, entry] of Object.entries(value)) {
190
+ if (entry === undefined || entry === null) continue;
191
+ if (Object.hasOwn(allowed, key) || acceptsExtra) out[key] = entry;
192
+ }
193
+ return out;
194
+ }
195
+
57
196
  function createSession() {
58
197
  return {
59
198
  workspace: null,
@@ -114,6 +253,18 @@ export async function forwardRuntimeApproval(getWorkspaceContext, request = {})
114
253
  return context.approvalManager?.approve(request) ?? { approved: false };
115
254
  }
116
255
 
256
+ export function resolvePreparedDelegationApproval({
257
+ autoApprove = false,
258
+ approvalManager = null,
259
+ runId,
260
+ } = {}) {
261
+ if (autoApprove !== true || typeof approvalManager?.approve !== 'function') {
262
+ return { approved: false, awaitingApproval: true };
263
+ }
264
+ const result = approvalManager.approve({ scope: 'run', runId });
265
+ return { approved: true, awaitingApproval: false, result };
266
+ }
267
+
117
268
  function timestampForFile() {
118
269
  return new Date().toISOString().replace(/[:.]/g, '-');
119
270
  }
@@ -819,34 +970,60 @@ async function runRuntime(argv, agent) {
819
970
  }
820
971
  const provider = selection.provider;
821
972
  let planResult;
822
- try {
823
- planResult = await callMcpTool(
824
- session.mcp,
825
- provider.serverName,
826
- 'agent_plan',
827
- {
828
- capability: selection.capability,
829
- operation: selection.operation,
830
- objective,
831
- workspace: { revision: String(Date.now()) },
832
- constraints: {
833
- maxConcurrency: resolveCapabilityConcurrency(
834
- provider,
835
- undefined,
836
- process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY,
837
- ),
838
- requireApprovalForMutations: true,
973
+ const orchestration = provider.description?.orchestration ?? {};
974
+ const canPlan = orchestration.canPlan !== false;
975
+ const singleTaskExecutor = orchestration.canPlan === false
976
+ && orchestration.singleTaskOnly === true;
977
+ let fragment;
978
+ if (canPlan) {
979
+ try {
980
+ planResult = await callMcpTool(
981
+ session.mcp,
982
+ provider.serverName,
983
+ 'agent_plan',
984
+ {
985
+ capability: selection.capability,
986
+ operation: selection.operation,
987
+ objective,
988
+ workspace: { revision: String(Date.now()) },
989
+ constraints: {
990
+ maxConcurrency: resolveCapabilityConcurrency(
991
+ provider,
992
+ undefined,
993
+ process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY,
994
+ ),
995
+ requireApprovalForMutations: true,
996
+ },
839
997
  },
998
+ );
999
+ } catch (err) {
1000
+ const endpoint = session.mcp?.[provider.serverName]?.url ?? 'unknown endpoint';
1001
+ throw new Error(
1002
+ `Delegation failed during agent_plan: provider=${provider.serverName} endpoint=${endpoint} ${errorDiagnostic(err)}`,
1003
+ { cause: err },
1004
+ );
1005
+ }
1006
+ fragment = parseJsonText(formatMcpToolResult(planResult));
1007
+ } else if (singleTaskExecutor) {
1008
+ const extractedArguments = await resolveExecutorArguments({
1009
+ llm: session.llm,
1010
+ objective,
1011
+ capability: provider.capability,
1012
+ signal: session._abortSignal,
1013
+ });
1014
+ fragment = buildExecutorOnlyFragment({
1015
+ objective,
1016
+ workspace: session.workspace ?? context.workspace ?? 'workspace',
1017
+ selection: {
1018
+ ...selection,
1019
+ arguments: { ...(selection.arguments ?? {}), ...extractedArguments },
840
1020
  },
841
- );
842
- } catch (err) {
843
- const endpoint = session.mcp?.[provider.serverName]?.url ?? 'unknown endpoint';
1021
+ });
1022
+ } else {
844
1023
  throw new Error(
845
- `Delegation failed during agent_plan: provider=${provider.serverName} endpoint=${endpoint} ${errorDiagnostic(err)}`,
846
- { cause: err },
1024
+ `Agent ${provider.agentInstanceId ?? provider.serverName} cannot plan and does not declare singleTaskOnly:true.`,
847
1025
  );
848
1026
  }
849
- const fragment = parseJsonText(formatMcpToolResult(planResult));
850
1027
  if (!Array.isArray(fragment?.tasks) || fragment.tasks.length === 0) {
851
1028
  throw new Error(fragment?.summary?.initialSynthesis?.[0] ?? `No task was planned for ${selection.capability}/${selection.operation}.`);
852
1029
  }
@@ -923,14 +1100,24 @@ async function runRuntime(argv, agent) {
923
1100
  throw new Error(`Delegated plan integration failed: ${(integrated.errors ?? []).map((error) => error.message ?? error.code ?? String(error)).join('; ')}`);
924
1101
  }
925
1102
  emitRuntimeLog(session, `delegation: ${prepared.fragment.tasks.length} validated task(s) integrated from ${prepared.provider.serverName}.agent_plan (${prepared.capability}/${prepared.operation})`);
926
- // Demandé = consenti: a directly-delegated run carries the user's
927
- // explicit consent, so auto-approve its initial plan. Persisting a
928
- // run-scope grant (via the approval manager) makes the scheduler's
929
- // readyTasks approval check pass, so the tasks run without re-prompting.
930
- // Replanned tasks are integrated later without a fresh grant.
931
- if (context.approvalManager?.approve) {
932
- context.approvalManager.approve({ scope: 'run', runId });
933
- emitRuntimeLog(session, `approval: run ${runId} auto-approved (user-requested action)`);
1103
+ // Real approval gate (opt-out): a directly-delegated run only skips the
1104
+ // human approval step when the caller explicitly opts in via
1105
+ // `autoApprove` (e.g. headless/CI, or a future "trust this run" toggle).
1106
+ // By default the run WAITS: integrate() above created the per-task
1107
+ // approval requests, and the scheduler's approvalCovered() filter blocks
1108
+ // the mutating tasks until a run-scope grant arrives (/approve or
1109
+ // "valide tout"). This keeps a visible pending_approval window instead of
1110
+ // resolving it programmatically ~30ms after launch, which no polled UI
1111
+ // could ever render.
1112
+ const approval = resolvePreparedDelegationApproval({
1113
+ autoApprove: body.autoApprove,
1114
+ approvalManager: context.approvalManager,
1115
+ runId,
1116
+ });
1117
+ if (approval.approved) {
1118
+ emitRuntimeLog(session, `approval: run ${runId} auto-approved (autoApprove opt-in)`);
1119
+ } else {
1120
+ emitRuntimeLog(session, `approval: run ${runId} awaiting explicit approval before mutations (/approve or « valide tout »)`);
934
1121
  }
935
1122
  body._planReady?.resolve?.({ runId, planRevision: session.agentProjection?.planRevision ?? 0 });
936
1123
  }
@@ -1182,10 +1369,6 @@ export async function runCli(argv) {
1182
1369
  if (argv[0] === 'runtime') {
1183
1370
  const scaffolded = ensureManagerScaffold({ log: (message) => console.log(`[wiki-manager] ${message}`) });
1184
1371
  if (scaffolded.length > 0) loadManagerEnv();
1185
- const imageRefresh = await refreshRunningContainers({
1186
- onStep: (message) => console.log(`[wiki-manager] ${message}`),
1187
- });
1188
- logImageRefreshErrors(imageRefresh);
1189
1372
  const agent = createAgentGraph();
1190
1373
  await runRuntime(argv.slice(1), agent);
1191
1374
  return;
@@ -1244,7 +1427,7 @@ export async function runCli(argv) {
1244
1427
  }
1245
1428
  const { runOpenTuiShell, runStartupWizard } = await import('../shell/tui.tsx');
1246
1429
  // Fresh directory → copy mcp.endpoints.json/.env from the packaged
1247
- // examples so external agents (cme, mailer, documents) connect out of
1430
+ // examples so external agents (cme and documents) connect out of
1248
1431
  // the box. Done here (and in `runtime`), NOT at import time: --version
1249
1432
  // in a random cwd must not litter files.
1250
1433
  const scaffolded = ensureManagerScaffold({ log: (message) => console.log(`[wiki-manager] ${message}`) });
@@ -1,6 +1,100 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { forwardRuntimeApproval } from './wiki-manager.js';
3
+ import {
4
+ buildExecutorOnlyFragment,
5
+ forwardRuntimeApproval,
6
+ resolveExecutorArguments,
7
+ resolvePreparedDelegationApproval,
8
+ } from './wiki-manager.js';
9
+
10
+ const COLLECT_CAPABILITY = {
11
+ description: 'Collect content from an external connector source.',
12
+ inputSchema: {
13
+ type: 'object',
14
+ additionalProperties: true,
15
+ properties: {
16
+ maxMessages: { type: 'integer' },
17
+ query: { type: 'string' },
18
+ },
19
+ },
20
+ };
21
+
22
+ test('executor-only capabilities receive one manager-authored executable task', () => {
23
+ const fragment = buildExecutorOnlyFragment({
24
+ objective: 'donne-moi mes derniers mails',
25
+ workspace: 'juno',
26
+ selection: {
27
+ capability: 'external-source.collect',
28
+ operation: 'collect',
29
+ arguments: { maxMessages: 10 },
30
+ provider: {
31
+ serverName: 'connectors',
32
+ agentInstanceId: 'connectors-1',
33
+ description: {
34
+ contractVersion: '1',
35
+ orchestration: { canPlan: false, singleTaskOnly: true },
36
+ },
37
+ capability: {
38
+ mutationClass: 'external-source',
39
+ defaultRequiresApproval: true,
40
+ },
41
+ },
42
+ },
43
+ });
44
+
45
+ assert.equal(fragment.agentInstanceId, 'connectors-1');
46
+ assert.equal(fragment.tasks.length, 1);
47
+ assert.equal(fragment.tasks[0].requiredCapability, 'external-source.collect');
48
+ assert.equal(fragment.tasks[0].operation, 'collect');
49
+ assert.deepEqual(fragment.tasks[0].arguments, { maxMessages: 10 });
50
+ assert.deepEqual(fragment.tasks[0].locks, ['external-source.collect:juno']);
51
+ assert.equal(fragment.tasks[0].requiresApproval, true);
52
+ assert.equal(fragment.tasks[0].approvalClass, 'external-source');
53
+ assert.match(fragment.tasks[0].idempotencyKey, /^[0-9a-f-]{36}$/);
54
+ });
55
+
56
+ test('generic argument extraction fills the executor task from the objective', async () => {
57
+ const llm = {
58
+ completeWithTools: async () => ({
59
+ tool_calls: [{
60
+ function: { name: 'set_task_arguments', arguments: JSON.stringify({ maxMessages: 10 }) },
61
+ }],
62
+ }),
63
+ };
64
+ const args = await resolveExecutorArguments({
65
+ llm,
66
+ objective: 'récupère les 10 derniers mails',
67
+ capability: COLLECT_CAPABILITY,
68
+ });
69
+ assert.deepEqual(args, { maxMessages: 10 });
70
+ });
71
+
72
+ test('argument extraction falls back to a JSON-text completion', async () => {
73
+ const llm = {
74
+ completeWithTools: async ({ tools }) =>
75
+ tools.length > 0 ? { content: '' } : { content: '{"query":"from:linkedin.com"}' },
76
+ };
77
+ const args = await resolveExecutorArguments({
78
+ llm,
79
+ objective: 'les mails de LinkedIn',
80
+ capability: COLLECT_CAPABILITY,
81
+ });
82
+ assert.deepEqual(args, { query: 'from:linkedin.com' });
83
+ });
84
+
85
+ test('argument extraction stays agnostic and safe when it cannot extract', async () => {
86
+ // No inputSchema → no extraction attempted at all.
87
+ assert.deepEqual(
88
+ await resolveExecutorArguments({ llm: { completeWithTools: async () => ({}) }, objective: 'x', capability: {} }),
89
+ {},
90
+ );
91
+ // Provider/LLM failure degrades to the executor's own defaults, never throws.
92
+ const throwing = { completeWithTools: async () => { throw new Error('gateway rejected tool_choice'); } };
93
+ assert.deepEqual(
94
+ await resolveExecutorArguments({ llm: throwing, objective: 'les 10 derniers mails', capability: COLLECT_CAPABILITY }),
95
+ {},
96
+ );
97
+ });
4
98
 
5
99
  test('runtime approval bridge preserves the complete run-scoped grant', async () => {
6
100
  let forwarded = null;
@@ -26,3 +120,39 @@ test('runtime approval bridge preserves the complete run-scoped grant', async ()
26
120
  assert.deepEqual(forwarded, request);
27
121
  assert.deepEqual(result, { approved: true });
28
122
  });
123
+
124
+ test('prepared delegation waits for explicit approval by default', () => {
125
+ let calls = 0;
126
+ const result = resolvePreparedDelegationApproval({
127
+ runId: 'run-gated',
128
+ approvalManager: {
129
+ approve() {
130
+ calls += 1;
131
+ },
132
+ },
133
+ });
134
+
135
+ assert.equal(calls, 0);
136
+ assert.deepEqual(result, { approved: false, awaitingApproval: true });
137
+ });
138
+
139
+ test('prepared delegation only approves when autoApprove is explicitly true', () => {
140
+ let forwarded = null;
141
+ const result = resolvePreparedDelegationApproval({
142
+ autoApprove: true,
143
+ runId: 'run-headless',
144
+ approvalManager: {
145
+ approve(request) {
146
+ forwarded = request;
147
+ return { approved: true };
148
+ },
149
+ },
150
+ });
151
+
152
+ assert.deepEqual(forwarded, { scope: 'run', runId: 'run-headless' });
153
+ assert.deepEqual(result, {
154
+ approved: true,
155
+ awaitingApproval: false,
156
+ result: { approved: true },
157
+ });
158
+ });