@dotdrelle/wiki-manager 0.15.43 → 0.15.49
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 +92 -24
- package/mcp.endpoints.example.json +1 -1
- package/package.json +2 -2
- package/src/agent/graph.js +288 -22
- package/src/agent/graph.test.js +551 -1
- package/src/agent/skillRecursion.test.js +98 -0
- package/src/cli/wiki-manager.js +209 -7
- package/src/cli/wiki-manager.test.js +89 -0
- package/src/commands/slash.js +28 -10
- package/src/contracts/schemas.js +1 -1
- package/src/core/agentEvents.js +50 -0
- package/src/core/agentEvents.test.js +52 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/env.js +20 -1
- package/src/core/env.test.js +34 -0
- package/src/core/mcp.js +1 -1
- package/src/core/runtimeLog.js +15 -0
- package/src/core/runtimeLog.test.js +15 -1
- package/src/core/skillChainView.js +84 -0
- package/src/core/skillChainView.test.js +50 -0
- package/src/core/skillCompiler.js +135 -0
- package/src/core/skillCompiler.test.js +91 -0
- package/src/core/skillInvocation.js +79 -0
- package/src/core/skillInvocation.test.js +73 -0
- package/src/core/skills.js +81 -19
- package/src/runtime/approvals.js +13 -1
- package/src/runtime/approvals.test.js +44 -0
- package/src/runtime/client.js +45 -4
- package/src/runtime/controlCancellation.js +33 -0
- package/src/runtime/controlCancellation.test.js +49 -0
- package/src/runtime/controlDrain.js +50 -0
- package/src/runtime/controlDrain.test.js +38 -0
- package/src/runtime/server.js +333 -15
- package/src/runtime/server.test.js +392 -0
- package/src/runtime/skillChain.e2e.test.js +394 -0
- package/src/runtime/skillRun.js +104 -0
- package/src/runtime/skillRun.test.js +84 -0
- package/src/runtime/store.js +69 -0
- package/src/runtime/store.test.js +11 -0
- package/src/shell/RightPane.tsx +3 -2
- package/src/shell/repl.js +40 -5
- package/src/shell/repl.test.js +41 -0
- package/src/shell/useSession.ts +43 -9
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { handleRuntimeControlTool } from './graph.js';
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Récursion observée en headless : `wiki-ingest` se réinvoquait au lieu de
|
|
7
|
+
lancer la production, quatre fois avant interruption manuelle.
|
|
8
|
+
|
|
9
|
+
La cause n'est pas le modèle. Le corps d'une compétence est compilé en
|
|
10
|
+
intentions MÉTIER, et une intention ressemble forcément à la description de la
|
|
11
|
+
compétence dont elle sort — « ingérer les fichiers en attente » est à la fois
|
|
12
|
+
l'objectif de /wiki-ingest et sa raison d'être. Le sélecteur la reconnaissait
|
|
13
|
+
donc légitimement. Ce qui manquait, c'est que le run ne savait pas d'où il
|
|
14
|
+
venait : `chainId` et `skillName` s'arrêtaient à l'item de contrôle.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function session(skillStack, ran = []) {
|
|
18
|
+
return {
|
|
19
|
+
// La garde d'entrée exige une URL de runtime ; le chemin run_skill passe
|
|
20
|
+
// ensuite par _runSkillWithinRun sans jamais l'appeler.
|
|
21
|
+
runtime: { url: 'http://runtime.invalid' },
|
|
22
|
+
_skillStack: skillStack,
|
|
23
|
+
_runSkillWithinRun: async (name) => { ran.push(name); return { ok: true, ran: name }; },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const call = (state, skillName) =>
|
|
28
|
+
handleRuntimeControlTool(state, 'run_skill', { skillName, _userInput: skillName })
|
|
29
|
+
.then((raw) => JSON.parse(raw));
|
|
30
|
+
|
|
31
|
+
test('refuse de relancer une compétence déjà dans la pile', async () => {
|
|
32
|
+
const result = await call(session(['wiki-ingest']), 'wiki-ingest');
|
|
33
|
+
|
|
34
|
+
assert.equal(result.ok, false);
|
|
35
|
+
assert.equal(result.terminal, true);
|
|
36
|
+
assert.equal(result.code, 'skill_recursion_blocked');
|
|
37
|
+
// Le diagnostic doit dire quoi faire à la place, sinon le modèle réessaie
|
|
38
|
+
// avec une autre formulation du même appel.
|
|
39
|
+
assert.match(result.message, /execute its objective directly/i);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('ignore la casse : le nom est le même concept', async () => {
|
|
43
|
+
const result = await call(session(['wiki-ingest']), 'Wiki-Ingest');
|
|
44
|
+
|
|
45
|
+
assert.equal(result.code, 'skill_recursion_blocked');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('détecte un cycle indirect, pas seulement l’auto-appel', async () => {
|
|
49
|
+
// a → b → a : chacun des deux appels est légitime pris isolément.
|
|
50
|
+
const result = await call(session(['wiki-sync', 'wiki-ingest']), 'wiki-sync');
|
|
51
|
+
|
|
52
|
+
assert.equal(result.code, 'skill_recursion_blocked');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('laisse passer une composition sans cycle', async () => {
|
|
56
|
+
// Le refus porte sur les cycles, pas sur la composition : une compétence a
|
|
57
|
+
// le droit d'en appeler une autre.
|
|
58
|
+
const result = await call(session(['wiki-sync']), 'deliver');
|
|
59
|
+
|
|
60
|
+
assert.equal(result.ok, true);
|
|
61
|
+
assert.equal(result.ran, 'deliver');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('laisse passer une invocation hors de toute chaîne', async () => {
|
|
65
|
+
const result = await call(session(undefined), 'wiki-ingest');
|
|
66
|
+
|
|
67
|
+
assert.equal(result.ok, true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/*
|
|
71
|
+
`/new-template` observé en conditions réelles : la compétence se rappelait
|
|
72
|
+
elle-même, le run finissait `done`, et rien n'avait été créé. Les tests
|
|
73
|
+
ci-dessus vérifiaient le code de refus ; celui-ci vérifie qu'AUCUN travail
|
|
74
|
+
n'est lancé — c'est ce qui distingue un refus d'un simple avertissement.
|
|
75
|
+
*/
|
|
76
|
+
test('un refus n’exécute rien du tout', async () => {
|
|
77
|
+
const ran = [];
|
|
78
|
+
const result = await handleRuntimeControlTool(
|
|
79
|
+
session(['new-template'], ran),
|
|
80
|
+
'run_skill',
|
|
81
|
+
{ skillName: 'new-template', _userInput: 'crée un modèle de présentation' },
|
|
82
|
+
).then((raw) => JSON.parse(raw));
|
|
83
|
+
|
|
84
|
+
assert.equal(result.code, 'skill_recursion_blocked');
|
|
85
|
+
assert.deepEqual(ran, [], 'aucun run imbriqué ne doit démarrer');
|
|
86
|
+
// La pile revient au modèle : sans elle il ne peut pas savoir laquelle des
|
|
87
|
+
// compétences ouvertes le bloque, et il reformule le même appel.
|
|
88
|
+
assert.deepEqual(result.skillStack, ['new-template']);
|
|
89
|
+
assert.match(result.message, /new-template/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('borne la profondeur même sans cycle', async () => {
|
|
93
|
+
// La détection de cycle ne voit pas une chaîne longue de compétences
|
|
94
|
+
// distinctes, qui épuiserait le budget aussi sûrement.
|
|
95
|
+
const result = await call(session(['a', 'b', 'c']), 'd');
|
|
96
|
+
|
|
97
|
+
assert.equal(result.code, 'skill_depth_exceeded');
|
|
98
|
+
});
|
package/src/cli/wiki-manager.js
CHANGED
|
@@ -31,6 +31,7 @@ import { runAgentTurn, runAgenticLoop } from '../core/agentLoop.js';
|
|
|
31
31
|
import { resolveCapabilityConcurrency } from '../orchestrator/scheduler.js';
|
|
32
32
|
import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
|
|
33
33
|
import { listWorkspaces } from '../core/workspaces.js';
|
|
34
|
+
import { findSkill } from '../core/skills.js';
|
|
34
35
|
// Runtime modules use node:sqlite (Node.js built-in unavailable in Bun).
|
|
35
36
|
// They are imported dynamically so the shell / TUI path never loads them.
|
|
36
37
|
|
|
@@ -288,6 +289,7 @@ export function createInteractiveSession(context, { runtimeUrl, turnId, signal =
|
|
|
288
289
|
'workspace', 'workspacePath', 'workspaceEnvFile', 'workspaceEnv',
|
|
289
290
|
'wikirc', 'wikircConfig', 'language', 'llm', 'mcp', 'commands',
|
|
290
291
|
'packageJson', 'queueStore', 'systemPrompt',
|
|
292
|
+
'_runSkillWithinRun',
|
|
291
293
|
]) {
|
|
292
294
|
if (source[key] !== undefined) session[key] = source[key];
|
|
293
295
|
}
|
|
@@ -599,7 +601,14 @@ async function runHeadless(argv, agent) {
|
|
|
599
601
|
const maxTurnsArg = valueAfter(argv, '--max-turns');
|
|
600
602
|
const timeoutMs = (Number.isFinite(Number(timeoutArg)) ? Math.max(1, Number(timeoutArg)) : 3600) * 1000;
|
|
601
603
|
const maxTurns = Number.isFinite(Number(maxTurnsArg)) ? Math.max(1, Number(maxTurnsArg)) : 20;
|
|
604
|
+
// --skill accepts its arguments inline, exactly like the Shell and serve
|
|
605
|
+
// invocation: --skill "deliver rapport polish".
|
|
606
|
+
const skillArgv = String(skillName ?? '').trim().split(/\s+/).filter(Boolean);
|
|
607
|
+
const skillId = skillArgv[0] ?? null;
|
|
608
|
+
const skillArgs = skillArgv.slice(1).join(' ');
|
|
602
609
|
// --skill uses the agentic loop (multi-turn); --prompt uses a single turn unless --wait is set.
|
|
610
|
+
// The loop is the legacy local path only: when the runtime answers, the skill
|
|
611
|
+
// is compiled and executed server-side and there is no local loop to run.
|
|
603
612
|
const useAgenticLoop = Boolean(skillName) && !argv.includes('--no-wait');
|
|
604
613
|
const wait = !useAgenticLoop && (argv.includes('--wait'));
|
|
605
614
|
const log = [`wiki-manager ${packageJson.version} headless`, `startedAt=${new Date().toISOString()}`];
|
|
@@ -653,7 +662,51 @@ async function runHeadless(argv, agent) {
|
|
|
653
662
|
session._onStep = step;
|
|
654
663
|
|
|
655
664
|
let input = prompt;
|
|
665
|
+
// Plan §24: headless must resolve executable skills through the same
|
|
666
|
+
// runtime resolver as the Shell and serve. Injecting the skill body into a
|
|
667
|
+
// local prompt bypasses the compiler, so a multi-capability skill such as
|
|
668
|
+
// wiki-sync would collapse into a single run here while producing two
|
|
669
|
+
// everywhere else — and the rewritten bodies are business intentions, not
|
|
670
|
+
// the step-by-step procedures the local prompt still expects.
|
|
671
|
+
if (skillId && session.runtime?.url) {
|
|
672
|
+
if (prompt) {
|
|
673
|
+
step('headless: --prompt is ignored when --skill runs through the runtime.');
|
|
674
|
+
}
|
|
675
|
+
const invocation = `/${skillId}${skillArgs ? ` ${skillArgs}` : ''}`;
|
|
676
|
+
log.push(`skill=${invocation}`);
|
|
677
|
+
const { postRuntimeRun } = await import('../runtime/client.js');
|
|
678
|
+
let accepted;
|
|
679
|
+
try {
|
|
680
|
+
accepted = await postRuntimeRun(invocation, {
|
|
681
|
+
url: session.runtime.url,
|
|
682
|
+
workspace: session.workspace ?? null,
|
|
683
|
+
skillName: skillId,
|
|
684
|
+
});
|
|
685
|
+
} catch (err) {
|
|
686
|
+
throw new Error(`Skill invocation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
687
|
+
}
|
|
688
|
+
if (accepted?.kind !== 'skill_chain') {
|
|
689
|
+
throw new Error(`Skill not found: ${skillId}`);
|
|
690
|
+
}
|
|
691
|
+
step(`skill: ${accepted.skill} compiled into ${accepted.objectives} objective(s), chain ${accepted.chainId}`);
|
|
692
|
+
const { exitCode: chainExit } = await waitForRuntimeChain(session, log, {
|
|
693
|
+
chainId: accepted.chainId,
|
|
694
|
+
timeoutMs,
|
|
695
|
+
autoApprove: argv.includes('--auto-approve'),
|
|
696
|
+
});
|
|
697
|
+
const saved = await writeHeadlessLog(session, log, logFile);
|
|
698
|
+
console.log(`Headless log: ${saved}`);
|
|
699
|
+
if (chainExit !== 0) process.exitCode = chainExit;
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
656
702
|
if (skillName) {
|
|
703
|
+
const legacySkill = findSkill(session, skillId);
|
|
704
|
+
if (!legacySkill) throw new Error(`Skill not found: ${skillId}`);
|
|
705
|
+
if (!argv.includes('--auto-approve')) {
|
|
706
|
+
throw new Error('Headless --no-runtime skill execution requires explicit --auto-approve before any mutation can run.');
|
|
707
|
+
}
|
|
708
|
+
session._skillStack = [legacySkill.name];
|
|
709
|
+
session._skillExecution = legacySkill.execution === 'direct' ? 'direct' : 'orchestrated';
|
|
657
710
|
const skillResult = await handleSlashCommand(`/skills run ${skillName}`, { packageJson, session, onStep: step });
|
|
658
711
|
if (skillResult.output && !skillResult.rawOutput) log.push(skillResult.output);
|
|
659
712
|
if (String(skillResult.output ?? '').startsWith('Skill not found')) throw new Error(`Skill not found: ${skillName}`);
|
|
@@ -717,6 +770,115 @@ async function runHeadless(argv, agent) {
|
|
|
717
770
|
}
|
|
718
771
|
}
|
|
719
772
|
|
|
773
|
+
// A skill may compile into several sequential runs, so waiting on "the run this
|
|
774
|
+
// turn created" is not enough: wiki-sync would report success as soon as the
|
|
775
|
+
// export finished, before the ingest had even started. The control queue is the
|
|
776
|
+
// only place where the whole chain is observable, so the wait is scoped to
|
|
777
|
+
// chainId and ends when every item of that chain is terminal.
|
|
778
|
+
const CHAIN_TERMINAL_STATUSES = new Set(['done', 'failed', 'cancelled', 'skipped']);
|
|
779
|
+
|
|
780
|
+
export async function waitForRuntimeChain(session, log, {
|
|
781
|
+
chainId,
|
|
782
|
+
timeoutMs,
|
|
783
|
+
pollMs = 1500,
|
|
784
|
+
autoApprove = false,
|
|
785
|
+
client = null,
|
|
786
|
+
} = {}) {
|
|
787
|
+
const { fetchRuntimeState, postRuntimeApprove } = client ?? await import('../runtime/client.js');
|
|
788
|
+
const url = session.runtime?.url;
|
|
789
|
+
const workspace = session.workspace ?? null;
|
|
790
|
+
if (!url || !chainId) return { exitCode: 0 };
|
|
791
|
+
const deadline = Date.now() + timeoutMs;
|
|
792
|
+
const approvedRevisions = new Set();
|
|
793
|
+
const reported = new Map();
|
|
794
|
+
let lastLogCount = 0;
|
|
795
|
+
while (Date.now() < deadline) {
|
|
796
|
+
let state;
|
|
797
|
+
try {
|
|
798
|
+
state = await fetchRuntimeState({ url, workspace });
|
|
799
|
+
} catch (err) {
|
|
800
|
+
const line = `chain-wait: state fetch failed (${err instanceof Error ? err.message : String(err)})`;
|
|
801
|
+
log.push(line); console.error(line);
|
|
802
|
+
return { exitCode: 1 };
|
|
803
|
+
}
|
|
804
|
+
const logs = Array.isArray(state?.logs) ? state.logs : [];
|
|
805
|
+
for (const entry of logs.slice(lastLogCount)) {
|
|
806
|
+
const text = typeof entry === 'string' ? entry : String(entry?.message ?? JSON.stringify(entry));
|
|
807
|
+
log.push(`runtime: ${text}`); console.log(`[runtime] ${text}`);
|
|
808
|
+
}
|
|
809
|
+
lastLogCount = logs.length;
|
|
810
|
+
|
|
811
|
+
const items = (Array.isArray(state?.controlQueue) ? state.controlQueue : [])
|
|
812
|
+
.filter((item) => item?.chainId === chainId)
|
|
813
|
+
.sort((a, b) => Number(a.chainSequence ?? 0) - Number(b.chainSequence ?? 0));
|
|
814
|
+
if (items.length === 0) {
|
|
815
|
+
const line = `chain-wait: chain ${chainId} is not visible in the runtime queue.`;
|
|
816
|
+
log.push(line); console.error(line);
|
|
817
|
+
return { exitCode: 1 };
|
|
818
|
+
}
|
|
819
|
+
for (const item of items) {
|
|
820
|
+
const status = String(item.status ?? 'queued');
|
|
821
|
+
if (reported.get(item.id) === status) continue;
|
|
822
|
+
reported.set(item.id, status);
|
|
823
|
+
const line = `chain-step ${Number(item.chainSequence ?? 0) + 1}/${items.length}: ${status}${item.skipReason ? ` (${item.skipReason})` : ''}`;
|
|
824
|
+
log.push(line); console.log(`[runtime] ${line}`);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const active = items.find((item) => item.status === 'running');
|
|
828
|
+
if (autoApprove && active?.runId) {
|
|
829
|
+
const pending = (Array.isArray(state?.approvals) ? state.approvals : [])
|
|
830
|
+
.filter((approval) => approval.status === 'pending_approval'
|
|
831
|
+
&& (approval.runId == null || String(approval.runId) === String(active.runId)));
|
|
832
|
+
const planRevision = state?.planRevision ?? 0;
|
|
833
|
+
const revisionKey = `${active.runId}:${planRevision}`;
|
|
834
|
+
if (pending.length > 0 && !approvedRevisions.has(revisionKey)) {
|
|
835
|
+
approvedRevisions.add(revisionKey);
|
|
836
|
+
const approvalClasses = [...new Set(pending.flatMap((approval) => {
|
|
837
|
+
const value = approval.approvalClasses ?? approval.approvalClass ?? [];
|
|
838
|
+
return Array.isArray(value) ? value : [value];
|
|
839
|
+
}).map(String).filter(Boolean))];
|
|
840
|
+
try {
|
|
841
|
+
await postRuntimeApprove({
|
|
842
|
+
url,
|
|
843
|
+
workspace,
|
|
844
|
+
runId: active.runId,
|
|
845
|
+
scope: 'run',
|
|
846
|
+
planRevision,
|
|
847
|
+
approvalClasses: approvalClasses.length > 0 ? approvalClasses : ['default'],
|
|
848
|
+
});
|
|
849
|
+
const line = `chain-wait: auto-approved run ${active.runId} (revision ${planRevision})`;
|
|
850
|
+
log.push(line); console.log(line);
|
|
851
|
+
} catch (err) {
|
|
852
|
+
const line = `chain-wait: auto-approve failed (${err instanceof Error ? err.message : String(err)})`;
|
|
853
|
+
log.push(line); console.error(line);
|
|
854
|
+
return { exitCode: 1 };
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
if (!autoApprove) {
|
|
859
|
+
const blocked = (Array.isArray(state?.approvals) ? state.approvals : [])
|
|
860
|
+
.some((approval) => approval.status === 'pending_approval');
|
|
861
|
+
if (blocked && active) {
|
|
862
|
+
const line = `chain-wait: chain ${chainId} is waiting for approval; re-run with --auto-approve to drive it through.`;
|
|
863
|
+
log.push(line); console.log(line);
|
|
864
|
+
return { exitCode: 0 };
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (items.every((item) => CHAIN_TERMINAL_STATUSES.has(String(item.status)))) {
|
|
869
|
+
const failed = items.filter((item) => item.status === 'failed');
|
|
870
|
+
const skipped = items.filter((item) => item.status === 'skipped');
|
|
871
|
+
const summary = `chain ${chainId}: ${items.length} step(s), ${failed.length} failed, ${skipped.length} skipped`;
|
|
872
|
+
log.push(summary); console.log(`[runtime] ${summary}`);
|
|
873
|
+
return { exitCode: failed.length > 0 ? 1 : 0 };
|
|
874
|
+
}
|
|
875
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
876
|
+
}
|
|
877
|
+
const line = `chain-wait: timeout waiting for chain ${chainId} to finish.`;
|
|
878
|
+
log.push(line); console.error(line);
|
|
879
|
+
return { exitCode: 1 };
|
|
880
|
+
}
|
|
881
|
+
|
|
720
882
|
async function runRuntime(argv, agent) {
|
|
721
883
|
if (argv.includes('--help') || argv.includes('-h')) {
|
|
722
884
|
console.log([
|
|
@@ -1013,7 +1175,7 @@ async function runRuntime(argv, agent) {
|
|
|
1013
1175
|
}
|
|
1014
1176
|
emitRuntimeLog(context.session, manual ? 'runtime: manual resume completed' : 'runtime: recovery completed');
|
|
1015
1177
|
const controlStarted = pollingActivities.length === 0
|
|
1016
|
-
? serverHandle?.drainControl?.(context) === true
|
|
1178
|
+
? await serverHandle?.drainControl?.(context) === true
|
|
1017
1179
|
: false;
|
|
1018
1180
|
return {
|
|
1019
1181
|
workspace: context.workspace ?? workspace ?? null,
|
|
@@ -1155,27 +1317,62 @@ async function runRuntime(argv, agent) {
|
|
|
1155
1317
|
const maxTurns = Number.isFinite(Number(body.maxTurns)) ? Math.max(1, Number(body.maxTurns)) : 20;
|
|
1156
1318
|
const maxReplans = Number.isFinite(Number(body.replans)) ? Math.max(0, Math.floor(Number(body.replans))) : undefined;
|
|
1157
1319
|
const runId = String(body.runId);
|
|
1320
|
+
// Déclarée hors du try : le finally doit pouvoir restaurer la pile même
|
|
1321
|
+
// quand le run échoue avant de l'avoir installée.
|
|
1322
|
+
const parentStack = Array.isArray(session._skillStack) ? session._skillStack : [];
|
|
1158
1323
|
try {
|
|
1159
1324
|
if (workspace && session.workspace !== workspace) {
|
|
1160
1325
|
const result = await handleSlashCommand(`/use ${workspace}`, { packageJson, session });
|
|
1161
1326
|
if (!session.workspacePath) throw new Error(result.output || `Workspace not loaded: ${workspace}`);
|
|
1162
1327
|
}
|
|
1163
1328
|
context.workspace = session.workspace ?? workspace ?? context.workspace ?? null;
|
|
1329
|
+
/*
|
|
1330
|
+
Pile des compétences en cours d'exécution.
|
|
1331
|
+
|
|
1332
|
+
Une intention compilée depuis une compétence ressemble, par
|
|
1333
|
+
construction, à la description de cette compétence : le sélecteur de
|
|
1334
|
+
l'agent la reconnaît et la relance. Garder la pile permet de refuser un
|
|
1335
|
+
cycle sans interdire une composition légitime.
|
|
1336
|
+
*/
|
|
1337
|
+
const skillChain = body.skillChain ?? null;
|
|
1338
|
+
/*
|
|
1339
|
+
La pile vient de l'ÉLÉMENT quand il en porte une.
|
|
1340
|
+
|
|
1341
|
+
Elle était reconstruite à partir de `session._skillStack`, c'est-à-dire
|
|
1342
|
+
de l'état laissé par le run précédent sur cette session. Mais un run de
|
|
1343
|
+
compétence imbriquée démarre après le `finally` de son parent, qui a déjà
|
|
1344
|
+
tout restauré : la pile héritée était donc celle du grand-parent, pas
|
|
1345
|
+
celle du parent. A→B→A traversait sans être vu.
|
|
1346
|
+
|
|
1347
|
+
Le repli sur `parentStack` couvre les éléments d'avant ce changement,
|
|
1348
|
+
encore en file dans un runtime qui redémarre, et l'invocation directe
|
|
1349
|
+
depuis une session interactive.
|
|
1350
|
+
*/
|
|
1351
|
+
session._skillStack = Array.isArray(skillChain?.skillStack) && skillChain.skillStack.length
|
|
1352
|
+
? skillChain.skillStack.map((entry) => String(entry))
|
|
1353
|
+
: skillChain?.skillName
|
|
1354
|
+
? [...parentStack, String(skillChain.skillName)]
|
|
1355
|
+
: parentStack;
|
|
1164
1356
|
session._currentRunIdentity = {
|
|
1165
1357
|
runId,
|
|
1166
1358
|
turnId: `${runId}:turn-0`,
|
|
1167
1359
|
workspace: context.workspace,
|
|
1360
|
+
...(skillChain ? { skillChain } : {}),
|
|
1168
1361
|
};
|
|
1169
1362
|
dispatchAgentEvent(session, createAgentEvent('run_started', {
|
|
1170
1363
|
origin: 'runtime',
|
|
1171
1364
|
runId,
|
|
1172
|
-
payload: { input, workspace: session._currentRunIdentity.workspace },
|
|
1173
|
-
}));
|
|
1174
|
-
dispatchAgentEvent(session, createAgentEvent('user_message', {
|
|
1175
|
-
origin: 'user',
|
|
1176
|
-
runId,
|
|
1177
|
-
payload: { content: input },
|
|
1365
|
+
payload: { input: String(body.publicInput ?? input), workspace: session._currentRunIdentity.workspace },
|
|
1178
1366
|
}));
|
|
1367
|
+
// Compiled skill objectives are private execution material. The
|
|
1368
|
+
// original slash invocation is the user-facing conversation turn.
|
|
1369
|
+
if (!skillChain) {
|
|
1370
|
+
dispatchAgentEvent(session, createAgentEvent('user_message', {
|
|
1371
|
+
origin: 'user',
|
|
1372
|
+
runId,
|
|
1373
|
+
payload: { content: input },
|
|
1374
|
+
}));
|
|
1375
|
+
}
|
|
1179
1376
|
session._abortSignal = signal ?? null;
|
|
1180
1377
|
session._runApprovalRequired = body.requireApproval === true;
|
|
1181
1378
|
session._runApprovalResolved = false;
|
|
@@ -1322,6 +1519,11 @@ async function runRuntime(argv, agent) {
|
|
|
1322
1519
|
delete session._runApprovalRequired;
|
|
1323
1520
|
delete session._runApprovalResolved;
|
|
1324
1521
|
delete session._approvalTimeoutMs;
|
|
1522
|
+
// La session survit au run : une pile laissée en place bloquerait une
|
|
1523
|
+
// invocation parfaitement légitime au run suivant, et le diagnostic
|
|
1524
|
+
// serait incompréhensible.
|
|
1525
|
+
if (parentStack.length) session._skillStack = parentStack;
|
|
1526
|
+
else delete session._skillStack;
|
|
1325
1527
|
}
|
|
1326
1528
|
}
|
|
1327
1529
|
|
|
@@ -8,8 +8,23 @@ import {
|
|
|
8
8
|
resolveExecutorArguments,
|
|
9
9
|
resolvePreparedDelegationApproval,
|
|
10
10
|
startupWizardGaps,
|
|
11
|
+
waitForRuntimeChain,
|
|
11
12
|
} from './wiki-manager.js';
|
|
12
13
|
|
|
14
|
+
const CHAIN_SESSION = { runtime: { url: 'http://127.0.0.1:7788' }, workspace: 'demo' };
|
|
15
|
+
|
|
16
|
+
function chainClient(states, { onApprove } = {}) {
|
|
17
|
+
let call = 0;
|
|
18
|
+
return {
|
|
19
|
+
fetchRuntimeState: async () => states[Math.min(call++, states.length - 1)],
|
|
20
|
+
postRuntimeApprove: async (args) => { onApprove?.(args); return { approved: true }; },
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function chainItem(sequence, status, extra = {}) {
|
|
25
|
+
return { id: `control-${sequence}`, chainId: 'chain-1', chainSequence: sequence, status, ...extra };
|
|
26
|
+
}
|
|
27
|
+
|
|
13
28
|
const COLLECT_CAPABILITY = {
|
|
14
29
|
description: 'Collect content from an external connector source.',
|
|
15
30
|
inputSchema: {
|
|
@@ -348,3 +363,77 @@ test('argument extraction keeps a value the vocabulary allows', async () => {
|
|
|
348
363
|
{ source_name: 'EAS_Avant_projet_ACPI' },
|
|
349
364
|
);
|
|
350
365
|
});
|
|
366
|
+
|
|
367
|
+
test('headless waits for every run of a skill chain, not just the first', async () => {
|
|
368
|
+
// wiki-sync compiles into two sequential runs: returning as soon as the
|
|
369
|
+
// export finishes would report success before the ingest had started.
|
|
370
|
+
const client = chainClient([
|
|
371
|
+
{ controlQueue: [chainItem(0, 'running', { runId: 'run-a' }), chainItem(1, 'queued')] },
|
|
372
|
+
{ controlQueue: [chainItem(0, 'done'), chainItem(1, 'running', { runId: 'run-b' })] },
|
|
373
|
+
{ controlQueue: [chainItem(0, 'done'), chainItem(1, 'done')] },
|
|
374
|
+
]);
|
|
375
|
+
const log = [];
|
|
376
|
+
const result = await waitForRuntimeChain(CHAIN_SESSION, log, {
|
|
377
|
+
chainId: 'chain-1', timeoutMs: 5000, pollMs: 1, client,
|
|
378
|
+
});
|
|
379
|
+
assert.equal(result.exitCode, 0);
|
|
380
|
+
assert.ok(log.some((line) => line.startsWith('chain-step 2/2: running')), 'second step must be observed');
|
|
381
|
+
assert.ok(log.some((line) => line.includes('2 step(s), 0 failed, 0 skipped')));
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('a failed chain step propagates a non-zero exit code and reports the skip', async () => {
|
|
385
|
+
const client = chainClient([
|
|
386
|
+
{ controlQueue: [chainItem(0, 'running', { runId: 'run-a' }), chainItem(1, 'queued')] },
|
|
387
|
+
{
|
|
388
|
+
controlQueue: [
|
|
389
|
+
chainItem(0, 'failed'),
|
|
390
|
+
chainItem(1, 'skipped', { skipReason: 'required_predecessor_failed' }),
|
|
391
|
+
],
|
|
392
|
+
},
|
|
393
|
+
]);
|
|
394
|
+
const log = [];
|
|
395
|
+
const result = await waitForRuntimeChain(CHAIN_SESSION, log, {
|
|
396
|
+
chainId: 'chain-1', timeoutMs: 5000, pollMs: 1, client,
|
|
397
|
+
});
|
|
398
|
+
assert.equal(result.exitCode, 1);
|
|
399
|
+
assert.ok(log.some((line) => line.includes('required_predecessor_failed')));
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('a chain blocked on approval returns instead of hanging until the timeout', async () => {
|
|
403
|
+
const blocked = {
|
|
404
|
+
controlQueue: [chainItem(0, 'running', { runId: 'run-a' })],
|
|
405
|
+
approvals: [{ status: 'pending_approval', runId: 'run-a' }],
|
|
406
|
+
};
|
|
407
|
+
const log = [];
|
|
408
|
+
const result = await waitForRuntimeChain(CHAIN_SESSION, log, {
|
|
409
|
+
chainId: 'chain-1', timeoutMs: 5000, pollMs: 1, client: chainClient([blocked]),
|
|
410
|
+
});
|
|
411
|
+
assert.equal(result.exitCode, 0);
|
|
412
|
+
assert.ok(log.some((line) => line.includes('--auto-approve')));
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test('--auto-approve grants the run-scoped approval of the active chain step once', async () => {
|
|
416
|
+
const approvals = [];
|
|
417
|
+
const client = chainClient([
|
|
418
|
+
{
|
|
419
|
+
controlQueue: [chainItem(0, 'running', { runId: 'run-a' })],
|
|
420
|
+
approvals: [{ status: 'pending_approval', runId: 'run-a', approvalClasses: ['mutation'] }],
|
|
421
|
+
planRevision: 3,
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
controlQueue: [chainItem(0, 'running', { runId: 'run-a' })],
|
|
425
|
+
approvals: [{ status: 'pending_approval', runId: 'run-a', approvalClasses: ['mutation'] }],
|
|
426
|
+
planRevision: 3,
|
|
427
|
+
},
|
|
428
|
+
{ controlQueue: [chainItem(0, 'done')] },
|
|
429
|
+
], { onApprove: (args) => approvals.push(args) });
|
|
430
|
+
const result = await waitForRuntimeChain(CHAIN_SESSION, [], {
|
|
431
|
+
chainId: 'chain-1', timeoutMs: 5000, pollMs: 1, autoApprove: true, client,
|
|
432
|
+
});
|
|
433
|
+
assert.equal(result.exitCode, 0);
|
|
434
|
+
assert.equal(approvals.length, 1, 'the same revision must not be approved twice');
|
|
435
|
+
assert.deepEqual(
|
|
436
|
+
{ runId: approvals[0].runId, scope: approvals[0].scope, planRevision: approvals[0].planRevision },
|
|
437
|
+
{ runId: 'run-a', scope: 'run', planRevision: 3 },
|
|
438
|
+
);
|
|
439
|
+
});
|
package/src/commands/slash.js
CHANGED
|
@@ -11,7 +11,7 @@ import { isTerminal } from '../orchestrator/taskStatuses.js';
|
|
|
11
11
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
12
12
|
import { openExternalUrl } from '../shell/openExternal.js';
|
|
13
13
|
import { classifyCommandFailure, failureHint, rawFailureText } from '../core/commandFailure.js';
|
|
14
|
-
import { join, relative } from 'node:path';
|
|
14
|
+
import { basename, join, relative } from 'node:path';
|
|
15
15
|
import { composeServices, listServices, otherWorkspacesRunning, runWikiCli, serviceLogs, serviceNames, serviceStates, startService, stopService } from '../core/compose.js';
|
|
16
16
|
import { agentServiceNames, profileServiceStatus } from '../core/agentsCompose.js';
|
|
17
17
|
import { GOOGLE_GRANTS, GOOGLE_GRANT_LABELS, defaultGoogleGrants } from '../core/googleGrants.js';
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
formatMcpTools,
|
|
27
27
|
} from '../core/mcp.js';
|
|
28
28
|
import { createWorkspace, findWorkspace, listWorkspaces } from '../core/workspaces.js';
|
|
29
|
-
import { findSkill, listSkills } from '../core/skills.js';
|
|
29
|
+
import { findSkill, inspectSkills, listSkills } from '../core/skills.js';
|
|
30
30
|
import { extractActivity, formatActivityError, formatActivityLine, formatActivitySummary, parseJsonText } from '../core/activity.js';
|
|
31
31
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
32
32
|
import {
|
|
@@ -464,23 +464,27 @@ export function compactMcpStatus(mcpStatus) {
|
|
|
464
464
|
}
|
|
465
465
|
|
|
466
466
|
function skillsText(session) {
|
|
467
|
-
const skills =
|
|
468
|
-
|
|
469
|
-
return skills
|
|
467
|
+
const { skills, rejected, warnings } = inspectSkills(session);
|
|
468
|
+
const active = skills
|
|
470
469
|
.map((skill) => {
|
|
471
470
|
const description = String(skill.description || 'workflow skill').replace(/\s+/g, ' ').trim();
|
|
472
471
|
const compact = description.length > 96 ? `${description.slice(0, 93)}...` : description;
|
|
473
472
|
return `${skill.name}\t${skill.scope}\t${compact}`;
|
|
474
473
|
})
|
|
475
474
|
.join('\n');
|
|
475
|
+
return [
|
|
476
|
+
active || 'No skills discovered.',
|
|
477
|
+
rejected.length ? `\nRejected\n${rejected.map((item) => `${item.relativePath}\t${item.reason}${item.name ? `\t${item.name}` : ''}`).join('\n')}` : null,
|
|
478
|
+
warnings.length ? `\nWarnings\n${warnings.map((item) => `${item.relativePath}\t${item.reason}\t${item.name}`).join('\n')}` : null,
|
|
479
|
+
].filter(Boolean).join('\n');
|
|
476
480
|
}
|
|
477
481
|
|
|
478
|
-
function skillDetailText(skill) {
|
|
482
|
+
function skillDetailText(skill, session) {
|
|
479
483
|
return [
|
|
480
484
|
`# ${skill.name}`,
|
|
481
485
|
'',
|
|
482
486
|
`Scope: ${skill.scope}`,
|
|
483
|
-
`Path: ${skill.path}`,
|
|
487
|
+
`Path: ${session?.workspacePath ? relative(session.workspacePath, skill.path) : basename(skill.path)}`,
|
|
484
488
|
skill.description ? `Description: ${skill.description}` : null,
|
|
485
489
|
skill.params?.length ? `Params: ${skill.params.join(', ')}` : null,
|
|
486
490
|
'',
|
|
@@ -523,7 +527,7 @@ function skillActionCommand(session, action, name) {
|
|
|
523
527
|
agentTrigger: buildSkillRunPrompt(skill),
|
|
524
528
|
};
|
|
525
529
|
}
|
|
526
|
-
return { output: skillDetailText(skill) };
|
|
530
|
+
return { output: skillDetailText(skill, session) };
|
|
527
531
|
}
|
|
528
532
|
|
|
529
533
|
function skillEditCommand(session, name) {
|
|
@@ -636,6 +640,8 @@ async function statusText(session) {
|
|
|
636
640
|
`path: ${compactPath(session.workspacePath ?? '-')}`,
|
|
637
641
|
`env: ${compactPath(session.workspaceEnvFile ?? '-')}`,
|
|
638
642
|
]);
|
|
643
|
+
const skillDiagnostics = inspectSkills(session);
|
|
644
|
+
const skillDiagnosticCount = skillDiagnostics.rejected.length + skillDiagnostics.warnings.length;
|
|
639
645
|
const configColumn = sectionBlock('Config', [
|
|
640
646
|
`wikirc: ${session.wikirc?.profile ?? '-'}${session.wikirc?.fileName ? ` (${session.wikirc.fileName})` : ''}`,
|
|
641
647
|
`language: ${session.language ?? '-'}`,
|
|
@@ -643,6 +649,7 @@ async function statusText(session) {
|
|
|
643
649
|
`provider: ${session.wikircConfig?.llm?.provider ?? '-'}`,
|
|
644
650
|
`model: ${session.wikircConfig?.llm?.model ?? '-'}`,
|
|
645
651
|
`baseUrl: ${compactBaseUrl(session.wikircConfig?.llm?.baseUrl)}`,
|
|
652
|
+
...(skillDiagnosticCount ? [`skill diagnostics: ${skillDiagnostics.rejected.length} rejected, ${skillDiagnostics.warnings.length} warning(s) (/skills)`] : []),
|
|
646
653
|
]);
|
|
647
654
|
const runtimeColumn = sectionBlock('Runtime', (states ? serviceStatesText(states) : 'Docker runtime not available or no workspace loaded.').split('\n'));
|
|
648
655
|
const mcpColumn = sectionBlock('MCP', compactMcpStatus(session.mcp).split('\n'));
|
|
@@ -721,9 +728,10 @@ Options:
|
|
|
721
728
|
--once <prompt> Run one agent turn and exit
|
|
722
729
|
--headless Run a workspace task non-interactively
|
|
723
730
|
--workspace <name> Initial workspace (interactive or --headless)
|
|
724
|
-
--skill <name>
|
|
731
|
+
--skill "<name> [args]" Skill invocation for --headless (implies --wait)
|
|
725
732
|
--prompt <text> Task or extra instruction for --headless
|
|
726
733
|
--log-file <path> Optional headless log path
|
|
734
|
+
--auto-approve Approve validated headless run revisions automatically
|
|
727
735
|
--wait Wait for active jobs to complete after agent turn (--prompt only)
|
|
728
736
|
--no-wait Disable agentic loop for --skill (single turn)
|
|
729
737
|
--timeout <seconds> Per-wave job wait timeout in seconds (default: 3600)
|
|
@@ -1431,7 +1439,17 @@ export async function handleSlashCommand(line, context) {
|
|
|
1431
1439
|
// would silently revert the item to waiting (fake cancel).
|
|
1432
1440
|
const localItem = (context.session.jobQueue ?? []).find((item) => String(item.id) === String(id));
|
|
1433
1441
|
if (localItem?.origin === 'runtime' || (!localItem && runtimeManagedItemId(context, id))) {
|
|
1434
|
-
return { output: 'Item géré par le runtime —
|
|
1442
|
+
if (!context.runtime?.url) return { output: 'Item géré par le runtime — reconnectez le runtime pour l’annuler, ou utilisez /run cancel ou /run kill.' };
|
|
1443
|
+
try {
|
|
1444
|
+
const result = await postRuntimeControl('cancel_item', {
|
|
1445
|
+
url: context.runtime.url,
|
|
1446
|
+
workspace: context.session.workspace ?? null,
|
|
1447
|
+
id,
|
|
1448
|
+
});
|
|
1449
|
+
return { output: result.cancelled ? `Cancelled runtime queue item ${id}.` : `Runtime queue item ${id} was not cancelled.` };
|
|
1450
|
+
} catch (err) {
|
|
1451
|
+
return { output: `Runtime queue cancellation failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
1452
|
+
}
|
|
1435
1453
|
}
|
|
1436
1454
|
const result = await cancelQueueItem(context.session, id);
|
|
1437
1455
|
return { output: result.message };
|
package/src/contracts/schemas.js
CHANGED
|
@@ -401,7 +401,7 @@ export const contractSchemas = {
|
|
|
401
401
|
required: ['action'],
|
|
402
402
|
additionalProperties: true,
|
|
403
403
|
properties: {
|
|
404
|
-
action: { type: 'string', enum: ['status', 'explain', 'message', 'enqueue', 'approve_patch', 'reject_patch'] },
|
|
404
|
+
action: { type: 'string', enum: ['status', 'explain', 'message', 'enqueue', 'cancel_item', 'approve_patch', 'reject_patch'] },
|
|
405
405
|
input: { type: 'string' },
|
|
406
406
|
message: { type: 'string' },
|
|
407
407
|
prompt: { type: 'string' },
|