@dotdrelle/wiki-manager 0.15.60 → 0.15.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/agent/graph.js +28 -2
- package/src/agent/skillRecursion.test.js +77 -0
- package/src/cli/wiki-manager.js +2 -2
- package/src/core/activity.js +60 -10
- package/src/core/activity.test.js +59 -1
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +1 -1
- package/src/core/skillChainView.js +14 -1
- package/src/core/skillChainView.test.js +13 -1
- package/src/core/skillCompiler.test.js +1 -1
- package/src/core/skillInvocation.js +48 -0
- package/src/runtime/controlMessages.js +1 -0
- package/src/runtime/server.js +117 -15
- package/src/runtime/server.test.js +11 -7
- package/src/runtime/skillChain.e2e.test.js +1 -1
- package/src/runtime/supervisor.js +3 -3
- package/src/shell/LeftPane.tsx +1 -1
- package/src/shell/RightPane.tsx +21 -15
- package/src/shell/repl.js +2 -2
- package/src/shell/useSession.ts +4 -4
package/README.md
CHANGED
|
@@ -401,6 +401,7 @@ answer "what is this and how do I start it", and stop there.
|
|
|
401
401
|
| [`docs/usage.md`](docs/usage.md) | The four ways to run wikiLLM, and how to configure the external agents |
|
|
402
402
|
| [`docs/configuration.md`](docs/configuration.md) | Every configuration key: root `.env`, Compose overrides, `mcp.endpoints.json`, workspace `.env`, `.wikirc.yaml`, parallelism |
|
|
403
403
|
| [`docs/technical-reference.md`](docs/technical-reference.md) | Workspace model, services, the `donna` shell, agent tooling, orchestration and activity contracts, security model |
|
|
404
|
+
| [`docs/authoring-skills.md`](docs/authoring-skills.md) | Writing a workspace skill: what splits a body into runs, chains, concurrency, parameters, and the interpretation rules |
|
|
404
405
|
| [`docs/claude-desktop.md`](docs/claude-desktop.md) | Using a workspace from Claude Desktop |
|
|
405
406
|
| [`CLAUDE.md`](CLAUDE.md) | Repository guidance: invariants to preserve when changing this code |
|
|
406
407
|
|
package/package.json
CHANGED
package/src/agent/graph.js
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
truncateToolResult,
|
|
20
20
|
} from '../core/mcp.js';
|
|
21
21
|
import { findSkill, formatSkillsForAgent } from '../core/skills.js';
|
|
22
|
-
import { RESERVED_SLASH_COMMANDS, explicitSkillReference } from '../core/skillInvocation.js';
|
|
22
|
+
import { RESERVED_SLASH_COMMANDS, explicitSkillReference, objectiveNamesSkill } from '../core/skillInvocation.js';
|
|
23
23
|
import { handleSlashCommand } from '../commands/slash.js';
|
|
24
24
|
import { extractActivity, formatActivitySummary, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
25
25
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
@@ -947,6 +947,32 @@ export async function handleRuntimeControlTool(session, tool, args = {}) {
|
|
|
947
947
|
message: `Skill "${skillName}" is already running in this chain: execute its objective directly instead of re-invoking it.`,
|
|
948
948
|
});
|
|
949
949
|
}
|
|
950
|
+
/*
|
|
951
|
+
Depuis une intention compilée, une compétence se lance par son NOM, pas
|
|
952
|
+
par ressemblance.
|
|
953
|
+
|
|
954
|
+
La garde de cycle ci-dessus ne voit que les répétitions. Elle laissait
|
|
955
|
+
donc passer la cascade réellement observée sur un `/wiki-ingest` :
|
|
956
|
+
l'intention n°2 du corps est mot pour mot celui de
|
|
957
|
+
`/wiki-rebuild-concepts`, dont l'intention décrit à son tour
|
|
958
|
+
`/wiki-reclassify`, puis `/wiki-taxonomy`. Trois compétences distinctes,
|
|
959
|
+
aucun cycle, et la grille de concepts comme la taxonomie reconstruites
|
|
960
|
+
plusieurs fois pour une seule demande.
|
|
961
|
+
|
|
962
|
+
Une intention compilée EST déjà le travail à faire : elle se délègue.
|
|
963
|
+
La composition explicite reste ouverte — un corps qui nomme sa cible dit
|
|
964
|
+
ce qu'il veut ; une intention qui se contente de la décrire ne le dit
|
|
965
|
+
pas.
|
|
966
|
+
*/
|
|
967
|
+
if (skillStack.length > 0 && !objectiveNamesSkill(args._userInput, skillName)) {
|
|
968
|
+
return JSON.stringify({
|
|
969
|
+
ok: false,
|
|
970
|
+
terminal: true,
|
|
971
|
+
code: 'nested_skill_match_blocked',
|
|
972
|
+
skillStack,
|
|
973
|
+
message: `The current objective does not name skill "${skillName}"; it only describes what that skill does. Execute the objective yourself with runtime__delegate instead of re-routing it to another skill.`,
|
|
974
|
+
});
|
|
975
|
+
}
|
|
950
976
|
if (skillStack.length >= MAX_SKILL_DEPTH) {
|
|
951
977
|
return JSON.stringify({
|
|
952
978
|
ok: false,
|
|
@@ -1184,7 +1210,7 @@ export function buildAgentSystemPrompt(state) {
|
|
|
1184
1210
|
skills,
|
|
1185
1211
|
'</skill_catalog>',
|
|
1186
1212
|
runningSkillStack.length > 0
|
|
1187
|
-
? `You are already executing the compiled objective of workspace skill ${JSON.stringify(runningSkillStack.at(-1))}. The current user message IS that objective: execute it directly${runningSkillExecution === 'direct' ? ' and stop after its requested direct mutation; delegation and nested skills are forbidden for this workflow' : ' by delegating it with runtime__delegate (or a matching direct tool)'}. Do not select or call that skill again, with or without a leading slash — the runtime refuses the re-invocation with skill_recursion_blocked, and that refusal means act on the objective yourself, not report an error. A skill run is not successful until its requested mutation has an affirmative tool result; never infer success from the runtime merely becoming idle or done, and never end the run with an empty reply or a bare "{}".`
|
|
1213
|
+
? `You are already executing the compiled objective of workspace skill ${JSON.stringify(runningSkillStack.at(-1))}. The current user message IS that objective: execute it directly${runningSkillExecution === 'direct' ? ' and stop after its requested direct mutation; delegation and nested skills are forbidden for this workflow' : ' by delegating it with runtime__delegate (or a matching direct tool)'}. Do not select or call that skill again, with or without a leading slash — the runtime refuses the re-invocation with skill_recursion_blocked, and that refusal means act on the objective yourself, not report an error. Do not select ANY OTHER skill by description either: an objective necessarily reads like the description of the neighbouring skill that performs it, and re-routing it there re-runs work this objective already covers. From inside a compiled objective, runtime__run_skill is only for a skill the objective names explicitly; everything else is delegated with runtime__delegate. A skill run is not successful until its requested mutation has an affirmative tool result; never infer success from the runtime merely becoming idle or done, and never end the run with an empty reply or a bare "{}".`
|
|
1188
1214
|
: null,
|
|
1189
1215
|
'In interactive agent mode, call only tools actually provided to you. Any directly offered tool stays direct; never substitute an orchestration-contract tool yourself.',
|
|
1190
1216
|
'When the user asks for an action that can be performed with connected MCP tools or safe primitives, do not answer with future intent such as "I will call...", "I am going to run...", or "launching..." unless you also call the tool in the same turn. Either call the tool now, ask for the exact missing required arguments, or explain the concrete blocker.',
|
|
@@ -96,3 +96,80 @@ test('borne la profondeur même sans cycle', async () => {
|
|
|
96
96
|
|
|
97
97
|
assert.equal(result.code, 'skill_depth_exceeded');
|
|
98
98
|
});
|
|
99
|
+
|
|
100
|
+
/*
|
|
101
|
+
Cascade observée sur un `/wiki-ingest` : deux `/wiki-rebuild-concepts`, puis
|
|
102
|
+
`/wiki-reclassify`, puis `/wiki-taxonomy`. Aucun cycle — trois compétences
|
|
103
|
+
distinctes — mais la grille de concepts et la taxonomie reconstruites
|
|
104
|
+
plusieurs fois pour une seule demande. La deuxième intention compilée de
|
|
105
|
+
`/wiki-ingest` est mot pour mot le corps de `/wiki-rebuild-concepts` : le
|
|
106
|
+
sélecteur par description la reconnaissait légitimement.
|
|
107
|
+
*/
|
|
108
|
+
const callWithObjective = (state, skillName, objective) =>
|
|
109
|
+
handleRuntimeControlTool(state, 'run_skill', { skillName, _userInput: objective })
|
|
110
|
+
.then((raw) => JSON.parse(raw));
|
|
111
|
+
|
|
112
|
+
test('refuse une compétence voisine que l’intention décrit sans la nommer', async () => {
|
|
113
|
+
const objective = 'Run the production pipeline steps concepts, reclassify-concepts and taxonomy, in that order.';
|
|
114
|
+
const ran = [];
|
|
115
|
+
const result = await callWithObjective(session(['wiki-ingest'], ran), 'wiki-rebuild-concepts', objective);
|
|
116
|
+
|
|
117
|
+
assert.equal(result.ok, false);
|
|
118
|
+
assert.equal(result.code, 'nested_skill_match_blocked');
|
|
119
|
+
assert.deepEqual(ran, [], 'un refus ne doit lancer aucun travail');
|
|
120
|
+
assert.match(result.message, /runtime__delegate/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('laisse passer la compétence que l’intention nomme explicitement', async () => {
|
|
124
|
+
const ran = [];
|
|
125
|
+
const result = await callWithObjective(session(['wiki-sync'], ran), 'deliver', 'Then run /deliver on the produced report.');
|
|
126
|
+
|
|
127
|
+
assert.equal(result.ok, true);
|
|
128
|
+
assert.deepEqual(ran, ['deliver']);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('ne confond pas un nom de compétence avec son préfixe', async () => {
|
|
132
|
+
const result = await callWithObjective(session(['pipeline']), 'wiki-build', 'Hand the result to the wiki-builder service.');
|
|
133
|
+
|
|
134
|
+
assert.equal(result.code, 'nested_skill_match_blocked');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('un chemin de fichier commençant par un nom de compétence ne vaut pas invocation', async () => {
|
|
138
|
+
const result = await callWithObjective(
|
|
139
|
+
session(['wiki-ingest']),
|
|
140
|
+
'wiki-build',
|
|
141
|
+
'Move the leaf file /wiki-build/unclassified/x.md into place then continue.',
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
assert.equal(result.code, 'nested_skill_match_blocked');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('hors de toute chaîne, la sélection par description reste permise', async () => {
|
|
148
|
+
const result = await callWithObjective(session(undefined), 'wiki-taxonomy', 'republish the graph taxonomy');
|
|
149
|
+
|
|
150
|
+
assert.equal(result.ok, true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/*
|
|
154
|
+
Le nom seul ne prouve rien. Plusieurs compétences du scaffold portent un nom
|
|
155
|
+
qui est aussi un mot courant : « Run the production pipeline steps concepts,
|
|
156
|
+
reclassify-concepts and taxonomy » nomme `pipeline`, dont le lancement rejoue
|
|
157
|
+
ingest + build + export + polish. Une intention doit citer sa cible EN TANT QUE
|
|
158
|
+
compétence, pas l'employer comme mot.
|
|
159
|
+
*/
|
|
160
|
+
test('un nom employé comme mot courant ne vaut pas invocation', async () => {
|
|
161
|
+
const objective = 'Run the production pipeline steps concepts, reclassify-concepts and taxonomy, in that order.';
|
|
162
|
+
const ran = [];
|
|
163
|
+
const result = await callWithObjective(session(['wiki-ingest'], ran), 'pipeline', objective);
|
|
164
|
+
|
|
165
|
+
assert.equal(result.code, 'nested_skill_match_blocked');
|
|
166
|
+
assert.deepEqual(ran, []);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('la tournure « the deliver skill » vaut invocation explicite', async () => {
|
|
170
|
+
const ran = [];
|
|
171
|
+
const result = await callWithObjective(session(['wiki-sync'], ran), 'deliver', 'Hand the report to the deliver skill.');
|
|
172
|
+
|
|
173
|
+
assert.equal(result.ok, true);
|
|
174
|
+
assert.deepEqual(ran, ['deliver']);
|
|
175
|
+
});
|
package/src/cli/wiki-manager.js
CHANGED
|
@@ -24,7 +24,7 @@ import { applySessionWikircProfile } from '../core/sessionConfig.js';
|
|
|
24
24
|
import { listWikircProfiles } from '../core/wikirc.js';
|
|
25
25
|
import { callMcpTool, formatMcpToolResult, readChatAccessConfig } from '../core/mcp.js';
|
|
26
26
|
import { deleteManagedMcpEndpoint, listManagedMcpEndpoints, upsertManagedMcpEndpoint } from '../core/mcpEndpoints.js';
|
|
27
|
-
import { extractActivity, parseJsonText, sessionActivities, terminalFailures } from '../core/activity.js';
|
|
27
|
+
import { extractActivity, mergePolledActivity, parseJsonText, sessionActivities, terminalFailures } from '../core/activity.js';
|
|
28
28
|
import { syncActivitiesToPlan, formatPlanStatus } from '../core/plan.js';
|
|
29
29
|
import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
|
|
30
30
|
import { runAgentTurn, runAgenticLoop } from '../core/agentLoop.js';
|
|
@@ -432,7 +432,7 @@ async function runHeadlessActivityLoop(session, log, { wait, timeoutMs }) {
|
|
|
432
432
|
try {
|
|
433
433
|
const result = await callMcpTool(session.mcp, activity.poll.server, activity.poll.tool, activity.poll.args ?? {});
|
|
434
434
|
const payload = parseJsonText(formatMcpToolResult(result));
|
|
435
|
-
const polledActivity = extractActivity(payload, { server: activity.poll.server, tool: activity.poll.tool });
|
|
435
|
+
const polledActivity = mergePolledActivity(activity, extractActivity(payload, { server: activity.poll.server, tool: activity.poll.tool }));
|
|
436
436
|
if (polledActivity) {
|
|
437
437
|
dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
|
|
438
438
|
origin: 'poll',
|
package/src/core/activity.js
CHANGED
|
@@ -104,7 +104,19 @@ export function normalizeActivity(activity, fallback = {}) {
|
|
|
104
104
|
return normalized;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
// A status payload can only be polled with the tool that produced it. Any
|
|
108
|
+
// `*_status` tool qualifies; anything else (a `*_start_job` call whose result
|
|
109
|
+
// carried the first snapshot) falls back to the production status tool, and
|
|
110
|
+
// for a non-production agent to null — the caller then keeps the poll
|
|
111
|
+
// descriptor the tracked activity already had.
|
|
112
|
+
function statusPollTool(context, source) {
|
|
113
|
+
const tool = String(context?.tool ?? '');
|
|
114
|
+
if (/status$/i.test(tool)) return tool;
|
|
115
|
+
return source === 'production' ? 'production_job_status' : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function activityFromStatusPayload(payload, context = {}) {
|
|
119
|
+
const source = String(context?.server ?? 'production');
|
|
108
120
|
const progress = payload?.progress;
|
|
109
121
|
const job = payload?.job;
|
|
110
122
|
const jobId = payload?.jobId ?? job?.jobId;
|
|
@@ -141,11 +153,12 @@ function productionActivityFromPayload(payload, context = {}) {
|
|
|
141
153
|
progressDetail,
|
|
142
154
|
progress?.lastEvent ? `last ${progress.lastEvent}` : null,
|
|
143
155
|
].filter(Boolean).join(' · ');
|
|
156
|
+
const prefix = source === 'production' ? 'Production' : source;
|
|
144
157
|
return normalizeActivity({
|
|
145
158
|
id: jobId,
|
|
146
|
-
source
|
|
159
|
+
source,
|
|
147
160
|
kind: job?.type ?? payload?.operation ?? payload?.type ?? progress?.phase ?? progress?.currentStep ?? 'job',
|
|
148
|
-
label: detail ?
|
|
161
|
+
label: detail ? `${prefix}: ${detail}` : `${prefix}: ${status}`,
|
|
149
162
|
status,
|
|
150
163
|
progress: {
|
|
151
164
|
...(progress ?? {}),
|
|
@@ -154,9 +167,9 @@ function productionActivityFromPayload(payload, context = {}) {
|
|
|
154
167
|
...(payload?.taskId ? { stepId: String(payload.taskId) } : {}),
|
|
155
168
|
},
|
|
156
169
|
plan: Array.isArray(progress?.steps) ? { steps: progress.steps } : null,
|
|
157
|
-
poll: jobId ? {
|
|
158
|
-
server:
|
|
159
|
-
tool: context
|
|
170
|
+
poll: jobId && statusPollTool(context, source) ? {
|
|
171
|
+
server: source,
|
|
172
|
+
tool: statusPollTool(context, source),
|
|
160
173
|
args: { jobId },
|
|
161
174
|
intervalMs: 2500,
|
|
162
175
|
} : null,
|
|
@@ -170,10 +183,47 @@ export function extractActivity(payload, context = {}) {
|
|
|
170
183
|
if (payload._activity) {
|
|
171
184
|
return normalizeActivity(payload._activity, { source: context.server });
|
|
172
185
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
186
|
+
// Without an explicit `_activity` opt-in, a status payload is inferred into an
|
|
187
|
+
// activity only where we already know how it would be polled: the production
|
|
188
|
+
// server (whose `*_start_job` result carries the first snapshot), or any agent
|
|
189
|
+
// answering through a `*_status` tool. This is what lets a job run by another
|
|
190
|
+
// executor (knowledge.update on agent-cme, polled via `agent_status`) update
|
|
191
|
+
// its percentage instead of the panel showing the dispatcher's initial 0 %
|
|
192
|
+
// for the whole run. An arbitrary agent-mode tool call that merely happens to
|
|
193
|
+
// return a `jobId`/`progress`-shaped object must NOT create or replace the run
|
|
194
|
+
// plan — monitoring stays opt-in (`_activity`) for it.
|
|
195
|
+
const source = String(context?.server ?? 'production');
|
|
196
|
+
if (source !== 'production' && !statusPollTool(context, source)) return null;
|
|
197
|
+
return activityFromStatusPayload(payload, context);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A poll answers with the AGENT's view of the job: it knows the job id, the
|
|
201
|
+
// status and the progress, but nothing about the plan task the orchestrator
|
|
202
|
+
// dispatched it for. Re-normalizing that answer on its own therefore dropped
|
|
203
|
+
// `progress.stepId` and could re-key the activity under a different source,
|
|
204
|
+
// breaking the task<->activity link the Activity/Plan panels use to attach a
|
|
205
|
+
// live percentage to a plan step. Merge onto the tracked activity instead:
|
|
206
|
+
// identity and linkage come from what we already know, live values from the
|
|
207
|
+
// poll.
|
|
208
|
+
export function mergePolledActivity(tracked, polled) {
|
|
209
|
+
if (!polled) return null;
|
|
210
|
+
if (!tracked) return polled;
|
|
211
|
+
const stepId = polled.progress?.stepId ?? tracked.progress?.stepId ?? null;
|
|
212
|
+
return normalizeActivity({
|
|
213
|
+
...polled,
|
|
214
|
+
// Identity: keep the tracked source/id so activityKey() stays stable.
|
|
215
|
+
id: tracked.id ?? polled.id,
|
|
216
|
+
source: tracked.source ?? polled.source,
|
|
217
|
+
// Keep polling with the descriptor that worked when the agent's answer
|
|
218
|
+
// does not carry one of its own.
|
|
219
|
+
poll: polled.poll ?? tracked.poll,
|
|
220
|
+
plan: polled.plan ?? tracked.plan ?? null,
|
|
221
|
+
startedAt: tracked.startedAt ?? polled.startedAt ?? null,
|
|
222
|
+
progress: {
|
|
223
|
+
...(polled.progress ?? {}),
|
|
224
|
+
...(stepId !== null ? { stepId } : {}),
|
|
225
|
+
},
|
|
226
|
+
});
|
|
177
227
|
}
|
|
178
228
|
|
|
179
229
|
export function rememberActivity(session, activity) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { normalizeActivity, extractActivity, isCancelledStatus, rememberActivity, rememberActivityFromPayload } from './activity.js';
|
|
3
|
+
import { normalizeActivity, extractActivity, mergePolledActivity, isCancelledStatus, rememberActivity, rememberActivityFromPayload } from './activity.js';
|
|
4
4
|
|
|
5
5
|
test('normalizeActivity: plan.steps preserved with id and label', () => {
|
|
6
6
|
const a = normalizeActivity({
|
|
@@ -171,3 +171,61 @@ test('rememberActivityFromPayload: returns null for irrelevant payload', () => {
|
|
|
171
171
|
const session = {};
|
|
172
172
|
assert.equal(rememberActivityFromPayload(session, { message: 'ok' }), null);
|
|
173
173
|
});
|
|
174
|
+
|
|
175
|
+
test('extractActivity: a non-production agent status payload yields an activity', () => {
|
|
176
|
+
const activity = extractActivity({
|
|
177
|
+
jobId: 'job-know-1',
|
|
178
|
+
status: 'running',
|
|
179
|
+
progress: { percent: 42, phase: 'knowledge.update', detail: 'chunk 3/7' },
|
|
180
|
+
}, { server: 'cme', tool: 'agent_status' });
|
|
181
|
+
assert.ok(activity, 'a knowledge.update job run outside the production server must still produce an activity');
|
|
182
|
+
assert.equal(activity.source, 'cme');
|
|
183
|
+
assert.equal(activity.progress.percent, 42);
|
|
184
|
+
assert.equal(activity.poll.server, 'cme');
|
|
185
|
+
assert.equal(activity.poll.tool, 'agent_status');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('extractActivity: a non-status tool result on another server is not inferred into an activity', () => {
|
|
189
|
+
// Agent mode: the LLM calls an arbitrary connector tool whose result happens
|
|
190
|
+
// to carry a jobId/progress-shaped object. Without an `_activity` opt-in and
|
|
191
|
+
// without a `*_status` poll tool, this must not create or replace the plan.
|
|
192
|
+
const activity = extractActivity({
|
|
193
|
+
jobId: 'export-42',
|
|
194
|
+
progress: { steps: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }] },
|
|
195
|
+
}, { server: 'connectors', tool: 'gmail_export_run' });
|
|
196
|
+
assert.equal(activity, null);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('extractActivity: production start-job result is still inferred without a status tool', () => {
|
|
200
|
+
const activity = extractActivity({
|
|
201
|
+
jobId: 'job-1',
|
|
202
|
+
status: 'running',
|
|
203
|
+
progress: { percent: 5, phase: 'ingest' },
|
|
204
|
+
}, { server: 'production', tool: 'production_start_job' });
|
|
205
|
+
assert.ok(activity);
|
|
206
|
+
assert.equal(activity.source, 'production');
|
|
207
|
+
assert.equal(activity.poll.tool, 'production_job_status');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('mergePolledActivity: keeps the tracked key, poll and stepId across polls', () => {
|
|
211
|
+
const tracked = normalizeActivity({
|
|
212
|
+
id: 'job-know-1',
|
|
213
|
+
source: 'cme',
|
|
214
|
+
kind: 'knowledge.update',
|
|
215
|
+
label: 'Mise a jour des connaissances',
|
|
216
|
+
status: 'queued',
|
|
217
|
+
progress: { percent: 0, stepId: 'task-3' },
|
|
218
|
+
poll: { server: 'cme', tool: 'agent_status', args: { jobId: 'job-know-1' }, intervalMs: 1000 },
|
|
219
|
+
});
|
|
220
|
+
const polled = extractActivity({
|
|
221
|
+
jobId: 'job-know-1',
|
|
222
|
+
status: 'running',
|
|
223
|
+
progress: { percent: 63, detail: 'chunk 5/7' },
|
|
224
|
+
}, { server: 'cme', tool: 'agent_status' });
|
|
225
|
+
const merged = mergePolledActivity(tracked, polled);
|
|
226
|
+
assert.equal(merged.key, tracked.key, 'the activity must not be re-keyed by a poll');
|
|
227
|
+
assert.equal(merged.progress.stepId, 'task-3', 'the plan task link must survive a poll');
|
|
228
|
+
assert.equal(merged.progress.percent, 63);
|
|
229
|
+
assert.equal(merged.poll.tool, 'agent_status');
|
|
230
|
+
assert.equal(merged.terminal, false);
|
|
231
|
+
});
|
package/src/core/buildInfo.json
CHANGED
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.64';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -16,6 +16,18 @@ const SYMBOLS = {
|
|
|
16
16
|
skipped: '–',
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
// The selection reason is an audit enum (`explicit_name` / `description_match`);
|
|
20
|
+
// leaking it verbatim into a queue label read as a broken token (`[explicit_name]`).
|
|
21
|
+
// Humanize it for display; keep the raw value on `selectionKind` for audit.
|
|
22
|
+
const SELECTION_KIND_LABELS = {
|
|
23
|
+
explicit_name: 'explicit name',
|
|
24
|
+
description_match: 'description match',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function selectionKindLabel(selectionKind) {
|
|
28
|
+
return SELECTION_KIND_LABELS[selectionKind] ?? selectionKind ?? null;
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
export const TERMINAL = new Set(['done', 'failed', 'cancelled', 'skipped']);
|
|
20
32
|
|
|
21
33
|
// Objectives are whole paragraphs; a chain view needs a line. Keep the first
|
|
@@ -61,6 +73,7 @@ export function projectSkillChains(controlQueue = []) {
|
|
|
61
73
|
chainId,
|
|
62
74
|
skillName: chainItems.find((item) => item.skillName)?.skillName ?? null,
|
|
63
75
|
selectionKind: chainItems.find((item) => item.selectionKind)?.selectionKind ?? null,
|
|
76
|
+
selectionLabel: selectionKindLabel(chainItems.find((item) => item.selectionKind)?.selectionKind ?? null),
|
|
64
77
|
steps,
|
|
65
78
|
status: chainStatus(steps),
|
|
66
79
|
};
|
|
@@ -79,7 +92,7 @@ function chainStatus(steps) {
|
|
|
79
92
|
// The text form used by the Shell; serve renders the same projection as DOM.
|
|
80
93
|
export function renderSkillChain(chain) {
|
|
81
94
|
if (!chain?.steps?.length) return '';
|
|
82
|
-
const selection = chain.
|
|
95
|
+
const selection = chain.selectionLabel ? ` · ${chain.selectionLabel}` : '';
|
|
83
96
|
const lines = [`${chain.skillName ?? 'skill'}${selection}`, ''];
|
|
84
97
|
for (const step of chain.steps) {
|
|
85
98
|
lines.push(`${step.symbol} ${step.label}`);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { chainStepLabel, projectSkillChains, renderSkillChain } from './skillChainView.js';
|
|
3
|
+
import { chainStepLabel, projectSkillChains, renderSkillChain, selectionKindLabel } from './skillChainView.js';
|
|
4
4
|
|
|
5
5
|
const WIKI_SYNC = [
|
|
6
6
|
{
|
|
@@ -48,3 +48,15 @@ test('standalone control items are not chains', () => {
|
|
|
48
48
|
assert.deepEqual(projectSkillChains([{ id: 'x', status: 'queued', input: 'do something' }]), []);
|
|
49
49
|
assert.deepEqual(projectSkillChains(), []);
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
test('the selection reason is humanized, not leaked as an audit enum', () => {
|
|
53
|
+
assert.equal(selectionKindLabel('explicit_name'), 'explicit name');
|
|
54
|
+
assert.equal(selectionKindLabel('description_match'), 'description match');
|
|
55
|
+
assert.equal(selectionKindLabel(null), null);
|
|
56
|
+
const [chain] = projectSkillChains([
|
|
57
|
+
{ id: 'c0', chainId: 'k', chainSequence: 0, skillName: 'wiki-taxonomy', selectionKind: 'explicit_name', status: 'running', input: '/wiki-taxonomy' },
|
|
58
|
+
]);
|
|
59
|
+
assert.equal(chain.selectionKind, 'explicit_name');
|
|
60
|
+
assert.equal(chain.selectionLabel, 'explicit name');
|
|
61
|
+
assert.equal(renderSkillChain(chain).split('\n')[0], 'wiki-taxonomy · explicit name');
|
|
62
|
+
});
|
|
@@ -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);
|
|
@@ -19,6 +19,54 @@ export function explicitSkillReference(input, skillName, language = null) {
|
|
|
19
19
|
return text.some((sentence) => patterns.some((pattern) => pattern.test(sentence)));
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/*
|
|
23
|
+
Une intention compilée NOMME-t-elle la compétence qu'on veut lancer depuis
|
|
24
|
+
elle ?
|
|
25
|
+
|
|
26
|
+
Le corps d'une compétence est compilé en intentions métier, et une intention
|
|
27
|
+
décrit forcément ce que fait une compétence voisine : la deuxième intention de
|
|
28
|
+
`/wiki-ingest` est mot pour mot le corps de `/wiki-rebuild-concepts`. Le
|
|
29
|
+
sélecteur par description la reconnaissait donc et relançait la compétence
|
|
30
|
+
voisine, qui relançait la suivante — concepts et taxonomie produits plusieurs
|
|
31
|
+
fois pour un seul `/wiki-ingest`.
|
|
32
|
+
|
|
33
|
+
La composition volontaire reste possible : un corps qui écrit `/deliver` ou
|
|
34
|
+
« the deliver skill » nomme sa cible, et se distingue ainsi d'une intention
|
|
35
|
+
qui se contente de la décrire. C'est le seul signal qui ne dépende pas de ce
|
|
36
|
+
que le modèle déclare de sa propre sélection.
|
|
37
|
+
*/
|
|
38
|
+
export function objectiveNamesSkill(input, skillName) {
|
|
39
|
+
const raw = String(skillName ?? '').trim();
|
|
40
|
+
const name = escapeRegExp(raw);
|
|
41
|
+
if (!name) return false;
|
|
42
|
+
const text = String(input ?? '').trim();
|
|
43
|
+
// Une invocation directe : la demande EST le nom, rien d'autre.
|
|
44
|
+
if (text.toLowerCase() === raw.toLowerCase()) return true;
|
|
45
|
+
/*
|
|
46
|
+
Le nom seul ne suffit pas : plusieurs compétences du scaffold portent un nom
|
|
47
|
+
qui est aussi un mot courant. « Run the production pipeline steps concepts,
|
|
48
|
+
reclassify-concepts and taxonomy » nomme ainsi la compétence `pipeline`, qui
|
|
49
|
+
relance ingest + build + export + polish — bien pire que la cascade qu'on
|
|
50
|
+
corrige. Le nom doit donc être cité EN TANT QUE compétence : forme slash, ou
|
|
51
|
+
tournure explicite. La borne droite est écrite à la main, `\b` ne bornant pas
|
|
52
|
+
après un `-` final (`wiki-build` ne doit pas matcher dans `wiki-builder`).
|
|
53
|
+
*/
|
|
54
|
+
const end = '(?![A-Za-z0-9_-])';
|
|
55
|
+
// The slash form is a command, not a path: `/wiki-build` followed by `/` is
|
|
56
|
+
// `wiki/concepts/...`-style text referencing a file, not an invocation of the
|
|
57
|
+
// `wiki-build` skill. A trailing `/` must not satisfy the right boundary here,
|
|
58
|
+
// or a compiled objective that merely names a path re-opens the nested-skill
|
|
59
|
+
// cascade this guard exists to close.
|
|
60
|
+
const slashEnd = '(?![A-Za-z0-9_/-])';
|
|
61
|
+
const keyword = '(?:skill|workflow|compétence)';
|
|
62
|
+
return [
|
|
63
|
+
new RegExp(`(?:^|[^A-Za-z0-9_-])/${name}${slashEnd}`, 'i'),
|
|
64
|
+
new RegExp(`\\b${keyword}\\s+/?${name}${end}`, 'i'),
|
|
65
|
+
new RegExp(`(?:^|[^A-Za-z0-9_-])/?${name}${end}\\s+${keyword}\\b`, 'i'),
|
|
66
|
+
new RegExp(`/skills\\s+run\\s+${name}${end}`, 'i'),
|
|
67
|
+
].some((pattern) => pattern.test(text));
|
|
68
|
+
}
|
|
69
|
+
|
|
22
70
|
export function matchSkillInvocation(session, input, { allowReserved = false } = {}) {
|
|
23
71
|
const match = INVOCATION_RE.exec(String(input ?? '').trim());
|
|
24
72
|
if (!match) return null;
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
const CONTROL_MESSAGES = {
|
|
15
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.',
|
|
16
17
|
plan_patch_proposed: 'Plan patch proposed. Approve it explicitly to apply it to the active plan.',
|
|
17
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.',
|
|
18
19
|
converse_while_running: 'Runtime run is still active. This message was treated as conversation and did not create a queued run.',
|
package/src/runtime/server.js
CHANGED
|
@@ -12,6 +12,7 @@ import { matchSkillInvocation } from '../core/skillInvocation.js';
|
|
|
12
12
|
import { reconcileControlQueue } from './controlDrain.js';
|
|
13
13
|
import { cancelControlChain, cancelQueuedControlItem } from './controlCancellation.js';
|
|
14
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();
|
|
@@ -780,7 +781,7 @@ export function startRuntimeServer({
|
|
|
780
781
|
return { killed: true, workspace: targetWorkspace, runId: targetRunId, runs, tasks, queued, ...(purged !== null ? { purged } : {}) };
|
|
781
782
|
}
|
|
782
783
|
|
|
783
|
-
function startRuntimeRun(context, body, { controlItemId = null, waitForPlan = false } = {}) {
|
|
784
|
+
function startRuntimeRun(context, body, { controlItemId = null, waitForPlan = false, announceLaunch = false } = {}) {
|
|
784
785
|
const runId = randomUUID();
|
|
785
786
|
const runWorkspace = context.workspace ?? body.workspace ?? null;
|
|
786
787
|
context.running = true;
|
|
@@ -803,6 +804,14 @@ export function startRuntimeServer({
|
|
|
803
804
|
workspace: runWorkspace,
|
|
804
805
|
payload: { id: controlItemId, runId },
|
|
805
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
|
+
}
|
|
806
815
|
}
|
|
807
816
|
const runPromise = run(context, runBody, { signal: context.currentAbortController.signal, runId });
|
|
808
817
|
runPromise
|
|
@@ -860,7 +869,7 @@ export function startRuntimeServer({
|
|
|
860
869
|
},
|
|
861
870
|
}
|
|
862
871
|
: {}),
|
|
863
|
-
}, { controlItemId: item.id }),
|
|
872
|
+
}, { controlItemId: item.id, announceLaunch: !item.chainId }),
|
|
864
873
|
skipItem: (item, reason) => {
|
|
865
874
|
privateControlInputsFor(context.session).delete(item.id);
|
|
866
875
|
emitControlSkipped(context, item, reason);
|
|
@@ -1091,7 +1100,11 @@ export function approvalRequestFromStatus(status) {
|
|
|
1091
1100
|
|
|
1092
1101
|
async function handleControlMessage(context, store, input, { intent = null, startNextControlRequest = () => false, cancel = null, approve = null } = {}) {
|
|
1093
1102
|
const status = controlStatus(context, store);
|
|
1094
|
-
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
|
+
});
|
|
1095
1108
|
if (classification.kind === 'observe') {
|
|
1096
1109
|
return readOnlyControlResponse('observe', classification, status, explainControlState(status));
|
|
1097
1110
|
}
|
|
@@ -1129,6 +1142,7 @@ async function handleControlMessage(context, store, input, { intent = null, star
|
|
|
1129
1142
|
// startNextControlRequest), which can change running/plan/status — a full
|
|
1130
1143
|
// controlStatus() recompute is required here, not just controlQueue.
|
|
1131
1144
|
void startNextControlRequest(context);
|
|
1145
|
+
const explanation = await generateControlAcknowledgment(context?.session, { kind: 'queued', input });
|
|
1132
1146
|
return {
|
|
1133
1147
|
statusCode: 202,
|
|
1134
1148
|
body: {
|
|
@@ -1137,7 +1151,7 @@ async function handleControlMessage(context, store, input, { intent = null, star
|
|
|
1137
1151
|
classification,
|
|
1138
1152
|
item,
|
|
1139
1153
|
...controlStatus(context, store),
|
|
1140
|
-
explanation
|
|
1154
|
+
explanation,
|
|
1141
1155
|
},
|
|
1142
1156
|
};
|
|
1143
1157
|
}
|
|
@@ -1158,6 +1172,61 @@ async function handleControlMessage(context, store, input, { intent = null, star
|
|
|
1158
1172
|
: controlMessage(context?.session, 'converse_while_idle'));
|
|
1159
1173
|
}
|
|
1160
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
|
+
|
|
1161
1230
|
/*
|
|
1162
1231
|
`skillStack` accompagne l'élément, il ne vit pas sur la session.
|
|
1163
1232
|
|
|
@@ -1353,13 +1422,15 @@ function rejectPlanPatch(context, store, patchId, reason) {
|
|
|
1353
1422
|
};
|
|
1354
1423
|
}
|
|
1355
1424
|
|
|
1356
|
-
//
|
|
1357
|
-
//
|
|
1358
|
-
//
|
|
1359
|
-
//
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1362
|
-
|
|
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 } = {}) {
|
|
1363
1434
|
// Caller (the /control message route) already trims and rejects empty input.
|
|
1364
1435
|
const lower = String(input ?? '').toLowerCase();
|
|
1365
1436
|
const intent = forcedIntent ? String(forcedIntent).toLowerCase() : null;
|
|
@@ -1377,22 +1448,53 @@ function classifyControlMessage(input, status, forcedIntent = null) {
|
|
|
1377
1448
|
if (explicit) {
|
|
1378
1449
|
return { kind: explicit, confidence: 1, reason: 'explicit_intent' };
|
|
1379
1450
|
}
|
|
1451
|
+
// Cancel stays a keyword: it is a runtime control verb, and an abort must not
|
|
1452
|
+
// wait on a model round-trip.
|
|
1380
1453
|
if (/\b(cancel|annule|stop|arr[eê]te|interromps|abort)\b/i.test(lower)) {
|
|
1381
1454
|
return { kind: 'cancel', confidence: 0.86, reason: 'cancel_request' };
|
|
1382
1455
|
}
|
|
1383
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)) {
|
|
1384
1457
|
return { kind: 'enqueue_run', confidence: 0.8, reason: 'future_run_request' };
|
|
1385
1458
|
}
|
|
1386
|
-
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)) {
|
|
1387
1460
|
return { kind: 'observe', confidence: 0.86, reason: 'status_or_explanation_request' };
|
|
1388
1461
|
}
|
|
1389
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)) {
|
|
1390
1463
|
return { kind: 'modify_run', confidence: 0.78, reason: 'active_run_change_request' };
|
|
1391
1464
|
}
|
|
1392
|
-
if (status.running
|
|
1393
|
-
|
|
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
|
+
}
|
|
1394
1496
|
}
|
|
1395
|
-
return { kind: '
|
|
1497
|
+
return { kind: 'ambiguous', confidence: 0.45, reason: 'action_vs_conversation_unclear' };
|
|
1396
1498
|
}
|
|
1397
1499
|
|
|
1398
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
|
}
|
|
@@ -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,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { openSync, readSync, closeSync, fstatSync } from 'node:fs';
|
|
2
2
|
import { isAbsolute, join, normalize, resolve } from 'node:path';
|
|
3
3
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
4
|
-
import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
4
|
+
import { extractActivity, mergePolledActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
5
5
|
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
6
6
|
import { normalizeRuntimeLog } from '../core/runtimeLog.js';
|
|
7
7
|
import { startNextQueuedJob, syncQueueWithActivity } from '../core/jobQueue.js';
|
|
@@ -107,10 +107,10 @@ export async function pollActivitiesOnce(session, {
|
|
|
107
107
|
try {
|
|
108
108
|
const result = await callTool(session.mcp, activity.poll.server, activity.poll.tool, activity.poll.args ?? {}, signal);
|
|
109
109
|
const payload = parseJsonText(formatMcpToolResult(result));
|
|
110
|
-
const polledActivity = extractActivity(payload, {
|
|
110
|
+
const polledActivity = mergePolledActivity(activity, extractActivity(payload, {
|
|
111
111
|
server: activity.poll.server,
|
|
112
112
|
tool: activity.poll.tool,
|
|
113
|
-
});
|
|
113
|
+
}));
|
|
114
114
|
if (polledActivity) {
|
|
115
115
|
dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
|
|
116
116
|
origin: 'runtime_poll',
|
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,11 @@ 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 MINIMALE du panneau Plan : 6 lignes visibles. Le panneau occupe
|
|
24
|
+
// desormais tout l'espace libre jusqu'au panneau Runtime/Agent status (il ne
|
|
25
|
+
// reste plus de trou de 6 lignes entre les deux) ; la scrollbox reste
|
|
26
|
+
// defilable des que le plan depasse cette fenetre.
|
|
27
|
+
const PLAN_MIN_VIEWPORT_ROWS = 6;
|
|
24
28
|
|
|
25
29
|
function wrapLine(value: string, width: number) {
|
|
26
30
|
const max = Math.max(8, width);
|
|
@@ -194,12 +198,10 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
|
|
|
194
198
|
// Keep one column for the native vertical scrollbar when the plan is long.
|
|
195
199
|
const lineWidth = () => Math.max(8, props.width - 3);
|
|
196
200
|
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);
|
|
201
|
+
// Plus de liseré bleu à gauche : l'icône et la couleur du texte suffisent à
|
|
202
|
+
// distinguer une étape en cours, et le cadre se voyait aussi sur une étape en
|
|
203
|
+
// attente (jaune). Toutes les étapes utilisent donc la même largeur.
|
|
204
|
+
const stepTextWidth = (_step: PlanStep) => lineWidth();
|
|
203
205
|
const icon = (rawStatus: string) => {
|
|
204
206
|
const status = String(rawStatus ?? '').toLowerCase();
|
|
205
207
|
if (DONE_STATUSES.includes(status)) return '[✓]';
|
|
@@ -214,12 +216,11 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
|
|
|
214
216
|
total + wrapLine(`${icon(step.status)} ${step.step}. ${step.description}`, stepTextWidth(step)).slice(0, 2).length, 0));
|
|
215
217
|
const title = () => {
|
|
216
218
|
const label = props.jobName ? `Plan : ${props.jobName}` : 'Plan';
|
|
217
|
-
return visualRows() >
|
|
219
|
+
return visualRows() > PLAN_MIN_VIEWPORT_ROWS ? `${label} (${props.plan.length}) · scroll` : label;
|
|
218
220
|
};
|
|
219
|
-
const viewportRows = () => Math.min(PLAN_VIEWPORT_ROWS, Math.max(1, visualRows()));
|
|
220
221
|
const summaryLines = () => props.summary ? wrapLine(props.summary, lineWidth()).slice(0, 2) : [];
|
|
221
222
|
return (
|
|
222
|
-
<box flexShrink={
|
|
223
|
+
<box flexGrow={1} flexShrink={1} minHeight={PLAN_MIN_VIEWPORT_ROWS + 2} flexDirection="column" padding={1}>
|
|
223
224
|
<text width={lineWidth()} fg="#D6DEE8" content={fit(title(), lineWidth())} />
|
|
224
225
|
<Show when={summaryLines().length > 0}>
|
|
225
226
|
<box flexShrink={0} flexDirection="column">
|
|
@@ -229,23 +230,24 @@ export function PlanPanel(props: { plan: PlanStep[]; width: number; jobName?: st
|
|
|
229
230
|
</box>
|
|
230
231
|
</Show>
|
|
231
232
|
<scrollbox
|
|
232
|
-
|
|
233
|
+
flexGrow={1}
|
|
234
|
+
flexShrink={1}
|
|
235
|
+
minHeight={Math.min(PLAN_MIN_VIEWPORT_ROWS, Math.max(1, visualRows()))}
|
|
233
236
|
focusable={false}
|
|
234
237
|
scrollY={true}
|
|
235
238
|
scrollX={false}
|
|
236
239
|
stickyStart="top"
|
|
237
240
|
viewportCulling={true}
|
|
238
|
-
verticalScrollbarOptions={{ visible: visualRows() >
|
|
241
|
+
verticalScrollbarOptions={{ visible: visualRows() > PLAN_MIN_VIEWPORT_ROWS }}
|
|
239
242
|
>
|
|
240
243
|
<Index each={props.plan}>
|
|
241
244
|
{(step) => {
|
|
242
245
|
// Wrap step descriptions over up to 2 lines instead of truncating —
|
|
243
246
|
// "Ingest des 39 documents raw/untrac…" hid the actual target.
|
|
244
|
-
const running = () => isRunningStep(step());
|
|
245
247
|
const textWidth = () => stepTextWidth(step());
|
|
246
248
|
const lines = () => wrapLine(`${icon(step().status)} ${step().step}. ${step().description}`, textWidth()).slice(0, 2);
|
|
247
249
|
return (
|
|
248
|
-
<box flexShrink={0} flexDirection="column"
|
|
250
|
+
<box flexShrink={0} flexDirection="column">
|
|
249
251
|
<text width={textWidth()} fg={planStepColor(step(), firstPending())} content={lines()[0]} />
|
|
250
252
|
<Show when={lines()[1]}>
|
|
251
253
|
<text width={textWidth()} fg={planStepColor(step(), firstPending())} content={` ${fit(lines()[1], Math.max(8, textWidth() - 4))}`} />
|
|
@@ -265,7 +267,7 @@ export function ActivityPanel(props: { activities: any[]; width: number }) {
|
|
|
265
267
|
const visibleSlots = () => visible().map((_activity, index) => index);
|
|
266
268
|
const activityAt = (index: number) => visible()[index] ?? null;
|
|
267
269
|
return (
|
|
268
|
-
<box flexShrink={0} flexDirection="column" paddingX={1}>
|
|
270
|
+
<box flexShrink={0} flexDirection="column" paddingX={1} backgroundColor="#111318">
|
|
269
271
|
<text width={lineWidth()} fg="#D6DEE8" content="Activity" />
|
|
270
272
|
<Show when={visible().length > 0} fallback={<text width={lineWidth()} fg="#7F8C8D" content="no active jobs" />}>
|
|
271
273
|
<Index each={visibleSlots()}>
|
|
@@ -385,6 +387,10 @@ export function LogPanel(props: { logs: string[]; width: number; filter?: string
|
|
|
385
387
|
.filter((line) => activeLogTab() === 'agent-status' ? isAgentStatus(line) : !isAgentStatus(line));
|
|
386
388
|
const allLines = createMemo(() => logRenderLines(filteredLogs(), lineWidth()));
|
|
387
389
|
return (
|
|
390
|
+
// No hardcoded marginTop here any more: the 6 blank lines it reserved
|
|
391
|
+
// above the Runtime/Agent status tabs left a dead gap under a short Plan
|
|
392
|
+
// and made the two panels look detached. The Plan/Queue panel now grows
|
|
393
|
+
// into that space instead, so its bottom edge meets this panel's header.
|
|
388
394
|
<box flexGrow={2} flexDirection="column" paddingX={1} focusable={false}>
|
|
389
395
|
<text width={lineWidth()} fg="#4B5563" content={'─'.repeat(lineWidth())} />
|
|
390
396
|
<box height={1} flexDirection="row">
|
package/src/shell/repl.js
CHANGED
|
@@ -11,7 +11,7 @@ import { markedTerminal } from 'marked-terminal';
|
|
|
11
11
|
import { buildAgentSystemPrompt, formatLlmUnavailableMessage, isOrchestrationBypassTool } from '../agent/graph.js';
|
|
12
12
|
import { handleSlashCommand, rawCommandAgentPrompt } from '../commands/slash.js';
|
|
13
13
|
import { serviceChoices as composeServiceChoices, serviceDescription } from '../core/compose.js';
|
|
14
|
-
import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
14
|
+
import { extractActivity, mergePolledActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
15
15
|
import { syncActivitiesToPlan } from '../core/plan.js';
|
|
16
16
|
import { buildLlmTools, callMcpTool, formatMcpToolResult, parseToolCallName, resolveToolCallName } from '../core/mcp.js';
|
|
17
17
|
import { runBoundedToolLoop } from '../core/toolLoop.js';
|
|
@@ -1948,7 +1948,7 @@ async function runTuiShell({ agent, packageJson, session, runtime = null }) {
|
|
|
1948
1948
|
void callMcpTool(session.mcp, activity.poll.server, activity.poll.tool, activity.poll.args ?? {})
|
|
1949
1949
|
.then((result) => {
|
|
1950
1950
|
const payload = parseJsonText(formatMcpToolResult(result));
|
|
1951
|
-
const polledActivity = extractActivity(payload, { server: activity.poll.server, tool: activity.poll.tool });
|
|
1951
|
+
const polledActivity = mergePolledActivity(activity, extractActivity(payload, { server: activity.poll.server, tool: activity.poll.tool }));
|
|
1952
1952
|
if (polledActivity) {
|
|
1953
1953
|
dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
|
|
1954
1954
|
origin: 'poll',
|
package/src/shell/useSession.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
|
3
3
|
import { createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
|
4
4
|
import { formatMcpToolResult, callMcpTool } from '../core/mcp.js';
|
|
5
5
|
import { versionWithBuild } from '../core/buildInfo.js';
|
|
6
|
-
import { extractActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
6
|
+
import { extractActivity, mergePolledActivity, parseJsonText, sessionActivities } from '../core/activity.js';
|
|
7
7
|
import { formatPlanStatus, formatCompletedActivities, formatPlanStep } from '../core/plan.js';
|
|
8
8
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
9
9
|
import { projectQueue, queueCounts, startNextQueuedJob, syncQueueWithActivity } from '../core/jobQueue.js';
|
|
@@ -277,7 +277,7 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
277
277
|
return {
|
|
278
278
|
...item,
|
|
279
279
|
id: item.id,
|
|
280
|
-
label: `${chain.skillName ?? 'skill'}${chain.
|
|
280
|
+
label: `${chain.skillName ?? 'skill'}${chain.selectionLabel ? ` [${chain.selectionLabel}]` : ''} ${position} · ${step.label}${reason}`,
|
|
281
281
|
status: item.status,
|
|
282
282
|
_runtime: true,
|
|
283
283
|
_control: true,
|
|
@@ -602,10 +602,10 @@ export function useSession(props: { agent: unknown; packageJson: Record<string,
|
|
|
602
602
|
void callMcpTool(session.mcp, activity.poll.server, activity.poll.tool, activity.poll.args ?? {})
|
|
603
603
|
.then((result) => {
|
|
604
604
|
const payload = parseJsonText(formatMcpToolResult(result));
|
|
605
|
-
const polledActivity = extractActivity(payload, {
|
|
605
|
+
const polledActivity = mergePolledActivity(activity, extractActivity(payload, {
|
|
606
606
|
server: activity.poll.server,
|
|
607
607
|
tool: activity.poll.tool,
|
|
608
|
-
});
|
|
608
|
+
}));
|
|
609
609
|
if (polledActivity) {
|
|
610
610
|
dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
|
|
611
611
|
origin: 'poll',
|