@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.
@@ -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
+ });
@@ -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
+ });
@@ -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 { extractHeadlessPlan, formatCompletedActivities, formatPlanStatus } from './plan.js';
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
- const conversationHistory = [];
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
- if (session.headlessPlan === null) {
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], /production job-1: done/);
81
- assert.deepEqual(callbacks, ['started:1', '- production job-1: done']);
80
+ assert.match(inputs[1], /- job: done/);
81
+ assert.deepEqual(callbacks, ['started:1', '- job: done']);
82
82
  });
83
83
 
84
- test('runAgenticLoop extracts a fallback numbered plan from first response', async () => {
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: 1,
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, false);
100
- assert.equal(result.maxTurns, true);
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
  });
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.12.10",
3
- "commit": "e256e16"
2
+ "version": "0.12.12",
3
+ "commit": "e1e43ae"
4
4
  }
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.10';
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
- const parsed = Number(process.env.WIKI_MANAGER_TOOL_RESULT_MAX_CHARS);
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
@@ -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
- return activities
145
- .filter((a) => a.terminal)
146
- .map((a) => `- ${a.source} ${a.id ?? a.kind}: ${a.status}${a.error ? ` (${a.error})` : ''}`)
147
- .join('\n');
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 { effectiveConcurrency, startReadyTasks } from './scheduler.js';
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();
@@ -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}`);
@@ -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);
@@ -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, resolveSchedulerConcurrency, startReadyTasks } from '../orchestrator/scheduler.js';
12
+ import { drainActive, resolvePlanConcurrency, startReadyTasks } from '../orchestrator/scheduler.js';
13
13
  import { emitRuntimeLog, pollActivitiesOnce } from './supervisor.js';
14
14
 
15
- const DEFAULT_MAX_REPLANS = 2;
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
- export async function runRuntimeAgenticLoop(agent, session, initialInput, { signal, timeoutMs, maxTurns, runId, pollBusy, parallelHandoff = false }) {
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 = resolveSchedulerConcurrency(),
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 limit = Math.max(1, Math.floor(Number(concurrency) || 1));
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 = process.env.WIKI_MANAGER_REPLANNER_MAX_REPLANS) {
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
  }