@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.
- package/README.md +39 -28
- package/docker-compose.yml +1 -1
- package/package.json +2 -2
- package/src/agent/graph.js +78 -26
- package/src/agent/graph.test.js +28 -2
- package/src/cli/wiki-manager.js +138 -18
- package/src/commands/slash.js +38 -4
- package/src/commands/slash.test.js +11 -1
- package/src/core/agentEvents.js +24 -3
- package/src/core/agentEvents.test.js +37 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/dockerCompose.test.js +14 -0
- package/src/core/googleGrants.js +0 -3
- package/src/core/json.js +9 -0
- package/src/core/mcp.js +1 -1
- package/src/core/plan.js +0 -4
- package/src/core/progressNotes.js +0 -4
- package/src/core/skillChainView.js +3 -1
- package/src/core/skillCompiler.test.js +21 -1
- package/src/core/toolLoop.js +56 -2
- package/src/core/toolLoop.test.js +35 -4
- package/src/orchestrator/agentRegistry.js +1 -3
- package/src/orchestrator/dependencyResolver.js +0 -3
- package/src/orchestrator/objectiveResolver.test.js +27 -0
- package/src/orchestrator/planValidator.js +1 -3
- package/src/orchestrator/providers/runtimeProvider.js +0 -14
- package/src/orchestrator/taskStatuses.js +8 -0
- package/src/runtime/client.js +0 -16
- package/src/runtime/controlDrain.js +6 -3
- package/src/runtime/deltaCoalescer.js +53 -0
- package/src/runtime/deltaCoalescer.test.js +56 -0
- package/src/runtime/loginPage.js +129 -0
- package/src/runtime/loginRoutes.test.js +131 -0
- package/src/runtime/loginSession.js +223 -0
- package/src/runtime/loginSession.test.js +143 -0
- package/src/runtime/qrCode.js +15 -0
- package/src/runtime/runner.js +29 -3
- package/src/runtime/runner.test.js +90 -1
- package/src/runtime/server.js +240 -1
- package/src/runtime/server.test.js +87 -0
- package/src/runtime/skillRun.js +1 -1
- package/src/runtime/skillRun.test.js +3 -0
- package/src/runtime/totp.js +87 -0
- package/src/runtime/totp.test.js +80 -0
- package/src/runtime/totpLogin.js +123 -0
- package/src/runtime/vendor/qrcode.cjs +2297 -0
- package/src/shell/repl.js +7 -3
- package/src/orchestrator/.fuse_hidden0000001c00000001 +0 -316
package/src/commands/slash.js
CHANGED
|
@@ -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,
|
|
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 =
|
|
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(
|
|
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: [
|
package/src/core/agentEvents.js
CHANGED
|
@@ -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',
|
package/src/core/buildInfo.json
CHANGED
|
@@ -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`,
|
package/src/core/googleGrants.js
CHANGED
package/src/core/json.js
ADDED
|
@@ -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.
|
|
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;
|
|
@@ -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 =
|
|
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
|
package/src/core/toolLoop.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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('
|
|
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({
|
|
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.
|
|
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
|
-
}
|
|
@@ -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']),
|
package/src/runtime/client.js
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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
|
-
|
|
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;
|