@dotdrelle/wiki-manager 0.15.93 → 0.15.96

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 (48) hide show
  1. package/README.md +39 -28
  2. package/docker-compose.yml +1 -1
  3. package/package.json +2 -2
  4. package/src/agent/graph.js +78 -26
  5. package/src/agent/graph.test.js +28 -2
  6. package/src/cli/wiki-manager.js +138 -18
  7. package/src/commands/slash.js +38 -4
  8. package/src/commands/slash.test.js +11 -1
  9. package/src/core/agentEvents.js +24 -3
  10. package/src/core/agentEvents.test.js +37 -0
  11. package/src/core/buildInfo.json +2 -2
  12. package/src/core/dockerCompose.test.js +14 -0
  13. package/src/core/googleGrants.js +0 -3
  14. package/src/core/json.js +9 -0
  15. package/src/core/mcp.js +1 -1
  16. package/src/core/plan.js +0 -4
  17. package/src/core/progressNotes.js +0 -4
  18. package/src/core/skillChainView.js +3 -1
  19. package/src/core/skillCompiler.test.js +21 -1
  20. package/src/core/toolLoop.js +56 -2
  21. package/src/core/toolLoop.test.js +35 -4
  22. package/src/orchestrator/agentRegistry.js +1 -3
  23. package/src/orchestrator/dependencyResolver.js +0 -3
  24. package/src/orchestrator/objectiveResolver.test.js +27 -0
  25. package/src/orchestrator/planValidator.js +1 -3
  26. package/src/orchestrator/providers/runtimeProvider.js +0 -14
  27. package/src/orchestrator/taskStatuses.js +8 -0
  28. package/src/runtime/client.js +0 -16
  29. package/src/runtime/controlDrain.js +6 -3
  30. package/src/runtime/deltaCoalescer.js +53 -0
  31. package/src/runtime/deltaCoalescer.test.js +56 -0
  32. package/src/runtime/loginPage.js +129 -0
  33. package/src/runtime/loginRoutes.test.js +131 -0
  34. package/src/runtime/loginSession.js +223 -0
  35. package/src/runtime/loginSession.test.js +143 -0
  36. package/src/runtime/qrCode.js +15 -0
  37. package/src/runtime/runner.js +29 -3
  38. package/src/runtime/runner.test.js +90 -1
  39. package/src/runtime/server.js +240 -1
  40. package/src/runtime/server.test.js +87 -0
  41. package/src/runtime/skillRun.js +1 -1
  42. package/src/runtime/skillRun.test.js +3 -0
  43. package/src/runtime/totp.js +87 -0
  44. package/src/runtime/totp.test.js +80 -0
  45. package/src/runtime/totpLogin.js +123 -0
  46. package/src/runtime/vendor/qrcode.cjs +2297 -0
  47. package/src/shell/repl.js +7 -3
  48. package/src/orchestrator/.fuse_hidden0000001c00000001 +0 -316
@@ -109,6 +109,26 @@ export function compactBaseUrl(value) {
109
109
  }
110
110
  }
111
111
 
112
+ /*
113
+ The web UI URL `/openui` opens. The runtime sets the `wiki_session` cookie on
114
+ its own origin when the ShellUI logs in, and cookies ignore the port, so we
115
+ reuse the runtime's loopback hostname: serve on that same host then validates
116
+ the shared session instead of asking for a second TOTP code. A non-loopback
117
+ runtime (remote deployment) shares no cookie with the local browser, so we
118
+ keep the plain `localhost` URL there — behaving exactly as before.
119
+ */
120
+ export function webUiUrl(port, runtimeUrl) {
121
+ let runtimeHost = null;
122
+ try {
123
+ if (runtimeUrl) runtimeHost = new URL(runtimeUrl).hostname || null;
124
+ } catch {
125
+ runtimeHost = null;
126
+ }
127
+ const loopback = runtimeHost === 'localhost' || runtimeHost === '::1' || /^127\./.test(runtimeHost ?? '');
128
+ const host = loopback ? runtimeHost : 'localhost';
129
+ return `http://${host}:${port}`;
130
+ }
131
+
112
132
  function commandLabel(value) {
113
133
  return `${styles.bold}${styles.cyan}${value}${styles.reset}`;
114
134
  }
@@ -726,8 +746,8 @@ async function statusText(session) {
726
746
  const runtimesColumn = runtimeProvidersSection(session);
727
747
  const stats = workspaceStatsColumns(workspaceStats, session);
728
748
 
729
- const wikiColumnAll = [workspaceColumn, stats.wiki, runtimeColumn].filter(Boolean).join('\n\n');
730
- const configColumnAll = [configColumn, stats.tuning, mcpColumn, runtimesColumn].filter(Boolean).join('\n\n');
749
+ const wikiColumnAll = [workspaceColumn, stats.wiki, runtimeColumn, mcpColumn].filter(Boolean).join('\n\n');
750
+ const configColumnAll = [configColumn, stats.tuning, runtimesColumn].filter(Boolean).join('\n\n');
731
751
 
732
752
  // Leading/trailing blank row so the boxed pair doesn't butt directly against
733
753
  // the pane border when the view is scrolled to show the tail. It is padding,
@@ -1734,9 +1754,23 @@ export async function handleSlashCommand(line, context) {
1734
1754
  }
1735
1755
  case 'openui': {
1736
1756
  const port = context.session.workspaceEnv?.WIKI_SERVE_PORT ?? '3100';
1737
- const url = `http://localhost:${port}`;
1757
+ const url = webUiUrl(port, context.runtime?.url ?? context.session?.runtime?.url ?? null);
1758
+ // Hand the ShellUI session to the browser so serve opens without a second
1759
+ // TOTP code: the token rides in the URL fragment (never sent to the
1760
+ // server, so it stays out of logs), and serve's login page exchanges it
1761
+ // for its cookie on load. Open `/login` directly rather than the root so
1762
+ // the fragment never has to survive a redirect. The printed line never
1763
+ // carries the token.
1764
+ let openUrl = url;
1765
+ try {
1766
+ const { currentSessionToken } = await import('../runtime/loginSession.js');
1767
+ const token = currentSessionToken();
1768
+ if (token) openUrl = `${url}/login#t=${encodeURIComponent(token)}`;
1769
+ } catch {
1770
+ // No readable session (gate off, or a custom state dir): plain URL.
1771
+ }
1738
1772
  const note = context.session.workspaceEnv ? '' : ' (no workspace loaded — using default port)';
1739
- if (openExternalUrl(url)) return { output: `Opening web UI: ${url}${note}` };
1773
+ if (openExternalUrl(openUrl)) return { output: `Opening web UI: ${url}${note}` };
1740
1774
  return { output: `Web UI: ${url}${note}` };
1741
1775
  }
1742
1776
  case 'clear': {
@@ -4,7 +4,7 @@ import { mkdtemp } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import test from 'node:test';
7
- import { agentConcurrencySections, compactBaseUrl, compactMcpStatus, handleSlashCommand, localizedOperationResult, refreshMcpRuntimeStatus } from './slash.js';
7
+ import { agentConcurrencySections, compactBaseUrl, compactMcpStatus, handleSlashCommand, localizedOperationResult, refreshMcpRuntimeStatus, webUiUrl } from './slash.js';
8
8
  import { completionContext } from '../shell/repl.js';
9
9
 
10
10
  test('deterministic operation results ask Donna to localize compact facts without leaking commands', () => {
@@ -52,6 +52,16 @@ test('/status base URL displays only its domain while retaining the full link',
52
52
  assert.equal(compactBaseUrl(undefined), '-');
53
53
  });
54
54
 
55
+ test('/openui reuses the loopback runtime host so serve shares the TOTP session', () => {
56
+ // The runtime sets wiki_session on its own origin and cookies ignore the
57
+ // port: opening serve on the same host avoids a second login. Non-loopback
58
+ // runtimes share no cookie, so the plain localhost URL stays.
59
+ assert.equal(webUiUrl('3200', 'http://127.0.0.1:7788'), 'http://127.0.0.1:3200');
60
+ assert.equal(webUiUrl('3200', 'http://localhost:7788'), 'http://localhost:3200');
61
+ assert.equal(webUiUrl('3200', null), 'http://localhost:3200');
62
+ assert.equal(webUiUrl('3200', 'https://manager.example.com'), 'http://localhost:3200');
63
+ });
64
+
55
65
  test('/status replaces Internal and Hints with effective agent concurrency', () => {
56
66
  const sections = agentConcurrencySections({
57
67
  agentRegistrySnapshot: [
@@ -1,4 +1,5 @@
1
1
  import { normalizeActivity } from './activity.js';
2
+ import { cloneJson } from './json.js';
2
3
  import { attachActivityToExistingPlan, syncActivitiesToPlan } from './plan.js';
3
4
  import { applyPlanPatch, normalizePlanPatch, normalizePlanRevision, rebasePlanPatch } from './planPatch.js';
4
5
  import { formatRuntimeLogPayload, isDispatchPlumbingLine, normalizeRuntimeLog, shortTaskLabel } from './runtimeLog.js';
@@ -215,6 +216,8 @@ export function conversationEventSequences(events = []) {
215
216
  function createProjectionState() {
216
217
  return {
217
218
  conversation: [],
219
+ conversationSeedStart: 0,
220
+ conversationSummary: null,
218
221
  chain: [],
219
222
  plan: null,
220
223
  activities: {},
@@ -234,6 +237,8 @@ function createProjectionState() {
234
237
  function publicProjection(state) {
235
238
  const projection = {
236
239
  conversation: state.conversation.map((message) => ({ ...message })),
240
+ conversationSeedStart: state.conversationSeedStart ?? 0,
241
+ conversationSummary: state.conversationSummary ?? null,
237
242
  chain: state.chain.map((step) => ({ ...step })),
238
243
  plan: state.plan ? state.plan.map((step) => ({ ...step })) : null,
239
244
  activities: sortedActivities(state.activities).map((activity) => ({ ...activity })),
@@ -313,6 +318,25 @@ function applyEvent(state, event) {
313
318
  case 'user_message':
314
319
  state.conversation.push({ role: 'user', content: String(event.payload?.content ?? '') });
315
320
  return;
321
+ case 'conversation_reset':
322
+ // A deliberate user action (the served chat's memory gauge): everything
323
+ // said before this point stops feeding conversationSeed's LLM context,
324
+ // WITHOUT touching the displayed conversation — the thread stays visible
325
+ // everywhere (ShellUI and serve), only what Donna is told going forward
326
+ // is reset. Nothing is removed from the event log. Marking the boundary
327
+ // (length at reset time) instead of clearing state.conversation is what
328
+ // keeps the two concerns apart: the gauge/seed read conversationSeedStart,
329
+ // the display reads conversation.
330
+ state.conversationSeedStart = state.conversation.length;
331
+ // The summary is best-effort (an LLM call the compact route makes before
332
+ // dispatching this event): when it succeeds it REPLACES the previous one
333
+ // — it is a rolling summary of "everything before this point", not an
334
+ // accumulating log — and when it fails or is skipped (no LLM configured)
335
+ // the previous summary survives rather than being wiped by an empty one.
336
+ if (typeof event.payload?.summary === 'string' && event.payload.summary.trim()) {
337
+ state.conversationSummary = event.payload.summary.trim();
338
+ }
339
+ return;
316
340
  case 'assistant_message':
317
341
  finalizeAssistantMessage(state, String(event.payload?.content ?? ''));
318
342
  return;
@@ -1217,6 +1241,3 @@ function sortedActivities(activities) {
1217
1241
  .sort((a, b) => String(a.updatedAt ?? '').localeCompare(String(b.updatedAt ?? '')));
1218
1242
  }
1219
1243
 
1220
- function cloneJson(value) {
1221
- return value == null ? value : JSON.parse(JSON.stringify(value));
1222
- }
@@ -39,6 +39,43 @@ test('a streamed reply keeps the sequence of the delta that created it', () => {
39
39
  assert.deepEqual(conversationEventSequences(events), [1, 2]);
40
40
  });
41
41
 
42
+ test('conversation_reset marks a seed boundary and keeps the displayed thread', () => {
43
+ const events = sequenced([
44
+ createAgentEvent('user_message', { origin: 'user', payload: { content: 'avant' } }),
45
+ createAgentEvent('assistant_message', { origin: 'runtime', payload: { content: 'réponse avant' } }),
46
+ createAgentEvent('conversation_reset', { origin: 'user', payload: {} }),
47
+ createAgentEvent('user_message', { origin: 'user', payload: { content: 'après' } }),
48
+ ]);
49
+
50
+ const projection = reduceAgentEvents(events);
51
+ // The thread stays whole — compacting must not erase what the reader sees…
52
+ assert.deepEqual(projection.conversation.map((message) => message.content), ['avant', 'réponse avant', 'après']);
53
+ // …only the grounding boundary moves.
54
+ assert.equal(projection.conversationSeedStart, 2);
55
+ });
56
+
57
+ test('conversation_reset stores the summary it carries and keeps it through a later compact with none', () => {
58
+ const events = sequenced([
59
+ createAgentEvent('user_message', { origin: 'user', payload: { content: 'avant' } }),
60
+ createAgentEvent('conversation_reset', { origin: 'user', payload: { summary: 'Résumé 1' } }),
61
+ createAgentEvent('user_message', { origin: 'user', payload: { content: 'entre' } }),
62
+ // A summary is best-effort (an LLM call): a compact with no summary (LLM
63
+ // unavailable, or nothing worth summarizing) must not erase the last one.
64
+ createAgentEvent('conversation_reset', { origin: 'user', payload: {} }),
65
+ ]);
66
+ const projection = reduceAgentEvents(events);
67
+ assert.equal(projection.conversationSummary, 'Résumé 1');
68
+ });
69
+
70
+ test('a later compact with a new summary replaces the previous one', () => {
71
+ const events = sequenced([
72
+ createAgentEvent('conversation_reset', { origin: 'user', payload: { summary: 'Résumé 1' } }),
73
+ createAgentEvent('conversation_reset', { origin: 'user', payload: { summary: 'Résumé 2' } }),
74
+ ]);
75
+ const projection = reduceAgentEvents(events);
76
+ assert.equal(projection.conversationSummary, 'Résumé 2');
77
+ });
78
+
42
79
  test('an independent queued skill invocation never inherits the active run identity', () => {
43
80
  const session = {
44
81
  workspace: 'docs',
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.15.93",
3
- "commit": "c83cc8e"
2
+ "version": "0.15.96",
3
+ "commit": "fbeddf2"
4
4
  }
@@ -27,6 +27,20 @@ test('workspace production agent enables restore by default', async () => {
27
27
  assert.match(String(allowed), /(?:^|,)restore(?:,|})/);
28
28
  });
29
29
 
30
+ test('the manager allowlist carries the new knowledge steps, not only the agent default', async () => {
31
+ // This env OVERRIDES agent-production's own default: a value missing
32
+ // ingest_rebuild/lint silently filters knowledge.rebuild/knowledge.check out
33
+ // of agent_describe (the capability is published only if one of its steps
34
+ // survives). That is exactly what made the wiki-row rebuild button run a
35
+ // plain ingest from raw/untracked instead of re-filing raw/ingested.
36
+ const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
37
+ const compose = YAML.parse(raw);
38
+ const allowed = String(compose.services['production-mcp'].environment
39
+ .find((entry) => String(entry).startsWith('PRODUCTION_ALLOWED_STEPS=')));
40
+ assert.match(allowed, /(?:^|,)ingest_rebuild(?:,|})/);
41
+ assert.match(allowed, /(?:^|,)lint(?:,|})/);
42
+ });
43
+
30
44
  test('the shipped default carries no step the engine retired in 0.15.66', async () => {
31
45
  /*
32
46
  0.15.66 a retiré du moteur llm-wiki les commandes `wiki concepts`,
@@ -33,6 +33,3 @@ export function defaultGoogleGrants() {
33
33
  return [...GOOGLE_GRANTS];
34
34
  }
35
35
 
36
- export function describeGoogleGrant(grant) {
37
- return GOOGLE_GRANT_LABELS[grant] ?? null;
38
- }
@@ -0,0 +1,9 @@
1
+ /*
2
+ * Deep clone via a JSON round-trip; `null`/`undefined` pass through.
3
+ *
4
+ * Three modules had grown their own byte-identical copy (agentEvents,
5
+ * agentRegistry, planValidator). One implementation, imported.
6
+ */
7
+ export function cloneJson(value) {
8
+ return value == null ? value : JSON.parse(JSON.stringify(value));
9
+ }
package/src/core/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
3
3
 
4
- const WIKI_MANAGER_VERSION = '0.15.93';
4
+ const WIKI_MANAGER_VERSION = '0.15.96';
5
5
 
6
6
  function envValue(key) {
7
7
  const filePath = managerEnvFile();
package/src/core/plan.js CHANGED
@@ -48,10 +48,6 @@ export function extractHeadlessPlan(text) {
48
48
  return steps;
49
49
  }
50
50
 
51
- export function matchCompletedToPlan(plan, completed) {
52
- if (!plan) return;
53
- syncActivitiesToPlan(plan, completed.filter((activity) => activity.terminal));
54
- }
55
51
 
56
52
  export function syncActivitiesToPlan(plan, activities) {
57
53
  if (!plan) return;
@@ -48,7 +48,3 @@ export function toolResultNote(name, ok, detail) {
48
48
  return reason ? `${tool} done: ${reason}` : `${tool} done.`;
49
49
  }
50
50
 
51
- export function turnDoneNote(steps) {
52
- if (!steps) return 'Done.';
53
- return `Done — ${steps} step${steps > 1 ? 's' : ''}.`;
54
- }
@@ -7,6 +7,8 @@
7
7
  * which is why the shaping lives here and not in either renderer.
8
8
  */
9
9
 
10
+ import { TERMINAL_STATUS_SET } from '../orchestrator/taskStatuses.js';
11
+
10
12
  const SYMBOLS = {
11
13
  done: '✓',
12
14
  running: '●',
@@ -29,7 +31,7 @@ export function selectionKindLabel(selectionKind) {
29
31
  return SELECTION_KIND_LABELS[selectionKind] ?? selectionKind ?? null;
30
32
  }
31
33
 
32
- export const TERMINAL = new Set(['done', 'failed', 'cancelled', 'skipped']);
34
+ export const TERMINAL = TERMINAL_STATUS_SET;
33
35
 
34
36
  // Objectives are whole paragraphs; a chain view needs a line. Keep the first
35
37
  // sentence, drop the parameter block the compiler appends, and never cut a word
@@ -31,7 +31,7 @@ test('validation rejects technical routing details', () => {
31
31
  });
32
32
 
33
33
  test('every shipped scaffold skill compiles to a single intention, deterministically', async () => {
34
- const expected = { pipeline: 1, 'wiki-sync': 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1 };
34
+ const expected = { pipeline: 1, 'wiki-sync': 1, 'wiki-ingest': 1, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-rebuild': 1 };
35
35
  // Passing no llmFallback used to make this test assert the one path
36
36
  // production never takes: an ambiguous body silently returns the safe
37
37
  // mono-intention fallback, so the count was 1 and the test was green while
@@ -47,6 +47,26 @@ test('every shipped scaffold skill compiles to a single intention, deterministic
47
47
  }
48
48
  });
49
49
 
50
+ test('the shipped wiki-rebuild skill resolves through the deterministic alias path only', async () => {
51
+ // The objective resolver's fast path fires only when EXACTLY ONE capability
52
+ // alias phrase matches the objective. Bare words other agents alias ('build',
53
+ // 'rebuild', 'ingest', 'check', 'export'…) would make the LLM resolver decide
54
+ // instead. The shipped body is worded to stay on the deterministic path: it
55
+ // must carry the agent-production knowledge.rebuild alias phrase and none of
56
+ // the colliding words — verified against the alias lists actually shipped in
57
+ // agent-production (knowledge.rebuild / knowledge.check) and the other
58
+ // agents (cme: 'export sources'…, gateway: 'check'…).
59
+ const raw = readFileSync(resolve('../llm-wiki/scaffold/workspace/.wiki/skills', 'wiki-rebuild.md'), 'utf8');
60
+ const { body } = parseFrontmatter(raw);
61
+ const objectives = await compileSkillObjectives({ body }, {});
62
+ assert.equal(objectives.length, 1);
63
+ const text = objectives[0].text;
64
+ assert.match(text, /file the archived sources/i);
65
+ for (const word of ['ingest', 'build', 'rebuild', 'export', 'publish', 'okf', 'frontmatter', 'diagnose', 'restore', 'pipeline', 'check', 'audit', 'review', 'analyze', 'compare']) {
66
+ assert.doesNotMatch(text, new RegExp(`\\b${word}\\b`, 'i'), `word "${word}" must not appear in the objective`);
67
+ }
68
+ });
69
+
50
70
  test('every orchestrated scaffold skill declares the capability it targets', () => {
51
71
  // Without a declaration the capability is inferred from the body's prose by
52
72
  // alias matching, which any runtime added to agent-runtimes.json can break by
@@ -38,7 +38,15 @@ export async function runBoundedToolLoop({
38
38
  // livre le texte au fil de l'eau. Sans lui, la réponse finale n'apparaissait
39
39
  // qu'une fois complète — le tour paraissait figé pendant toute sa durée.
40
40
  const canStream = typeof onTextDelta === 'function' && typeof llm?.streamWithTools === 'function';
41
+ // The exact same tool + arguments called again is a loop, not progress: a
42
+ // model that keeps re-issuing `search("x")` will never finish, and burning
43
+ // the whole iteration cap on it only produced "could not finish". Track the
44
+ // signatures and stop as soon as a turn repeats one already executed.
45
+ const seen = new Set();
46
+ const signature = (call) => `${call?.function?.name ?? ''}\u0000${String(call?.function?.arguments ?? '')}`;
47
+ let iterations = 0;
41
48
  for (let i = 0; i < cap; i += 1) {
49
+ iterations = i + 1;
42
50
  onStep?.(i + 1, cap);
43
51
  let streamedText = false;
44
52
  const result = canStream
@@ -62,10 +70,12 @@ export async function runBoundedToolLoop({
62
70
  if (calls.length === 0) {
63
71
  return {
64
72
  content: result?.content ?? result?.message?.content ?? '',
65
- iterations: i + 1,
73
+ iterations,
66
74
  capped: false,
67
75
  };
68
76
  }
77
+ if (calls.every((call) => seen.has(signature(call)))) break;
78
+ for (const call of calls) seen.add(signature(call));
69
79
  convo.push(result.message ?? { role: 'assistant', content: result.content ?? '', tool_calls: calls });
70
80
  // Tool calls within one turn are independent: dispatch concurrently, then
71
81
  // replay results in the model's call order so the transcript stays stable.
@@ -77,5 +87,49 @@ export async function runBoundedToolLoop({
77
87
  convo.push({ role: 'tool', tool_call_id: outcome.tool_call_id, content: outcome.content });
78
88
  }
79
89
  }
80
- return { content: '', iterations: cap, capped: true };
90
+ // Cap reached or a loop detected: ask once more WITHOUT tools for the best
91
+ // answer the results gathered so far support. Returning '' here is what made
92
+ // a long search end in a dead-end instead of the partial answer it had
93
+ // already collected.
94
+ const content = await finalAnswerWithoutTools({ llm, system, convo, canStream, onTextDelta, onTextReset, signal });
95
+ return { content, iterations, capped: true };
96
+ }
97
+
98
+ async function finalAnswerWithoutTools({
99
+ llm,
100
+ system,
101
+ convo,
102
+ canStream,
103
+ onTextDelta,
104
+ onTextReset,
105
+ signal,
106
+ }) {
107
+ try {
108
+ if (canStream) {
109
+ let text = '';
110
+ const result = await llm.streamWithTools({
111
+ system,
112
+ tools: [],
113
+ messages: convo,
114
+ toolChoice: 'auto',
115
+ onTextDelta: (delta) => { text += delta; onTextDelta(delta); },
116
+ signal,
117
+ });
118
+ // A tool call despite the empty toolset is not an answer: drop whatever
119
+ // it streamed and let the caller fall back to its own message.
120
+ if (result?.tool_calls?.length) { onTextReset?.(); return ''; }
121
+ return String(result?.content ?? text ?? '').trim();
122
+ }
123
+ const result = await llm.completeWithTools({
124
+ system,
125
+ tools: [],
126
+ messages: convo,
127
+ toolChoice: 'auto',
128
+ signal,
129
+ });
130
+ if (result?.tool_calls?.length) return '';
131
+ return String(result?.content ?? result?.message?.content ?? '').trim();
132
+ } catch {
133
+ return '';
134
+ }
81
135
  }
@@ -61,16 +61,47 @@ test('runs concurrent tool calls and replays results in call order', async () =>
61
61
  assert.deepEqual(order, ['a', 'b']); // preserved model call order
62
62
  });
63
63
 
64
- test('reports capped when the model keeps calling tools past the cap', async () => {
64
+ test('stops on a repeated identical tool call instead of burning the cap', async () => {
65
65
  const llm = {
66
- async completeWithTools() {
66
+ async completeWithTools({ tools }) {
67
+ if (tools.length === 0) return { content: 'Synthèse des résultats.', tool_calls: [] };
67
68
  const calls = [toolCall('x', 's__status')];
68
69
  return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
69
70
  },
70
71
  };
71
- const out = await runBoundedToolLoop({ llm, tools: [], executeCall: async () => 'r', maxIterations: 3 });
72
+ const out = await runBoundedToolLoop({
73
+ llm,
74
+ tools: [{ function: { name: 's__status' } }],
75
+ executeCall: async () => 'r',
76
+ maxIterations: 8,
77
+ });
78
+ assert.equal(out.capped, true);
79
+ // The same call twice is a loop: it stopped well before the cap.
80
+ assert.ok(out.iterations < 8, `expected an early stop, got ${out.iterations}`);
81
+ // And the turn still answers from what it gathered instead of a dead-end.
82
+ assert.equal(out.content, 'Synthèse des résultats.');
83
+ });
84
+
85
+ test('answers from the gathered results when the cap is reached', async () => {
86
+ let round = 0;
87
+ const llm = {
88
+ async completeWithTools({ tools }) {
89
+ round += 1;
90
+ if (round <= 2 && tools.length > 0) {
91
+ const calls = [toolCall('x', 's__search', `{"q":"${round}"}`)];
92
+ return { message: { role: 'assistant', content: '', tool_calls: calls }, tool_calls: calls };
93
+ }
94
+ return { content: "Voici ce que j'ai trouvé.", tool_calls: [] };
95
+ },
96
+ };
97
+ const out = await runBoundedToolLoop({
98
+ llm,
99
+ tools: [{ function: { name: 's__search' } }],
100
+ executeCall: async () => 'r',
101
+ maxIterations: 2,
102
+ });
72
103
  assert.equal(out.capped, true);
73
- assert.equal(out.iterations, 3);
104
+ assert.equal(out.content, "Voici ce que j'ai trouvé.");
74
105
  });
75
106
 
76
107
  test('propagates an abort thrown by executeCall', async () => {
@@ -1,5 +1,6 @@
1
1
  import { createAgentEvent, dispatchAgentEvent, dispatchRuntimeLog } from '../core/agentEvents.js';
2
2
  import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
3
+ import { cloneJson } from '../core/json.js';
3
4
  import { assertContract } from '../contracts/schemas.js';
4
5
 
5
6
  const AVAILABLE = 'available';
@@ -320,6 +321,3 @@ function cloneAgent(agent) {
320
321
  };
321
322
  }
322
323
 
323
- function cloneJson(value) {
324
- return value == null ? value : JSON.parse(JSON.stringify(value));
325
- }
@@ -174,6 +174,3 @@ function taskId(task) {
174
174
  return String(task?.id ?? task?.step);
175
175
  }
176
176
 
177
- export function isTerminalTask(task) {
178
- return isTerminal(statusOf(task));
179
- }
@@ -108,6 +108,33 @@ test('resolveObjective resolves an ingest objective deterministically despite no
108
108
  assert.equal(result.operation, 'ingest');
109
109
  });
110
110
 
111
+ test('"rebuild" resolves to knowledge.rebuild, not document.build', async () => {
112
+ // document.build used to alias "rebuild" too, so the wiki row's rebuild
113
+ // button ran a template build instead of re-filing the archive. The alias is
114
+ // unique to knowledge.rebuild now, and the bare /wiki-rebuild invocation must
115
+ // resolve deterministically (no LLM) to ingest_rebuild.
116
+ const knowledgeRebuild = makeCapability('knowledge.rebuild', {
117
+ operations: ['ingest_rebuild'],
118
+ aliases: ['file the archived sources', 'archived sources into the wiki', 'concept pages from the archive', 'rebuild', 'rebuild concepts'],
119
+ description: 'Re-file the archived sources into their concept folders.',
120
+ });
121
+ const documentBuild = makeCapability('document.build', {
122
+ operations: ['build'],
123
+ aliases: ['build', 'generate deliverable'],
124
+ description: 'Build llm-wiki deliverables from templates.',
125
+ });
126
+ const session = sessionWith([
127
+ provider('production-1', knowledgeRebuild),
128
+ provider('production-1', documentBuild),
129
+ ]);
130
+ session.llm.completeWithTools = async () => {
131
+ throw new Error('the rebuild alias must resolve without the LLM');
132
+ };
133
+ const result = await resolveObjective('/wiki-rebuild', session);
134
+ assert.equal(result.capability, 'knowledge.rebuild');
135
+ assert.equal(result.operation, 'ingest_rebuild');
136
+ });
137
+
111
138
  test('resolveObjective resolves diagnose via alias despite the notification "send"', async () => {
112
139
  const session = sessionWith([
113
140
  provider('production-1', diagnose),
@@ -1,4 +1,5 @@
1
1
  import { validateContract } from '../contracts/schemas.js';
2
+ import { cloneJson } from '../core/json.js';
2
3
 
3
4
  const SUPPORTED_CONTRACT_VERSIONS = new Set(['1']);
4
5
  const MUTATING_OPERATIONS = new Set([
@@ -529,6 +530,3 @@ function issue(code, message, details = {}) {
529
530
  return { code, message, details };
530
531
  }
531
532
 
532
- function cloneJson(value) {
533
- return value == null ? value : JSON.parse(JSON.stringify(value));
534
- }
@@ -43,20 +43,6 @@ import { assertContract } from '../../contracts/schemas.js';
43
43
 
44
44
  export const RUNTIME_PROTOCOL_VERSION = '1';
45
45
 
46
- export const RUNTIME_EVENT_TYPES = [
47
- 'run_created',
48
- 'run_started',
49
- 'agent_thinking',
50
- 'tool_started',
51
- 'tool_finished',
52
- 'subagent_started',
53
- 'subagent_finished',
54
- 'message',
55
- 'approval_required',
56
- 'run_completed',
57
- 'run_failed',
58
- 'run_cancelled',
59
- ];
60
46
 
61
47
  export class RuntimeProviderUnavailableError extends Error {
62
48
  constructor(runtime, reason) {
@@ -28,6 +28,14 @@ export const PENDING_STATUSES_LIST = Object.freeze(['pending', 'pending_approval
28
28
  /** En cours : un agent y travaille en ce moment. */
29
29
  export const ACTIVE_STATUSES = Object.freeze(['running', 'in_progress', 'started', 'starting']);
30
30
 
31
+ /**
32
+ * Terminal, réduit à ses quatre formes canoniques (les alias sont normalisés
33
+ * avant comparaison). Les modules qui recopiaient `['done','failed',
34
+ * 'cancelled','skipped']` dans un `Set` importent celui-ci à la place.
35
+ */
36
+ export const TERMINAL_STATUSES = Object.freeze(['done', 'failed', 'cancelled', 'skipped']);
37
+ export const TERMINAL_STATUS_SET = new Set(TERMINAL_STATUSES);
38
+
31
39
  const ALIASES = new Map([
32
40
  ...SUCCESS_STATUSES.map((status) => [status, 'done']),
33
41
  ...FAILURE_STATUSES.map((status) => [status, 'failed']),
@@ -238,19 +238,6 @@ export async function postRuntimeShutdown({
238
238
  return response.json();
239
239
  }
240
240
 
241
- export async function postRuntimeResume({
242
- url = runtimeUrlFromEnv(),
243
- token = runtimeToken(),
244
- workspace = null,
245
- } = {}) {
246
- const response = await fetch(runtimeEndpoint(url, '/resume', workspace), {
247
- method: 'POST',
248
- headers: runtimeHeaders(token),
249
- });
250
- if (!response.ok) throw new Error(`Runtime resume failed: HTTP ${response.status}`);
251
- return response.json();
252
- }
253
-
254
241
  export async function postRuntimeApprove({
255
242
  url = runtimeUrlFromEnv(),
256
243
  token = runtimeToken(),
@@ -331,6 +318,3 @@ export async function* streamRuntimeEvents({
331
318
  }
332
319
  }
333
320
 
334
- export function runtimeFetchOptions(token = runtimeToken()) {
335
- return { headers: runtimeHeaders(token) };
336
- }
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * @statuses-vocabulary
3
3
  * Control items are queued run requests, not orchestrator tasks. Their
4
- * terminal vocabulary deliberately includes chain-level `skipped` and is
5
- * projected by core/agentEvents.js rather than taskStatuses.js.
4
+ * terminal vocabulary is the same four canonical statuses as a task's
5
+ * (`TERMINAL_STATUS_SET`), including chain-level `skipped`; the projection is
6
+ * still done by core/agentEvents.js, this only shares the vocabulary.
6
7
  */
7
- const TERMINAL = new Set(['done', 'failed', 'cancelled', 'skipped']);
8
+ import { TERMINAL_STATUS_SET } from '../orchestrator/taskStatuses.js';
9
+
10
+ const TERMINAL = TERMINAL_STATUS_SET;
8
11
 
9
12
  export function reconcileControlQueue(context, { startItem, skipItem } = {}) {
10
13
  if (!context?.session || context.running || context.controlDrainActive) return false;