@dotdrelle/wiki-manager 0.12.10 → 0.12.12
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/.env.example +9 -6
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/agent/graph.js +110 -23
- package/src/agent/graph.test.js +135 -0
- package/src/cli/wiki-manager.js +70 -4
- package/src/commands/slash.js +49 -9
- package/src/contracts/schemas.js +33 -0
- package/src/contracts/schemas.test.js +14 -0
- package/src/core/agentEvents.js +6 -0
- package/src/core/agentEvents.test.js +26 -0
- package/src/core/agentLoop.js +21 -16
- package/src/core/agentLoop.test.js +59 -9
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +2 -3
- package/src/core/mcp.test.js +0 -12
- package/src/core/plan.js +11 -4
- package/src/orchestrator/scheduler.js +24 -0
- package/src/orchestrator/scheduler.test.js +40 -1
- package/src/runtime/client.js +2 -1
- package/src/runtime/lifecycle.js +1 -1
- package/src/runtime/runner.js +116 -6
- package/src/runtime/runner.test.js +36 -1
- package/src/runtime/supervisor.js +61 -0
- package/src/runtime/supervisor.test.js +31 -0
- package/src/shell/LeftPane.tsx +13 -3
- package/src/shell/repl.js +12 -4
- package/src/shell/tui.tsx +26 -3
package/src/contracts/schemas.js
CHANGED
|
@@ -131,6 +131,38 @@ const agentDescriptionSchema = {
|
|
|
131
131
|
},
|
|
132
132
|
};
|
|
133
133
|
|
|
134
|
+
const pendingInputSchema = {
|
|
135
|
+
$id: 'https://dotdrelle.dev/wiki-manager/contracts/pending-input/v1',
|
|
136
|
+
title: 'PendingInput',
|
|
137
|
+
schemaVersion: '1',
|
|
138
|
+
type: 'object',
|
|
139
|
+
required: ['type', 'ref'],
|
|
140
|
+
additionalProperties: true,
|
|
141
|
+
properties: {
|
|
142
|
+
type: { type: 'string', minLength: 1 },
|
|
143
|
+
ref: { type: 'string', minLength: 1 },
|
|
144
|
+
label: nullableString,
|
|
145
|
+
mediaType: nullableString,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const capabilityStatusSchema = {
|
|
150
|
+
$id: 'https://dotdrelle.dev/wiki-manager/contracts/capability-status/v1',
|
|
151
|
+
title: 'CapabilityStatus',
|
|
152
|
+
schemaVersion: '1',
|
|
153
|
+
type: 'object',
|
|
154
|
+
required: ['contractVersion', 'agentInstanceId', 'capability', 'operation', 'available', 'pendingInputs'],
|
|
155
|
+
additionalProperties: true,
|
|
156
|
+
properties: {
|
|
157
|
+
contractVersion: { type: 'string', minLength: 1 },
|
|
158
|
+
agentInstanceId: { type: 'string', minLength: 1 },
|
|
159
|
+
capability: { type: 'string', minLength: 1 },
|
|
160
|
+
operation: { type: 'string', minLength: 1 },
|
|
161
|
+
available: { type: 'boolean' },
|
|
162
|
+
pendingInputs: { type: 'array', items: pendingInputSchema },
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
134
166
|
const taskGroupSchema = {
|
|
135
167
|
$id: 'https://dotdrelle.dev/wiki-manager/contracts/task-group/v1',
|
|
136
168
|
title: 'TaskGroup',
|
|
@@ -424,6 +456,7 @@ export const contractSchemas = {
|
|
|
424
456
|
outputReference: outputReferenceSchema,
|
|
425
457
|
capabilityDescription: capabilityDescriptionSchema,
|
|
426
458
|
agentDescription: agentDescriptionSchema,
|
|
459
|
+
capabilityStatus: capabilityStatusSchema,
|
|
427
460
|
retryPolicy: retryPolicySchema,
|
|
428
461
|
taskGroup: taskGroupSchema,
|
|
429
462
|
plannedTask: plannedTaskSchema,
|
|
@@ -236,3 +236,17 @@ test('agent description contract validates orchestrable agent capabilities', ()
|
|
|
236
236
|
assert.equal(validateContract('capabilityDescription', description.capabilities[0]).ok, true);
|
|
237
237
|
assert.equal(validateContract('agentDescription', { ...description, health: { status: 'offline' } }).ok, false);
|
|
238
238
|
});
|
|
239
|
+
|
|
240
|
+
test('capability status contract carries dynamic pending inputs without prescribing storage paths', () => {
|
|
241
|
+
const status = {
|
|
242
|
+
contractVersion: '1',
|
|
243
|
+
agentInstanceId: 'production-main',
|
|
244
|
+
capability: 'knowledge.update',
|
|
245
|
+
operation: 'ingest',
|
|
246
|
+
available: true,
|
|
247
|
+
pendingInputs: [{ type: 'file', ref: 'provider-owned/source-a', label: 'source-a.md', mediaType: 'text/markdown' }],
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
assert.equal(validateContract('capabilityStatus', status).ok, true);
|
|
251
|
+
assert.equal(validateContract('capabilityStatus', { ...status, pendingInputs: [{ type: 'file' }] }).ok, false);
|
|
252
|
+
});
|
package/src/core/agentEvents.js
CHANGED
|
@@ -490,6 +490,12 @@ function applyEvent(state, event) {
|
|
|
490
490
|
case 'run_error':
|
|
491
491
|
state.status = 'error';
|
|
492
492
|
state.logs.push(String(event.payload?.message ?? 'Agent run failed.'));
|
|
493
|
+
// A dead run must not leave "pending" plan steps and spinning
|
|
494
|
+
// activities in the persisted projection: they reappeared as ghosts
|
|
495
|
+
// at every relaunch ("des trucs dans le plan qui n'existent pas") and
|
|
496
|
+
// /kill honestly reported 0 because nothing was actually running.
|
|
497
|
+
cancelPendingPlanSteps(state.plan);
|
|
498
|
+
cancelActiveActivities(state.activities, event.ts);
|
|
493
499
|
finishControlByRun(state.controlQueue, event.runId ?? event.payload?.runId ?? null, 'failed', event.ts);
|
|
494
500
|
return;
|
|
495
501
|
case 'control_enqueued':
|
|
@@ -440,3 +440,29 @@ test('empty assistant_message finalize is a no-op without a streaming entry', ()
|
|
|
440
440
|
dispatchAgentEvent(session, createAgentEvent('assistant_message', { origin: 'llm', payload: { content: '' } }));
|
|
441
441
|
assert.equal(session.agentProjection.conversation.length, 1);
|
|
442
442
|
});
|
|
443
|
+
|
|
444
|
+
test('run_error cancels pending plan steps and active activities (no ghosts at relaunch)', () => {
|
|
445
|
+
const session = {};
|
|
446
|
+
dispatchAgentEvent(session, createAgentEvent('plan_set', {
|
|
447
|
+
origin: 'runtime',
|
|
448
|
+
payload: { steps: [
|
|
449
|
+
{ id: 'a', description: 'Ingest a.md', status: 'pending', requiredCapability: 'knowledge.update', operation: 'ingest_plan' },
|
|
450
|
+
{ id: 'b', description: 'Ingest b.md', status: 'done' },
|
|
451
|
+
] },
|
|
452
|
+
}));
|
|
453
|
+
dispatchAgentEvent(session, createAgentEvent('activity_upserted', {
|
|
454
|
+
origin: 'runtime_poll',
|
|
455
|
+
payload: { activity: { key: 'production:j1', id: 'j1', label: 'Ingest', status: 'running', terminal: false } },
|
|
456
|
+
}));
|
|
457
|
+
dispatchAgentEvent(session, createAgentEvent('run_error', {
|
|
458
|
+
origin: 'runtime',
|
|
459
|
+
payload: { message: 'Plan is stalled: no_ready_plan_task' },
|
|
460
|
+
}));
|
|
461
|
+
|
|
462
|
+
const plan = session.agentProjection.plan;
|
|
463
|
+
assert.equal(plan.find((step) => step.id === 'a').status, 'cancelled');
|
|
464
|
+
assert.equal(plan.find((step) => step.id === 'b').status, 'done', 'completed work stays done');
|
|
465
|
+
const activity = session.agentProjection.activities.find((item) => item.id === 'j1');
|
|
466
|
+
assert.equal(activity.status, 'cancelled');
|
|
467
|
+
assert.equal(activity.terminal, true);
|
|
468
|
+
});
|
package/src/core/agentLoop.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildAgentSystemPrompt, formatLlmUnavailableMessage } from '../agent/graph.js';
|
|
2
2
|
import { createAgentEvent, dispatchAgentEvent } from './agentEvents.js';
|
|
3
3
|
import { activitySnapshot, newNonTerminalActivities } from './activity.js';
|
|
4
|
-
import {
|
|
4
|
+
import { formatCompletedActivities, formatPlanStatus } from './plan.js';
|
|
5
5
|
import { formatReadyTaskPrompt, nextReadyPlanTask, readyPlanTasks, sanitizePlanForExecution } from './planPatch.js';
|
|
6
6
|
|
|
7
7
|
export function abortError(message = 'Agent run cancelled.') {
|
|
@@ -66,12 +66,17 @@ export async function runAgenticLoop(agent, session, initialInput, {
|
|
|
66
66
|
onPendingSteps = null,
|
|
67
67
|
onActivitiesStarted = null,
|
|
68
68
|
onActivitiesCompleted = null,
|
|
69
|
+
deterministicTerminalSummary = false,
|
|
69
70
|
onMaxTurns = null,
|
|
70
71
|
abortMessage = 'Agent run cancelled.',
|
|
71
72
|
parallelHandoff = false,
|
|
73
|
+
initialMessages = [],
|
|
72
74
|
} = {}) {
|
|
73
75
|
if (!waitForActivities) throw new Error('runAgenticLoop requires waitForActivities.');
|
|
74
|
-
|
|
76
|
+
// Seeded with the chat that led to this run: a run that starts amnesiac
|
|
77
|
+
// receives an orphan sentence ("lance l'ingestion") and the model invents
|
|
78
|
+
// the missing context — the root of most "Donna répond sans savoir".
|
|
79
|
+
const conversationHistory = [...initialMessages];
|
|
75
80
|
let currentInput = initialInput;
|
|
76
81
|
|
|
77
82
|
for (let turn = 1; turn <= maxTurns; turn += 1) {
|
|
@@ -94,21 +99,16 @@ export async function runAgenticLoop(agent, session, initialInput, {
|
|
|
94
99
|
{ role: 'assistant', content: response },
|
|
95
100
|
);
|
|
96
101
|
|
|
97
|
-
if (turn === 1) {
|
|
98
|
-
|
|
99
|
-
const extractedPlan = extractHeadlessPlan(response);
|
|
100
|
-
if (extractedPlan) {
|
|
101
|
-
dispatchAgentEvent(session, createAgentEvent('plan_set', {
|
|
102
|
-
origin: planOrigin,
|
|
103
|
-
runId,
|
|
104
|
-
payload: { steps: extractedPlan },
|
|
105
|
-
}));
|
|
106
|
-
onPlanExtracted?.({ steps: session.headlessPlan ?? extractedPlan, fallback: true });
|
|
107
|
-
}
|
|
108
|
-
} else {
|
|
109
|
-
onPlanAlreadySet?.({ steps: session.headlessPlan });
|
|
110
|
-
}
|
|
102
|
+
if (turn === 1 && session.headlessPlan !== null) {
|
|
103
|
+
onPlanAlreadySet?.({ steps: session.headlessPlan });
|
|
111
104
|
}
|
|
105
|
+
// NOTE: the deprecated text-plan extraction is gone. It converted any
|
|
106
|
+
// numbered list in a chatty LLM answer into an executable plan — the
|
|
107
|
+
// model's own questions ("Souhaitez-vous que je vous guide ?") became
|
|
108
|
+
// pending tasks, each step re-invoked the LLM, which produced another
|
|
109
|
+
// list… an infinite work-inventing loop. Plans now come ONLY from
|
|
110
|
+
// explicit channels: wiki__plan_set, _activity.plan.steps, or an
|
|
111
|
+
// integrated agent_plan fragment. Prose stays prose.
|
|
112
112
|
sanitizeSessionPlan(session, { runId });
|
|
113
113
|
|
|
114
114
|
const newPending = newNonTerminalActivities(snapshot, session);
|
|
@@ -149,6 +149,11 @@ export async function runAgenticLoop(agent, session, initialInput, {
|
|
|
149
149
|
const completed = waitResult.completed ?? [];
|
|
150
150
|
const summary = formatCompletedActivities(completed);
|
|
151
151
|
onActivitiesCompleted?.({ completed, summary });
|
|
152
|
+
const unfinished = (session.headlessPlan ?? []).some((step) =>
|
|
153
|
+
['pending', 'pending_approval', 'running', 'starting', 'queued'].includes(String(step.status ?? '').toLowerCase()));
|
|
154
|
+
if (deterministicTerminalSummary && !unfinished) {
|
|
155
|
+
return { ok: true, completed, summary, deterministicSummary: true };
|
|
156
|
+
}
|
|
152
157
|
if (parallelHandoff && readyPlanTasks(session.headlessPlan).length > 1) {
|
|
153
158
|
return { ok: true, handoff: true };
|
|
154
159
|
}
|
|
@@ -77,27 +77,77 @@ test('runAgenticLoop waits for new activities and continues with a completion su
|
|
|
77
77
|
assert.equal(result.ok, true);
|
|
78
78
|
assert.equal(inputs.length, 2);
|
|
79
79
|
assert.match(inputs[1], /Completed activities:/);
|
|
80
|
-
assert.match(inputs[1],
|
|
81
|
-
assert.deepEqual(callbacks, ['started:1', '-
|
|
80
|
+
assert.match(inputs[1], /- job: done/);
|
|
81
|
+
assert.deepEqual(callbacks, ['started:1', '- job: done']);
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
test('runAgenticLoop
|
|
84
|
+
test('runAgenticLoop can finish from terminal activity facts without another LLM turn', async () => {
|
|
85
|
+
const session = { activities: {}, headlessPlan: null };
|
|
86
|
+
let turns = 0;
|
|
87
|
+
const result = await runAgenticLoop({
|
|
88
|
+
async invoke({ session: turnSession }) {
|
|
89
|
+
turns += 1;
|
|
90
|
+
dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
|
|
91
|
+
payload: {
|
|
92
|
+
activity: {
|
|
93
|
+
id: 'job-build',
|
|
94
|
+
source: 'production',
|
|
95
|
+
kind: 'build',
|
|
96
|
+
label: 'Build workspace',
|
|
97
|
+
status: 'running',
|
|
98
|
+
terminal: false,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
}));
|
|
102
|
+
return { response: 'Job started.' };
|
|
103
|
+
},
|
|
104
|
+
}, session, 'Build workspace', {
|
|
105
|
+
maxTurns: 3,
|
|
106
|
+
timeoutMs: 1000,
|
|
107
|
+
deterministicTerminalSummary: true,
|
|
108
|
+
waitForActivities: async (turnSession) => {
|
|
109
|
+
dispatchAgentEvent(turnSession, createAgentEvent('activity_upserted', {
|
|
110
|
+
payload: {
|
|
111
|
+
activity: {
|
|
112
|
+
id: 'job-build',
|
|
113
|
+
source: 'production',
|
|
114
|
+
kind: 'build',
|
|
115
|
+
label: 'Build workspace',
|
|
116
|
+
status: 'done',
|
|
117
|
+
terminal: true,
|
|
118
|
+
outputRefs: ['deliverables/result.md'],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
}));
|
|
122
|
+
return { ok: true, completed: Object.values(turnSession.activities) };
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
assert.equal(turns, 1);
|
|
127
|
+
assert.equal(result.deterministicSummary, true);
|
|
128
|
+
assert.match(result.summary, /build: done/);
|
|
129
|
+
assert.match(result.summary, /output: deliverables\/result\.md/);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('runAgenticLoop never turns a chatty numbered answer into a plan', async () => {
|
|
133
|
+
// Regression guard for the removed text-plan extraction: the model's own
|
|
134
|
+
// numbered prose ("1. … 2. … Souhaitez-vous… ?") used to become pending
|
|
135
|
+
// tasks and re-invoke the LLM in an infinite work-inventing loop. A chatty
|
|
136
|
+
// answer with no declared plan and no activity is simply a COMPLETE reply.
|
|
85
137
|
const session = {
|
|
86
138
|
activities: {},
|
|
87
139
|
headlessPlan: null,
|
|
88
140
|
};
|
|
89
141
|
const result = await runAgenticLoop({
|
|
90
142
|
async invoke() {
|
|
91
|
-
return { response: '1. Collect sources\n2. Build page' };
|
|
143
|
+
return { response: '1. Collect sources\n2. Build page\nSouhaitez-vous que je vous guide ?' };
|
|
92
144
|
},
|
|
93
145
|
}, session, 'Plan task', {
|
|
94
|
-
maxTurns:
|
|
146
|
+
maxTurns: 3,
|
|
95
147
|
timeoutMs: 1000,
|
|
96
148
|
waitForActivities: async () => assert.fail('No activities should be waited for.'),
|
|
97
149
|
});
|
|
98
150
|
|
|
99
|
-
assert.equal(result.ok,
|
|
100
|
-
assert.equal(
|
|
101
|
-
assert.equal(session.headlessPlan.length, 2);
|
|
102
|
-
assert.equal(session.headlessPlan[0].description, 'Collect sources');
|
|
151
|
+
assert.equal(result.ok, true, 'the run completes with the reply instead of inventing steps');
|
|
152
|
+
assert.equal(session.headlessPlan, null);
|
|
103
153
|
});
|
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.12.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.12.12';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -305,8 +305,7 @@ export function formatMcpToolResult(result) {
|
|
|
305
305
|
const DEFAULT_TOOL_RESULT_MAX_CHARS = 16000;
|
|
306
306
|
|
|
307
307
|
function toolResultMaxChars() {
|
|
308
|
-
|
|
309
|
-
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : DEFAULT_TOOL_RESULT_MAX_CHARS;
|
|
308
|
+
return DEFAULT_TOOL_RESULT_MAX_CHARS;
|
|
310
309
|
}
|
|
311
310
|
|
|
312
311
|
// Bound what a tool result injects into the LLM context and the conversation
|
package/src/core/mcp.test.js
CHANGED
|
@@ -529,15 +529,3 @@ test('truncateToolResult keeps short results intact and bounds long ones head+ta
|
|
|
529
529
|
assert.match(bounded, /caractères tronqués/);
|
|
530
530
|
});
|
|
531
531
|
|
|
532
|
-
test('truncateToolResult honours WIKI_MANAGER_TOOL_RESULT_MAX_CHARS', () => {
|
|
533
|
-
const previous = process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS;
|
|
534
|
-
process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS = '500';
|
|
535
|
-
try {
|
|
536
|
-
const bounded = truncateToolResult('y'.repeat(5000));
|
|
537
|
-
assert.ok(bounded.length < 700);
|
|
538
|
-
assert.match(bounded, /caractères tronqués/);
|
|
539
|
-
} finally {
|
|
540
|
-
if (previous === undefined) delete process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS;
|
|
541
|
-
else process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS = previous;
|
|
542
|
-
}
|
|
543
|
-
});
|
package/src/core/plan.js
CHANGED
|
@@ -141,10 +141,17 @@ export function formatConfigValue(value) {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
export function formatCompletedActivities(activities) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
.
|
|
144
|
+
const terminal = activities.filter((activity) => activity.terminal);
|
|
145
|
+
const lines = terminal.map((activity) => {
|
|
146
|
+
const label = activity.kind ?? activity.label ?? `${activity.source} ${activity.id ?? 'activity'}`;
|
|
147
|
+
return `- ${label}: ${activity.status}${activity.error ? ` (${activity.error})` : ''}`;
|
|
148
|
+
});
|
|
149
|
+
const outputs = [...new Set(terminal.flatMap((activity) => activity.outputRefs ?? []).map((ref) => {
|
|
150
|
+
if (ref && typeof ref === 'object') return String(ref.ref ?? ref.path ?? ref.url ?? '').trim();
|
|
151
|
+
return String(ref ?? '').trim();
|
|
152
|
+
}).filter(Boolean))];
|
|
153
|
+
if (outputs.length > 0) lines.push(...outputs.map((output) => `- output: ${output}`));
|
|
154
|
+
return lines.join('\n');
|
|
148
155
|
}
|
|
149
156
|
|
|
150
157
|
function findMatchingPlanStepByStructure(plan, activity) {
|
|
@@ -9,6 +9,30 @@ export function resolveSchedulerConcurrency(value = process.env.WIKI_MANAGER_SCH
|
|
|
9
9
|
: DEFAULT_SCHEDULER_CONCURRENCY;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export function resolvePlanConcurrency({ plan = [], agents = [], configured = null } = {}) {
|
|
13
|
+
const capabilities = new Set(plan.map((task) => task?.requiredCapability).filter(Boolean).map(String));
|
|
14
|
+
const assignedAgents = new Set(plan.map((task) => task?.agentInstanceId).filter(Boolean).map(String));
|
|
15
|
+
const relevantAgents = agents.filter((agent) => {
|
|
16
|
+
const id = String(agent?.agentInstanceId ?? agent?.description?.agentInstanceId ?? '');
|
|
17
|
+
if (id && assignedAgents.has(id)) return true;
|
|
18
|
+
return (agent?.description?.capabilities ?? []).some((capability) => capabilities.has(String(capability?.id ?? '')));
|
|
19
|
+
});
|
|
20
|
+
const values = [
|
|
21
|
+
positiveInteger(configured),
|
|
22
|
+
...plan.flatMap(concurrencyValues),
|
|
23
|
+
...relevantAgents.flatMap(concurrencyValues),
|
|
24
|
+
].filter(Boolean);
|
|
25
|
+
return values.length > 0 ? Math.max(1, Math.min(...values)) : DEFAULT_SCHEDULER_CONCURRENCY;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveCapabilityConcurrency(agent = null, ...constraints) {
|
|
29
|
+
const values = [
|
|
30
|
+
...concurrencyValues(agent),
|
|
31
|
+
...constraints.map(positiveInteger),
|
|
32
|
+
].filter(Boolean);
|
|
33
|
+
return values.length > 0 ? Math.max(1, Math.min(...values)) : DEFAULT_SCHEDULER_CONCURRENCY;
|
|
34
|
+
}
|
|
35
|
+
|
|
12
36
|
export function effectiveConcurrency(group = null, agent = null, donna = null, provider = null) {
|
|
13
37
|
const limits = [
|
|
14
38
|
...concurrencyValues(donna),
|
|
@@ -6,7 +6,12 @@ import test from 'node:test';
|
|
|
6
6
|
import { createBudgetManager } from './budgetManager.js';
|
|
7
7
|
import { readyTasks } from './dependencyResolver.js';
|
|
8
8
|
import { createLockManager } from './lockManager.js';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
effectiveConcurrency,
|
|
11
|
+
resolveCapabilityConcurrency,
|
|
12
|
+
resolvePlanConcurrency,
|
|
13
|
+
startReadyTasks,
|
|
14
|
+
} from './scheduler.js';
|
|
10
15
|
|
|
11
16
|
test('dependencyResolver holds a barrier task until its dependsOnGroup is done', () => {
|
|
12
17
|
const plan = [
|
|
@@ -76,6 +81,40 @@ test('scheduler.effectiveConcurrency returns the minimum effective concurrency',
|
|
|
76
81
|
assert.equal(effectiveConcurrency(group, agent, donna, provider), 2);
|
|
77
82
|
});
|
|
78
83
|
|
|
84
|
+
test('scheduler uses the relevant agent declaration instead of hard-capping plans at three', () => {
|
|
85
|
+
const plan = [{ id: 'task-1', requiredCapability: 'ingest' }];
|
|
86
|
+
const agents = [{
|
|
87
|
+
description: {
|
|
88
|
+
capabilities: [{ id: 'ingest' }],
|
|
89
|
+
limits: { recommendedConcurrency: 10, maxConcurrency: 12 },
|
|
90
|
+
},
|
|
91
|
+
}];
|
|
92
|
+
|
|
93
|
+
assert.equal(resolvePlanConcurrency({ plan, agents }), 10);
|
|
94
|
+
assert.equal(resolvePlanConcurrency({ plan, agents, configured: 3 }), 3);
|
|
95
|
+
assert.equal(resolvePlanConcurrency({ plan, agents, configured: 20 }), 10);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('scheduler ignores unrelated agents and falls back to three without declarations', () => {
|
|
99
|
+
const plan = [{ id: 'task-1', requiredCapability: 'ingest' }];
|
|
100
|
+
const agents = [{
|
|
101
|
+
description: {
|
|
102
|
+
capabilities: [{ id: 'production' }],
|
|
103
|
+
limits: { recommendedConcurrency: 1 },
|
|
104
|
+
},
|
|
105
|
+
}];
|
|
106
|
+
|
|
107
|
+
assert.equal(resolvePlanConcurrency({ plan, agents }), 3);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('capability constraints can lower but never raise an agent declaration', () => {
|
|
111
|
+
const agent = { description: { limits: { recommendedConcurrency: 6, maxConcurrency: 10 } } };
|
|
112
|
+
|
|
113
|
+
assert.equal(resolveCapabilityConcurrency(agent), 6);
|
|
114
|
+
assert.equal(resolveCapabilityConcurrency(agent, 2), 2);
|
|
115
|
+
assert.equal(resolveCapabilityConcurrency(agent, 20), 6);
|
|
116
|
+
});
|
|
117
|
+
|
|
79
118
|
test('startReadyTasks starts only ready tasks and respects lock starvation', () => {
|
|
80
119
|
const active = new Map();
|
|
81
120
|
const lockManager = createLockManager();
|
package/src/runtime/client.js
CHANGED
|
@@ -52,6 +52,7 @@ export async function postRuntimeRun(input, {
|
|
|
52
52
|
workspace = null,
|
|
53
53
|
evaluate = undefined,
|
|
54
54
|
replans = undefined,
|
|
55
|
+
capabilityPlan = undefined,
|
|
55
56
|
} = {}) {
|
|
56
57
|
const response = await fetch(runtimeEndpoint(url, '/run', workspace), {
|
|
57
58
|
method: 'POST',
|
|
@@ -59,7 +60,7 @@ export async function postRuntimeRun(input, {
|
|
|
59
60
|
...runtimeHeaders(token),
|
|
60
61
|
'Content-Type': 'application/json',
|
|
61
62
|
},
|
|
62
|
-
body: JSON.stringify(Object.assign({ input, workspace }, evaluate !== undefined && { evaluate }, replans !== undefined && { replans })),
|
|
63
|
+
body: JSON.stringify(Object.assign({ input, workspace }, evaluate !== undefined && { evaluate }, replans !== undefined && { replans }, capabilityPlan !== undefined && { capabilityPlan })),
|
|
63
64
|
});
|
|
64
65
|
if (!response.ok) {
|
|
65
66
|
const err = new Error(`Runtime run failed: HTTP ${response.status}`);
|
package/src/runtime/lifecycle.js
CHANGED
|
@@ -109,7 +109,7 @@ export async function runtimeHealthOrNull(url = runtimeUrlFromEnv(), token = run
|
|
|
109
109
|
// alive after exit produced zombie runtimes running yesterday's code and
|
|
110
110
|
// yesterday's endpoints. Nuance preserved: if a run is active anywhere, the
|
|
111
111
|
// runtime is left alive so the run survives the shell (that promise stays).
|
|
112
|
-
export async function shutdownOwnedRuntime(runtime, { log = () => {} } = {}) {
|
|
112
|
+
export async function shutdownOwnedRuntime(runtime, { log = (_message) => {} } = {}) {
|
|
113
113
|
if (!runtime?.url || !runtime?.started) return { action: 'kept', reason: 'not_owned' };
|
|
114
114
|
try {
|
|
115
115
|
const health = await runtimeHealthOrNull(runtime.url, runtime.token);
|
package/src/runtime/runner.js
CHANGED
|
@@ -9,10 +9,16 @@ import { createBudgetManager, BudgetExceededError } from '../orchestrator/budget
|
|
|
9
9
|
import { createDispatcher } from '../orchestrator/dispatcher.js';
|
|
10
10
|
import { assertValidatedFragment } from '../orchestrator/planValidator.js';
|
|
11
11
|
import { createResultAggregator } from '../orchestrator/resultAggregator.js';
|
|
12
|
-
import { drainActive,
|
|
12
|
+
import { drainActive, resolvePlanConcurrency, startReadyTasks } from '../orchestrator/scheduler.js';
|
|
13
13
|
import { emitRuntimeLog, pollActivitiesOnce } from './supervisor.js';
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// 0 by default: automatic replans turn evaluator/replanner TEXT into
|
|
16
|
+
// executable pseudo-tasks (no capability, no operation) that stall at 0%
|
|
17
|
+
// and pile up as replan-1/2/3 ghost work — the same disease as the removed
|
|
18
|
+
// text-plan extraction. Failures now end with an honest report; the user
|
|
19
|
+
// (or a stronger model) decides what to do next. Re-enable explicitly with
|
|
20
|
+
// WIKI_MANAGER_REPLANNER_MAX_REPLANS if desired.
|
|
21
|
+
const DEFAULT_MAX_REPLANS = 0;
|
|
16
22
|
|
|
17
23
|
async function waitForRuntimeActivities(session, startedActivities, { timeoutMs, signal, pollBusy }) {
|
|
18
24
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -43,13 +49,36 @@ async function waitForRuntimeActivities(session, startedActivities, { timeoutMs,
|
|
|
43
49
|
return { ok: false, timedOut: true, completed: tracked };
|
|
44
50
|
}
|
|
45
51
|
|
|
46
|
-
|
|
52
|
+
// Last chat exchanges (user/assistant) that preceded this run, so the run's
|
|
53
|
+
// LLM knows WHAT was agreed before acting. Long messages are clipped: the
|
|
54
|
+
// context is for grounding, not for re-reading novels.
|
|
55
|
+
// Env knobs (documented in .env.example): every tunable introduced by the
|
|
56
|
+
// grounding/orchestration work is overridable — nothing business-critical
|
|
57
|
+
// is frozen in code.
|
|
58
|
+
export function conversationSeed(session, currentInput, { limit = 12, maxChars = 2000 } = {}) {
|
|
59
|
+
const conversation = Array.isArray(session.agentProjection?.conversation)
|
|
60
|
+
? session.agentProjection.conversation
|
|
61
|
+
: [];
|
|
62
|
+
const seed = conversation
|
|
63
|
+
.filter((message) => ['user', 'assistant'].includes(message?.role) && String(message?.content ?? '').trim())
|
|
64
|
+
.slice(-limit)
|
|
65
|
+
.map((message) => ({ role: message.role, content: String(message.content).slice(0, maxChars) }));
|
|
66
|
+
// The run's own triggering user message is appended by the loop itself —
|
|
67
|
+
// drop it from the seed to avoid sending it twice.
|
|
68
|
+
const last = seed.at(-1);
|
|
69
|
+
if (last && last.role === 'user' && last.content === String(currentInput ?? '').slice(0, maxChars)) seed.pop();
|
|
70
|
+
return seed;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function runRuntimeAgenticLoop(agent, session, initialInput, { signal, timeoutMs, maxTurns, runId, pollBusy, parallelHandoff = false, initialMessages = [] }) {
|
|
47
74
|
return runAgenticLoop(agent, session, initialInput, {
|
|
48
75
|
signal,
|
|
49
76
|
timeoutMs,
|
|
50
77
|
maxTurns,
|
|
51
78
|
runId,
|
|
52
79
|
parallelHandoff,
|
|
80
|
+
initialMessages,
|
|
81
|
+
deterministicTerminalSummary: true,
|
|
53
82
|
abortMessage: 'Runtime run cancelled.',
|
|
54
83
|
waitForActivities: (turnSession, startedActivities, waitOptions) =>
|
|
55
84
|
waitForRuntimeActivities(turnSession, startedActivities, { ...waitOptions, pollBusy }),
|
|
@@ -78,6 +107,14 @@ export async function runRuntimeAgenticLoop(agent, session, initialInput, { sign
|
|
|
78
107
|
onActivitiesStarted: ({ activities }) => {
|
|
79
108
|
emitRuntimeLog(session, `agentic-loop: ${activities.length} new activity(s), waiting`);
|
|
80
109
|
},
|
|
110
|
+
onActivitiesCompleted: ({ summary }) => {
|
|
111
|
+
emitRuntimeLog(session, `agentic-loop: completed activities:\n${summary}`);
|
|
112
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
113
|
+
origin: 'runtime',
|
|
114
|
+
runId,
|
|
115
|
+
payload: { content: summary || 'Action terminée.' },
|
|
116
|
+
}));
|
|
117
|
+
},
|
|
81
118
|
onMaxTurns: ({ maxTurns: totalTurns }) => {
|
|
82
119
|
emitRuntimeLog(session, `agentic-loop: max turns (${totalTurns}) reached`);
|
|
83
120
|
},
|
|
@@ -98,6 +135,9 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
98
135
|
} = {}) {
|
|
99
136
|
let currentInput = initialInput ?? input;
|
|
100
137
|
let replansLeft = Math.max(0, Math.floor(Number(maxReplans) || 0));
|
|
138
|
+
// Computed ONCE at run start: the pre-run chat. Re-computing inside the
|
|
139
|
+
// loop would re-ingest this run's own turns and duplicate them.
|
|
140
|
+
const runConversationSeed = conversationSeed(session, currentInput);
|
|
101
141
|
|
|
102
142
|
while (true) {
|
|
103
143
|
sanitizeSessionPlanForExecution(session, runId);
|
|
@@ -118,6 +158,7 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
118
158
|
runId,
|
|
119
159
|
pollBusy,
|
|
120
160
|
parallelHandoff: true,
|
|
161
|
+
initialMessages: runConversationSeed,
|
|
121
162
|
});
|
|
122
163
|
if (result.ok && result.handoff) continue;
|
|
123
164
|
if (!result.ok) {
|
|
@@ -203,6 +244,20 @@ export async function runRuntimeAgenticWorkflow(agent, session, input, {
|
|
|
203
244
|
continue;
|
|
204
245
|
}
|
|
205
246
|
}
|
|
247
|
+
// Surface the verdict in the CHAT: the work that ran stays done, the
|
|
248
|
+
// user sees why the evaluator was unsatisfied and decides — no
|
|
249
|
+
// self-generated follow-up tasks.
|
|
250
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
251
|
+
origin: 'runtime',
|
|
252
|
+
runId,
|
|
253
|
+
payload: {
|
|
254
|
+
content: [
|
|
255
|
+
`Le run est terminé mais l'évaluation le juge incomplet : ${evaluation.reason}`,
|
|
256
|
+
evaluation.suggestedAction ? `Piste suggérée : ${evaluation.suggestedAction}` : null,
|
|
257
|
+
'Aucune tâche supplémentaire n\'a été créée automatiquement — dis-moi si tu veux poursuivre.',
|
|
258
|
+
].filter(Boolean).join('\n'),
|
|
259
|
+
},
|
|
260
|
+
}));
|
|
206
261
|
dispatchAgentEvent(session, createAgentEvent('run_error', {
|
|
207
262
|
origin: 'runtime',
|
|
208
263
|
runId,
|
|
@@ -231,7 +286,7 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
231
286
|
maxTurns,
|
|
232
287
|
runId = null,
|
|
233
288
|
pollBusy,
|
|
234
|
-
concurrency =
|
|
289
|
+
concurrency = null,
|
|
235
290
|
fragment = null,
|
|
236
291
|
assignmentManager = null,
|
|
237
292
|
attemptManager = null,
|
|
@@ -243,7 +298,15 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
243
298
|
dispatcherPollIntervalMs = 250,
|
|
244
299
|
} = {}) {
|
|
245
300
|
if (fragment != null) assertValidatedFragment(fragment);
|
|
246
|
-
const
|
|
301
|
+
const agents = session.agentRegistry?.snapshot?.() ?? session.agentRegistrySnapshot ?? [];
|
|
302
|
+
const configuredConcurrency = Number(concurrency) > 0
|
|
303
|
+
? Number(concurrency)
|
|
304
|
+
: Number(process.env.WIKI_MANAGER_CAPABILITY_CONCURRENCY || process.env.WIKI_MANAGER_SCHEDULER_CONCURRENCY);
|
|
305
|
+
const limit = resolvePlanConcurrency({
|
|
306
|
+
plan: session.headlessPlan ?? [],
|
|
307
|
+
agents,
|
|
308
|
+
configured: configuredConcurrency,
|
|
309
|
+
});
|
|
247
310
|
const active = new Map();
|
|
248
311
|
const attempts = attemptManager ?? createAttemptManager();
|
|
249
312
|
const assigner = assignmentManager ?? createAssignmentManager({ session });
|
|
@@ -269,6 +332,15 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
269
332
|
sanitizeSessionPlanForExecution(session, runId);
|
|
270
333
|
ensurePlanProjection(session, runId);
|
|
271
334
|
emitRuntimeLog(session, `scheduler: parallel plan enabled (concurrency ${limit})`);
|
|
335
|
+
let approvalNoticeSent = false;
|
|
336
|
+
// Interactive approvals do NOT expire: the user has /approve, "valide
|
|
337
|
+
// tout", /cancel and /run kill — an arbitrary timer only created mystery
|
|
338
|
+
// failures. A deadline exists only when explicitly configured (headless
|
|
339
|
+
// runs, CI) via the session or the env escape hatch.
|
|
340
|
+
const configuredApprovalWait = Number(session._approvalTimeoutMs) > 0
|
|
341
|
+
? Number(session._approvalTimeoutMs)
|
|
342
|
+
: (Number(process.env.WIKI_MANAGER_APPROVAL_TIMEOUT_MS) > 0 ? Number(process.env.WIKI_MANAGER_APPROVAL_TIMEOUT_MS) : null);
|
|
343
|
+
const approvalDeadline = configuredApprovalWait ? Date.now() + configuredApprovalWait : Infinity;
|
|
272
344
|
|
|
273
345
|
try {
|
|
274
346
|
while (true) {
|
|
@@ -362,6 +434,44 @@ export async function runRuntimeParallelPlan(agent, session, input, {
|
|
|
362
434
|
emitRuntimeLog(session, `scheduler: budget exceeded (${exceeded.reason})`);
|
|
363
435
|
return { ok: false, budgetExceeded: true, reason: exceeded.reason, budget: exceeded, completed: sessionActivities(session), failures };
|
|
364
436
|
}
|
|
437
|
+
const needingApproval = pending.filter((step) => step.requiresApproval === true);
|
|
438
|
+
if (needingApproval.length > 0) {
|
|
439
|
+
// The plan is only blocked on a HUMAN decision — wait for it
|
|
440
|
+
// (bounded) instead of declaring the run stalled. Announce once in
|
|
441
|
+
// the chat: users cannot approve what they never saw asked.
|
|
442
|
+
if (!approvalNoticeSent) {
|
|
443
|
+
approvalNoticeSent = true;
|
|
444
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
445
|
+
origin: 'runtime',
|
|
446
|
+
runId,
|
|
447
|
+
payload: {
|
|
448
|
+
content: [
|
|
449
|
+
`⏸ Approbation requise avant exécution : ${needingApproval.length} tâche(s) mutante(s) en attente.`,
|
|
450
|
+
...needingApproval.slice(0, 5).map((step) => ` - ${step.description ?? step.id}`),
|
|
451
|
+
needingApproval.length > 5 ? ` … et ${needingApproval.length - 5} autre(s).` : null,
|
|
452
|
+
'Réponds « valide tout » (ou tape /approve) pour lancer, « annule » pour abandonner.',
|
|
453
|
+
].filter(Boolean).join('\n'),
|
|
454
|
+
},
|
|
455
|
+
}));
|
|
456
|
+
emitRuntimeLog(session, `scheduler: waiting for approval (${needingApproval.length} task(s))`);
|
|
457
|
+
}
|
|
458
|
+
if (Date.now() < approvalDeadline) {
|
|
459
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, 500));
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
// Configured timeout (headless/CI) reached: say it PLAINLY in the
|
|
463
|
+
// chat and let run_error clean the plan/activities so nothing
|
|
464
|
+
// lingers in the panels.
|
|
465
|
+
emitRuntimeLog(session, 'scheduler: approval wait timed out');
|
|
466
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
467
|
+
origin: 'runtime',
|
|
468
|
+
runId,
|
|
469
|
+
payload: {
|
|
470
|
+
content: `⏱ Approbation non reçue dans le délai imparti — run arrêté, ${needingApproval.length} tâche(s) annulée(s). Relance la demande quand tu veux.`,
|
|
471
|
+
},
|
|
472
|
+
}));
|
|
473
|
+
return { ok: false, stalled: true, reason: 'awaiting_approval', completed: sessionActivities(session), failures };
|
|
474
|
+
}
|
|
365
475
|
const reason = pending.every((step) => approvalWaitingStatus(step.status)) ? 'awaiting_approval' : 'no_ready_plan_task';
|
|
366
476
|
emitRuntimeLog(session, `scheduler: stalled (${reason})`);
|
|
367
477
|
return { ok: false, stalled: true, reason, completed: sessionActivities(session), failures };
|
|
@@ -1007,7 +1117,7 @@ function formatRecentConversation(session, n = 12) {
|
|
|
1007
1117
|
.join('\n');
|
|
1008
1118
|
}
|
|
1009
1119
|
|
|
1010
|
-
function resolveMaxReplans(value
|
|
1120
|
+
function resolveMaxReplans(value) {
|
|
1011
1121
|
const parsed = Number(value);
|
|
1012
1122
|
return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : DEFAULT_MAX_REPLANS;
|
|
1013
1123
|
}
|