@dotdrelle/wiki-manager 0.15.59 → 0.15.62
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/docker-compose.yml +1 -1
- package/package.json +1 -1
- package/src/agent/graph.js +3 -20
- package/src/agent/graph.test.js +8 -8
- package/src/agent/llm.js +2 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/dockerCompose.test.js +30 -0
- package/src/core/mcp.js +20 -7
- package/src/core/mcp.test.js +66 -0
- package/src/core/skillCompiler.test.js +1 -1
- package/src/orchestrator/objectiveResolver.js +21 -4
- package/src/orchestrator/objectiveResolver.test.js +64 -2
- package/src/runtime/controlMessages.js +16 -32
- package/src/runtime/controlMessages.test.js +8 -14
- package/src/runtime/server.js +122 -18
- package/src/runtime/server.test.js +11 -7
- package/src/runtime/skillChain.e2e.test.js +2 -2
- package/src/runtime/skillRun.js +44 -0
- package/src/runtime/skillRun.test.js +36 -1
- package/src/shell/LeftPane.tsx +1 -1
- package/src/shell/RightPane.tsx +11 -11
- package/src/shell/repl.js +11 -7
- package/src/shell/useAgent.ts +7 -19
package/docker-compose.yml
CHANGED
|
@@ -130,7 +130,7 @@ services:
|
|
|
130
130
|
# error. Every compose-deployed ingest then ran without the Lot 4 barrier
|
|
131
131
|
# and left the published map stale — the very defect that work fixed.
|
|
132
132
|
# `copy` stays out on purpose: it is the legacy step, opt-in only.
|
|
133
|
-
- PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,taxonomy,build,export,polish,restore,pipeline}
|
|
133
|
+
- PRODUCTION_ALLOWED_STEPS=${PRODUCTION_ALLOWED_STEPS:-doctor,ingest,ingest_plan,ingest_apply,concepts,reclassify-concepts,taxonomy,build,export,polish,restore,pipeline}
|
|
134
134
|
- PRODUCTION_REQUIRE_CONFIRMATION=${PRODUCTION_REQUIRE_CONFIRMATION:-false}
|
|
135
135
|
# Parallelism levers — effective concurrency ≈ recommendedConcurrency.
|
|
136
136
|
# Intermediate defaults (4/8). Low profile 2/4, high profile 8/16.
|
package/package.json
CHANGED
package/src/agent/graph.js
CHANGED
|
@@ -28,7 +28,6 @@ import { loadWorkspaceProfile, updateWorkspaceProfilePreference } from '../core/
|
|
|
28
28
|
import { artifactFromToolCall, currentArtifactFor, currentArtifactPromptLine, rememberArtifact } from '../core/currentArtifact.js';
|
|
29
29
|
import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
|
|
30
30
|
import { fetchRuntimeState, postRuntimeCancel, postRuntimeControl, postRuntimeDelegate, postRuntimeKill, postRuntimeSkill } from '../runtime/client.js';
|
|
31
|
-
import { controlLanguage } from '../runtime/controlMessages.js';
|
|
32
31
|
|
|
33
32
|
const MAX_TOOL_ITERATIONS = 80;
|
|
34
33
|
/**
|
|
@@ -433,10 +432,6 @@ export function bareToolCallJson(content, tools = []) {
|
|
|
433
432
|
return hasArguments ? name : null;
|
|
434
433
|
}
|
|
435
434
|
|
|
436
|
-
function localizedFailure(session, english, french) {
|
|
437
|
-
return controlLanguage(session) === 'fr' ? french : english;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
435
|
function parseActionJson(text) {
|
|
441
436
|
const cleaned = String(text ?? '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
|
442
437
|
if (!cleaned) return null;
|
|
@@ -1548,11 +1543,7 @@ export function createAgentGraph(options = {}) {
|
|
|
1548
1543
|
invalidToolCallRetries: retries + 1,
|
|
1549
1544
|
};
|
|
1550
1545
|
}
|
|
1551
|
-
const failure =
|
|
1552
|
-
state.session,
|
|
1553
|
-
'Action not executed: the model generated an incomplete tool call.',
|
|
1554
|
-
'Action non exécutée : l’appel d’outil généré par le modèle était incomplet.',
|
|
1555
|
-
);
|
|
1546
|
+
const failure = 'Action not executed: the model generated an incomplete tool call.';
|
|
1556
1547
|
emitAgentEvent(state.session, 'assistant_message', 'agent_guard', { content: failure });
|
|
1557
1548
|
return { response: failure, pendingToolCalls: null, readyToStream: false };
|
|
1558
1549
|
}
|
|
@@ -1616,11 +1607,7 @@ export function createAgentGraph(options = {}) {
|
|
|
1616
1607
|
};
|
|
1617
1608
|
}
|
|
1618
1609
|
state.session._onStreamReset?.();
|
|
1619
|
-
const failure =
|
|
1620
|
-
state.session,
|
|
1621
|
-
'Action not executed: Donna repeatedly printed an internal tool request instead of calling it. No result was created.',
|
|
1622
|
-
'Action non exécutée : Donna a affiché à plusieurs reprises une requête interne au lieu d’appeler l’outil. Aucun résultat n’a été créé.',
|
|
1623
|
-
);
|
|
1610
|
+
const failure = 'Action not executed: Donna repeatedly printed an internal tool request instead of calling it. No result was created.';
|
|
1624
1611
|
emitAgentEvent(state.session, 'assistant_message', 'agent_guard', { content: failure });
|
|
1625
1612
|
return { response: failure, pendingToolCalls: null, readyToStream: false };
|
|
1626
1613
|
}
|
|
@@ -1689,11 +1676,7 @@ export function createAgentGraph(options = {}) {
|
|
|
1689
1676
|
|
|
1690
1677
|
if (runtimeExecution && state.retryWithoutTool) {
|
|
1691
1678
|
state.session._onStreamReset?.();
|
|
1692
|
-
const failure =
|
|
1693
|
-
state.session,
|
|
1694
|
-
'Action not executed: Donna did not call any available tool. No job or result was created.',
|
|
1695
|
-
'Action non exécutée : Donna n’a appelé aucun outil disponible. Aucun job ni résultat n’a été créé.',
|
|
1696
|
-
);
|
|
1679
|
+
const failure = 'Action not executed: Donna did not call any available tool. No job or result was created.';
|
|
1697
1680
|
emitAgentEvent(state.session, 'assistant_message', 'agent_guard', { content: failure });
|
|
1698
1681
|
return {
|
|
1699
1682
|
response: failure,
|
package/src/agent/graph.test.js
CHANGED
|
@@ -2281,8 +2281,8 @@ test('LOT F: repeated bare tool-call JSON never reaches the user', async () => {
|
|
|
2281
2281
|
assert.ok(offered.includes('production__production_start_job'), 'the turn must really offer the tool');
|
|
2282
2282
|
assert.equal(calls, 3, 'two retries, then the guard');
|
|
2283
2283
|
assert.doesNotMatch(result.response, /production__production_start_job/);
|
|
2284
|
-
assert.match(result.response, /
|
|
2285
|
-
assert.match(result.response, /
|
|
2284
|
+
assert.match(result.response, /repeatedly printed an internal tool request/);
|
|
2285
|
+
assert.match(result.response, /No .*result was created/);
|
|
2286
2286
|
});
|
|
2287
2287
|
|
|
2288
2288
|
/*
|
|
@@ -2326,7 +2326,7 @@ test('LOT F: a skill re-invoking itself as bare JSON is neither executed nor sho
|
|
|
2326
2326
|
assert.deepEqual(ran, [], 'a call written as text must never reach the skill runner');
|
|
2327
2327
|
assert.doesNotMatch(result.response, /runtime__run_skill/);
|
|
2328
2328
|
assert.doesNotMatch(result.response, /[{}]/, 'no JSON fragment may survive in the user-facing answer');
|
|
2329
|
-
assert.match(result.response, /
|
|
2329
|
+
assert.match(result.response, /repeatedly printed an internal tool request/);
|
|
2330
2330
|
assert.ok(streamResets >= 1, 'the partially streamed payload must be wiped before the guard message');
|
|
2331
2331
|
assert.ok(calls >= 2, 'the first occurrence is retried, not surfaced');
|
|
2332
2332
|
|
|
@@ -2338,7 +2338,7 @@ test('LOT F: a skill re-invoking itself as bare JSON is neither executed nor sho
|
|
|
2338
2338
|
assert.equal(guard[0].payload.content, result.response);
|
|
2339
2339
|
});
|
|
2340
2340
|
|
|
2341
|
-
test('LOT F: the guard message
|
|
2341
|
+
test('LOT F: the guard message stays English-only for every session language', async () => {
|
|
2342
2342
|
const raw = '{"name":"production__production_start_job","arguments":{"type":"build"}}';
|
|
2343
2343
|
const answer = async (language) => {
|
|
2344
2344
|
const session = sessionBase({
|
|
@@ -2353,10 +2353,10 @@ test('LOT F: the guard message follows the session language', async () => {
|
|
|
2353
2353
|
return result.response;
|
|
2354
2354
|
};
|
|
2355
2355
|
|
|
2356
|
-
//
|
|
2357
|
-
//
|
|
2358
|
-
//
|
|
2356
|
+
// The guard message is produced by deterministic code, not by the model, and
|
|
2357
|
+
// this lane deliberately does not run an LLM turn — so it is English-only
|
|
2358
|
+
// rather than a hardcoded catalog that would leave most languages unanswered.
|
|
2359
2359
|
assert.match(await answer('en-US'), /repeatedly printed an internal tool request/);
|
|
2360
2360
|
assert.match(await answer(undefined), /repeatedly printed an internal tool request/);
|
|
2361
|
-
assert.match(await answer('fr'), /
|
|
2361
|
+
assert.match(await answer('fr'), /repeatedly printed an internal tool request/);
|
|
2362
2362
|
});
|
package/src/agent/llm.js
CHANGED
|
@@ -26,9 +26,10 @@ export function createLlmClientFromWikiConfig(config) {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
return {
|
|
29
|
-
async complete({ system, input }) {
|
|
29
|
+
async complete({ system, input, signal }) {
|
|
30
30
|
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
31
31
|
method: 'POST',
|
|
32
|
+
signal,
|
|
32
33
|
headers: {
|
|
33
34
|
Authorization: `Bearer ${apiKey}`,
|
|
34
35
|
'Content-Type': 'application/json',
|
package/src/core/buildInfo.json
CHANGED
|
@@ -45,6 +45,36 @@ test('every shipped default allows the taxonomy step', async () => {
|
|
|
45
45
|
assert.match(String(allowed), /(?:^|,)taxonomy(?:,|})/);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
test('every shipped default allows the concepts step', async () => {
|
|
49
|
+
/*
|
|
50
|
+
Same silent-omission risk as `taxonomy` above, one lot earlier: without
|
|
51
|
+
`concepts`, `wiki concepts --apply` (which writes wiki/concepts-grid.md) is
|
|
52
|
+
never reachable through /wiki-sync, /pipeline, or any orchestrated flow.
|
|
53
|
+
Every ingest then files every concept page under the reserved
|
|
54
|
+
`unclassified` class forever, with nothing surfacing why.
|
|
55
|
+
*/
|
|
56
|
+
const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
|
|
57
|
+
const compose = YAML.parse(raw);
|
|
58
|
+
const allowed = compose.services['production-mcp'].environment
|
|
59
|
+
.find((entry) => String(entry).startsWith('PRODUCTION_ALLOWED_STEPS='));
|
|
60
|
+
assert.match(String(allowed), /(?:^|,)concepts(?:,|})/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('every shipped default allows the reclassify-concepts step', async () => {
|
|
64
|
+
/*
|
|
65
|
+
One step further than `concepts`: without `reclassify-concepts`, a page
|
|
66
|
+
already stuck under wiki/concepts/unclassified stays there even after a
|
|
67
|
+
grid exists — re-ingesting its source is not a reliable fix, since the
|
|
68
|
+
ingest prompt updates an existing leaf at its existing path instead of
|
|
69
|
+
moving it.
|
|
70
|
+
*/
|
|
71
|
+
const raw = await readFile(new URL('../../docker-compose.yml', import.meta.url), 'utf8');
|
|
72
|
+
const compose = YAML.parse(raw);
|
|
73
|
+
const allowed = compose.services['production-mcp'].environment
|
|
74
|
+
.find((entry) => String(entry).startsWith('PRODUCTION_ALLOWED_STEPS='));
|
|
75
|
+
assert.match(String(allowed), /(?:^|,)reclassify-concepts(?:,|})/);
|
|
76
|
+
});
|
|
77
|
+
|
|
48
78
|
test('shipped compose files never carry a build context', async () => {
|
|
49
79
|
// Ces deux fichiers partent dans le paquet npm, où les dépôts frères
|
|
50
80
|
// (`../agent-external/…`) n'existent pas : un `build:` y rend toute commande
|
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.62';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -76,6 +76,17 @@ function normalizeExternalUrlForRuntime(url) {
|
|
|
76
76
|
// server's entry shaped differently from every other. It is folded into
|
|
77
77
|
// `allow` on read so existing installs keep working without regenerating
|
|
78
78
|
// their endpoints file, but nothing writes it any more.
|
|
79
|
+
// The packaged example and every scaffolded mcp.endpoints.json declare the
|
|
80
|
+
// two built-in workspace servers' chatAccess under their public/documented
|
|
81
|
+
// names ("llm-wiki", "wiki-production" — see PROTECTED_SERVERS in
|
|
82
|
+
// mcpEndpoints.js and the root CLAUDE.md). Those servers are actually
|
|
83
|
+
// discovered into session.mcp under different internal keys ("wiki",
|
|
84
|
+
// "production" — MCP_SERVICE_MAP below). Without this alias, chatAllowedTools'
|
|
85
|
+
// intersection of session.mcp against chatAccess.servers never matches the
|
|
86
|
+
// built-in servers, so /chat silently gets zero wiki/production tools no
|
|
87
|
+
// matter what is configured — alias both spellings onto the internal key.
|
|
88
|
+
const BUILTIN_CHAT_ACCESS_ALIASES = { 'llm-wiki': 'wiki', 'wiki-production': 'production' };
|
|
89
|
+
|
|
79
90
|
export function readChatAccessConfig() {
|
|
80
91
|
const filePath = managerMcpEndpointsFile();
|
|
81
92
|
if (!existsSync(filePath)) return null;
|
|
@@ -84,19 +95,21 @@ export function readChatAccessConfig() {
|
|
|
84
95
|
const chatAccess = raw?.chatAccess;
|
|
85
96
|
if (!chatAccess || typeof chatAccess !== 'object' || Array.isArray(chatAccess)) return null;
|
|
86
97
|
const servers = {};
|
|
87
|
-
for (const [
|
|
98
|
+
for (const [rawName, entry] of Object.entries(chatAccess.servers ?? {})) {
|
|
99
|
+
const name = BUILTIN_CHAT_ACCESS_ALIASES[rawName] ?? rawName;
|
|
88
100
|
// "*" is also commonly written as a one-element array (["*"]) since every
|
|
89
101
|
// other "allow" example in this config is an array of tool names — treat
|
|
90
102
|
// both forms as the same wildcard rather than silently allowing nothing.
|
|
91
103
|
const legacyActions = Array.isArray(entry?.allowActions)
|
|
92
104
|
? entry.allowActions.map(String).filter(Boolean)
|
|
93
105
|
: [];
|
|
94
|
-
|
|
106
|
+
const isWildcard = entry?.allow === '*' || (Array.isArray(entry?.allow) && entry.allow.length === 1 && entry.allow[0] === '*');
|
|
107
|
+
const priorAllow = servers[name]?.allow;
|
|
108
|
+
if (isWildcard || priorAllow === '*') {
|
|
95
109
|
servers[name] = { allow: '*' };
|
|
96
|
-
} else if (Array.isArray(entry?.allow)) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
servers[name] = { allow: legacyActions };
|
|
110
|
+
} else if (Array.isArray(entry?.allow) || legacyActions.length > 0) {
|
|
111
|
+
const merged = [...(Array.isArray(priorAllow) ? priorAllow : []), ...(Array.isArray(entry?.allow) ? entry.allow.map(String).filter(Boolean) : []), ...legacyActions];
|
|
112
|
+
servers[name] = { allow: [...new Set(merged)] };
|
|
100
113
|
}
|
|
101
114
|
}
|
|
102
115
|
const maxToolIterations = Number.isFinite(Number(chatAccess.maxToolIterations)) && Number(chatAccess.maxToolIterations) > 0
|
package/src/core/mcp.test.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
callMcpTool,
|
|
10
10
|
discoverMcpTools,
|
|
11
11
|
formatMcpToolsForAgent,
|
|
12
|
+
readChatAccessConfig,
|
|
12
13
|
resetMcpSessionsForTests,
|
|
13
14
|
resetMcpThrottleForTests,
|
|
14
15
|
resolveRetryPolicy,
|
|
@@ -159,6 +160,71 @@ test('buildMcpStatus interpolates external endpoints from manager .env', async (
|
|
|
159
160
|
}
|
|
160
161
|
});
|
|
161
162
|
|
|
163
|
+
test('readChatAccessConfig aliases the built-in servers\' public names onto their internal session.mcp keys', async () => {
|
|
164
|
+
// Every shipped example and scaffolded mcp.endpoints.json declares
|
|
165
|
+
// chatAccess for the built-in workspace servers under their public names
|
|
166
|
+
// ("llm-wiki", "wiki-production"), but session.mcp discovers them under
|
|
167
|
+
// different internal keys ("wiki", "production" — MCP_SERVICE_MAP).
|
|
168
|
+
// Without aliasing, chatAllowedTools' intersection against session.mcp
|
|
169
|
+
// never matches these entries and /chat silently gets zero wiki/production
|
|
170
|
+
// tools no matter what is configured.
|
|
171
|
+
const originalCwd = process.cwd();
|
|
172
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'wiki-manager-chat-access-'));
|
|
173
|
+
await writeFile(
|
|
174
|
+
path.join(root, 'mcp.endpoints.json'),
|
|
175
|
+
JSON.stringify({
|
|
176
|
+
mcpServers: {},
|
|
177
|
+
chatAccess: {
|
|
178
|
+
maxToolIterations: 8,
|
|
179
|
+
servers: {
|
|
180
|
+
'llm-wiki': { allow: ['wiki_search_context', 'wiki_read_page'] },
|
|
181
|
+
'wiki-production': { allow: ['production_job_status'] },
|
|
182
|
+
cme: { allow: ['cme_status'] },
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
'utf8',
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
process.chdir(root);
|
|
191
|
+
const config = readChatAccessConfig();
|
|
192
|
+
assert.deepEqual(config.servers.wiki, { allow: ['wiki_search_context', 'wiki_read_page'] });
|
|
193
|
+
assert.deepEqual(config.servers.production, { allow: ['production_job_status'] });
|
|
194
|
+
assert.deepEqual(config.servers.cme, { allow: ['cme_status'] });
|
|
195
|
+
assert.equal(config.servers['llm-wiki'], undefined);
|
|
196
|
+
assert.equal(config.servers['wiki-production'], undefined);
|
|
197
|
+
} finally {
|
|
198
|
+
process.chdir(originalCwd);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('readChatAccessConfig merges a wildcard from either the public or internal built-in name', async () => {
|
|
203
|
+
const originalCwd = process.cwd();
|
|
204
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'wiki-manager-chat-access-wildcard-'));
|
|
205
|
+
await writeFile(
|
|
206
|
+
path.join(root, 'mcp.endpoints.json'),
|
|
207
|
+
JSON.stringify({
|
|
208
|
+
mcpServers: {},
|
|
209
|
+
chatAccess: {
|
|
210
|
+
servers: {
|
|
211
|
+
wiki: { allow: ['wiki_read_page'] },
|
|
212
|
+
'llm-wiki': { allow: '*' },
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
}),
|
|
216
|
+
'utf8',
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
process.chdir(root);
|
|
221
|
+
const config = readChatAccessConfig();
|
|
222
|
+
assert.deepEqual(config.servers.wiki, { allow: '*' });
|
|
223
|
+
} finally {
|
|
224
|
+
process.chdir(originalCwd);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
162
228
|
test('buildMcpStatus reloads external endpoint keys changed in manager .env', async () => {
|
|
163
229
|
const originalCwd = process.cwd();
|
|
164
230
|
const originalToken = process.env.TEST_EXTERNAL_TOKEN;
|
|
@@ -31,7 +31,7 @@ test('validation rejects technical routing details', () => {
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
test('scaffold skills preserve existing capabilities and split only wiki-sync', async () => {
|
|
34
|
-
const expected = { pipeline: 1, 'wiki-ingest':
|
|
34
|
+
const expected = { pipeline: 1, 'wiki-ingest': 2, 'wiki-build': 1, deliver: 1, diagnose: 1, status: 1, 'new-template': 1, 'wiki-sync': 2 };
|
|
35
35
|
for (const [name, count] of Object.entries(expected)) {
|
|
36
36
|
const raw = readFileSync(resolve('../llm-wiki/scaffold/workspace/.wiki/skills', `${name}.md`), 'utf8');
|
|
37
37
|
const { meta, body } = parseFrontmatter(raw);
|
|
@@ -106,9 +106,22 @@ function resolveMentionedRegistryOperation(objective, candidates) {
|
|
|
106
106
|
const text = normalizeText(objective);
|
|
107
107
|
|
|
108
108
|
const aliasHits = candidates
|
|
109
|
-
.
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
.map((candidate) => {
|
|
110
|
+
const matchedAlias = (candidate.aliases ?? []).find((alias) =>
|
|
111
|
+
phraseIn(normalizePhrase(alias), words, text));
|
|
112
|
+
if (matchedAlias === undefined) return null;
|
|
113
|
+
// A capability with more than one operation may declare
|
|
114
|
+
// aliasOperations, mapping the specific alias phrase that matched to
|
|
115
|
+
// the operation it actually names. Without it, operations[0]
|
|
116
|
+
// (alphabetical) is a silent guess: for knowledge.concepts this always
|
|
117
|
+
// picked the destructive grid-rebuild "concepts" operation, even when
|
|
118
|
+
// the matched alias ("reclassify concepts", "file unclassified
|
|
119
|
+
// concepts") named the safe, mechanical "reclassify-concepts" one —
|
|
120
|
+
// making that operation structurally unreachable from natural language.
|
|
121
|
+
const operation = candidate.aliasOperations?.[matchedAlias] ?? candidate.operations[0];
|
|
122
|
+
return { capability: candidate.id, operation };
|
|
123
|
+
})
|
|
124
|
+
.filter(Boolean);
|
|
112
125
|
if (aliasHits.length === 1) return aliasHits[0];
|
|
113
126
|
if (aliasHits.length > 1) return null;
|
|
114
127
|
|
|
@@ -142,8 +155,12 @@ export function capabilityCandidates(session) {
|
|
|
142
155
|
const id = versionedId.includes('@') ? versionedId.slice(0, versionedId.lastIndexOf('@')) : versionedId;
|
|
143
156
|
const operations = [...new Set((providers ?? []).flatMap((provider) => provider?.capability?.supportedOperations ?? []))].sort();
|
|
144
157
|
const aliases = [...new Set((providers ?? []).flatMap((provider) => provider?.capability?.aliases ?? []))].sort();
|
|
158
|
+
const aliasOperations = Object.assign(
|
|
159
|
+
{},
|
|
160
|
+
...(providers ?? []).map((provider) => provider?.capability?.aliasOperations ?? {}),
|
|
161
|
+
);
|
|
145
162
|
const description = (providers ?? []).map((provider) => provider?.capability?.description).find(Boolean) ?? '';
|
|
146
|
-
byId.set(id, { id, description, operations, aliases });
|
|
163
|
+
byId.set(id, { id, description, operations, aliases, aliasOperations });
|
|
147
164
|
}
|
|
148
165
|
return [...byId.values()].filter((item) => item.operations.length > 0).sort((a, b) => a.id.localeCompare(b.id));
|
|
149
166
|
}
|
|
@@ -7,8 +7,8 @@ import {
|
|
|
7
7
|
ObjectiveNotOrchestrableError,
|
|
8
8
|
} from './objectiveResolver.js';
|
|
9
9
|
|
|
10
|
-
function makeCapability(id, { operations = [], aliases = [], description = '' } = {}) {
|
|
11
|
-
return { id, version: '1', description, supportedOperations: operations, aliases };
|
|
10
|
+
function makeCapability(id, { operations = [], aliases = [], aliasOperations = {}, description = '' } = {}) {
|
|
11
|
+
return { id, version: '1', description, supportedOperations: operations, aliases, aliasOperations };
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
function provider(agentInstanceId, capability) {
|
|
@@ -63,6 +63,7 @@ test('capabilityCandidates exposes aliases from the closed live registry', () =>
|
|
|
63
63
|
description: 'Update knowledge from pending sources.',
|
|
64
64
|
operations: ['ingest', 'ingest_apply', 'ingest_plan'],
|
|
65
65
|
aliases: ['ingest', 'ingestion'],
|
|
66
|
+
aliasOperations: {},
|
|
66
67
|
}]);
|
|
67
68
|
});
|
|
68
69
|
|
|
@@ -121,6 +122,67 @@ test('resolveObjective resolves diagnose via alias despite the notification "sen
|
|
|
121
122
|
assert.equal(result.operation, 'doctor');
|
|
122
123
|
});
|
|
123
124
|
|
|
125
|
+
const concepts = makeCapability('knowledge.concepts', {
|
|
126
|
+
operations: ['concepts', 'reclassify-concepts'],
|
|
127
|
+
aliases: ['concept grid', 'reclassify concepts', 'file unclassified concepts'],
|
|
128
|
+
aliasOperations: {
|
|
129
|
+
'concept grid': 'concepts',
|
|
130
|
+
'reclassify concepts': 'reclassify-concepts',
|
|
131
|
+
'file unclassified concepts': 'reclassify-concepts',
|
|
132
|
+
},
|
|
133
|
+
description: 'Synthesize the concept grid or file unclassified pages into it.',
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/*
|
|
137
|
+
Regression: with two operations, [...new Set(supportedOperations)].sort()
|
|
138
|
+
alphabetizes to ["concepts", "reclassify-concepts"], so a naive alias hit
|
|
139
|
+
defaulting to operations[0] would ALWAYS resolve to "concepts" — the
|
|
140
|
+
destructive grid rebuild — even for aliases explicitly authored to reach the
|
|
141
|
+
safe "reclassify-concepts" operation. aliasOperations must be consulted
|
|
142
|
+
first.
|
|
143
|
+
*/
|
|
144
|
+
test('resolveObjective routes "reclassify concepts" to reclassify-concepts, not operations[0]', async () => {
|
|
145
|
+
const session = sessionWith([provider('production-1', concepts)]);
|
|
146
|
+
session.llm.completeWithTools = async () => {
|
|
147
|
+
throw new Error('the aliased operation must not depend on LLM selection');
|
|
148
|
+
};
|
|
149
|
+
const result = await resolveObjective('Please reclassify concepts in the workspace', session);
|
|
150
|
+
assert.equal(result.capability, 'knowledge.concepts');
|
|
151
|
+
assert.equal(result.operation, 'reclassify-concepts');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('resolveObjective routes "file unclassified concepts" to reclassify-concepts', async () => {
|
|
155
|
+
const session = sessionWith([provider('production-1', concepts)]);
|
|
156
|
+
session.llm.completeWithTools = async () => {
|
|
157
|
+
throw new Error('the aliased operation must not depend on LLM selection');
|
|
158
|
+
};
|
|
159
|
+
const result = await resolveObjective('File unclassified concepts into the grid', session);
|
|
160
|
+
assert.equal(result.capability, 'knowledge.concepts');
|
|
161
|
+
assert.equal(result.operation, 'reclassify-concepts');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('resolveObjective routes "concept grid" to the concepts operation', async () => {
|
|
165
|
+
const session = sessionWith([provider('production-1', concepts)]);
|
|
166
|
+
session.llm.completeWithTools = async () => {
|
|
167
|
+
throw new Error('the aliased operation must not depend on LLM selection');
|
|
168
|
+
};
|
|
169
|
+
const result = await resolveObjective('Rebuild the concept grid', session);
|
|
170
|
+
assert.equal(result.capability, 'knowledge.concepts');
|
|
171
|
+
assert.equal(result.operation, 'concepts');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('resolveObjective falls back to operations[0] when a matched alias has no aliasOperations entry', async () => {
|
|
175
|
+
// A capability that never declares aliasOperations (every existing
|
|
176
|
+
// single-operation capability) must keep working exactly as before.
|
|
177
|
+
const session = sessionWith(
|
|
178
|
+
[provider('production-1', diagnose)],
|
|
179
|
+
{ capability: 'workspace.diagnose', operation: 'doctor' },
|
|
180
|
+
);
|
|
181
|
+
const result = await resolveObjective('diagnose the workspace', session);
|
|
182
|
+
assert.equal(result.capability, 'workspace.diagnose');
|
|
183
|
+
assert.equal(result.operation, 'doctor');
|
|
184
|
+
});
|
|
185
|
+
|
|
124
186
|
test('objectiveForResolution strips notification and negative guardrails', () => {
|
|
125
187
|
const clean = objectiveForResolution(
|
|
126
188
|
'Ingest files. Do not build or publish deliverables. If a messaging connector is available, send a summary; otherwise skip notification silently.',
|
|
@@ -1,43 +1,27 @@
|
|
|
1
|
-
// Deterministic,
|
|
1
|
+
// Deterministic, English-only messages for the runtime control lane.
|
|
2
2
|
//
|
|
3
3
|
// Control-lane acknowledgements (run queued, ambiguous input, conversation
|
|
4
4
|
// fallback…) are intentionally NOT generated by Donna: spending an LLM turn
|
|
5
5
|
// to say "your request is queued" would reintroduce exactly the per-message
|
|
6
|
-
// cost the orchestration refactor removed.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// cost the orchestration refactor removed. They are therefore English-only —
|
|
7
|
+
// the one language this deterministic lane can guarantee — and are never
|
|
8
|
+
// localized by a hardcoded fr/en catalog (a French-only fallback would still
|
|
9
|
+
// leave every other language unanswered). Localized, personalised replies are
|
|
10
|
+
// Donna's job on the conversational paths (see generateSkillAcknowledgment).
|
|
11
|
+
// This catalog is the single source for these strings — never hardcode a
|
|
12
|
+
// control-lane message in the shell or the server directly.
|
|
10
13
|
|
|
11
14
|
const CONTROL_MESSAGES = {
|
|
12
|
-
queued_for_future_run:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
fr: 'Modification de plan proposée. Approuvez-la explicitement pour l’appliquer au plan actif.',
|
|
19
|
-
},
|
|
20
|
-
ambiguous_control: {
|
|
21
|
-
en: 'A run is already active, and this looks like a new action. Say "queue it" to run it after the current run, "modify the run" to change the active plan, "cancel" to stop the current run first — or wait for it to finish.',
|
|
22
|
-
fr: 'Un run est déjà actif et ta demande ressemble à une nouvelle action. Dis « mets en file » pour l\'exécuter après le run en cours, « modifie le run » pour changer le plan actif, « annule » pour arrêter le run actuel — ou attends la fin.',
|
|
23
|
-
},
|
|
24
|
-
converse_while_running: {
|
|
25
|
-
en: 'Runtime run is still active. This message was treated as conversation and did not create a queued run.',
|
|
26
|
-
fr: 'Un run est toujours actif. Ce message a été traité comme conversation et n’a pas créé de run en file.',
|
|
27
|
-
},
|
|
28
|
-
converse_while_idle: {
|
|
29
|
-
en: 'Runtime is idle. This message was treated as conversation and did not create a run.',
|
|
30
|
-
fr: 'Le runtime est inactif. Ce message a été traité comme conversation et n’a pas créé de run.',
|
|
31
|
-
},
|
|
15
|
+
queued_for_future_run: 'Request added to the queue — it will start automatically after the current run.',
|
|
16
|
+
control_run_started: 'A queued request is now starting.',
|
|
17
|
+
plan_patch_proposed: 'Plan patch proposed. Approve it explicitly to apply it to the active plan.',
|
|
18
|
+
ambiguous_control: 'A run is already active, and this looks like a new action. Say "queue it" to run it after the current run, "modify the run" to change the active plan, "cancel" to stop the current run first — or wait for it to finish.',
|
|
19
|
+
converse_while_running: 'Runtime run is still active. This message was treated as conversation and did not create a queued run.',
|
|
20
|
+
converse_while_idle: 'Runtime is idle. This message was treated as conversation and did not create a run.',
|
|
32
21
|
};
|
|
33
22
|
|
|
34
|
-
export function
|
|
35
|
-
const raw = String(session?.language ?? 'en').toLowerCase();
|
|
36
|
-
return raw.startsWith('fr') ? 'fr' : 'en';
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function controlMessage(session, key) {
|
|
23
|
+
export function controlMessage(_session, key) {
|
|
40
24
|
const entry = CONTROL_MESSAGES[key];
|
|
41
25
|
if (!entry) throw new Error(`Unknown control message key: ${key}`);
|
|
42
|
-
return entry
|
|
26
|
+
return entry;
|
|
43
27
|
}
|
|
@@ -1,21 +1,15 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import {
|
|
3
|
+
import { controlMessage } from './controlMessages.js';
|
|
4
4
|
|
|
5
|
-
test('
|
|
6
|
-
assert.
|
|
7
|
-
assert.
|
|
8
|
-
assert.
|
|
9
|
-
assert.equal(controlLanguage({ language: null }), 'en');
|
|
10
|
-
assert.equal(controlLanguage(null), 'en');
|
|
5
|
+
test('controlMessage returns the deterministic English acknowledgement regardless of locale', () => {
|
|
6
|
+
assert.match(controlMessage({ language: 'fr-FR' }, 'queued_for_future_run'), /added to the queue/);
|
|
7
|
+
assert.match(controlMessage({ language: 'es' }, 'queued_for_future_run'), /added to the queue/);
|
|
8
|
+
assert.match(controlMessage(null, 'queued_for_future_run'), /added to the queue/);
|
|
11
9
|
});
|
|
12
10
|
|
|
13
|
-
test('controlMessage
|
|
14
|
-
assert.match(controlMessage({ language: 'fr
|
|
15
|
-
assert.match(controlMessage({ language: '
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
test('controlMessage falls back to en for unknown locales and throws on unknown keys', () => {
|
|
19
|
-
assert.match(controlMessage({ language: 'de-DE' }, 'queued_for_future_run'), /added to the queue/);
|
|
11
|
+
test('controlMessage keeps every key in English and throws on unknown keys', () => {
|
|
12
|
+
assert.match(controlMessage({ language: 'fr' }, 'ambiguous_control'), /queue it/);
|
|
13
|
+
assert.match(controlMessage({ language: 'fr' }, 'converse_while_idle'), /treated as conversation/);
|
|
20
14
|
assert.throws(() => controlMessage({ language: 'fr-FR' }, 'nope'), /Unknown control message key/);
|
|
21
15
|
});
|
package/src/runtime/server.js
CHANGED
|
@@ -11,7 +11,8 @@ import { approvalClassForTask } from '../orchestrator/approvalPolicy.js';
|
|
|
11
11
|
import { matchSkillInvocation } from '../core/skillInvocation.js';
|
|
12
12
|
import { reconcileControlQueue } from './controlDrain.js';
|
|
13
13
|
import { cancelControlChain, cancelQueuedControlItem } from './controlCancellation.js';
|
|
14
|
-
import { runSkillChain } from './skillRun.js';
|
|
14
|
+
import { generateSkillAcknowledgment, runSkillChain } from './skillRun.js';
|
|
15
|
+
import { emitRuntimeLog } from './supervisor.js';
|
|
15
16
|
import { findSkill, listSkills } from '../core/skills.js';
|
|
16
17
|
|
|
17
18
|
const PRIVATE_CONTROL_INPUTS = new WeakMap();
|
|
@@ -347,7 +348,8 @@ export function startRuntimeServer({
|
|
|
347
348
|
if (skillMatch) {
|
|
348
349
|
try {
|
|
349
350
|
const result = await enqueueSkillInvocation(context, skillMatch);
|
|
350
|
-
|
|
351
|
+
const explanation = await generateSkillAcknowledgment(context.session, result);
|
|
352
|
+
sendJson(response, 202, { accepted: true, kind: 'skill_chain', explanation, ...result, ...controlStatus(context, store) });
|
|
351
353
|
} catch (err) {
|
|
352
354
|
const error = skillInvocationErrorMessage(err);
|
|
353
355
|
publishSkillInvocationFailure(context, input, error);
|
|
@@ -421,7 +423,8 @@ export function startRuntimeServer({
|
|
|
421
423
|
if (skillMatch) {
|
|
422
424
|
try {
|
|
423
425
|
const result = await enqueueSkillInvocation(context, skillMatch);
|
|
424
|
-
|
|
426
|
+
const explanation = await generateSkillAcknowledgment(context.session, result);
|
|
427
|
+
sendJson(response, 202, { accepted: true, kind: 'skill_chain', explanation, ...result, ...controlStatus(context, store) });
|
|
425
428
|
} catch (err) {
|
|
426
429
|
const error = skillInvocationErrorMessage(err);
|
|
427
430
|
publishSkillInvocationFailure(context, input, error);
|
|
@@ -778,7 +781,7 @@ export function startRuntimeServer({
|
|
|
778
781
|
return { killed: true, workspace: targetWorkspace, runId: targetRunId, runs, tasks, queued, ...(purged !== null ? { purged } : {}) };
|
|
779
782
|
}
|
|
780
783
|
|
|
781
|
-
function startRuntimeRun(context, body, { controlItemId = null, waitForPlan = false } = {}) {
|
|
784
|
+
function startRuntimeRun(context, body, { controlItemId = null, waitForPlan = false, announceLaunch = false } = {}) {
|
|
782
785
|
const runId = randomUUID();
|
|
783
786
|
const runWorkspace = context.workspace ?? body.workspace ?? null;
|
|
784
787
|
context.running = true;
|
|
@@ -801,6 +804,14 @@ export function startRuntimeServer({
|
|
|
801
804
|
workspace: runWorkspace,
|
|
802
805
|
payload: { id: controlItemId, runId },
|
|
803
806
|
}));
|
|
807
|
+
// A control item is now a live run: announce it in the conversation so the
|
|
808
|
+
// "queued" acknowledgement is closed out by a "starting" one. A task is a
|
|
809
|
+
// task whether it waited or not — it is about to go through approval — so
|
|
810
|
+
// this is not gated on having waited. Skill-chain steps are skipped: they
|
|
811
|
+
// already announced the whole skill at invocation.
|
|
812
|
+
if (announceLaunch) {
|
|
813
|
+
announceControlLaunch(context.session, body.publicInput ?? body.input, runWorkspace);
|
|
814
|
+
}
|
|
804
815
|
}
|
|
805
816
|
const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
|
|
806
817
|
runPromise
|
|
@@ -858,7 +869,7 @@ export function startRuntimeServer({
|
|
|
858
869
|
},
|
|
859
870
|
}
|
|
860
871
|
: {}),
|
|
861
|
-
}, { controlItemId: item.id }),
|
|
872
|
+
}, { controlItemId: item.id, announceLaunch: !item.chainId }),
|
|
862
873
|
skipItem: (item, reason) => {
|
|
863
874
|
privateControlInputsFor(context.session).delete(item.id);
|
|
864
875
|
emitControlSkipped(context, item, reason);
|
|
@@ -1089,7 +1100,11 @@ export function approvalRequestFromStatus(status) {
|
|
|
1089
1100
|
|
|
1090
1101
|
async function handleControlMessage(context, store, input, { intent = null, startNextControlRequest = () => false, cancel = null, approve = null } = {}) {
|
|
1091
1102
|
const status = controlStatus(context, store);
|
|
1092
|
-
const classification = classifyControlMessage(input, status,
|
|
1103
|
+
const classification = await classifyControlMessage(input, status, {
|
|
1104
|
+
forcedIntent: intent,
|
|
1105
|
+
llm: context?.session?.llm,
|
|
1106
|
+
session: context?.session,
|
|
1107
|
+
});
|
|
1093
1108
|
if (classification.kind === 'observe') {
|
|
1094
1109
|
return readOnlyControlResponse('observe', classification, status, explainControlState(status));
|
|
1095
1110
|
}
|
|
@@ -1127,6 +1142,7 @@ async function handleControlMessage(context, store, input, { intent = null, star
|
|
|
1127
1142
|
// startNextControlRequest), which can change running/plan/status — a full
|
|
1128
1143
|
// controlStatus() recompute is required here, not just controlQueue.
|
|
1129
1144
|
void startNextControlRequest(context);
|
|
1145
|
+
const explanation = await generateControlAcknowledgment(context?.session, { kind: 'queued', input });
|
|
1130
1146
|
return {
|
|
1131
1147
|
statusCode: 202,
|
|
1132
1148
|
body: {
|
|
@@ -1135,7 +1151,7 @@ async function handleControlMessage(context, store, input, { intent = null, star
|
|
|
1135
1151
|
classification,
|
|
1136
1152
|
item,
|
|
1137
1153
|
...controlStatus(context, store),
|
|
1138
|
-
explanation
|
|
1154
|
+
explanation,
|
|
1139
1155
|
},
|
|
1140
1156
|
};
|
|
1141
1157
|
}
|
|
@@ -1156,6 +1172,61 @@ async function handleControlMessage(context, store, input, { intent = null, star
|
|
|
1156
1172
|
: controlMessage(context?.session, 'converse_while_idle'));
|
|
1157
1173
|
}
|
|
1158
1174
|
|
|
1175
|
+
/*
|
|
1176
|
+
Control-lane acknowledgements are Donna's to localize.
|
|
1177
|
+
|
|
1178
|
+
The control lane stays deterministic in its CLASSIFICATION and its actions,
|
|
1179
|
+
but the acknowledgement the user reads ("queued, will run after this one" /
|
|
1180
|
+
"the queued task is starting") is a conversational reply: it goes through a
|
|
1181
|
+
single bounded LLM completion, like the skill-launch acknowledgement, and
|
|
1182
|
+
falls back to the deterministic English catalog when no LLM is configured or
|
|
1183
|
+
the call fails. The fallback is what keeps the lane deterministic-under-failure.
|
|
1184
|
+
*/
|
|
1185
|
+
async function generateControlAcknowledgment(session, { kind, input }) {
|
|
1186
|
+
const language = String(session?.language ?? '').trim().toLowerCase() || 'en';
|
|
1187
|
+
const llm = session?.llm;
|
|
1188
|
+
const fallback = kind === 'queued'
|
|
1189
|
+
? controlMessage(session, 'queued_for_future_run')
|
|
1190
|
+
: controlMessage(session, 'control_run_started');
|
|
1191
|
+
if (llm && typeof llm.complete === 'function') {
|
|
1192
|
+
try {
|
|
1193
|
+
const scenario = kind === 'queued'
|
|
1194
|
+
? 'The user requested a new task while a run is active. It was queued and will start automatically after the current run finishes.'
|
|
1195
|
+
: 'A task the user queued earlier is now starting.';
|
|
1196
|
+
const instruction = kind === 'queued'
|
|
1197
|
+
? 'their request is queued and will run after the current run finishes'
|
|
1198
|
+
: 'the queued task is now starting';
|
|
1199
|
+
const reply = await llm.complete({
|
|
1200
|
+
system: 'You are Donna, the workspace assistant. You acknowledge a runtime queue event in the user\'s language. Be concise: exactly one short sentence.',
|
|
1201
|
+
input: `${scenario}\n\nThe task is: ${input}\n\nWrite ONE short sentence in ${language} that tells the user ${instruction}. Return only that sentence, nothing else.`,
|
|
1202
|
+
signal: AbortSignal.timeout(8_000),
|
|
1203
|
+
});
|
|
1204
|
+
const text = String(reply ?? '').trim();
|
|
1205
|
+
if (text) return text;
|
|
1206
|
+
emitRuntimeLog(session, 'control-acknowledgment: LLM returned an empty reply, using the deterministic fallback');
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
// A degradation must announce itself: silently falling through here
|
|
1209
|
+
// hides the difference between "no LLM configured" (expected) and "the
|
|
1210
|
+
// configured LLM is failing every call" (a real problem) — both would
|
|
1211
|
+
// otherwise look identical from the Shell or serve UI.
|
|
1212
|
+
emitRuntimeLog(session, `control-acknowledgment: LLM call failed, using the deterministic fallback — ${err instanceof Error ? err.message : String(err)}`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
return fallback;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function announceControlLaunch(session, input, workspace) {
|
|
1219
|
+
void generateControlAcknowledgment(session, { kind: 'started', input })
|
|
1220
|
+
.then((content) => {
|
|
1221
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
1222
|
+
origin: 'runtime',
|
|
1223
|
+
workspace,
|
|
1224
|
+
payload: { content, independent: true },
|
|
1225
|
+
}));
|
|
1226
|
+
})
|
|
1227
|
+
.catch(() => {});
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1159
1230
|
/*
|
|
1160
1231
|
`skillStack` accompagne l'élément, il ne vit pas sur la session.
|
|
1161
1232
|
|
|
@@ -1351,13 +1422,15 @@ function rejectPlanPatch(context, store, patchId, reason) {
|
|
|
1351
1422
|
};
|
|
1352
1423
|
}
|
|
1353
1424
|
|
|
1354
|
-
//
|
|
1355
|
-
//
|
|
1356
|
-
//
|
|
1357
|
-
//
|
|
1358
|
-
//
|
|
1359
|
-
//
|
|
1360
|
-
|
|
1425
|
+
// Classifier for control §4.2 of the plan directeur. The plan expects an
|
|
1426
|
+
// LLM-backed classification — "the classification LLM se trompera" — and this
|
|
1427
|
+
// is that, now: the only deterministic matches left are the runtime's own
|
|
1428
|
+
// control verbs (cancel, an explicit "later/queue", status and plan-change
|
|
1429
|
+
// wording). Deciding "is this a NEW task to queue vs plain conversation" is a
|
|
1430
|
+
// semantic judgement about the workspace's domain, so it is never a keyword
|
|
1431
|
+
// list here — it goes to the model, bounded, and falls back to the choice menu
|
|
1432
|
+
// (`ambiguous`) rather than guessing when no model is available.
|
|
1433
|
+
async function classifyControlMessage(input, status, { forcedIntent = null, llm = null, session = null } = {}) {
|
|
1361
1434
|
// Caller (the /control message route) already trims and rejects empty input.
|
|
1362
1435
|
const lower = String(input ?? '').toLowerCase();
|
|
1363
1436
|
const intent = forcedIntent ? String(forcedIntent).toLowerCase() : null;
|
|
@@ -1375,22 +1448,53 @@ function classifyControlMessage(input, status, forcedIntent = null) {
|
|
|
1375
1448
|
if (explicit) {
|
|
1376
1449
|
return { kind: explicit, confidence: 1, reason: 'explicit_intent' };
|
|
1377
1450
|
}
|
|
1451
|
+
// Cancel stays a keyword: it is a runtime control verb, and an abort must not
|
|
1452
|
+
// wait on a model round-trip.
|
|
1378
1453
|
if (/\b(cancel|annule|stop|arr[eê]te|interromps|abort)\b/i.test(lower)) {
|
|
1379
1454
|
return { kind: 'cancel', confidence: 0.86, reason: 'cancel_request' };
|
|
1380
1455
|
}
|
|
1381
1456
|
if (/\b(plus tard|later|ensuite|apr[eè]s ce run|enqueue|mets en file|met en file|futur|next run|future run)\b/i.test(lower)) {
|
|
1382
1457
|
return { kind: 'enqueue_run', confidence: 0.8, reason: 'future_run_request' };
|
|
1383
1458
|
}
|
|
1384
|
-
if (/\b(o[uù] en es[t-]|status|statut|progress|progression|
|
|
1459
|
+
if (/\b(o[uù] en es[t-]|status|statut|progress|progression|logs?|explique|explain|inspect|show|montre|quoi de neuf)\b/i.test(lower)) {
|
|
1385
1460
|
return { kind: 'observe', confidence: 0.86, reason: 'status_or_explanation_request' };
|
|
1386
1461
|
}
|
|
1387
1462
|
if (status.running && /\b(ajoute|add|change|modifie|modify|remplace|replace|retire|remove|skip|ignore|apr[eè]s|before|after|chaque|each|plan|step|t[aâ]che)\b/i.test(lower)) {
|
|
1388
1463
|
return { kind: 'modify_run', confidence: 0.78, reason: 'active_run_change_request' };
|
|
1389
1464
|
}
|
|
1390
|
-
if (status.running
|
|
1391
|
-
|
|
1465
|
+
if (!status.running) return { kind: 'converse', confidence: 0.62, reason: 'plain_conversation' };
|
|
1466
|
+
// A run is active and none of the runtime control verbs matched. The message
|
|
1467
|
+
// is either a request to perform a NEW mutating task (→ queue it to run
|
|
1468
|
+
// after the current one) or ordinary conversation — that is a judgement about
|
|
1469
|
+
// the workspace's domain, so the model decides it, never a keyword list.
|
|
1470
|
+
if (llm && typeof llm.complete === 'function') {
|
|
1471
|
+
try {
|
|
1472
|
+
const reply = await llm.complete({
|
|
1473
|
+
system: 'You classify one user message typed while a run is already active. Return exactly one word, nothing else.',
|
|
1474
|
+
input: [
|
|
1475
|
+
`The user typed this while a run is active: "${input}"`,
|
|
1476
|
+
'',
|
|
1477
|
+
'Choose ONE of:',
|
|
1478
|
+
'- "action" — a request to perform a NEW task (generate, create, ingest, build, export, convert, send, publish, produce…), which must run after the current run.',
|
|
1479
|
+
'- "conversation" — ordinary conversation, a question, or an unrelated remark.',
|
|
1480
|
+
'',
|
|
1481
|
+
'Return only that one word.',
|
|
1482
|
+
].join('\n'),
|
|
1483
|
+
signal: AbortSignal.timeout(8_000),
|
|
1484
|
+
});
|
|
1485
|
+
const kind = String(reply ?? '').trim().toLowerCase();
|
|
1486
|
+
if (kind.startsWith('action')) return { kind: 'enqueue_run', confidence: 0.85, reason: 'llm_classified_action' };
|
|
1487
|
+
if (kind.startsWith('conversation')) return { kind: 'converse', confidence: 0.85, reason: 'llm_classified_conversation' };
|
|
1488
|
+
emitRuntimeLog(session, `control-classify: LLM returned an unrecognized reply, falling back to the choice menu — ${JSON.stringify(kind).slice(0, 200)}`);
|
|
1489
|
+
} catch (err) {
|
|
1490
|
+
// A degradation must announce itself: silently falling through here
|
|
1491
|
+
// hides the difference between "no LLM configured" (expected) and "the
|
|
1492
|
+
// configured LLM is failing every call" (a real problem) — both would
|
|
1493
|
+
// otherwise look identical from the Shell or serve UI.
|
|
1494
|
+
emitRuntimeLog(session, `control-classify: LLM call failed, falling back to the choice menu — ${err instanceof Error ? err.message : String(err)}`);
|
|
1495
|
+
}
|
|
1392
1496
|
}
|
|
1393
|
-
return { kind: '
|
|
1497
|
+
return { kind: 'ambiguous', confidence: 0.45, reason: 'action_vs_conversation_unclear' };
|
|
1394
1498
|
}
|
|
1395
1499
|
|
|
1396
1500
|
function isAuthorized(request, token) {
|
|
@@ -1434,10 +1434,14 @@ test('runtime server control message records active plan mutation as a proposal'
|
|
|
1434
1434
|
}
|
|
1435
1435
|
});
|
|
1436
1436
|
|
|
1437
|
-
test('runtime server
|
|
1437
|
+
test('runtime server auto-queues a clear new action while a run is active', async (t) => {
|
|
1438
1438
|
const session = {
|
|
1439
1439
|
workspace: 'acme',
|
|
1440
1440
|
controlQueue: [],
|
|
1441
|
+
_onAgentEvent: () => {},
|
|
1442
|
+
// The classifier asks the model whether the message is a new action; a
|
|
1443
|
+
// keyword list is deliberately not part of the code path.
|
|
1444
|
+
llm: { complete: async () => 'action' },
|
|
1441
1445
|
};
|
|
1442
1446
|
let runCount = 0;
|
|
1443
1447
|
let handle;
|
|
@@ -1451,6 +1455,7 @@ test('runtime server control message reports ambiguity without starting a run',
|
|
|
1451
1455
|
status: 'running',
|
|
1452
1456
|
plan: [{ step: 1, description: 'Generate', status: 'running' }],
|
|
1453
1457
|
queue: [],
|
|
1458
|
+
controlQueue: session.controlQueue,
|
|
1454
1459
|
approvals: [],
|
|
1455
1460
|
summary: null,
|
|
1456
1461
|
}),
|
|
@@ -1478,12 +1483,11 @@ test('runtime server control message reports ambiguity without starting a run',
|
|
|
1478
1483
|
headers: { 'Content-Type': 'application/json' },
|
|
1479
1484
|
body: JSON.stringify({ action: 'message', input: 'Lance aussi la publication' }),
|
|
1480
1485
|
});
|
|
1481
|
-
assert.equal(response.status,
|
|
1486
|
+
assert.equal(response.status, 202);
|
|
1482
1487
|
const body = await response.json();
|
|
1483
|
-
assert.equal(body.kind, '
|
|
1484
|
-
assert.equal(body.
|
|
1485
|
-
assert.equal(
|
|
1486
|
-
assert.equal(runCount, 0);
|
|
1488
|
+
assert.equal(body.kind, 'enqueue_run');
|
|
1489
|
+
assert.equal(body.item.status, 'queued');
|
|
1490
|
+
assert.equal(runCount, 0, 'the queued task must not start while the current run is active');
|
|
1487
1491
|
} finally {
|
|
1488
1492
|
await handle.close();
|
|
1489
1493
|
}
|
|
@@ -1545,7 +1549,7 @@ test('runtime server drains queued control requests when idle', async (t) => {
|
|
|
1545
1549
|
assert.match(receivedBody.runId, /^[0-9a-f-]{36}$/);
|
|
1546
1550
|
assert.equal(session.controlQueue[0].status, 'running');
|
|
1547
1551
|
assert.equal(session.controlQueue[0].runId, receivedBody.runId);
|
|
1548
|
-
assert.deepEqual(events.map((event) => event.type), ['control_enqueued', 'control_started']);
|
|
1552
|
+
assert.deepEqual(events.map((event) => event.type), ['control_enqueued', 'control_started', 'assistant_message']);
|
|
1549
1553
|
} finally {
|
|
1550
1554
|
await handle.close();
|
|
1551
1555
|
}
|
|
@@ -142,7 +142,7 @@ test('E2E-002 wiki-sync: two objectives, two ordered runs, one chainId', async (
|
|
|
142
142
|
assert.equal(body.objectives, 2);
|
|
143
143
|
assert.equal(env.runs.length, 2, 'the second objective must run after the first');
|
|
144
144
|
assert.match(env.runs[0].input, /^Export the requested Confluence source/);
|
|
145
|
-
assert.match(env.runs[1].input, /^
|
|
145
|
+
assert.match(env.runs[1].input, /^Run the production pipeline over the newly exported Markdown/);
|
|
146
146
|
// CME first, Production second — and the parameter reaches the step that
|
|
147
147
|
// consumes it, not only the last objective.
|
|
148
148
|
for (const run of env.runs) assert.match(run.input, /User parameters:\nsource: docs/);
|
|
@@ -202,7 +202,7 @@ test('E2E-003 cancel: the running step and its chain stop, unrelated queue survi
|
|
|
202
202
|
// that silently fragments would show up as extra runs, not as extra objectives.
|
|
203
203
|
const PERFORMANCE_TABLE = {
|
|
204
204
|
pipeline: 1,
|
|
205
|
-
'wiki-ingest':
|
|
205
|
+
'wiki-ingest': 2,
|
|
206
206
|
'wiki-build': 1,
|
|
207
207
|
deliver: 1,
|
|
208
208
|
diagnose: 1,
|
package/src/runtime/skillRun.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { applyLegacySkillPlaceholders, parseSkillArguments } from '../core/skillInvocation.js';
|
|
3
3
|
import { compileSkillObjectives, createSkillCompilerFallback } from '../core/skillCompiler.js';
|
|
4
|
+
import { emitRuntimeLog } from './supervisor.js';
|
|
4
5
|
|
|
5
6
|
const SKILL_PARAM_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
6
7
|
const DANGEROUS_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
|
@@ -86,6 +87,7 @@ export async function runSkillChain(context, skill, {
|
|
|
86
87
|
skill: skill.name,
|
|
87
88
|
objectives: objectives.length,
|
|
88
89
|
items,
|
|
90
|
+
publicInput,
|
|
89
91
|
deprecatedPlaceholders: legacy.deprecatedPlaceholders,
|
|
90
92
|
};
|
|
91
93
|
}
|
|
@@ -97,6 +99,48 @@ export function formatPublicSkillInvocation(name, args = {}) {
|
|
|
97
99
|
return `/${String(name ?? '').trim()}${values.length ? ` ${values.join(' ')}` : ''}`;
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Donna's launch acknowledgement for an executable skill.
|
|
104
|
+
*
|
|
105
|
+
* The reply is user-facing and echoes the caller's own arguments (a source, a
|
|
106
|
+
* file, a deliverable), so it must be generated by Donna in the session's
|
|
107
|
+
* configured language — a hardcoded fr/en catalog cannot cover every language
|
|
108
|
+
* and would drop the very parameters the user asked for. This is the single
|
|
109
|
+
* place that turns a compiled skill chain into a conversational reply; the
|
|
110
|
+
* browser and the shell both read the resulting `explanation`, never a
|
|
111
|
+
* language-specific string of their own.
|
|
112
|
+
*
|
|
113
|
+
* Degrades honestly: without an LLM client (no `.wikirc` LLM configured) there
|
|
114
|
+
* is nothing to localize with, so it falls back to a neutral, language-free
|
|
115
|
+
* acknowledgement instead of guessing a language.
|
|
116
|
+
*/
|
|
117
|
+
export async function generateSkillAcknowledgment(session, { publicInput, objectives }) {
|
|
118
|
+
const count = Number(objectives) || 1;
|
|
119
|
+
const language = String(session?.language ?? '').trim().toLowerCase() || 'en';
|
|
120
|
+
const llm = session?.llm;
|
|
121
|
+
if (llm && typeof llm.complete === 'function') {
|
|
122
|
+
try {
|
|
123
|
+
// Same bound as compileSkillObjectives' llmFallback above: a hung or
|
|
124
|
+
// slow provider must not block the skill-launch HTTP response forever.
|
|
125
|
+
const reply = await llm.complete({
|
|
126
|
+
system: 'You are Donna, the workspace assistant. You acknowledge a launched workflow in the user\'s language. Be concise: exactly one short sentence.',
|
|
127
|
+
input: `The user just launched the workspace skill ${publicInput}. It was compiled into ${count} step(s) and is now running.\n\nWrite ONE short sentence in ${language} that confirms the launch, echoes the skill and its arguments, and says progress will be reported. Return only that sentence, nothing else.`,
|
|
128
|
+
signal: AbortSignal.timeout(8_000),
|
|
129
|
+
});
|
|
130
|
+
const text = String(reply ?? '').trim();
|
|
131
|
+
if (text) return text;
|
|
132
|
+
emitRuntimeLog(session, 'skill-acknowledgment: LLM returned an empty reply, using the neutral fallback');
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// A degradation must announce itself: silently falling through here
|
|
135
|
+
// hides the difference between "no LLM configured" (expected) and "the
|
|
136
|
+
// configured LLM is failing every call" (a real problem) — both would
|
|
137
|
+
// otherwise look identical from the Shell or serve UI.
|
|
138
|
+
emitRuntimeLog(session, `skill-acknowledgment: LLM call failed, using the neutral fallback — ${err instanceof Error ? err.message : String(err)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return `Started ${publicInput} — ${count} step(s) in progress.`;
|
|
142
|
+
}
|
|
143
|
+
|
|
100
144
|
function argumentError(message) {
|
|
101
145
|
const error = new Error(message);
|
|
102
146
|
error.code = 'skill_arguments_invalid';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
|
-
import { formatPublicSkillInvocation, runSkillChain, validateNamedSkillArguments } from './skillRun.js';
|
|
3
|
+
import { formatPublicSkillInvocation, generateSkillAcknowledgment, runSkillChain, validateNamedSkillArguments } from './skillRun.js';
|
|
4
4
|
|
|
5
5
|
const skill = { name: 'deliver', params: ['deliverable', 'polish'], body: 'Deliver the requested output.' };
|
|
6
6
|
|
|
@@ -82,3 +82,38 @@ test('runSkillChain starts a fresh stack for a top-level invocation', async () =
|
|
|
82
82
|
|
|
83
83
|
assert.deepEqual(queued[0].skillStack, ['deliver']);
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
test('generateSkillAcknowledgment asks Donna in the session language and echoes the invocation', async () => {
|
|
87
|
+
const calls = [];
|
|
88
|
+
const session = {
|
|
89
|
+
language: 'es',
|
|
90
|
+
llm: { complete: async (request) => { calls.push(request); return 'Lanzado /deliver deliverable="Informe" — 1 paso en cola.'; } },
|
|
91
|
+
};
|
|
92
|
+
const reply = await generateSkillAcknowledgment(session, { publicInput: '/deliver deliverable="Informe"', objectives: 1 });
|
|
93
|
+
assert.equal(reply, 'Lanzado /deliver deliverable="Informe" — 1 paso en cola.');
|
|
94
|
+
assert.equal(calls.length, 1);
|
|
95
|
+
assert.match(calls[0].input, /es/);
|
|
96
|
+
assert.match(calls[0].input, /\/deliver deliverable="Informe"/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('generateSkillAcknowledgment degrades to a neutral message without an LLM client', async () => {
|
|
100
|
+
const reply = await generateSkillAcknowledgment({ language: 'fr' }, { publicInput: '/wiki-ingest docs', objectives: 2 });
|
|
101
|
+
assert.equal(reply, 'Started /wiki-ingest docs — 2 step(s) in progress.');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('generateSkillAcknowledgment falls back when the LLM call fails', async () => {
|
|
105
|
+
const session = { language: 'en', llm: { complete: async () => { throw new Error('down'); } } };
|
|
106
|
+
const reply = await generateSkillAcknowledgment(session, { publicInput: '/deliver', objectives: 1 });
|
|
107
|
+
assert.equal(reply, 'Started /deliver — 1 step(s) in progress.');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('generateSkillAcknowledgment announces an LLM failure instead of degrading silently', async () => {
|
|
111
|
+
// A degradation must announce itself: falling back to the neutral message
|
|
112
|
+
// with no trace anywhere makes "LLM unconfigured" (expected) and "LLM
|
|
113
|
+
// failing every call" (a real problem) look identical in the UI.
|
|
114
|
+
const session = { language: 'en', llm: { complete: async () => { throw new Error('provider timeout'); } } };
|
|
115
|
+
await generateSkillAcknowledgment(session, { publicInput: '/deliver', objectives: 1 });
|
|
116
|
+
const runtimeLogs = (session.agentEvents ?? []).filter((event) => event.type === 'runtime_log');
|
|
117
|
+
assert.equal(runtimeLogs.length, 1);
|
|
118
|
+
assert.match(runtimeLogs[0].payload.detail ?? runtimeLogs[0].payload.message, /provider timeout/);
|
|
119
|
+
});
|
package/src/shell/LeftPane.tsx
CHANGED
|
@@ -859,7 +859,7 @@ export function LeftPane(props: {
|
|
|
859
859
|
above the composer keeps the current job in view while composing; the
|
|
860
860
|
right pane keeps the full Plan/Queue/Logs detail.
|
|
861
861
|
*/}
|
|
862
|
-
<box flexShrink={0} height={4} flexDirection="column" overflow="hidden">
|
|
862
|
+
<box flexShrink={0} height={4} flexDirection="column" overflow="hidden" backgroundColor="#111318">
|
|
863
863
|
<ActivityPanel activities={props.activities} width={props.width - 2} />
|
|
864
864
|
</box>
|
|
865
865
|
<ChatInput
|
package/src/shell/RightPane.tsx
CHANGED
|
@@ -20,7 +20,10 @@ type LogLineParts = { time: string | null; message: string };
|
|
|
20
20
|
// 4 slots (was 6): items can now span up to 5 lines each (wrapped label +
|
|
21
21
|
// wrapped status/error), so fewer, readable entries beat more, truncated ones.
|
|
22
22
|
const ACTIVITY_SLOTS = Array.from({ length: 4 }, (_, index) => index);
|
|
23
|
-
|
|
23
|
+
// Hauteur du panneau Plan : 6 lignes visibles au maximum. Les etapes suivantes
|
|
24
|
+
// restent atteignables en faisant defiler la scrollbox (barre de defilement
|
|
25
|
+
// affichee des que le plan depasse la fenetre).
|
|
26
|
+
const PLAN_VIEWPORT_ROWS = 6;
|
|
24
27
|
|
|
25
28
|
function wrapLine(value: string, width: number) {
|
|
26
29
|
const max = Math.max(8, width);
|
|
@@ -194,12 +197,10 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
|
|
|
194
197
|
// Keep one column for the native vertical scrollbar when the plan is long.
|
|
195
198
|
const lineWidth = () => Math.max(8, props.width - 3);
|
|
196
199
|
const firstPending = () => props.plan.find((s) => s.status === 'pending')?.step ?? null;
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
const isRunningStep = (step: PlanStep) => String(step.status ?? '').toLowerCase() === 'running';
|
|
202
|
-
const stepTextWidth = (step: PlanStep) => lineWidth() - (isRunningStep(step) ? 1 : 0);
|
|
200
|
+
// Plus de liseré bleu à gauche : l'icône et la couleur du texte suffisent à
|
|
201
|
+
// distinguer une étape en cours, et le cadre se voyait aussi sur une étape en
|
|
202
|
+
// attente (jaune). Toutes les étapes utilisent donc la même largeur.
|
|
203
|
+
const stepTextWidth = (_step: PlanStep) => lineWidth();
|
|
203
204
|
const icon = (rawStatus: string) => {
|
|
204
205
|
const status = String(rawStatus ?? '').toLowerCase();
|
|
205
206
|
if (DONE_STATUSES.includes(status)) return '[✓]';
|
|
@@ -241,11 +242,10 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
|
|
|
241
242
|
{(step) => {
|
|
242
243
|
// Wrap step descriptions over up to 2 lines instead of truncating —
|
|
243
244
|
// "Ingest des 39 documents raw/untrac…" hid the actual target.
|
|
244
|
-
const running = () => isRunningStep(step());
|
|
245
245
|
const textWidth = () => stepTextWidth(step());
|
|
246
246
|
const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, textWidth()).slice(0, 2);
|
|
247
247
|
return (
|
|
248
|
-
<box flexShrink={0} flexDirection="column"
|
|
248
|
+
<box flexShrink={0} flexDirection="column">
|
|
249
249
|
<text width={textWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
|
|
250
250
|
<Show when={lines()[1]}>
|
|
251
251
|
<text width={textWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, textWidth() - 4))}`} />
|
|
@@ -265,7 +265,7 @@ export function ActivityPanel(props: { activities: any[]; width: number }) {
|
|
|
265
265
|
const visibleSlots = () => visible().map((_activity, index) => index);
|
|
266
266
|
const activityAt = (index: number) => visible()[index] ?? null;
|
|
267
267
|
return (
|
|
268
|
-
<box flexShrink={0} flexDirection="column" paddingX={1}>
|
|
268
|
+
<box flexShrink={0} flexDirection="column" paddingX={1} backgroundColor="#111318">
|
|
269
269
|
<text width={lineWidth()} fg="#D6DEE8" content="Activity" />
|
|
270
270
|
<Show when={visible().length > 0} fallback={<text width={lineWidth()} fg="#7F8C8D" content="no active jobs" />}>
|
|
271
271
|
<Index each={visibleSlots()}>
|
|
@@ -385,7 +385,7 @@ export function LogPanel(props: { logs: string[]; width: number; filter?: string
|
|
|
385
385
|
.filter((line) => activeLogTab() === 'agent-status' ? isAgentStatus(line) : !isAgentStatus(line));
|
|
386
386
|
const allLines = createMemo(() => logRenderLines(filteredLogs(), lineWidth()));
|
|
387
387
|
return (
|
|
388
|
-
<box flexGrow={2} flexDirection="column" paddingX={1} focusable={false}>
|
|
388
|
+
<box flexGrow={2} flexDirection="column" paddingX={1} marginTop={6} focusable={false}>
|
|
389
389
|
<text width={lineWidth()} fg="#4B5563" content={'─'.repeat(lineWidth())} />
|
|
390
390
|
<box height={1} flexDirection="row">
|
|
391
391
|
<text
|
package/src/shell/repl.js
CHANGED
|
@@ -1110,19 +1110,23 @@ export function shouldHandleFreeTextLocally(_line, session, { llmAvailable = Boo
|
|
|
1110
1110
|
return { local: true, classification };
|
|
1111
1111
|
}
|
|
1112
1112
|
|
|
1113
|
-
// Shared by
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1113
|
+
// Shared by every caller that turns a submitRuntimeRun()/submitRuntimeTurn()
|
|
1114
|
+
// outcome into the conversation message(s) it produces and a short log line —
|
|
1115
|
+
// the legacy TTY shell (runLine, the interactive free-text path, /approve's
|
|
1116
|
+
// ambiguous-choice resubmission below) and the OpenTUI shell (useAgent.ts) —
|
|
1116
1117
|
// so the classification → message mapping isn't duplicated per call site.
|
|
1117
|
-
|
|
1118
|
+
// 'turn' (submitRuntimeTurn's default kind for a plain accepted turn) is
|
|
1119
|
+
// treated the same as submitRuntimeRun's 'accepted': the actual reply arrives
|
|
1120
|
+
// separately over the event stream, this call only logs.
|
|
1121
|
+
export function applyRuntimeOutcome(session, outcome, onLog, {
|
|
1118
1122
|
ambiguousFallback = 'Runtime could not classify that message.',
|
|
1119
1123
|
} = {}) {
|
|
1120
|
-
if (outcome.kind === 'accepted') {
|
|
1124
|
+
if (outcome.kind === 'accepted' || outcome.kind === 'turn') {
|
|
1121
1125
|
onLog('runtime: run accepted');
|
|
1122
|
-
} else if (outcome.kind === 'queued') {
|
|
1126
|
+
} else if (outcome.kind === 'queued' || outcome.kind === 'enqueue_run') {
|
|
1123
1127
|
conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Request added to the queue.') });
|
|
1124
1128
|
onLog('runtime: control queued');
|
|
1125
|
-
} else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === '
|
|
1129
|
+
} else if (outcome.kind === 'observe' || outcome.kind === 'converse' || outcome.kind === 'modify_run') {
|
|
1126
1130
|
conversationMessages(session).push({ role: 'command', content: String(outcome.result?.explanation ?? 'Runtime control message accepted.') });
|
|
1127
1131
|
onLog(`runtime: ${outcome.kind}`);
|
|
1128
1132
|
} else if (outcome.kind === 'ambiguous') {
|
package/src/shell/useAgent.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createSignal } from 'solid-js';
|
|
2
2
|
import { postRuntimeCancel } from '../runtime/client.js';
|
|
3
|
-
import { conversationMessages, recordRuntimeUnavailableAgentInput, runLine, shouldHandleFreeTextLocally, submitRuntimeTurn } from './repl.js';
|
|
3
|
+
import { applyRuntimeOutcome, conversationMessages, recordRuntimeUnavailableAgentInput, runLine, shouldHandleFreeTextLocally, submitRuntimeTurn } from './repl.js';
|
|
4
4
|
|
|
5
5
|
export function useAgent(props: { agent: unknown; packageJson: Record<string, unknown>; session: Record<string, any>; chatMode: () => boolean; runtimeUrl?: string | null; runtimeUnavailableReason?: string | null; refresh: () => void; addLog: (line: string) => void; onRuntimeAccepted?: () => void }) {
|
|
6
6
|
const [busy, setBusy] = createSignal(false);
|
|
@@ -41,24 +41,12 @@ export function useAgent(props: { agent: unknown; packageJson: Record<string, un
|
|
|
41
41
|
runtime: { url: props.runtimeUrl },
|
|
42
42
|
session: props.session,
|
|
43
43
|
});
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const explanation = (outcome as any).result?.explanation ?? 'Request added to the queue.';
|
|
51
|
-
conversationMessages(props.session).push({ role: 'command', content: String(explanation) });
|
|
52
|
-
props.addLog('runtime: control queued');
|
|
53
|
-
} else if ((outcome as any).result?.explanation) {
|
|
54
|
-
// Control-lane kinds (cancel / approve / observe / modify_run…):
|
|
55
|
-
// surface the server's localized explanation instead of an error.
|
|
56
|
-
conversationMessages(props.session).push({ role: 'command', content: String((outcome as any).result.explanation) });
|
|
57
|
-
props.addLog(`runtime: ${outcome.kind}`);
|
|
58
|
-
} else {
|
|
59
|
-
conversationMessages(props.session).push({ role: 'command', content: `Runtime error: ${outcome.message}` });
|
|
60
|
-
props.addLog(`runtime error: ${outcome.message}`);
|
|
61
|
-
}
|
|
44
|
+
// Same classification → message mapping as the legacy TTY shell —
|
|
45
|
+
// see applyRuntimeOutcome in repl.js. Control-lane acknowledgements
|
|
46
|
+
// (src/runtime/controlMessages.js) are deterministic and English-only
|
|
47
|
+
// by design, never localized; this only supplies the last-resort
|
|
48
|
+
// fallback text for a response that carries neither.
|
|
49
|
+
applyRuntimeOutcome(props.session, outcome, props.addLog);
|
|
62
50
|
props.refresh();
|
|
63
51
|
return { exit: false, runtime: true };
|
|
64
52
|
}
|