@dotdrelle/wiki-manager 0.14.6 → 0.14.7
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 +3 -0
- package/README.md +4 -0
- package/package.json +1 -1
- package/src/agent/graph.js +6 -1
- package/src/agent/graph.test.js +8 -1
- package/src/cli/wiki-manager.js +82 -2
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +51 -1
- package/src/core/mcp.test.js +39 -1
- package/src/runtime/server.js +68 -0
- package/src/runtime/server.test.js +81 -0
package/.env.example
CHANGED
|
@@ -59,6 +59,9 @@ DOCUMENTS_MCP_AUTH_TOKEN=
|
|
|
59
59
|
# Tool calls are retried on transient HTTP/MCP errors before the run fails.
|
|
60
60
|
# WIKI_MANAGER_MCP_RETRY_MAX_ATTEMPTS=2
|
|
61
61
|
# WIKI_MANAGER_MCP_RETRY_BACKOFF_MS=500
|
|
62
|
+
# Outbound MCP control budget, separate from .wikirc LLM requestsPerMinute.
|
|
63
|
+
# Default: 45 RPM (headroom for MCP servers limited to 50 RPM).
|
|
64
|
+
# WIKI_MANAGER_MCP_REQUESTS_PER_MINUTE=45
|
|
62
65
|
|
|
63
66
|
# ── Runtime evaluator (optional) ───────────────────────────────────────────────
|
|
64
67
|
|
package/README.md
CHANGED
|
@@ -498,6 +498,10 @@ Copy `mcp.endpoints.example.json` to `mcp.endpoints.json` and set the matching
|
|
|
498
498
|
token variables in `.env`.
|
|
499
499
|
|
|
500
500
|
MCP `tools/call` requests retry transient HTTP/MCP failures before the run fails.
|
|
501
|
+
They also share a per-endpoint outbound control budget (45 RPM by default,
|
|
502
|
+
configurable with `WIKI_MANAGER_MCP_REQUESTS_PER_MINUTE`). This budget is
|
|
503
|
+
independent from `.wikirc` `requestsPerMinute`, which remains reserved for LLM,
|
|
504
|
+
embedding, and reranking provider calls.
|
|
501
505
|
Set global defaults with `WIKI_MANAGER_MCP_RETRY_MAX_ATTEMPTS` and
|
|
502
506
|
`WIKI_MANAGER_MCP_RETRY_BACKOFF_MS`, or override them per endpoint with `retry`
|
|
503
507
|
and per tool with `toolRetries`.
|
package/package.json
CHANGED
package/src/agent/graph.js
CHANGED
|
@@ -711,7 +711,12 @@ async function handleRuntimeControlTool(session, tool, args = {}) {
|
|
|
711
711
|
if (!objective) return 'Delegation rejected: missing objective.';
|
|
712
712
|
const result = await postRuntimeDelegate(objective, { url, workspace });
|
|
713
713
|
return result?.runId
|
|
714
|
-
?
|
|
714
|
+
? JSON.stringify({
|
|
715
|
+
delegated: true,
|
|
716
|
+
runId: result.runId,
|
|
717
|
+
summary: result.delegation ?? null,
|
|
718
|
+
message: `Action lancée (${String(result.runId).slice(0, 8)}) après validation du plan réel : ${result.delegation?.tasks ?? 0} tâche(s), ${result.delegation?.agent ?? 'agent résolu'}. Exécution en cours.`,
|
|
719
|
+
})
|
|
715
720
|
: `Délégation refusée : ${result?.error ?? JSON.stringify(result)}`;
|
|
716
721
|
}
|
|
717
722
|
if (tool === 'enqueue') {
|
package/src/agent/graph.test.js
CHANGED
|
@@ -372,17 +372,24 @@ test('Donna delegates the objective without choosing technical identifiers', asy
|
|
|
372
372
|
}],
|
|
373
373
|
};
|
|
374
374
|
}
|
|
375
|
+
const delegateResult = (messages ?? []).filter((message) => message.role === 'tool').at(-1);
|
|
376
|
+
const parsedDelegateResult = JSON.parse(String(delegateResult?.content ?? '{}'));
|
|
377
|
+
assert.equal(parsedDelegateResult.delegated, true);
|
|
378
|
+
assert.equal(parsedDelegateResult.runId, 'run-1');
|
|
379
|
+
assert.equal(parsedDelegateResult.summary.tasks, 5);
|
|
375
380
|
return { content: 'Plan validé.', message: { role: 'assistant', content: 'Plan validé.' }, tool_calls: null };
|
|
376
381
|
},
|
|
377
382
|
},
|
|
378
383
|
});
|
|
379
384
|
|
|
380
385
|
try {
|
|
381
|
-
await createAgentGraph().invoke({ input: 'ingère tout', session });
|
|
386
|
+
const result = await createAgentGraph().invoke({ input: 'ingère tout', session });
|
|
382
387
|
assert.match(request.url, /\/delegate/);
|
|
383
388
|
assert.deepEqual(request.body, { objective: 'Ingérer tous les fichiers en attente', workspace: 'docs' });
|
|
384
389
|
assert.equal('capability' in request.body, false);
|
|
385
390
|
assert.equal('operation' in request.body, false);
|
|
391
|
+
assert.equal(result.response, 'Plan validé.');
|
|
392
|
+
assert.doesNotMatch(result.response, /delegated|run-1|production/);
|
|
386
393
|
} finally {
|
|
387
394
|
globalThis.fetch = originalFetch;
|
|
388
395
|
}
|
package/src/cli/wiki-manager.js
CHANGED
|
@@ -13,7 +13,7 @@ import { listWikircProfiles } from '../core/wikirc.js';
|
|
|
13
13
|
import { callMcpTool, formatMcpToolResult } from '../core/mcp.js';
|
|
14
14
|
import { extractActivity, parseJsonText, sessionActivities, terminalFailures } from '../core/activity.js';
|
|
15
15
|
import { syncActivitiesToPlan, formatPlanStatus } from '../core/plan.js';
|
|
16
|
-
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
16
|
+
import { createAgentEvent, dispatchAgentEvent, reduceAgentEvents } from '../core/agentEvents.js';
|
|
17
17
|
import { runAgentTurn, runAgenticLoop } from '../core/agentLoop.js';
|
|
18
18
|
import { resolveCapabilityConcurrency } from '../orchestrator/scheduler.js';
|
|
19
19
|
import { capabilityRegistryForSession } from '../orchestrator/capabilityRegistry.js';
|
|
@@ -57,6 +57,40 @@ function createSession() {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
export function createInteractiveSession(context, { runtimeUrl, turnId, signal = null } = {}) {
|
|
61
|
+
const source = context.session;
|
|
62
|
+
const session = createSession();
|
|
63
|
+
for (const key of [
|
|
64
|
+
'workspace', 'workspacePath', 'workspaceEnvFile', 'workspaceEnv',
|
|
65
|
+
'wikirc', 'wikircConfig', 'language', 'llm', 'mcp', 'commands',
|
|
66
|
+
'packageJson', 'queueStore', 'systemPrompt',
|
|
67
|
+
]) {
|
|
68
|
+
if (source[key] !== undefined) session[key] = source[key];
|
|
69
|
+
}
|
|
70
|
+
session.runtime = runtimeUrl ? { url: runtimeUrl } : null;
|
|
71
|
+
session.headless = true;
|
|
72
|
+
session.chatMode = false;
|
|
73
|
+
session.chatAccess = null;
|
|
74
|
+
session.conversations = { [session.workspace || '__global__']: [] };
|
|
75
|
+
session.agentEvents = [];
|
|
76
|
+
session.activities = {};
|
|
77
|
+
session.productionActivity = null;
|
|
78
|
+
session.jobQueue = [];
|
|
79
|
+
session.headlessPlan = null;
|
|
80
|
+
session.turnId = turnId ?? null;
|
|
81
|
+
session._abortSignal = signal;
|
|
82
|
+
return session;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function ensureInteractiveAssistantMessage(session, response, { turnId, workspace } = {}) {
|
|
86
|
+
const content = String(response ?? '').trim();
|
|
87
|
+
if (!content || session.agentEvents.some((event) => event.type === 'assistant_message')) return false;
|
|
88
|
+
dispatchAgentEvent(session, createAgentEvent('assistant_message', {
|
|
89
|
+
origin: 'runtime_turn', turnId, workspace, payload: { content: String(response) },
|
|
90
|
+
}));
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
60
94
|
export async function forwardRuntimeApproval(getWorkspaceContext, request = {}) {
|
|
61
95
|
const context = await getWorkspaceContext(request.workspace ?? null);
|
|
62
96
|
return context.approvalManager?.approve(request) ?? { approved: false };
|
|
@@ -455,7 +489,7 @@ async function runRuntime(argv, agent) {
|
|
|
455
489
|
const { resolveRuntimeAuthToken } = await import('../runtime/auth.js');
|
|
456
490
|
const { createSqliteQueueStore } = await import('../runtime/queueStore.js');
|
|
457
491
|
const { createApprovalManager } = await import('../runtime/approvals.js');
|
|
458
|
-
const { runRuntimeAgenticWorkflow } = await import('../runtime/runner.js');
|
|
492
|
+
const { conversationSeed, runRuntimeAgenticWorkflow } = await import('../runtime/runner.js');
|
|
459
493
|
|
|
460
494
|
const host = valueAfter(argv, '--host') ?? process.env.WIKI_MANAGER_RUNTIME_HOST ?? '127.0.0.1';
|
|
461
495
|
const port = Number(valueAfter(argv, '--port') ?? process.env.WIKI_MANAGER_RUNTIME_PORT ?? 7788);
|
|
@@ -465,6 +499,8 @@ async function runRuntime(argv, agent) {
|
|
|
465
499
|
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
466
500
|
throw new Error(`Invalid runtime port: ${port}`);
|
|
467
501
|
}
|
|
502
|
+
const selfRuntimeUrl = process.env.WIKI_MANAGER_RUNTIME_URL
|
|
503
|
+
?? `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
|
|
468
504
|
|
|
469
505
|
const store = openRuntimeStore({ stateDir });
|
|
470
506
|
let serverHandle = null;
|
|
@@ -480,6 +516,7 @@ async function runRuntime(argv, agent) {
|
|
|
480
516
|
session.headless = true;
|
|
481
517
|
session.chatMode = false;
|
|
482
518
|
session.packageJson = packageJson;
|
|
519
|
+
session.runtime = { url: selfRuntimeUrl };
|
|
483
520
|
|
|
484
521
|
if (requestedWorkspace) {
|
|
485
522
|
const result = await handleSlashCommand(`/use ${requestedWorkspace}`, { packageJson, session });
|
|
@@ -968,6 +1005,48 @@ async function runRuntime(argv, agent) {
|
|
|
968
1005
|
}
|
|
969
1006
|
}
|
|
970
1007
|
|
|
1008
|
+
async function executeInteractiveTurn(context, body, { signal, turnId } = {}) {
|
|
1009
|
+
const input = String(body.input ?? body.prompt ?? '').trim();
|
|
1010
|
+
if (!input) throw new Error('Missing input.');
|
|
1011
|
+
const ephemeral = createInteractiveSession(context, { runtimeUrl: selfRuntimeUrl, turnId, signal });
|
|
1012
|
+
// Seed from a freshly reduced COPY of persisted events. Interactive turn
|
|
1013
|
+
// events deliberately do not mutate the canonical run projection, so the
|
|
1014
|
+
// canonical session alone is not a reliable conversation-history source.
|
|
1015
|
+
const persistedProjection = reduceAgentEvents(store.listEvents({
|
|
1016
|
+
workspace: context.workspace ?? ephemeral.workspace ?? null,
|
|
1017
|
+
}));
|
|
1018
|
+
const messages = conversationSeed({ agentProjection: persistedProjection }, input);
|
|
1019
|
+
ephemeral._onAgentEvent = (event) => {
|
|
1020
|
+
const interactiveEvent = {
|
|
1021
|
+
...event,
|
|
1022
|
+
origin: 'runtime_turn',
|
|
1023
|
+
turnId,
|
|
1024
|
+
runId: null,
|
|
1025
|
+
workspace: context.workspace ?? ephemeral.workspace ?? null,
|
|
1026
|
+
};
|
|
1027
|
+
store.persistEvent(interactiveEvent);
|
|
1028
|
+
serverHandle?.publish(interactiveEvent);
|
|
1029
|
+
};
|
|
1030
|
+
ephemeral._onStep = (message) => dispatchAgentEvent(ephemeral, createAgentEvent('runtime_log', {
|
|
1031
|
+
origin: 'runtime_turn',
|
|
1032
|
+
turnId,
|
|
1033
|
+
workspace: context.workspace ?? null,
|
|
1034
|
+
payload: { message },
|
|
1035
|
+
}));
|
|
1036
|
+
dispatchAgentEvent(ephemeral, createAgentEvent('user_message', {
|
|
1037
|
+
origin: 'runtime_turn',
|
|
1038
|
+
turnId,
|
|
1039
|
+
workspace: context.workspace ?? null,
|
|
1040
|
+
payload: { content: input },
|
|
1041
|
+
}));
|
|
1042
|
+
const response = await runAgentTurn(agent, ephemeral, input, { messages, signal });
|
|
1043
|
+
ensureInteractiveAssistantMessage(ephemeral, response, {
|
|
1044
|
+
turnId,
|
|
1045
|
+
workspace: context.workspace ?? null,
|
|
1046
|
+
});
|
|
1047
|
+
return response;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
971
1050
|
serverHandle = await startRuntimeServer({
|
|
972
1051
|
host,
|
|
973
1052
|
port,
|
|
@@ -977,6 +1056,7 @@ async function runRuntime(argv, agent) {
|
|
|
977
1056
|
.filter((context) => context?.running)
|
|
978
1057
|
.map((context) => ({ workspace: context.workspace ?? null, runId: context.currentRunId ?? null })),
|
|
979
1058
|
run: executeRun,
|
|
1059
|
+
turn: executeInteractiveTurn,
|
|
980
1060
|
delegate: prepareDelegation,
|
|
981
1061
|
cancel: (context) => emitRuntimeLog(context.session, 'runtime: cancel requested'),
|
|
982
1062
|
resume: ({ workspace }) => recoverRuntime({ workspace, manual: true }),
|
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.14.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.14.7';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -125,6 +125,14 @@ const DEFAULT_MCP_RETRY_POLICY = {
|
|
|
125
125
|
backoffMs: 500,
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
+
// MCP control traffic has its own budget. It must never consume or reduce the
|
|
129
|
+
// provider RPM configured in .wikirc, which is reserved for LLM/vector calls.
|
|
130
|
+
// Keep a little headroom below the commonly deployed 50 RPM MCP limit for
|
|
131
|
+
// initialize/list-tools and other non-tool-call requests.
|
|
132
|
+
const DEFAULT_MCP_REQUESTS_PER_MINUTE = 45;
|
|
133
|
+
const mcpThrottleQueues = new Map();
|
|
134
|
+
const mcpThrottleStarts = new Map();
|
|
135
|
+
|
|
128
136
|
export function buildMcpStatus(session) {
|
|
129
137
|
// Attach the /chat read-tool policy to the session alongside MCP status.
|
|
130
138
|
// Only /chat (repl.js) reads session.chatAccess; /agent ignores it.
|
|
@@ -304,6 +312,7 @@ export async function callMcpTool(mcpStatus, serverName, toolName, args = {}, si
|
|
|
304
312
|
const timeoutMs = serverName === 'documents' && toolName === 'documents_convert_to_markdown' ? 600_000 : 8000;
|
|
305
313
|
const retry = resolveRetryPolicy(endpoint, toolName, options.retry);
|
|
306
314
|
return withRetry(async () => {
|
|
315
|
+
await throttleMcpRequestStart(endpoint, signal);
|
|
307
316
|
const payload = await mcpRequest(endpoint, 'tools/call', {
|
|
308
317
|
name: toolName,
|
|
309
318
|
arguments: toolArgs,
|
|
@@ -315,6 +324,47 @@ export async function callMcpTool(mcpStatus, serverName, toolName, args = {}, si
|
|
|
315
324
|
}, retry, { signal, onRetry: options.onRetry });
|
|
316
325
|
}
|
|
317
326
|
|
|
327
|
+
async function throttleMcpRequestStart(endpoint, signal) {
|
|
328
|
+
const configured = Number(
|
|
329
|
+
endpoint.requestsPerMinute
|
|
330
|
+
?? endpoint.rateLimit?.requestsPerMinute
|
|
331
|
+
?? envValue('WIKI_MANAGER_MCP_REQUESTS_PER_MINUTE'),
|
|
332
|
+
);
|
|
333
|
+
const requestsPerMinute = Number.isFinite(configured) && configured > 0
|
|
334
|
+
? Math.floor(configured)
|
|
335
|
+
: DEFAULT_MCP_REQUESTS_PER_MINUTE;
|
|
336
|
+
const configuredWindowMs = Number(envValue('WIKI_MANAGER_MCP_RATE_LIMIT_WINDOW_MS'));
|
|
337
|
+
const windowMs = Number.isFinite(configuredWindowMs) && configuredWindowMs > 0
|
|
338
|
+
? configuredWindowMs
|
|
339
|
+
: 60_000;
|
|
340
|
+
const key = String(endpoint.url ?? endpoint.name ?? 'mcp');
|
|
341
|
+
const previous = mcpThrottleQueues.get(key) ?? Promise.resolve();
|
|
342
|
+
const next = previous.catch(() => {}).then(async () => {
|
|
343
|
+
while (true) {
|
|
344
|
+
if (signal?.aborted) throw signal.reason ?? new Error('MCP request aborted.');
|
|
345
|
+
const now = Date.now();
|
|
346
|
+
const starts = (mcpThrottleStarts.get(key) ?? []).filter((at) => now - at < windowMs);
|
|
347
|
+
if (starts.length < requestsPerMinute) {
|
|
348
|
+
starts.push(now);
|
|
349
|
+
mcpThrottleStarts.set(key, starts);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
await retryDelay(Math.max(1, windowMs - (now - starts[0])), signal);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
mcpThrottleQueues.set(key, next);
|
|
356
|
+
try {
|
|
357
|
+
await next;
|
|
358
|
+
} finally {
|
|
359
|
+
if (mcpThrottleQueues.get(key) === next) mcpThrottleQueues.delete(key);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function resetMcpThrottleForTests() {
|
|
364
|
+
mcpThrottleQueues.clear();
|
|
365
|
+
mcpThrottleStarts.clear();
|
|
366
|
+
}
|
|
367
|
+
|
|
318
368
|
export function formatMcpToolResult(result) {
|
|
319
369
|
if (!result) return 'No result.';
|
|
320
370
|
const content = result.content;
|
package/src/core/mcp.test.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
callMcpTool,
|
|
9
9
|
discoverMcpTools,
|
|
10
10
|
formatMcpToolsForAgent,
|
|
11
|
+
resetMcpThrottleForTests,
|
|
11
12
|
resolveRetryPolicy,
|
|
12
13
|
resolveToolCallName,
|
|
13
14
|
truncateToolResult,
|
|
@@ -361,6 +362,44 @@ test('callMcpTool sends configured endpoint headers', async () => {
|
|
|
361
362
|
}
|
|
362
363
|
});
|
|
363
364
|
|
|
365
|
+
test('callMcpTool throttles MCP traffic independently per endpoint', async () => {
|
|
366
|
+
const originalFetch = globalThis.fetch;
|
|
367
|
+
const originalWindow = process.env.WIKI_MANAGER_MCP_RATE_LIMIT_WINDOW_MS;
|
|
368
|
+
const starts = [];
|
|
369
|
+
globalThis.fetch = async () => {
|
|
370
|
+
starts.push(Date.now());
|
|
371
|
+
return {
|
|
372
|
+
ok: true,
|
|
373
|
+
status: 200,
|
|
374
|
+
headers: { get: () => null },
|
|
375
|
+
text: async () => JSON.stringify({ result: { content: [{ type: 'text', text: '{"ok":true}' }] } }),
|
|
376
|
+
};
|
|
377
|
+
};
|
|
378
|
+
process.env.WIKI_MANAGER_MCP_RATE_LIMIT_WINDOW_MS = '30';
|
|
379
|
+
resetMcpThrottleForTests();
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
const status = {
|
|
383
|
+
production: {
|
|
384
|
+
status: 'connected',
|
|
385
|
+
url: 'http://127.0.0.1:3000/mcp/',
|
|
386
|
+
requestsPerMinute: 1,
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
await Promise.all([
|
|
390
|
+
callMcpTool(status, 'production', 'agent_status', { jobId: 'a' }),
|
|
391
|
+
callMcpTool(status, 'production', 'agent_status', { jobId: 'b' }),
|
|
392
|
+
]);
|
|
393
|
+
assert.equal(starts.length, 2);
|
|
394
|
+
assert.ok(starts[1] - starts[0] >= 20, `expected throttling delay, got ${starts[1] - starts[0]}ms`);
|
|
395
|
+
} finally {
|
|
396
|
+
globalThis.fetch = originalFetch;
|
|
397
|
+
if (originalWindow == null) delete process.env.WIKI_MANAGER_MCP_RATE_LIMIT_WINDOW_MS;
|
|
398
|
+
else process.env.WIKI_MANAGER_MCP_RATE_LIMIT_WINDOW_MS = originalWindow;
|
|
399
|
+
resetMcpThrottleForTests();
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
364
403
|
test('callMcpTool retries transient MCP failures', async () => {
|
|
365
404
|
const originalFetch = globalThis.fetch;
|
|
366
405
|
let attempts = 0;
|
|
@@ -528,4 +567,3 @@ test('truncateToolResult keeps short results intact and bounds long ones head+ta
|
|
|
528
567
|
assert.match(bounded, /-END$/);
|
|
529
568
|
assert.match(bounded, /caractères tronqués/);
|
|
530
569
|
});
|
|
531
|
-
|
package/src/runtime/server.js
CHANGED
|
@@ -15,6 +15,7 @@ export function startRuntimeServer({
|
|
|
15
15
|
session = null,
|
|
16
16
|
getContext,
|
|
17
17
|
run,
|
|
18
|
+
turn,
|
|
18
19
|
delegate,
|
|
19
20
|
cancel,
|
|
20
21
|
resume,
|
|
@@ -276,6 +277,73 @@ export function startRuntimeServer({
|
|
|
276
277
|
}
|
|
277
278
|
return;
|
|
278
279
|
}
|
|
280
|
+
if (request.method === 'POST' && url.pathname === '/turn') {
|
|
281
|
+
const { body, context } = await resolveBodyContext(request, url);
|
|
282
|
+
const input = String(body.input ?? body.prompt ?? '').trim();
|
|
283
|
+
if (!input) {
|
|
284
|
+
sendJson(response, 400, { error: 'Missing input.' });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (context.running) {
|
|
288
|
+
const result = await handleControlMessage(context, store, input, {
|
|
289
|
+
intent: body.intent,
|
|
290
|
+
startNextControlRequest,
|
|
291
|
+
cancel,
|
|
292
|
+
approve,
|
|
293
|
+
});
|
|
294
|
+
sendJson(response, result.statusCode, result.body);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (typeof turn !== 'function') {
|
|
298
|
+
sendJson(response, 501, { error: 'Runtime interactive turns are unavailable.' });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const turnId = `turn-${randomUUID()}`;
|
|
302
|
+
const controller = new AbortController();
|
|
303
|
+
const previous = context.interactiveTurn ?? Promise.resolve();
|
|
304
|
+
const current = previous.catch(() => {}).then(async () => {
|
|
305
|
+
// A preceding serialized turn may have delegated and started a run
|
|
306
|
+
// after this request was accepted. Reclassify against the fresh
|
|
307
|
+
// state instead of starting another interactive decision in parallel.
|
|
308
|
+
if (context.running) {
|
|
309
|
+
const result = await handleControlMessage(context, store, input, {
|
|
310
|
+
intent: body.intent,
|
|
311
|
+
startNextControlRequest,
|
|
312
|
+
cancel,
|
|
313
|
+
approve,
|
|
314
|
+
});
|
|
315
|
+
publish(createAgentEvent('assistant_message', {
|
|
316
|
+
origin: 'runtime_turn',
|
|
317
|
+
turnId,
|
|
318
|
+
workspace: context.workspace ?? null,
|
|
319
|
+
payload: { content: result.body?.explanation ?? 'Runtime control request processed.' },
|
|
320
|
+
}));
|
|
321
|
+
return result.body;
|
|
322
|
+
}
|
|
323
|
+
return turn(context, { ...body, input }, {
|
|
324
|
+
signal: controller.signal,
|
|
325
|
+
turnId,
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
context.interactiveTurn = current;
|
|
329
|
+
void current.catch((err) => {
|
|
330
|
+
publish(createAgentEvent('assistant_message', {
|
|
331
|
+
origin: 'runtime_turn',
|
|
332
|
+
turnId,
|
|
333
|
+
workspace: context.workspace ?? null,
|
|
334
|
+
payload: { content: `Runtime turn failed: ${err instanceof Error ? err.message : String(err)}` },
|
|
335
|
+
}));
|
|
336
|
+
}).finally(() => {
|
|
337
|
+
if (context.interactiveTurn === current) context.interactiveTurn = null;
|
|
338
|
+
});
|
|
339
|
+
sendJson(response, 202, {
|
|
340
|
+
accepted: true,
|
|
341
|
+
kind: 'turn',
|
|
342
|
+
turnId,
|
|
343
|
+
workspace: context.workspace ?? null,
|
|
344
|
+
});
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
279
347
|
if (request.method === 'POST' && url.pathname === '/delegate') {
|
|
280
348
|
const { body, context } = await resolveBodyContext(request, url);
|
|
281
349
|
const objective = String(body.objective ?? '').trim();
|
|
@@ -1,8 +1,45 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import test from 'node:test';
|
|
3
3
|
import { createAgentEvent, dispatchAgentEvent } from '../core/agentEvents.js';
|
|
4
|
+
import { createInteractiveSession, ensureInteractiveAssistantMessage } from '../cli/wiki-manager.js';
|
|
4
5
|
import { startRuntimeServer } from './server.js';
|
|
5
6
|
|
|
7
|
+
test('interactive runtime sessions isolate canonical run state', () => {
|
|
8
|
+
const mcp = { wiki: { status: 'connected' } };
|
|
9
|
+
const session = createInteractiveSession({ session: {
|
|
10
|
+
workspace: 'demo', workspacePath: '/workspace/demo', mcp,
|
|
11
|
+
llm: { invoke() {} }, commands: ['status'], packageJson: {}, queueStore: {},
|
|
12
|
+
_currentRunIdentity: { runId: 'run-1' }, headlessPlan: [{ id: 'task-1' }],
|
|
13
|
+
agentProjection: { status: 'running' }, _agentProjectionState: {},
|
|
14
|
+
controlQueue: [{}], planPatches: [{}], _requestApproval() {}, agents: [{}], agentRegistry: {},
|
|
15
|
+
} }, { runtimeUrl: 'http://127.0.0.1:7788', turnId: 'turn-1' });
|
|
16
|
+
|
|
17
|
+
assert.equal(session.mcp, mcp);
|
|
18
|
+
assert.deepEqual(session.runtime, { url: 'http://127.0.0.1:7788' });
|
|
19
|
+
assert.equal(session.headlessPlan, null);
|
|
20
|
+
assert.deepEqual(session.activities, {});
|
|
21
|
+
assert.deepEqual(session.jobQueue, []);
|
|
22
|
+
for (const key of [
|
|
23
|
+
'_currentRunIdentity', 'agentProjection', '_agentProjectionState', 'controlQueue',
|
|
24
|
+
'planPatches', '_requestApproval', 'agents', 'agentRegistry',
|
|
25
|
+
]) assert.equal(Object.hasOwn(session, key), false, `${key} must not leak`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('interactive turns publish a fallback assistant message exactly once', () => {
|
|
29
|
+
const published = [];
|
|
30
|
+
const session = { agentEvents: [], _onAgentEvent: (event) => published.push(event) };
|
|
31
|
+
assert.equal(ensureInteractiveAssistantMessage(session, 'Réponse concise.', {
|
|
32
|
+
turnId: 'turn-1', workspace: 'demo',
|
|
33
|
+
}), true);
|
|
34
|
+
assert.equal(ensureInteractiveAssistantMessage(session, 'Réponse dupliquée.', {
|
|
35
|
+
turnId: 'turn-1', workspace: 'demo',
|
|
36
|
+
}), false);
|
|
37
|
+
assert.equal(published.length, 1);
|
|
38
|
+
assert.equal(published[0].type, 'assistant_message');
|
|
39
|
+
assert.equal(published[0].origin, 'runtime_turn');
|
|
40
|
+
assert.equal(published[0].payload.content, 'Réponse concise.');
|
|
41
|
+
});
|
|
42
|
+
|
|
6
43
|
test('runtime server checks bearer and x-runtime-token credentials', async (t) => {
|
|
7
44
|
let handle;
|
|
8
45
|
try {
|
|
@@ -1397,6 +1434,50 @@ test('runtime server answers control messages posted to /run during an active ru
|
|
|
1397
1434
|
}
|
|
1398
1435
|
});
|
|
1399
1436
|
|
|
1437
|
+
test('runtime server accepts an interactive turn without starting a run', async (t) => {
|
|
1438
|
+
const context = { workspace: 'demo', session: {}, running: false };
|
|
1439
|
+
let received = null;
|
|
1440
|
+
let handle;
|
|
1441
|
+
try {
|
|
1442
|
+
handle = await startRuntimeServer({
|
|
1443
|
+
host: '127.0.0.1',
|
|
1444
|
+
port: 0,
|
|
1445
|
+
token: 'runtime-secret',
|
|
1446
|
+
store: {
|
|
1447
|
+
dbPath: ':memory:',
|
|
1448
|
+
getState: () => ({ status: 'idle' }),
|
|
1449
|
+
listEvents: () => [],
|
|
1450
|
+
},
|
|
1451
|
+
getContext: async () => context,
|
|
1452
|
+
run: async () => assert.fail('/turn must not start a runtime run'),
|
|
1453
|
+
turn: async (_context, body, meta) => { received = { body, meta }; },
|
|
1454
|
+
});
|
|
1455
|
+
} catch (err) {
|
|
1456
|
+
if (err?.code === 'EPERM') {
|
|
1457
|
+
t.skip('network listen is not permitted in this sandbox');
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
throw err;
|
|
1461
|
+
}
|
|
1462
|
+
try {
|
|
1463
|
+
const response = await fetch(`http://127.0.0.1:${handle.port}/turn`, {
|
|
1464
|
+
method: 'POST',
|
|
1465
|
+
headers: { authorization: 'Bearer runtime-secret', 'content-type': 'application/json' },
|
|
1466
|
+
body: JSON.stringify({ input: 'Quels documents sont en attente ?', workspace: 'demo' }),
|
|
1467
|
+
});
|
|
1468
|
+
assert.equal(response.status, 202);
|
|
1469
|
+
const body = await response.json();
|
|
1470
|
+
assert.equal(body.kind, 'turn');
|
|
1471
|
+
assert.match(body.turnId, /^turn-/);
|
|
1472
|
+
await context.interactiveTurn;
|
|
1473
|
+
assert.equal(received.body.input, 'Quels documents sont en attente ?');
|
|
1474
|
+
assert.equal(received.meta.turnId, body.turnId);
|
|
1475
|
+
assert.equal(context.running, false);
|
|
1476
|
+
} finally {
|
|
1477
|
+
await handle.close();
|
|
1478
|
+
}
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1400
1481
|
test('runtime health reports active runs across workspaces', async (t) => {
|
|
1401
1482
|
let handle;
|
|
1402
1483
|
try {
|