@dotdrelle/wiki-manager 0.15.96 → 0.15.97
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/package.json +1 -1
- package/src/agent/graph.js +25 -3
- package/src/agent/graph.test.js +68 -1
- package/src/core/agentEvents.js +20 -1
- package/src/core/agentEvents.test.js +34 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +1 -1
- package/src/runtime/server.js +156 -32
- package/src/runtime/server.test.js +133 -14
- package/src/runtime/skillRun.js +2 -2
- package/src/runtime/skillRun.test.js +5 -3
package/package.json
CHANGED
package/src/agent/graph.js
CHANGED
|
@@ -436,6 +436,19 @@ export function bareToolCallJson(content, tools = []) {
|
|
|
436
436
|
return hasArguments ? name : null;
|
|
437
437
|
}
|
|
438
438
|
|
|
439
|
+
/**
|
|
440
|
+
* A model that has NO tool to call sometimes writes the call as text:
|
|
441
|
+
* `runtime__delegate{"objective":"…"}`. Unlike `bareToolCallJson` it is not
|
|
442
|
+
* JSON and cannot be validated against the offered set (there is none), but the
|
|
443
|
+
* `<namespace>__<tool>{` shape is never legitimate prose. Observed after a
|
|
444
|
+
* terminal failure stripped the tools from the synthesis turn: the raw call
|
|
445
|
+
* reached the user and nothing ran.
|
|
446
|
+
*/
|
|
447
|
+
export function narratedToolCallText(content) {
|
|
448
|
+
const match = String(content ?? '').trim().match(/^([a-z][a-z0-9_-]*__[a-z][a-z0-9_-]*)\s*\{/i);
|
|
449
|
+
return match ? match[1] : null;
|
|
450
|
+
}
|
|
451
|
+
|
|
439
452
|
function parseActionJson(text) {
|
|
440
453
|
const cleaned = String(text ?? '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
|
441
454
|
if (!cleaned) return null;
|
|
@@ -979,7 +992,7 @@ export async function handleRuntimeControlTool(session, tool, args = {}) {
|
|
|
979
992
|
}
|
|
980
993
|
if (tool === 'run_skill') {
|
|
981
994
|
const skillName = String(args.skillName ?? '').trim();
|
|
982
|
-
if (!skillName) return JSON.stringify({ ok: false, terminal:
|
|
995
|
+
if (!skillName) return JSON.stringify({ ok: false, terminal: false, code: 'skill_not_found', message: 'skillName is required: pass the exact skill name, or delegate the objective with runtime__delegate.', availableSkills: [] });
|
|
983
996
|
const selectedSkill = findSkill(session, skillName);
|
|
984
997
|
const suppliedArguments = args.arguments && typeof args.arguments === 'object' && !Array.isArray(args.arguments)
|
|
985
998
|
? args.arguments
|
|
@@ -1696,10 +1709,14 @@ export function createAgentGraph(options = {}) {
|
|
|
1696
1709
|
// text really is a call to one of them: a legitimate answer that happens
|
|
1697
1710
|
// to contain JSON (a config excerpt, an API sample) must go through
|
|
1698
1711
|
// untouched, which is why this is not a "content starts with {" test.
|
|
1699
|
-
const bareCall = tools.length > 0 ? bareToolCallJson(result.content, tools) : null
|
|
1712
|
+
const bareCall = (tools.length > 0 ? bareToolCallJson(result.content, tools) : null)
|
|
1713
|
+
?? narratedToolCallText(result.content);
|
|
1700
1714
|
if (bareCall) {
|
|
1701
1715
|
const retries = Number(state.invalidToolCallRetries ?? 0);
|
|
1702
|
-
|
|
1716
|
+
// Retry only when a tool can still be called; when none are offered (the
|
|
1717
|
+
// synthesis turn after a terminal failure) the call is unexecutable and
|
|
1718
|
+
// the honest failure below is the whole answer.
|
|
1719
|
+
if (tools.length > 0 && retries < 2) {
|
|
1703
1720
|
state.session._onStreamReset?.();
|
|
1704
1721
|
state.session._onStep?.('Agent: tool call written as JSON text rejected; retrying…');
|
|
1705
1722
|
return {
|
|
@@ -2014,6 +2031,11 @@ export function createAgentGraph(options = {}) {
|
|
|
2014
2031
|
),
|
|
2015
2032
|
objectives: Number(skillResult.objectiveCount ?? skillResult.objectives ?? 1) || 1,
|
|
2016
2033
|
};
|
|
2034
|
+
} else if (skillResult?.ok === false) {
|
|
2035
|
+
// A recoverable refusal (guessed skill, missing input): keep the
|
|
2036
|
+
// turn alive so the model can correct itself or delegate, but do
|
|
2037
|
+
// not let the progress note call it a success.
|
|
2038
|
+
ok = false;
|
|
2017
2039
|
}
|
|
2018
2040
|
}
|
|
2019
2041
|
if (tool === 'delegate' && /^Runtime control error \(delegate\):/i.test(resultText)) {
|
package/src/agent/graph.test.js
CHANGED
|
@@ -626,7 +626,7 @@ test('an explicitly selected skill runs through the intra-runtime path with name
|
|
|
626
626
|
const result = await createAgentGraph().invoke({ input: 'lance le skill deliver avec le template Quarterly report', session });
|
|
627
627
|
// Launching a skill ends the turn: the acknowledgement is generated once and
|
|
628
628
|
// the model is not given a second chance to re-delegate or contradict it.
|
|
629
|
-
assert.equal(result.response, 'Started /deliver deliverable="Quarterly report" — 1 step(s)
|
|
629
|
+
assert.equal(result.response, 'Started /deliver deliverable="Quarterly report" — 1 step(s) queued.');
|
|
630
630
|
assert.equal(mainCalls, 1);
|
|
631
631
|
// `skillStack` accompagne désormais la demande : le run imbriqué démarre après
|
|
632
632
|
// le nettoyage de celui-ci, et c'est le seul canal par lequel il peut savoir
|
|
@@ -709,6 +709,73 @@ test('a terminal skill refusal stops the whole turn before a delegate fallback',
|
|
|
709
709
|
assert.equal(delegated, false);
|
|
710
710
|
});
|
|
711
711
|
|
|
712
|
+
test('a narrated tool call on the tool-less synthesis turn is never shown, and nothing runs', async () => {
|
|
713
|
+
// After a terminal failure the synthesis turn offers no tools; a model that
|
|
714
|
+
// still wants to act writes the call as text (`runtime__delegate{"…"}`) and
|
|
715
|
+
// the turn did nothing. That raw call must never reach the user.
|
|
716
|
+
let delegated = false;
|
|
717
|
+
const narrated = 'runtime__delegate{"objective":"nettoyer le wiki"}';
|
|
718
|
+
const session = sessionBase({
|
|
719
|
+
runtime: { url: 'http://runtime.test' },
|
|
720
|
+
_runSkillWithinRun: async () => ({ ok: false, terminal: true, code: 'skill_not_found', availableSkills: [] }),
|
|
721
|
+
_delegateWithinRun: async () => { delegated = true; return { runId: 'bad' }; },
|
|
722
|
+
llm: {
|
|
723
|
+
async completeWithTools({ tools }) {
|
|
724
|
+
if (tools.some((tool) => tool.function?.name === 'classify_action_request')) {
|
|
725
|
+
return { content: null, message: { role: 'assistant', content: null }, tool_calls: [{ id: 'classify', type: 'function', function: { name: 'classify_action_request', arguments: '{"action":true}' } }] };
|
|
726
|
+
}
|
|
727
|
+
if (tools.length > 0) {
|
|
728
|
+
return { content: null, message: { role: 'assistant', content: null }, tool_calls: [
|
|
729
|
+
{ id: 'missing', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"missing","selectionKind":"explicit_name"}' } },
|
|
730
|
+
] };
|
|
731
|
+
}
|
|
732
|
+
return { content: narrated, message: { role: 'assistant', content: narrated }, tool_calls: null };
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
});
|
|
736
|
+
const result = await createAgentGraph().invoke({ input: 'nettoie le wiki', session });
|
|
737
|
+
assert.equal(delegated, false);
|
|
738
|
+
assert.match(result.response, /printed an internal tool request/);
|
|
739
|
+
assert.doesNotMatch(result.response, /runtime__delegate/);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
test('a recoverable skill refusal lets the delegate fallback run in the same turn', async () => {
|
|
743
|
+
// The observed defect: the model guessed `/diagnose`, the skill runner
|
|
744
|
+
// answered skill_not_found, and because that was terminal the tools were
|
|
745
|
+
// stripped from the next turn — the model then wrote
|
|
746
|
+
// `runtime__delegate{...}` as plain text and the turn did nothing. A guessed
|
|
747
|
+
// skill must be recoverable so the fallback can actually run.
|
|
748
|
+
let delegated = false;
|
|
749
|
+
const session = sessionBase({
|
|
750
|
+
runtime: { url: 'http://runtime.test' },
|
|
751
|
+
_runSkillWithinRun: async () => ({
|
|
752
|
+
ok: false,
|
|
753
|
+
terminal: false,
|
|
754
|
+
code: 'skill_not_found',
|
|
755
|
+
message: 'No skill named "/diagnose". Pass the exact name without a leading slash, or delegate the objective with runtime__delegate.',
|
|
756
|
+
availableSkills: ['diagnose'],
|
|
757
|
+
}),
|
|
758
|
+
_delegateWithinRun: async () => { delegated = true; return { runId: 'run-1', summary: { tasks: 1, agent: 'gateway' } }; },
|
|
759
|
+
llm: {
|
|
760
|
+
async completeWithTools({ tools }) {
|
|
761
|
+
if (tools.some((tool) => tool.function?.name === 'classify_action_request')) {
|
|
762
|
+
return { content: null, message: { role: 'assistant', content: null }, tool_calls: [{ id: 'classify', type: 'function', function: { name: 'classify_action_request', arguments: '{"action":true}' } }] };
|
|
763
|
+
}
|
|
764
|
+
return {
|
|
765
|
+
content: null, message: { role: 'assistant', content: null },
|
|
766
|
+
tool_calls: [
|
|
767
|
+
{ id: 'guess', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"/diagnose","arguments":{}}' } },
|
|
768
|
+
{ id: 'fallback', type: 'function', function: { name: 'runtime__delegate', arguments: '{"objective":"nettoyer le wiki, corriger les doublons et les affirmations non sourcées"}' } },
|
|
769
|
+
],
|
|
770
|
+
};
|
|
771
|
+
},
|
|
772
|
+
},
|
|
773
|
+
});
|
|
774
|
+
const result = await createAgentGraph().invoke({ input: 'nettoie le wiki, corrige les doublons et les affirmations non sourcées', session });
|
|
775
|
+
assert.equal(delegated, true);
|
|
776
|
+
assert.notEqual(result.terminalToolFailure, true);
|
|
777
|
+
});
|
|
778
|
+
|
|
712
779
|
test('tool argument normalization repairs only an unambiguous schema-compatible field name', () => {
|
|
713
780
|
const schema = {
|
|
714
781
|
type: 'object',
|
package/src/core/agentEvents.js
CHANGED
|
@@ -6,7 +6,7 @@ import { formatRuntimeLogPayload, isDispatchPlumbingLine, normalizeRuntimeLog, s
|
|
|
6
6
|
import { projectSkillChains, TERMINAL as CONTROL_TERMINAL_STATUSES } from './skillChainView.js';
|
|
7
7
|
import { projectWorkflow } from './workflow.js';
|
|
8
8
|
import { validateContractInDev } from '../contracts/schemas.js';
|
|
9
|
-
import { isTerminal, isSuccessful, isUnknownStatus, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
|
|
9
|
+
import { isActive, isTerminal, isSuccessful, isUnknownStatus, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
|
|
10
10
|
|
|
11
11
|
const SESSION_PROJECTION_EVENTS = new Set([
|
|
12
12
|
'run_started',
|
|
@@ -292,6 +292,11 @@ export function applyAgentProjectionToSession(session, projection) {
|
|
|
292
292
|
} : session.productionActivity ?? null;
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
function hasRunningPlanStep(state) {
|
|
296
|
+
return (Array.isArray(state.plan) ? state.plan : [])
|
|
297
|
+
.some((step) => isActive(step?.status));
|
|
298
|
+
}
|
|
299
|
+
|
|
295
300
|
function applyEvent(state, event) {
|
|
296
301
|
switch (event.type) {
|
|
297
302
|
case 'run_started':
|
|
@@ -598,6 +603,13 @@ function applyEvent(state, event) {
|
|
|
598
603
|
reason: event.payload?.reason ?? null,
|
|
599
604
|
createdAt: event.ts,
|
|
600
605
|
});
|
|
606
|
+
// A run waiting for a human is not "running": showing it as running is
|
|
607
|
+
// how the chat could claim a rebuild was executing before anyone
|
|
608
|
+
// approved it. Mirror `run_pending_approval` (AGENTS.md: the run status
|
|
609
|
+
// is pending_approval while the decision is outstanding), but only when
|
|
610
|
+
// no task is actually executing — a parallel run may have work in flight
|
|
611
|
+
// while one branch waits.
|
|
612
|
+
if (!hasRunningPlanStep(state)) state.status = 'pending_approval';
|
|
601
613
|
return;
|
|
602
614
|
case 'approval.granted': {
|
|
603
615
|
const grant = {
|
|
@@ -617,6 +629,13 @@ function applyEvent(state, event) {
|
|
|
617
629
|
};
|
|
618
630
|
upsertApproval(state, grant);
|
|
619
631
|
markCoveredApprovalsApproved(state.approvals, grant, event.ts);
|
|
632
|
+
// The decision is in: the run goes back to running unless another
|
|
633
|
+
// approval is still outstanding (a run-scoped grant clears its covered
|
|
634
|
+
// ones, markCoveredApprovalsApproved above).
|
|
635
|
+
if (state.status === 'pending_approval'
|
|
636
|
+
&& !(state.approvals ?? []).some((approval) => approval.status === 'pending_approval')) {
|
|
637
|
+
state.status = 'running';
|
|
638
|
+
}
|
|
620
639
|
return;
|
|
621
640
|
}
|
|
622
641
|
case 'approval.rejected':
|
|
@@ -127,6 +127,40 @@ test('reduceAgentEvents: interactive (user) run_started clears state but is not
|
|
|
127
127
|
assert.notEqual(projection.status, 'running');
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
test('reduceAgentEvents: a run blocked on approval is not shown as running', () => {
|
|
131
|
+
// The chat claimed a rebuild was executing while its only task was still
|
|
132
|
+
// waiting for a human: the run status stayed 'running' through the per-task
|
|
133
|
+
// approval request. It must mirror run_pending_approval.
|
|
134
|
+
const projection = reduceAgentEvents([
|
|
135
|
+
createAgentEvent('run_started', { origin: 'runtime' }),
|
|
136
|
+
createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Rebuild the concepts'] } }),
|
|
137
|
+
createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'waiting_approval' } }),
|
|
138
|
+
createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
|
|
139
|
+
]);
|
|
140
|
+
assert.equal(projection.status, 'pending_approval');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('reduceAgentEvents: granting the approval puts the run back to running', () => {
|
|
144
|
+
const projection = reduceAgentEvents([
|
|
145
|
+
createAgentEvent('run_started', { origin: 'runtime' }),
|
|
146
|
+
createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Rebuild the concepts'] } }),
|
|
147
|
+
createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'waiting_approval' } }),
|
|
148
|
+
createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
|
|
149
|
+
createAgentEvent('approval.granted', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
|
|
150
|
+
]);
|
|
151
|
+
assert.equal(projection.status, 'running');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('reduceAgentEvents: an approval request does not hide a genuinely running task', () => {
|
|
155
|
+
const projection = reduceAgentEvents([
|
|
156
|
+
createAgentEvent('run_started', { origin: 'runtime' }),
|
|
157
|
+
createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Export', 'Build'] } }),
|
|
158
|
+
createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'running' } }),
|
|
159
|
+
createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't2' } }),
|
|
160
|
+
]);
|
|
161
|
+
assert.equal(projection.status, 'running');
|
|
162
|
+
});
|
|
163
|
+
|
|
130
164
|
test('reduceAgentEvents: tracks manual plan and step updates', () => {
|
|
131
165
|
const projection = reduceAgentEvents([
|
|
132
166
|
createAgentEvent('plan_set', {
|
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.97';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
package/src/runtime/server.js
CHANGED
|
@@ -7,6 +7,7 @@ import { validateContractInDev } from '../contracts/schemas.js';
|
|
|
7
7
|
import { runtimeTokenFromEnv } from './auth.js';
|
|
8
8
|
import { controlMessage } from './controlMessages.js';
|
|
9
9
|
import { tasksAwaitingApproval } from '../orchestrator/dependencyResolver.js';
|
|
10
|
+
import { isActive, isCancelled, isFailed, isSuccessful } from '../orchestrator/taskStatuses.js';
|
|
10
11
|
import { approvalClassForTask } from '../orchestrator/approvalPolicy.js';
|
|
11
12
|
import { RUNTIME_SHUTDOWN_ABORT_REASON } from '../orchestrator/dispatcher.js';
|
|
12
13
|
import { matchSkillInvocation } from '../core/skillInvocation.js';
|
|
@@ -502,7 +503,7 @@ export function startRuntimeServer({
|
|
|
502
503
|
}
|
|
503
504
|
if (request.method === 'POST' && url.pathname === '/turn') {
|
|
504
505
|
const { body, context } = await resolveBodyContext(request, url);
|
|
505
|
-
|
|
506
|
+
let input = String(body.input ?? body.prompt ?? '').trim();
|
|
506
507
|
if (!input) {
|
|
507
508
|
sendJson(response, 400, { error: 'Missing input.' });
|
|
508
509
|
return;
|
|
@@ -529,19 +530,15 @@ export function startRuntimeServer({
|
|
|
529
530
|
}
|
|
530
531
|
return;
|
|
531
532
|
}
|
|
532
|
-
// A run/job status question
|
|
533
|
-
// the
|
|
534
|
-
//
|
|
535
|
-
//
|
|
533
|
+
// A run/job status question must NEVER surface the raw system text in
|
|
534
|
+
// the thread: the runtime collects the facts and hands them to Donna,
|
|
535
|
+
// who synthesizes them in the session language. `readOnlyChat` so the
|
|
536
|
+
// turn is a conversation, not a control decision — and the facts are
|
|
537
|
+
// supplied here so the model cannot mistake the runtime run id for a
|
|
538
|
+
// production job id ("job not found").
|
|
536
539
|
if (asksForRunStatus(input)) {
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
accepted: true,
|
|
540
|
-
kind: 'observe',
|
|
541
|
-
...status,
|
|
542
|
-
explanation: explainControlState(status),
|
|
543
|
-
});
|
|
544
|
-
return;
|
|
540
|
+
input = runtimeStatusSynthesisPrompt(input, controlStatus(context, store));
|
|
541
|
+
readOnlyChat = true;
|
|
545
542
|
}
|
|
546
543
|
if (context.running && !readOnlyChat) {
|
|
547
544
|
// Agent-mode message while a run is active. Classify once: control
|
|
@@ -552,7 +549,13 @@ export function startRuntimeServer({
|
|
|
552
549
|
llm: context?.session?.llm,
|
|
553
550
|
session: context?.session,
|
|
554
551
|
});
|
|
555
|
-
if (classification.kind
|
|
552
|
+
if (classification.kind === 'observe') {
|
|
553
|
+
// An observation is system facts, not a control action: Donna gets
|
|
554
|
+
// them and answers in the session language, never a raw English
|
|
555
|
+
// line pushed into the thread.
|
|
556
|
+
input = runtimeStatusSynthesisPrompt(input, controlStatus(context, store));
|
|
557
|
+
readOnlyChat = true;
|
|
558
|
+
} else if (classification.kind !== 'converse') {
|
|
556
559
|
const result = await handleControlMessage(context, store, input, {
|
|
557
560
|
intent: body.intent,
|
|
558
561
|
startNextControlRequest,
|
|
@@ -561,8 +564,9 @@ export function startRuntimeServer({
|
|
|
561
564
|
});
|
|
562
565
|
sendJson(response, result.statusCode, result.body);
|
|
563
566
|
return;
|
|
567
|
+
} else {
|
|
568
|
+
readOnlyChat = true;
|
|
564
569
|
}
|
|
565
|
-
readOnlyChat = true;
|
|
566
570
|
}
|
|
567
571
|
if (typeof turn !== 'function') {
|
|
568
572
|
sendJson(response, 501, { error: 'Runtime interactive turns are unavailable.' });
|
|
@@ -580,7 +584,10 @@ export function startRuntimeServer({
|
|
|
580
584
|
llm: context?.session?.llm,
|
|
581
585
|
session: context?.session,
|
|
582
586
|
});
|
|
583
|
-
if (classification.kind
|
|
587
|
+
if (classification.kind === 'observe') {
|
|
588
|
+
input = runtimeStatusSynthesisPrompt(input, controlStatus(context, store));
|
|
589
|
+
readOnlyChat = true;
|
|
590
|
+
} else if (classification.kind !== 'converse') {
|
|
584
591
|
const result = await handleControlMessage(context, store, input, {
|
|
585
592
|
intent: body.intent,
|
|
586
593
|
startNextControlRequest,
|
|
@@ -867,12 +874,24 @@ export function startRuntimeServer({
|
|
|
867
874
|
if (context?.session) {
|
|
868
875
|
context.session._runSkillWithinRun = async (skillName, args = {}, metadata = {}) => {
|
|
869
876
|
const skill = findSkill(context.session, skillName);
|
|
870
|
-
if (!skill)
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
877
|
+
if (!skill) {
|
|
878
|
+
const available = listSkills(context.session).map((item) => item.name);
|
|
879
|
+
/*
|
|
880
|
+
A skill the model GUESSED is recoverable, not terminal: the observed
|
|
881
|
+
failure was `/diagnose` (leading slash copied from the catalogue) →
|
|
882
|
+
skill_not_found → the terminal path stripped the tools from the
|
|
883
|
+
synthesis turn → the model wrote `runtime__delegate{...}` as plain
|
|
884
|
+
text and the turn did nothing. An explicitly user-named missing skill
|
|
885
|
+
stays terminal: there is nothing to fall back to.
|
|
886
|
+
*/
|
|
887
|
+
return {
|
|
888
|
+
ok: false,
|
|
889
|
+
terminal: metadata.selectionKind === 'explicit_name',
|
|
890
|
+
code: 'skill_not_found',
|
|
891
|
+
message: `No skill named "${skillName}". Pass the exact name without a leading slash (${available.join(', ') || 'none'}), or delegate the objective with runtime__delegate.`,
|
|
892
|
+
availableSkills: available,
|
|
893
|
+
};
|
|
894
|
+
}
|
|
876
895
|
try {
|
|
877
896
|
const idempotencyKey = metadata.idempotencyKey
|
|
878
897
|
? String(metadata.idempotencyKey)
|
|
@@ -1161,22 +1180,109 @@ export function runtimeState(context, store, { workspace = null, session = null
|
|
|
1161
1180
|
// own history) so those replies surface. The log is a superset of the
|
|
1162
1181
|
// canonical run conversation, so run rendering is unaffected.
|
|
1163
1182
|
conversation: reduceAgentEvents(store.listEvents({ workspace })).conversation,
|
|
1164
|
-
|
|
1183
|
+
// `context.running` keeps the process alive while the scheduler waits for an
|
|
1184
|
+
// approval, but the run is then NOT running — the reducer already says
|
|
1185
|
+
// `pending_approval`. Prefer it over the blanket override.
|
|
1186
|
+
status: context?.running
|
|
1187
|
+
? (state.status === 'pending_approval' ? 'pending_approval' : 'running')
|
|
1188
|
+
: state.status ?? 'idle',
|
|
1165
1189
|
running: Boolean(context?.running),
|
|
1166
1190
|
runId: context?.currentRunId ?? state.runId ?? null,
|
|
1167
1191
|
workspace: context?.currentRunWorkspace ?? context?.workspace ?? state.workspace ?? workspace ?? null,
|
|
1168
1192
|
};
|
|
1169
1193
|
}
|
|
1170
1194
|
|
|
1195
|
+
// The live figures of the activity the run is on, in one line: percent, plan
|
|
1196
|
+
// step, build batch, instruction count and the stabilize counters. A status
|
|
1197
|
+
// that only named the current step could not tell 5% from 95%, nor what the
|
|
1198
|
+
// running batch had actually done.
|
|
1199
|
+
function describeActivityProgress(activity) {
|
|
1200
|
+
const progress = activity?.progress ?? {};
|
|
1201
|
+
const bits = [];
|
|
1202
|
+
if (Number.isFinite(Number(progress.percent))) bits.push(`${Number(progress.percent)}%`);
|
|
1203
|
+
if (progress.stepIndex != null && progress.stepTotal != null) bits.push(`step ${progress.stepIndex}/${progress.stepTotal}`);
|
|
1204
|
+
if (progress.batchIndex != null && progress.batchCount != null) bits.push(`batch ${Number(progress.batchIndex) + 1}/${progress.batchCount}`);
|
|
1205
|
+
if (progress.instructionCount != null) bits.push(`${progress.instructionCount} instruction${Number(progress.instructionCount) > 1 ? 's' : ''}`);
|
|
1206
|
+
const stabilize = [progress.stabilizeKept, progress.stabilizeMerged, progress.stabilizeInserted, progress.stabilizeRemoved];
|
|
1207
|
+
if (stabilize.some((value) => value != null)) {
|
|
1208
|
+
bits.push(`kept ${progress.stabilizeKept ?? 0}, merged ${progress.stabilizeMerged ?? 0}, inserted ${progress.stabilizeInserted ?? 0}, removed ${progress.stabilizeRemoved ?? 0}`);
|
|
1209
|
+
}
|
|
1210
|
+
const detail = progress.detail && !bits.includes(String(progress.detail)) ? String(progress.detail) : null;
|
|
1211
|
+
return { bits: bits.join(' · '), detail, label: activity?.label ?? null };
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// The facts a status answer is built from, rendered server-side ONCE. A status
|
|
1215
|
+
// question is answered by Donna, never by pushing this text into the thread:
|
|
1216
|
+
// the runtime supplies the figures, she phrases them in the session language.
|
|
1217
|
+
function runtimeStatusFacts(status) {
|
|
1218
|
+
const plan = Array.isArray(status.plan) ? status.plan : [];
|
|
1219
|
+
const approvals = Array.isArray(status.approvals) ? status.approvals : [];
|
|
1220
|
+
const queue = Array.isArray(status.controlQueue) ? status.controlQueue : [];
|
|
1221
|
+
const activities = Array.isArray(status.activities)
|
|
1222
|
+
? status.activities
|
|
1223
|
+
: Object.values(status.activities ?? {});
|
|
1224
|
+
const lines = [
|
|
1225
|
+
`Runtime status: ${status.status ?? 'idle'}`,
|
|
1226
|
+
`Workspace: ${status.workspace ?? '-'}`,
|
|
1227
|
+
];
|
|
1228
|
+
if (status.runId) lines.push(`Run id: ${status.runId}`);
|
|
1229
|
+
for (const activity of activities.filter((entry) => !entry?.terminal).slice(0, 8)) {
|
|
1230
|
+
const info = describeActivityProgress(activity);
|
|
1231
|
+
const detail = [info.bits, info.detail].filter(Boolean).join(' · ');
|
|
1232
|
+
lines.push(
|
|
1233
|
+
`Activity: ${info.label ?? activity.label ?? activity.id ?? '-'} — ${activity.status ?? '-'}${detail ? ` (${detail})` : ''}`,
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
for (const [index, step] of plan.slice(0, 60).entries()) {
|
|
1237
|
+
lines.push(`Task ${step.step ?? index + 1}: ${step.status ?? 'pending'} - ${step.description ?? step.label ?? step.id ?? 'step'}`);
|
|
1238
|
+
}
|
|
1239
|
+
for (const approval of approvals.filter((entry) => entry.status === 'pending_approval')) {
|
|
1240
|
+
lines.push(`Pending approval: ${approval.reason ?? approval.taskId ?? approval.id ?? '-'}`);
|
|
1241
|
+
}
|
|
1242
|
+
for (const item of queue.filter((entry) => entry.status === 'queued')) {
|
|
1243
|
+
lines.push(`Queued: ${item.label ?? item.input ?? item.id ?? '-'}`);
|
|
1244
|
+
}
|
|
1245
|
+
return lines.join('\n');
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function runtimeStatusSynthesisPrompt(asked, status) {
|
|
1249
|
+
return [
|
|
1250
|
+
'The runtime facts below are the authoritative status the system just collected (this is a runtime run, not a production job — do not look up a job id).',
|
|
1251
|
+
`User question: ${asked}`,
|
|
1252
|
+
'Answer in the session language with a concise, natural status: name the requested target first, then progress, blockers (pending approvals), queued items and the next step.',
|
|
1253
|
+
'Keep every figure (percent, step, batch, instruction and stabilize counts) and every task status accurate; never invent, drop or round away a figure. Do not paste the fact block verbatim; summarize it into prose.',
|
|
1254
|
+
'',
|
|
1255
|
+
'Runtime facts:',
|
|
1256
|
+
runtimeStatusFacts(status),
|
|
1257
|
+
].join('\n');
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1171
1260
|
function explainControlState(status) {
|
|
1172
1261
|
const plan = Array.isArray(status.plan) ? status.plan : [];
|
|
1262
|
+
const activities = Array.isArray(status.activities)
|
|
1263
|
+
? status.activities
|
|
1264
|
+
: Object.values(status.activities ?? {});
|
|
1265
|
+
// A run whose only outstanding work is a human decision is not "running":
|
|
1266
|
+
// `status.running` mirrors the process, which stays alive while the scheduler
|
|
1267
|
+
// waits. Mirrors the reducer's rule — pending_approval, or a pending approval
|
|
1268
|
+
// with no step actually executing.
|
|
1269
|
+
const approvals = Array.isArray(status.approvals) ? status.approvals : [];
|
|
1270
|
+
const pendingApproval = approvals.find((approval) => approval.status === 'pending_approval');
|
|
1271
|
+
const awaitingApproval = pendingApproval
|
|
1272
|
+
&& (status.status === 'pending_approval' || !plan.some((step) => isActive(step?.status)));
|
|
1273
|
+
if (awaitingApproval) {
|
|
1274
|
+
return `Runtime is waiting for approval: ${pendingApproval.reason ?? pendingApproval.id}.`;
|
|
1275
|
+
}
|
|
1173
1276
|
if (status.running) {
|
|
1174
1277
|
const runningStep = plan.find((step) => step.status === 'running');
|
|
1278
|
+
const activity = activities.find((entry) => !entry?.terminal) ?? activities[0] ?? null;
|
|
1279
|
+
const info = activity ? describeActivityProgress(activity) : { bits: '', detail: null, label: null };
|
|
1280
|
+
const detailText = [info.bits, info.detail].filter(Boolean).join(' · ');
|
|
1281
|
+
const suffix = detailText ? ` (${detailText})` : '';
|
|
1175
1282
|
return runningStep
|
|
1176
|
-
? `Runtime run is active. Current step: ${runningStep.description ?? runningStep.label ?? runningStep.step}
|
|
1177
|
-
:
|
|
1283
|
+
? `Runtime run is active. Current step: ${runningStep.description ?? runningStep.label ?? runningStep.step}.${suffix}`
|
|
1284
|
+
: `Runtime run is active. No current plan step is available yet.${suffix}`;
|
|
1178
1285
|
}
|
|
1179
|
-
const pendingApproval = status.approvals.find((approval) => approval.status === 'pending_approval');
|
|
1180
1286
|
if (pendingApproval) {
|
|
1181
1287
|
return `Runtime is waiting for approval: ${pendingApproval.reason ?? pendingApproval.id}.`;
|
|
1182
1288
|
}
|
|
@@ -1187,6 +1293,15 @@ function explainControlState(status) {
|
|
|
1187
1293
|
if (plan.some((step) => step.status === 'pending')) {
|
|
1188
1294
|
return 'Runtime is idle with pending plan steps visible from the last run.';
|
|
1189
1295
|
}
|
|
1296
|
+
// Idle at the end of a run: say what the last run did, not just "idle" — that
|
|
1297
|
+
// is the question the operator actually asks when the thread goes quiet.
|
|
1298
|
+
const failed = plan.filter((step) => isFailed(step.status) || isCancelled(step.status)).length;
|
|
1299
|
+
const done = plan.filter((step) => isSuccessful(step.status)).length;
|
|
1300
|
+
if (plan.length > 0) {
|
|
1301
|
+
return failed > 0
|
|
1302
|
+
? `Runtime is idle. Last run: ${done}/${plan.length} task(s) succeeded, ${failed} failed or cancelled.`
|
|
1303
|
+
: `Runtime is idle. Last run: ${done}/${plan.length} task(s) succeeded.`;
|
|
1304
|
+
}
|
|
1190
1305
|
return 'Runtime is idle.';
|
|
1191
1306
|
}
|
|
1192
1307
|
|
|
@@ -1646,13 +1761,22 @@ function rejectPlanPatch(context, store, patchId, reason) {
|
|
|
1646
1761
|
};
|
|
1647
1762
|
}
|
|
1648
1763
|
|
|
1649
|
-
// A question about the run/job currently executing.
|
|
1650
|
-
// status word AND a run/job noun — so it never hijacks
|
|
1651
|
-
// X works" question. Such a question must be answered
|
|
1652
|
-
// left to the model, a runtime runId was mistaken for a
|
|
1653
|
-
// reported as "not found", and a read-only chat turn had
|
|
1764
|
+
// A question about the run/job currently executing. The free-text form is
|
|
1765
|
+
// deliberately narrow — a status word AND a run/job noun — so it never hijacks
|
|
1766
|
+
// an ordinary "explain how X works" question. Such a question must be answered
|
|
1767
|
+
// by the runtime itself: left to the model, a runtime runId was mistaken for a
|
|
1768
|
+
// production job id and reported as "not found", and a read-only chat turn had
|
|
1769
|
+
// no runtime status tool.
|
|
1770
|
+
//
|
|
1771
|
+
// The reserved built-in `/status` is ALWAYS a runtime status, in every surface:
|
|
1772
|
+
// `RESERVED_SLASH_COMMANDS` keeps the homonymous workspace skill out of
|
|
1773
|
+
// `matchSkillInvocation`, but without this branch `/turn` still handed the
|
|
1774
|
+
// literal command to the model, which ran the skill (English "status" output) or
|
|
1775
|
+
// an unrelated review instead of reporting anything. Serve types `/status` into
|
|
1776
|
+
// this endpoint; the ShellUI answers it locally.
|
|
1654
1777
|
function asksForRunStatus(input) {
|
|
1655
|
-
const text = String(input ?? '');
|
|
1778
|
+
const text = String(input ?? '').trim();
|
|
1779
|
+
if (/^\/status(?:\s|$)/i.test(text)) return true;
|
|
1656
1780
|
const statusWord = /\b(status|statut|progression|progress|avancement|o[uù] en est|o[uù] en sont)\b/i;
|
|
1657
1781
|
const runNoun = /\b(job|run|t[aâ]che|task|build|ingest|pipeline|export|polish|traitement)\b/i;
|
|
1658
1782
|
return statusWord.test(text) && runNoun.test(text);
|
|
@@ -1733,7 +1733,7 @@ test('POST /turn keeps informational skill and build questions conversational',
|
|
|
1733
1733
|
}
|
|
1734
1734
|
});
|
|
1735
1735
|
|
|
1736
|
-
test('POST /turn
|
|
1736
|
+
test('POST /turn hands a run status question to Donna with the runtime facts', async (t) => {
|
|
1737
1737
|
const session = { workspace: 'acme', controlQueue: [] };
|
|
1738
1738
|
const context = { workspace: 'acme', session, running: true, currentAbortController: null };
|
|
1739
1739
|
const status = {
|
|
@@ -1746,6 +1746,8 @@ test('POST /turn answers a run status question from the runtime instead of the m
|
|
|
1746
1746
|
conversation: [],
|
|
1747
1747
|
};
|
|
1748
1748
|
let turns = 0;
|
|
1749
|
+
let turnInput = '';
|
|
1750
|
+
let turnMode = null;
|
|
1749
1751
|
let handle;
|
|
1750
1752
|
try {
|
|
1751
1753
|
handle = await startRuntimeServer({
|
|
@@ -1753,24 +1755,84 @@ test('POST /turn answers a run status question from the runtime instead of the m
|
|
|
1753
1755
|
store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
|
|
1754
1756
|
getContext: async () => context,
|
|
1755
1757
|
run: async () => new Promise(() => {}),
|
|
1756
|
-
turn: async () => { turns += 1; return { ok: true }; },
|
|
1758
|
+
turn: async (_context, options) => { turns += 1; turnInput = options.input; turnMode = options.mode; return { ok: true }; },
|
|
1757
1759
|
});
|
|
1758
1760
|
} catch (err) {
|
|
1759
1761
|
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1760
1762
|
throw err;
|
|
1761
1763
|
}
|
|
1762
1764
|
try {
|
|
1763
|
-
//
|
|
1764
|
-
//
|
|
1765
|
+
// System facts never reach the thread as raw text: the runtime supplies
|
|
1766
|
+
// them to Donna, who synthesizes the answer. The facts also prevent the
|
|
1767
|
+
// model mistaking the runtime runId for a production job id.
|
|
1765
1768
|
const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
|
|
1766
1769
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1767
1770
|
body: JSON.stringify({ input: 'donne le status du job en cours', mode: 'agent' }),
|
|
1768
1771
|
});
|
|
1769
1772
|
const body = await response.json();
|
|
1770
|
-
assert.equal(response.status,
|
|
1771
|
-
assert.equal(body.kind, '
|
|
1772
|
-
|
|
1773
|
-
|
|
1773
|
+
assert.equal(response.status, 202);
|
|
1774
|
+
assert.equal(body.kind, 'turn');
|
|
1775
|
+
// The turn is dispatched asynchronously after the 202.
|
|
1776
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1777
|
+
assert.equal(turns, 1);
|
|
1778
|
+
assert.equal(turnMode, 'chat');
|
|
1779
|
+
assert.match(turnInput, /Build TechSections/);
|
|
1780
|
+
assert.match(turnInput, /runtime run, not a production job/i);
|
|
1781
|
+
} finally {
|
|
1782
|
+
context.currentAbortController?.abort();
|
|
1783
|
+
await handle.close();
|
|
1784
|
+
}
|
|
1785
|
+
});
|
|
1786
|
+
|
|
1787
|
+
test('a run blocked on approval is described as waiting, not as running', async (t) => {
|
|
1788
|
+
const session = { workspace: 'acme', controlQueue: [] };
|
|
1789
|
+
const context = { workspace: 'acme', session, running: true, currentAbortController: null };
|
|
1790
|
+
const status = {
|
|
1791
|
+
status: 'pending_approval',
|
|
1792
|
+
running: true,
|
|
1793
|
+
plan: [{ step: 1, description: 'Rebuild the concepts', status: 'pending_approval' }],
|
|
1794
|
+
queue: [],
|
|
1795
|
+
controlQueue: [],
|
|
1796
|
+
approvals: [{ id: 'a1', status: 'pending_approval', reason: 'a mutating task needs approval' }],
|
|
1797
|
+
conversation: [],
|
|
1798
|
+
};
|
|
1799
|
+
let turns = 0;
|
|
1800
|
+
let turnInput = '';
|
|
1801
|
+
let handle;
|
|
1802
|
+
try {
|
|
1803
|
+
handle = await startRuntimeServer({
|
|
1804
|
+
host: '127.0.0.1', port: 0,
|
|
1805
|
+
store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
|
|
1806
|
+
getContext: async () => context,
|
|
1807
|
+
run: async () => new Promise(() => {}),
|
|
1808
|
+
turn: async (_context, options) => { turns += 1; turnInput = options.input; return { ok: true }; },
|
|
1809
|
+
});
|
|
1810
|
+
} catch (err) {
|
|
1811
|
+
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1812
|
+
throw err;
|
|
1813
|
+
}
|
|
1814
|
+
try {
|
|
1815
|
+
// The controller (`explainControlState`) must not call a pending approval
|
|
1816
|
+
// "running": the scheduler keeps `context.running` true while it waits.
|
|
1817
|
+
const control = await fetch(`http://127.0.0.1:${handle.port}/control?workspace=acme`, {
|
|
1818
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1819
|
+
body: JSON.stringify({ action: 'explain' }),
|
|
1820
|
+
});
|
|
1821
|
+
const controlBody = await control.json();
|
|
1822
|
+
assert.match(controlBody.explanation, /waiting for approval/i);
|
|
1823
|
+
assert.doesNotMatch(controlBody.explanation, /is active/i);
|
|
1824
|
+
|
|
1825
|
+
// And the turn hands the same facts to Donna rather than dumping them.
|
|
1826
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
|
|
1827
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1828
|
+
body: JSON.stringify({ input: 'donne le status du run en cours', mode: 'agent' }),
|
|
1829
|
+
});
|
|
1830
|
+
const body = await response.json();
|
|
1831
|
+
assert.equal(response.status, 202);
|
|
1832
|
+
assert.equal(body.kind, 'turn');
|
|
1833
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1834
|
+
assert.equal(turns, 1);
|
|
1835
|
+
assert.match(turnInput, /Pending approval: a mutating task needs approval/);
|
|
1774
1836
|
} finally {
|
|
1775
1837
|
context.currentAbortController?.abort();
|
|
1776
1838
|
await handle.close();
|
|
@@ -1790,6 +1852,7 @@ test('POST /turn treats a bare confirmation during a run as a status check', asy
|
|
|
1790
1852
|
conversation: [],
|
|
1791
1853
|
};
|
|
1792
1854
|
let turns = 0;
|
|
1855
|
+
let turnInput = '';
|
|
1793
1856
|
let handle;
|
|
1794
1857
|
try {
|
|
1795
1858
|
handle = await startRuntimeServer({
|
|
@@ -1797,23 +1860,79 @@ test('POST /turn treats a bare confirmation during a run as a status check', asy
|
|
|
1797
1860
|
store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
|
|
1798
1861
|
getContext: async () => context,
|
|
1799
1862
|
run: async () => new Promise(() => {}),
|
|
1800
|
-
turn: async () => { turns += 1; return { ok: true }; },
|
|
1863
|
+
turn: async (_context, options) => { turns += 1; turnInput = options.input; return { ok: true }; },
|
|
1801
1864
|
});
|
|
1802
1865
|
} catch (err) {
|
|
1803
1866
|
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1804
1867
|
throw err;
|
|
1805
1868
|
}
|
|
1806
1869
|
try {
|
|
1807
|
-
// "oui" answers the launch acknowledgement. It
|
|
1808
|
-
//
|
|
1870
|
+
// "oui" answers the launch acknowledgement. It is an observation, so it
|
|
1871
|
+
// reaches Donna with the runtime facts — not a deterministic English line
|
|
1872
|
+
// and not a read-only chat turn that lectures about switching modes.
|
|
1809
1873
|
const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
|
|
1810
1874
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1811
1875
|
body: JSON.stringify({ input: 'oui', mode: 'agent' }),
|
|
1812
1876
|
});
|
|
1813
1877
|
const body = await response.json();
|
|
1814
|
-
assert.equal(response.status,
|
|
1815
|
-
assert.equal(body.kind, '
|
|
1816
|
-
|
|
1878
|
+
assert.equal(response.status, 202);
|
|
1879
|
+
assert.equal(body.kind, 'turn');
|
|
1880
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1881
|
+
assert.equal(turns, 1);
|
|
1882
|
+
assert.match(turnInput, /Runtime facts:/);
|
|
1883
|
+
assert.match(turnInput, /Rebuild the wiki/);
|
|
1884
|
+
} finally {
|
|
1885
|
+
context.currentAbortController?.abort();
|
|
1886
|
+
await handle.close();
|
|
1887
|
+
}
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
test('POST /turn answers the reserved /status command itself, never the homonymous skill', async (t) => {
|
|
1891
|
+
// A workspace skill named `status` exists precisely to prove the built-in
|
|
1892
|
+
// wins: `/status` was handed to the model, which ran that skill (English
|
|
1893
|
+
// output) or an unrelated review instead of reporting anything. Serve types
|
|
1894
|
+
// `/status` into /turn; only `/skills run status` may reach the skill.
|
|
1895
|
+
const root = mkdtempSync(join(tmpdir(), 'runtime-status-builtin-'));
|
|
1896
|
+
mkdirSync(join(root, '.wiki', 'skills'), { recursive: true });
|
|
1897
|
+
writeFileSync(join(root, '.wiki', 'skills', 'status.md'), '---\nname: status\n---\nInspect services.');
|
|
1898
|
+
const session = { workspace: 'acme', workspacePath: root, controlQueue: [] };
|
|
1899
|
+
const context = { workspace: 'acme', session, running: false, currentAbortController: null };
|
|
1900
|
+
const status = {
|
|
1901
|
+
status: 'idle',
|
|
1902
|
+
running: false,
|
|
1903
|
+
plan: [{ step: 1, description: 'Rebuild the wiki', status: 'done' }],
|
|
1904
|
+
queue: [],
|
|
1905
|
+
controlQueue: [],
|
|
1906
|
+
approvals: [],
|
|
1907
|
+
conversation: [],
|
|
1908
|
+
};
|
|
1909
|
+
let turns = 0;
|
|
1910
|
+
let turnInput = '';
|
|
1911
|
+
let handle;
|
|
1912
|
+
try {
|
|
1913
|
+
handle = await startRuntimeServer({
|
|
1914
|
+
host: '127.0.0.1', port: 0,
|
|
1915
|
+
store: { dbPath: ':memory:', getState: () => status, listEvents: () => [] },
|
|
1916
|
+
getContext: async () => context,
|
|
1917
|
+
run: async () => new Promise(() => {}),
|
|
1918
|
+
turn: async (_context, options) => { turns += 1; turnInput = options.input; return { ok: true }; },
|
|
1919
|
+
});
|
|
1920
|
+
} catch (err) {
|
|
1921
|
+
if (err?.code === 'EPERM') { t.skip('network listen is not permitted in this sandbox'); return; }
|
|
1922
|
+
throw err;
|
|
1923
|
+
}
|
|
1924
|
+
try {
|
|
1925
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/turn?workspace=acme`, {
|
|
1926
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
1927
|
+
body: JSON.stringify({ input: '/status', mode: 'agent' }),
|
|
1928
|
+
});
|
|
1929
|
+
const body = await response.json();
|
|
1930
|
+
assert.equal(response.status, 202);
|
|
1931
|
+
assert.equal(body.kind, 'turn');
|
|
1932
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1933
|
+
assert.equal(turns, 1, 'the built-in status reaches Donna, never the homonymous skill');
|
|
1934
|
+
assert.match(turnInput, /Runtime facts:/);
|
|
1935
|
+
assert.doesNotMatch(turnInput, /Inspect services/);
|
|
1817
1936
|
} finally {
|
|
1818
1937
|
context.currentAbortController?.abort();
|
|
1819
1938
|
await handle.close();
|
package/src/runtime/skillRun.js
CHANGED
|
@@ -165,7 +165,7 @@ export async function generateSkillAcknowledgment(session, { publicInput, object
|
|
|
165
165
|
// slow provider must not block the skill-launch HTTP response forever.
|
|
166
166
|
const reply = await llm.complete({
|
|
167
167
|
system: 'You are Donna, the workspace assistant. You acknowledge a launched workflow in the user\'s language. Be concise: exactly one short sentence.',
|
|
168
|
-
input: `The user just launched the workspace skill ${publicInput}. It was compiled into ${count} step(s) and
|
|
168
|
+
input: `The user just launched the workspace skill ${publicInput}. It was compiled into ${count} step(s) and has been queued.\n\nWrite ONE short sentence in ${language} that confirms the launch, echoes the skill and its arguments, and says progress will be reported. Do not claim the work is running, executing or done: a mutating step waits for the user's approval before it runs. Do not ask a question, do not propose options, and do not offer to check, monitor or cancel anything: the runtime reports progress on its own and this acknowledgement is not a decision point. Return only that sentence, nothing else.`,
|
|
169
169
|
signal: AbortSignal.timeout(8_000),
|
|
170
170
|
});
|
|
171
171
|
const text = String(reply ?? '').trim();
|
|
@@ -179,7 +179,7 @@ export async function generateSkillAcknowledgment(session, { publicInput, object
|
|
|
179
179
|
emitRuntimeLog(session, `skill-acknowledgment: LLM call failed, using the neutral fallback — ${err instanceof Error ? err.message : String(err)}`);
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
|
-
return `Started ${publicInput} — ${count} step(s)
|
|
182
|
+
return `Started ${publicInput} — ${count} step(s) queued.`;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
185
|
function argumentError(message) {
|
|
@@ -95,19 +95,21 @@ test('generateSkillAcknowledgment asks Donna in the session language and echoes
|
|
|
95
95
|
assert.match(calls[0].input, /es/);
|
|
96
96
|
assert.match(calls[0].input, /\/deliver deliverable="Informe"/);
|
|
97
97
|
// The acknowledgement is not a decision point: it must not invite the user
|
|
98
|
-
// into a dialog the runtime cannot act on
|
|
98
|
+
// into a dialog the runtime cannot act on, and it must not claim the work is
|
|
99
|
+
// already executing — a mutating step waits for approval.
|
|
99
100
|
assert.match(calls[0].input, /Do not ask a question/);
|
|
101
|
+
assert.match(calls[0].input, /Do not claim the work is running/);
|
|
100
102
|
});
|
|
101
103
|
|
|
102
104
|
test('generateSkillAcknowledgment degrades to a neutral message without an LLM client', async () => {
|
|
103
105
|
const reply = await generateSkillAcknowledgment({ language: 'fr' }, { publicInput: '/wiki-ingest docs', objectives: 2 });
|
|
104
|
-
assert.equal(reply, 'Started /wiki-ingest docs — 2 step(s)
|
|
106
|
+
assert.equal(reply, 'Started /wiki-ingest docs — 2 step(s) queued.');
|
|
105
107
|
});
|
|
106
108
|
|
|
107
109
|
test('generateSkillAcknowledgment falls back when the LLM call fails', async () => {
|
|
108
110
|
const session = { language: 'en', llm: { complete: async () => { throw new Error('down'); } } };
|
|
109
111
|
const reply = await generateSkillAcknowledgment(session, { publicInput: '/deliver', objectives: 1 });
|
|
110
|
-
assert.equal(reply, 'Started /deliver — 1 step(s)
|
|
112
|
+
assert.equal(reply, 'Started /deliver — 1 step(s) queued.');
|
|
111
113
|
});
|
|
112
114
|
|
|
113
115
|
test('generateSkillAcknowledgment announces an LLM failure instead of degrading silently', async () => {
|