@dotdrelle/wiki-manager 0.15.59 → 0.15.60
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/orchestrator/objectiveResolver.js +21 -4
- package/src/orchestrator/objectiveResolver.test.js +64 -2
- package/src/runtime/controlMessages.js +15 -32
- package/src/runtime/controlMessages.test.js +8 -14
- package/src/runtime/server.js +5 -3
- package/src/runtime/skillChain.e2e.test.js +1 -1
- package/src/runtime/skillRun.js +44 -0
- package/src/runtime/skillRun.test.js +36 -1
- 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.60';
|
|
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;
|
|
@@ -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,26 @@
|
|
|
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
|
-
en: 'Plan patch proposed. Approve it explicitly to apply it to the active plan.',
|
|
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
|
+
plan_patch_proposed: 'Plan patch proposed. Approve it explicitly to apply it to the active plan.',
|
|
17
|
+
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.',
|
|
18
|
+
converse_while_running: 'Runtime run is still active. This message was treated as conversation and did not create a queued run.',
|
|
19
|
+
converse_while_idle: 'Runtime is idle. This message was treated as conversation and did not create a run.',
|
|
32
20
|
};
|
|
33
21
|
|
|
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) {
|
|
22
|
+
export function controlMessage(_session, key) {
|
|
40
23
|
const entry = CONTROL_MESSAGES[key];
|
|
41
24
|
if (!entry) throw new Error(`Unknown control message key: ${key}`);
|
|
42
|
-
return entry
|
|
25
|
+
return entry;
|
|
43
26
|
}
|
|
@@ -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,7 @@ 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
15
|
import { findSkill, listSkills } from '../core/skills.js';
|
|
16
16
|
|
|
17
17
|
const PRIVATE_CONTROL_INPUTS = new WeakMap();
|
|
@@ -347,7 +347,8 @@ export function startRuntimeServer({
|
|
|
347
347
|
if (skillMatch) {
|
|
348
348
|
try {
|
|
349
349
|
const result = await enqueueSkillInvocation(context, skillMatch);
|
|
350
|
-
|
|
350
|
+
const explanation = await generateSkillAcknowledgment(context.session, result);
|
|
351
|
+
sendJson(response, 202, { accepted: true, kind: 'skill_chain', explanation, ...result, ...controlStatus(context, store) });
|
|
351
352
|
} catch (err) {
|
|
352
353
|
const error = skillInvocationErrorMessage(err);
|
|
353
354
|
publishSkillInvocationFailure(context, input, error);
|
|
@@ -421,7 +422,8 @@ export function startRuntimeServer({
|
|
|
421
422
|
if (skillMatch) {
|
|
422
423
|
try {
|
|
423
424
|
const result = await enqueueSkillInvocation(context, skillMatch);
|
|
424
|
-
|
|
425
|
+
const explanation = await generateSkillAcknowledgment(context.session, result);
|
|
426
|
+
sendJson(response, 202, { accepted: true, kind: 'skill_chain', explanation, ...result, ...controlStatus(context, store) });
|
|
425
427
|
} catch (err) {
|
|
426
428
|
const error = skillInvocationErrorMessage(err);
|
|
427
429
|
publishSkillInvocationFailure(context, input, error);
|
|
@@ -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/);
|
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/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
|
}
|