mixdog 0.9.23 → 0.9.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/scripts/boot-smoke.mjs +1 -1
- package/scripts/channel-daemon-smoke.mjs +327 -9
- package/scripts/channel-daemon-stub.mjs +12 -1
- package/scripts/debounced-skills-async-save-test.mjs +57 -0
- package/scripts/explore-bench-tmp.mjs +17 -0
- package/scripts/find-fuzzy-hidden-test.mjs +145 -0
- package/scripts/mcp-grace-deferred-test.mjs +149 -0
- package/scripts/tool-smoke.mjs +38 -30
- package/src/defaults/cycle3-review-prompt.md +11 -4
- package/src/defaults/memory-promote-prompt.md +9 -0
- package/src/rules/agent/30-explorer.md +6 -0
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
- package/src/runtime/agent/orchestrator/config.mjs +33 -7
- package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +39 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +145 -31
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +7 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +43 -16
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +70 -21
- package/src/runtime/channels/backends/discord.mjs +10 -3
- package/src/runtime/channels/lib/crash-log.mjs +4 -2
- package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
- package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +65 -180
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -13
- package/src/runtime/channels/lib/runtime-paths.mjs +6 -6
- package/src/runtime/channels/lib/tool-dispatch.mjs +9 -17
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +9 -28
- package/src/runtime/memory/lib/cycle-scheduler.mjs +17 -1
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
- package/src/runtime/memory/lib/query-handlers.mjs +4 -1
- package/src/runtime/memory/lib/recall-format.mjs +7 -3
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +130 -2
- package/src/runtime/shared/background-tasks.mjs +1 -1
- package/src/runtime/shared/config.mjs +53 -1
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +19 -0
- package/src/runtime/shared/update-checker.mjs +3 -0
- package/src/runtime/shared/user-data-guard.mjs +66 -0
- package/src/session-runtime/config-lifecycle.mjs +175 -15
- package/src/session-runtime/mcp-glue.mjs +30 -0
- package/src/session-runtime/runtime-core.mjs +91 -7
- package/src/session-runtime/session-turn-api.mjs +42 -16
- package/src/session-runtime/tool-catalog.mjs +44 -0
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +3 -1
- package/src/standalone/channel-daemon-transport.mjs +202 -8
- package/src/standalone/channel-daemon.mjs +54 -17
- package/src/standalone/channel-worker.mjs +18 -7
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/tui/App.jsx +2 -2
- package/src/tui/components/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +14 -2
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +246 -47
- package/src/tui/engine/agent-job-feed.mjs +47 -3
- package/src/tui/engine/notification-plan.mjs +5 -0
- package/src/tui/engine/session-api.mjs +6 -1
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +31 -12
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +15 -5
- package/src/workflows/default/WORKFLOW.md +29 -38
- package/src/workflows/solo/WORKFLOW.md +15 -20
- package/src/runtime/channels/lib/seat-lock.mjs +0 -196
|
@@ -86,6 +86,45 @@ export async function collectDescendants(rootPid) {
|
|
|
86
86
|
return descendantsOf(childrenOf, rootPid);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Immediate, non-blocking tree kill for a transport whose MCP handshake never
|
|
91
|
+
* completed (shutdown-during-connect). Unlike shutdownStdioChild there is no
|
|
92
|
+
* grace wait and no identity-checked enumeration on Windows — the ChildProcess
|
|
93
|
+
* handle is live, so the pid is current, and taskkill /T reaps the still-
|
|
94
|
+
* parented tree. Everything is unref'd/fire-and-forget so the kill can never
|
|
95
|
+
* hold the caller's event loop open (the caller is a process about to exit).
|
|
96
|
+
*/
|
|
97
|
+
export function killStdioChildTreeFast(transport) {
|
|
98
|
+
const proc = transport?._process;
|
|
99
|
+
const pid = (typeof transport?.pid === 'number' ? transport.pid : null) ?? proc?.pid ?? null;
|
|
100
|
+
if (!pid) return false;
|
|
101
|
+
if (proc && (proc.exitCode !== null || proc.signalCode !== null)) return false;
|
|
102
|
+
try { proc?.unref?.(); } catch { /* ignore */ }
|
|
103
|
+
for (const s of [proc?.stdin, proc?.stdout, proc?.stderr]) {
|
|
104
|
+
try { s?.unref?.(); } catch { /* ignore */ }
|
|
105
|
+
try { s?.destroy?.(); } catch { /* ignore */ }
|
|
106
|
+
}
|
|
107
|
+
if (isWin) {
|
|
108
|
+
try {
|
|
109
|
+
const cp = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], {
|
|
110
|
+
windowsHide: true,
|
|
111
|
+
detached: true,
|
|
112
|
+
stdio: 'ignore',
|
|
113
|
+
});
|
|
114
|
+
cp.unref();
|
|
115
|
+
} catch { /* ignore */ }
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
// POSIX: cheap ps enumerate, then SIGKILL descendants + root. Best-effort
|
|
119
|
+
// and un-awaited; a straggler orphan is reparented to init, not leaked by us.
|
|
120
|
+
void collectDescendants(pid).then((pids) => {
|
|
121
|
+
for (const p of [...pids, pid]) {
|
|
122
|
+
try { process.kill(p, 'SIGKILL'); } catch { /* ignore */ }
|
|
123
|
+
}
|
|
124
|
+
}).catch(() => { /* ignore */ });
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
89
128
|
function isAlive(pid) {
|
|
90
129
|
try {
|
|
91
130
|
process.kill(pid, 0);
|
|
@@ -4,7 +4,7 @@ import { join } from 'path';
|
|
|
4
4
|
import { tmpdir } from 'os';
|
|
5
5
|
import { randomUUID } from 'crypto';
|
|
6
6
|
import { smartReadTruncate } from '../tools/builtin/read-formatting.mjs';
|
|
7
|
-
import { shutdownStdioChild } from './child-tree.mjs';
|
|
7
|
+
import { shutdownStdioChild, killStdioChildTreeFast } from './child-tree.mjs';
|
|
8
8
|
// --- Types ---
|
|
9
9
|
/** Known auto-detect targets: port file path relative to tmpdir.
|
|
10
10
|
* Note: `mixdog` used to self-loopback via active-instance.json's
|
|
@@ -16,6 +16,8 @@ const AUTO_DETECT_PORTS = {
|
|
|
16
16
|
'mixdog-memory': { dir: 'mixdog', file: 'active-instance.json', portField: 'memory_port', endpoint: '/mcp' },
|
|
17
17
|
};
|
|
18
18
|
const DEFAULT_MCP_CALL_TIMEOUT_MS = 0;
|
|
19
|
+
// Per-server STARTUP handshake budget (connect + listTools). Codex parity: 10s.
|
|
20
|
+
export const DEFAULT_MCP_STARTUP_TIMEOUT_MS = 10000;
|
|
19
21
|
// --- State ---
|
|
20
22
|
const servers = new Map();
|
|
21
23
|
let mcpSdkPromise = null;
|
|
@@ -97,21 +99,31 @@ export function resolveMcpTransportKind(cfg) {
|
|
|
97
99
|
* Supports stdio (child process) and http (Streamable HTTP) transports.
|
|
98
100
|
*/
|
|
99
101
|
export async function connectMcpServers(config) {
|
|
102
|
+
// Capture the abort generation SYNCHRONOUSLY at entry: the boot path fires
|
|
103
|
+
// this un-awaited, so a runtime close can land while connectServer is still
|
|
104
|
+
// loading the SDK. A capture taken any later would already see the bumped
|
|
105
|
+
// generation and register the server anyway (leaking its stdio child).
|
|
106
|
+
const genAtStart = _connectAbortGeneration;
|
|
100
107
|
const failures = [];
|
|
101
|
-
|
|
108
|
+
const entries = Object.entries(config).filter(([name, cfg]) => {
|
|
102
109
|
if (cfg?.enabled === false) {
|
|
103
110
|
mcpLog(`[mcp-client] Skipping disabled server "${name}"\n`);
|
|
104
|
-
|
|
111
|
+
return false;
|
|
105
112
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
return true;
|
|
114
|
+
});
|
|
115
|
+
// Connect all servers in PARALLEL: a slow/hung server (bounded by its
|
|
116
|
+
// per-server startup timeout) must never delay the others' handshakes.
|
|
117
|
+
const settled = await Promise.allSettled(
|
|
118
|
+
entries.map(([name, cfg]) => connectServer(name, cfg, genAtStart)),
|
|
119
|
+
);
|
|
120
|
+
settled.forEach((res, i) => {
|
|
121
|
+
if (res.status !== 'rejected') return;
|
|
122
|
+
const [name] = entries[i];
|
|
123
|
+
const msg = res.reason instanceof Error ? res.reason.message : String(res.reason);
|
|
124
|
+
mcpLog(`[mcp-client] Failed to connect "${name}": ${msg}\n`);
|
|
125
|
+
failures.push({ name, msg });
|
|
126
|
+
});
|
|
115
127
|
if (failures.length > 0) {
|
|
116
128
|
const detail = failures.map(f => `${f.name}: ${f.msg}`).join('; ');
|
|
117
129
|
const err = new Error(`[mcp-client] ${failures.length} MCP server(s) failed to connect — ${detail}`);
|
|
@@ -234,6 +246,26 @@ export function isMcpToolCallTimeoutError(err) {
|
|
|
234
246
|
return err?.code === 'EMCPTOOLTIMEOUT';
|
|
235
247
|
}
|
|
236
248
|
|
|
249
|
+
// MCP per-server STARTUP timeout: bounds the connect + listTools handshake so a
|
|
250
|
+
// slow or hung server can't stall boot or the first turn. Default 10s (codex
|
|
251
|
+
// parity). Per-server override: startupTimeoutMs / startupTimeoutSec. Global
|
|
252
|
+
// env: MIXDOG_MCP_STARTUP_TIMEOUT_MS. A value of 0/off/none/false disables it.
|
|
253
|
+
export function resolveMcpStartupTimeoutMs(cfg = {}, env = process.env) {
|
|
254
|
+
const rawMs = cfg?.startupTimeoutMs ?? cfg?.startup_timeout_ms;
|
|
255
|
+
const rawSec = cfg?.startupTimeoutSec ?? cfg?.startup_timeout_sec;
|
|
256
|
+
const rawEnv = env?.MIXDOG_MCP_STARTUP_TIMEOUT_MS;
|
|
257
|
+
let raw;
|
|
258
|
+
let scale = 1;
|
|
259
|
+
if (rawMs != null && rawMs !== '') raw = rawMs;
|
|
260
|
+
else if (rawSec != null && rawSec !== '') { raw = rawSec; scale = 1000; }
|
|
261
|
+
else if (rawEnv != null && rawEnv !== '') raw = rawEnv;
|
|
262
|
+
else return DEFAULT_MCP_STARTUP_TIMEOUT_MS;
|
|
263
|
+
if (raw === 0 || (typeof raw === 'string' && /^(0|off|none|false)$/i.test(raw.trim()))) return 0;
|
|
264
|
+
const parsed = Number(raw) * scale;
|
|
265
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MCP_STARTUP_TIMEOUT_MS;
|
|
266
|
+
return Math.round(parsed);
|
|
267
|
+
}
|
|
268
|
+
|
|
237
269
|
async function _callToolWithTimeout(server, toolName, args) {
|
|
238
270
|
let timer;
|
|
239
271
|
const timeoutMs = resolveMcpCallTimeoutMs(server?.cfg);
|
|
@@ -339,6 +371,22 @@ export function mcpToolHasField(name, field) {
|
|
|
339
371
|
* Disconnect all MCP servers.
|
|
340
372
|
*/
|
|
341
373
|
export async function disconnectAll() {
|
|
374
|
+
// Abort handshakes still in flight: bump the generation so a connect that
|
|
375
|
+
// completes after this point tears itself down instead of registering, and
|
|
376
|
+
// reap any already-spawned stdio child now so its ref'd ChildProcess handle
|
|
377
|
+
// can't keep the event loop alive (close-during-connect previously leaked
|
|
378
|
+
// the uvx/npx wrapper tree and hung process exit).
|
|
379
|
+
_connectAbortGeneration++;
|
|
380
|
+
for (const entry of [..._pendingConnects]) {
|
|
381
|
+
_pendingConnects.delete(entry);
|
|
382
|
+
// Mid-handshake child: nothing to shut down gracefully — hard-kill the
|
|
383
|
+
// tree without holding the event loop (this path runs during process
|
|
384
|
+
// exit; the spec-order grace dance would delay exit by seconds).
|
|
385
|
+
try { killStdioChildTreeFast(entry.transport); }
|
|
386
|
+
catch { /* ignore */ }
|
|
387
|
+
try { void entry.client.close().catch(() => { /* ignore */ }); }
|
|
388
|
+
catch { /* ignore */ }
|
|
389
|
+
}
|
|
342
390
|
for (const [name, server] of servers) {
|
|
343
391
|
try {
|
|
344
392
|
await _closeServer(server);
|
|
@@ -382,7 +430,13 @@ async function _closeServer(server) {
|
|
|
382
430
|
try { await server.client.close(); }
|
|
383
431
|
catch { /* ignore */ }
|
|
384
432
|
}
|
|
385
|
-
|
|
433
|
+
// Connects whose handshake has not finished yet: disconnectAll() must be able
|
|
434
|
+
// to see (and tear down) their transports, because `servers` only lists fully
|
|
435
|
+
// handshaken entries. Generation token aborts a connect that outlives a
|
|
436
|
+
// disconnectAll() issued mid-handshake (runtime close during boot connect).
|
|
437
|
+
let _connectAbortGeneration = 0;
|
|
438
|
+
const _pendingConnects = new Set();
|
|
439
|
+
async function connectServer(name, cfg, genAtStart = _connectAbortGeneration) {
|
|
386
440
|
const {
|
|
387
441
|
Client,
|
|
388
442
|
StdioClientTransport,
|
|
@@ -390,6 +444,11 @@ async function connectServer(name, cfg) {
|
|
|
390
444
|
SSEClientTransport,
|
|
391
445
|
WebSocketClientTransport,
|
|
392
446
|
} = await loadMcpSdk();
|
|
447
|
+
if (genAtStart !== _connectAbortGeneration) {
|
|
448
|
+
// disconnectAll() ran while the SDK was loading: nothing spawned yet —
|
|
449
|
+
// abort before creating a transport/child at all.
|
|
450
|
+
throw new Error(`MCP server "${name}" connect aborted by shutdown`);
|
|
451
|
+
}
|
|
393
452
|
const client = new Client({ name: `mixdog-agent/${name}`, version: '1.0.0' });
|
|
394
453
|
let transport;
|
|
395
454
|
const kind = resolveMcpTransportKind(cfg);
|
|
@@ -472,23 +531,78 @@ async function connectServer(name, cfg) {
|
|
|
472
531
|
else {
|
|
473
532
|
throw new Error(`Invalid config for "${name}": need autoDetect, type (stdio/http/sse/ws), url (http), or command (stdio)`);
|
|
474
533
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
534
|
+
const pending = { name, client, transport };
|
|
535
|
+
_pendingConnects.add(pending);
|
|
536
|
+
try {
|
|
537
|
+
// Bound the connect + listTools handshake so a slow/hung server can't
|
|
538
|
+
// stall boot or the first turn. On expiry we tear down the pending
|
|
539
|
+
// transport/child (nothing leaks) and fail this server like any other
|
|
540
|
+
// connect failure — the parallel Promise.allSettled means other servers
|
|
541
|
+
// are unaffected.
|
|
542
|
+
const startupTimeoutMs = resolveMcpStartupTimeoutMs(cfg);
|
|
543
|
+
let startupTimer = null;
|
|
544
|
+
let startupTimedOut = false;
|
|
545
|
+
const handshake = (async () => {
|
|
546
|
+
await client.connect(transport);
|
|
547
|
+
const instructionsRaw = typeof client.getInstructions === 'function'
|
|
548
|
+
? client.getInstructions()
|
|
549
|
+
: undefined;
|
|
550
|
+
const instr = typeof instructionsRaw === 'string' ? instructionsRaw.trim() : '';
|
|
551
|
+
const result = await client.listTools();
|
|
552
|
+
return { instructions: instr, toolsResult: result };
|
|
553
|
+
})();
|
|
554
|
+
let instructions;
|
|
555
|
+
let toolsResult;
|
|
556
|
+
try {
|
|
557
|
+
if (startupTimeoutMs > 0) {
|
|
558
|
+
const guard = new Promise((_, rej) => {
|
|
559
|
+
startupTimer = setTimeout(() => {
|
|
560
|
+
startupTimedOut = true;
|
|
561
|
+
const err = new Error(`MCP server "${name}" startup exceeded ${startupTimeoutMs}ms budget — raise per-server "startupTimeoutSec"/"startupTimeoutMs" or env MIXDOG_MCP_STARTUP_TIMEOUT_MS`);
|
|
562
|
+
err.code = 'EMCPSTARTUPTIMEOUT';
|
|
563
|
+
rej(err);
|
|
564
|
+
}, startupTimeoutMs);
|
|
565
|
+
});
|
|
566
|
+
({ instructions, toolsResult } = await Promise.race([handshake, guard]));
|
|
567
|
+
} else {
|
|
568
|
+
({ instructions, toolsResult } = await handshake);
|
|
569
|
+
}
|
|
570
|
+
} catch (err) {
|
|
571
|
+
if (startupTimedOut) {
|
|
572
|
+
// Tear down the pending transport/child so a hung handshake never
|
|
573
|
+
// leaks a stdio process or socket — fire-and-forget (like the
|
|
574
|
+
// tool-call timeout path) so a slow tree-kill never delays this
|
|
575
|
+
// server's failure or the parallel batch's resolution.
|
|
576
|
+
try { _closeServer({ client, transport }).catch(() => {}); }
|
|
577
|
+
catch { /* ignore */ }
|
|
578
|
+
// Never let the late handshake settle into an unhandled rejection.
|
|
579
|
+
handshake.catch(() => {});
|
|
580
|
+
}
|
|
581
|
+
throw err;
|
|
582
|
+
} finally {
|
|
583
|
+
if (startupTimer) clearTimeout(startupTimer);
|
|
584
|
+
}
|
|
585
|
+
if (!toolsResult || !Array.isArray(toolsResult.tools)) {
|
|
586
|
+
throw new Error(`[mcp-client] ListTools returned invalid shape for "${name}": missing or non-array tools field`);
|
|
587
|
+
}
|
|
588
|
+
if (genAtStart !== _connectAbortGeneration) {
|
|
589
|
+
// disconnectAll() ran mid-handshake: never register — tear down.
|
|
590
|
+
try { await _closeServer({ client, transport }); }
|
|
591
|
+
catch { /* ignore */ }
|
|
592
|
+
throw new Error(`MCP server "${name}" connect aborted by shutdown`);
|
|
593
|
+
}
|
|
594
|
+
const tools = toolsResult.tools.map((t) => ({
|
|
595
|
+
name: `mcp__${name}__${t.name}`,
|
|
596
|
+
description: t.description || '',
|
|
597
|
+
inputSchema: (t.inputSchema || { type: 'object', properties: {} }),
|
|
598
|
+
...(t.annotations && typeof t.annotations === 'object' ? { annotations: t.annotations } : {}),
|
|
599
|
+
}));
|
|
600
|
+
const toolNames = tools.map(t => t.name);
|
|
601
|
+
servers.set(name, { name, client, transport, tools, cfg, instructions });
|
|
602
|
+
_invalidateMcpToolFieldMemo();
|
|
603
|
+
mcpLog(`[mcp] connected: ${tools.length} tools — ${toolNames.join(', ')}\n`);
|
|
604
|
+
}
|
|
605
|
+
finally {
|
|
606
|
+
_pendingConnects.delete(pending);
|
|
483
607
|
}
|
|
484
|
-
const tools = toolsResult.tools.map((t) => ({
|
|
485
|
-
name: `mcp__${name}__${t.name}`,
|
|
486
|
-
description: t.description || '',
|
|
487
|
-
inputSchema: (t.inputSchema || { type: 'object', properties: {} }),
|
|
488
|
-
...(t.annotations && typeof t.annotations === 'object' ? { annotations: t.annotations } : {}),
|
|
489
|
-
}));
|
|
490
|
-
const toolNames = tools.map(t => t.name);
|
|
491
|
-
servers.set(name, { name, client, transport, tools, cfg, instructions });
|
|
492
|
-
_invalidateMcpToolFieldMemo();
|
|
493
|
-
mcpLog(`[mcp] connected: ${tools.length} tools — ${toolNames.join(', ')}\n`);
|
|
494
608
|
}
|
|
@@ -301,6 +301,21 @@ function nativeAnthropicTools(opts) {
|
|
|
301
301
|
? opts.nativeTools.filter(t => t && typeof t === 'object')
|
|
302
302
|
: [];
|
|
303
303
|
}
|
|
304
|
+
// Map the orchestrator-level opts.toolChoice into Anthropic's tool_choice.
|
|
305
|
+
// Only 'none' is activated: it lets the hard-cap final turn keep the tool
|
|
306
|
+
// DEFINITIONS in-request (so the tools->system->messages prefix — and its
|
|
307
|
+
// prompt-cache prefix — stay byte-identical to prior turns) while forbidding
|
|
308
|
+
// tool USE, so the model can only emit text. Forced values
|
|
309
|
+
// ('required'->{type:'any'}, {name}->{type:'tool'}) are deliberately NOT
|
|
310
|
+
// mapped: Anthropic returns a 400 for any forced tool_choice while
|
|
311
|
+
// extended/adaptive thinking is enabled, and the only caller that sets
|
|
312
|
+
// opts.toolChoice='required' (the forced-first-tool turn) runs with
|
|
313
|
+
// effort/thinking active on reasoning models — activating it would convert a
|
|
314
|
+
// previously-harmless no-op into a hard 400 on exactly that turn. Attached
|
|
315
|
+
// only when the request actually carries tools (see buildRequestBody).
|
|
316
|
+
function toAnthropicToolChoice(toolChoice) {
|
|
317
|
+
return toolChoice === 'none' ? { type: 'none' } : undefined;
|
|
318
|
+
}
|
|
304
319
|
function deferredAnthropicTools(activeTools, opts) {
|
|
305
320
|
if (opts?.session?.deferredNativeTools !== true) return [];
|
|
306
321
|
// A request whose ONLY tools are deferred is rejected by the API with
|
|
@@ -464,10 +479,25 @@ function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_
|
|
|
464
479
|
return -1;
|
|
465
480
|
};
|
|
466
481
|
|
|
482
|
+
const firstRequestUserPromptIdx = () => {
|
|
483
|
+
// Iteration-1 fallback: on the very first request a session has only the
|
|
484
|
+
// current user prompt — no tool_result tail and no earlier user turn, so
|
|
485
|
+
// both anchors above return -1 and NO message breakpoint is placed. That
|
|
486
|
+
// left the whole tools+system prefix (~4.2k) uncached on iter1: nothing
|
|
487
|
+
// was written, so iter2 re-sent it as a fresh full write instead of a
|
|
488
|
+
// read hit. Anchor the current prompt's tail so the stable prefix is
|
|
489
|
+
// cache-written on the first ask and read back on the next. Only used
|
|
490
|
+
// when neither real anchor exists, so later turns still prefer the
|
|
491
|
+
// tool_result / previous-user-text anchors (never the volatile new
|
|
492
|
+
// prompt). Synthetic <system-reminder> turns are excluded by hasUserText.
|
|
493
|
+
if (latestToolResultTailIdx() !== -1 || previousUserTextAnchorIdx() !== -1) return -1;
|
|
494
|
+
const tailIdx = sanitizedMessages.length - 1;
|
|
495
|
+
return hasUserText(sanitizedMessages[tailIdx]) ? tailIdx : -1;
|
|
496
|
+
};
|
|
467
497
|
if (messageTtl !== null) {
|
|
468
498
|
const slots = Math.max(0, Math.min(4, Number(messageSlots) || 0));
|
|
469
499
|
const marked = new Set();
|
|
470
|
-
const candidates = [latestToolResultTailIdx(), previousUserTextAnchorIdx()];
|
|
500
|
+
const candidates = [latestToolResultTailIdx(), previousUserTextAnchorIdx(), firstRequestUserPromptIdx()];
|
|
471
501
|
for (const idx of candidates) {
|
|
472
502
|
if (slots <= 0) break;
|
|
473
503
|
if (idx < 0 || marked.has(idx) || !canMarkMessageIdx(idx)) continue;
|
|
@@ -597,6 +627,13 @@ function buildRequestBody(messages, model, tools, sendOpts) {
|
|
|
597
627
|
// a slot that's better spent on messages tail.
|
|
598
628
|
body.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredTools])];
|
|
599
629
|
}
|
|
630
|
+
// tool_choice only when tools are actually present (Anthropic rejects
|
|
631
|
+
// tool_choice without tools). 'none' rides the hard-cap final turn to
|
|
632
|
+
// forbid tool USE while keeping the tools prefix stable for cache reuse.
|
|
633
|
+
if (body.tools) {
|
|
634
|
+
const toolChoice = toAnthropicToolChoice(opts.toolChoice);
|
|
635
|
+
if (toolChoice) body.tool_choice = toolChoice;
|
|
636
|
+
}
|
|
600
637
|
|
|
601
638
|
applyAnthropicEffortToBody(body, {
|
|
602
639
|
model,
|
|
@@ -308,6 +308,22 @@ function nativeAnthropicTools(opts) {
|
|
|
308
308
|
? opts.nativeTools.filter(t => t && typeof t === 'object')
|
|
309
309
|
: [];
|
|
310
310
|
}
|
|
311
|
+
// Map the orchestrator-level opts.toolChoice into Anthropic's tool_choice.
|
|
312
|
+
// Only 'none' is activated: it lets the hard-cap final turn keep the tool
|
|
313
|
+
// DEFINITIONS in-request (so the tools->system->messages prefix — and its
|
|
314
|
+
// prompt-cache prefix — stay byte-identical to prior turns) while forbidding
|
|
315
|
+
// tool USE, so the model can only emit text. Forced values
|
|
316
|
+
// ('required'->{type:'any'}, {name}->{type:'tool'}) are deliberately NOT
|
|
317
|
+
// mapped: Anthropic returns a 400 for any forced tool_choice while
|
|
318
|
+
// extended/adaptive thinking is enabled, and the only caller that sets
|
|
319
|
+
// opts.toolChoice='required' (the forced-first-tool turn) runs with
|
|
320
|
+
// effort/thinking active on reasoning models — activating it would convert a
|
|
321
|
+
// previously-harmless no-op into a hard 400 on exactly that turn. Attached
|
|
322
|
+
// only when the request actually carries tools (see _doSend). Mirrors
|
|
323
|
+
// anthropic-oauth.mjs.
|
|
324
|
+
function toAnthropicToolChoice(toolChoice) {
|
|
325
|
+
return toolChoice === 'none' ? { type: 'none' } : undefined;
|
|
326
|
+
}
|
|
311
327
|
function deferredAnthropicTools(activeTools, opts) {
|
|
312
328
|
if (opts?.session?.deferredNativeTools !== true) return [];
|
|
313
329
|
// Guard against an all-deferred tools array — the API rejects it with
|
|
@@ -458,10 +474,25 @@ function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_
|
|
|
458
474
|
return -1;
|
|
459
475
|
};
|
|
460
476
|
|
|
477
|
+
const firstRequestUserPromptIdx = () => {
|
|
478
|
+
// Iteration-1 fallback: on the very first request a session has only the
|
|
479
|
+
// current user prompt — no tool_result tail and no earlier user turn, so
|
|
480
|
+
// both anchors above return -1 and NO message breakpoint is placed. That
|
|
481
|
+
// left the whole tools+system prefix (~4.2k) uncached on iter1: nothing
|
|
482
|
+
// was written, so iter2 re-sent it as a fresh full write instead of a
|
|
483
|
+
// read hit. Anchor the current prompt's tail so the stable prefix is
|
|
484
|
+
// cache-written on the first ask and read back on the next. Only used
|
|
485
|
+
// when neither real anchor exists, so later turns still prefer the
|
|
486
|
+
// tool_result / previous-user-text anchors (never the volatile new
|
|
487
|
+
// prompt). Synthetic <system-reminder> turns are excluded by hasUserText.
|
|
488
|
+
if (latestToolResultTailIdx() !== -1 || previousUserTextAnchorIdx() !== -1) return -1;
|
|
489
|
+
const tailIdx = sanitizedMessages.length - 1;
|
|
490
|
+
return hasUserText(sanitizedMessages[tailIdx]) ? tailIdx : -1;
|
|
491
|
+
};
|
|
461
492
|
if (messageTtl !== null) {
|
|
462
493
|
const slots = Math.max(0, Math.min(4, Number(messageSlots) || 0));
|
|
463
494
|
const marked = new Set();
|
|
464
|
-
const candidates = [latestToolResultTailIdx(), previousUserTextAnchorIdx()];
|
|
495
|
+
const candidates = [latestToolResultTailIdx(), previousUserTextAnchorIdx(), firstRequestUserPromptIdx()];
|
|
465
496
|
for (const idx of candidates) {
|
|
466
497
|
if (slots <= 0) break;
|
|
467
498
|
if (idx < 0 || marked.has(idx) || !canMarkMessageIdx(idx)) continue;
|
|
@@ -578,6 +609,13 @@ export class AnthropicProvider {
|
|
|
578
609
|
// Anthropic prefix semantics (order: tools → system → messages).
|
|
579
610
|
params.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredAnthropicTools(tools || [], opts)])];
|
|
580
611
|
}
|
|
612
|
+
// tool_choice only when tools are actually present (Anthropic rejects
|
|
613
|
+
// tool_choice without tools). 'none' rides the hard-cap final turn to
|
|
614
|
+
// forbid tool USE while keeping the tools prefix stable for cache reuse.
|
|
615
|
+
if (params.tools) {
|
|
616
|
+
const toolChoice = toAnthropicToolChoice(opts.toolChoice);
|
|
617
|
+
if (toolChoice) params.tool_choice = toolChoice;
|
|
618
|
+
}
|
|
581
619
|
// Known tool names for the shared parseSSEStream leaked-tool-call guard
|
|
582
620
|
// (same guard fixes both Anthropic providers). Recovered leaked calls
|
|
583
621
|
// are only synthesized when they name a tool actually offered here.
|
|
@@ -291,8 +291,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
291
291
|
if (iterations >= maxLoopIterations) {
|
|
292
292
|
// Final-answer turn: instead of breaking mid-transcript (which
|
|
293
293
|
// yields an empty final for locator-style agents that never got to
|
|
294
|
-
// answer), give the model ONE
|
|
295
|
-
//
|
|
294
|
+
// answer), give the model ONE text-only turn to wrap up, then stop.
|
|
295
|
+
// Tool DEFINITIONS stay in-request (stable cache prefix) but tool
|
|
296
|
+
// USE is forbidden via tool_choice:'none'; any tool call a
|
|
297
|
+
// toolChoice-ignoring provider still emits gets a refusal stub.
|
|
296
298
|
if (_capFinalTurnUsed) {
|
|
297
299
|
process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
|
|
298
300
|
terminatedByCap = true;
|
|
@@ -362,15 +364,22 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
362
364
|
const nextIteration = iterations + 1;
|
|
363
365
|
opts.iteration = nextIteration;
|
|
364
366
|
opts.providerState = providerState;
|
|
365
|
-
if (
|
|
367
|
+
if (_capFinalToolsDisabled) {
|
|
368
|
+
// Hard-cap final turn: forbid tool USE (tool_choice:'none') instead
|
|
369
|
+
// of stripping tool DEFINITIONS. Sending tools:[] changed the
|
|
370
|
+
// tools→system→messages prefix chain, so Anthropic could no longer
|
|
371
|
+
// prefix-match and re-prefilled the whole prompt (~10k, cache
|
|
372
|
+
// read=0) on the final capped turn. Keeping the tools in-request
|
|
373
|
+
// holds the prefix byte-stable; 'none' makes the model emit text
|
|
374
|
+
// only. Overrides the forced-first-tool path below.
|
|
375
|
+
opts.toolChoice = 'none';
|
|
376
|
+
} else if (forcedFirstTool && toolCallsTotal === 0) {
|
|
366
377
|
opts.toolChoice = 'required';
|
|
367
378
|
} else {
|
|
368
379
|
delete opts.toolChoice;
|
|
369
380
|
}
|
|
370
|
-
// Hard-cap final turn: send NO tool definitions so the provider can
|
|
371
|
-
// only emit text. Overrides the forced-first-tool path.
|
|
372
381
|
const sendTools = _capFinalToolsDisabled
|
|
373
|
-
?
|
|
382
|
+
? tools
|
|
374
383
|
: (forcedFirstToolDef && toolCallsTotal === 0 ? [forcedFirstToolDef] : tools);
|
|
375
384
|
// Eager-dispatch queue: when the provider streams a tool-call event,
|
|
376
385
|
// start read-only tools immediately so execution overlaps with the
|
|
@@ -387,7 +396,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
387
396
|
getNextIteration: () => nextIteration,
|
|
388
397
|
repeatFailLimit: REPEAT_FAIL_LIMIT,
|
|
389
398
|
});
|
|
390
|
-
|
|
399
|
+
// Hard-cap final turn: forbid eager dispatch. Tools are still sent (to
|
|
400
|
+
// hold the cache prefix) but tool_choice:'none' means Anthropic emits
|
|
401
|
+
// no calls; a toolChoice-IGNORING provider could still stream calls,
|
|
402
|
+
// and an attached onToolCall would eager-run read-only tools mid-stream
|
|
403
|
+
// (real cost/UI/network side effects) whose results are then discarded
|
|
404
|
+
// by the refusal-stub path. Leave it unset so nothing dispatches; opts
|
|
405
|
+
// is reused across iterations but onToolCall is cleared to undefined
|
|
406
|
+
// after send() below, so non-cap iterations are unaffected.
|
|
407
|
+
opts.onToolCall = _capFinalToolsDisabled ? undefined : eager.onToolCall;
|
|
391
408
|
// Reattach separated tool results, then drop only truly dangling
|
|
392
409
|
// assistant/orphan pairs before the provider sees the transcript.
|
|
393
410
|
repairTranscriptBeforeProviderSend(messages, sessionId);
|
|
@@ -108,6 +108,7 @@ export function markSessionAskStart(id) {
|
|
|
108
108
|
entry.lastStreamDeltaAt = null;
|
|
109
109
|
entry.lastToolCall = null;
|
|
110
110
|
entry.toolStartedAt = null;
|
|
111
|
+
entry.toolSelfDeadlineMs = null;
|
|
111
112
|
entry.lastError = null;
|
|
112
113
|
// A new ask starts a fresh turn lifecycle — clear any stale empty-final
|
|
113
114
|
// classification from the prior turn so inspectBridgeEntry doesn't keep
|
|
@@ -166,11 +167,18 @@ export async function markSessionStreamDelta(id) {
|
|
|
166
167
|
}
|
|
167
168
|
entry.updatedAt = now;
|
|
168
169
|
}
|
|
169
|
-
export function markSessionToolCall(id, toolName) {
|
|
170
|
+
export function markSessionToolCall(id, toolName, selfDeadlineMs) {
|
|
170
171
|
if (!id) return;
|
|
171
172
|
const entry = _touchRuntime(id);
|
|
172
173
|
entry.stage = 'tool_running';
|
|
173
174
|
entry.lastToolCall = toolName || null;
|
|
175
|
+
// Self-enforced deadline (ms) for tools that kill themselves at a known
|
|
176
|
+
// budget (shell timeout / task wait). The watchdog raises the tool-running
|
|
177
|
+
// ceiling to this + grace instead of aborting at toolRunningMs. Null/<=0
|
|
178
|
+
// means unknown -> plain toolRunningMs behavior.
|
|
179
|
+
entry.toolSelfDeadlineMs = (typeof selfDeadlineMs === 'number' && selfDeadlineMs > 0)
|
|
180
|
+
? selfDeadlineMs
|
|
181
|
+
: null;
|
|
174
182
|
entry.toolStartedAt = Date.now();
|
|
175
183
|
entry.lastProgressAt = entry.toolStartedAt;
|
|
176
184
|
entry.updatedAt = entry.toolStartedAt;
|
|
@@ -289,6 +297,8 @@ export function getSessionProgressSnapshot(sessionId) {
|
|
|
289
297
|
firstActivityAt,
|
|
290
298
|
lastStreamDeltaAt: entry.lastStreamDeltaAt || 0,
|
|
291
299
|
toolStartedAt: entry.toolStartedAt || 0,
|
|
300
|
+
currentTool: entry.lastToolCall || null,
|
|
301
|
+
toolSelfDeadlineMs: entry.toolSelfDeadlineMs || 0,
|
|
292
302
|
lastProgressAt: entry.lastProgressAt || 0,
|
|
293
303
|
updatedAt: entry.updatedAt || 0,
|
|
294
304
|
hasFirstActivity: Boolean(firstActivityAt && (!askStartedAt || firstActivityAt >= askStartedAt)),
|
|
@@ -167,8 +167,9 @@ export function createSession(opts) {
|
|
|
167
167
|
// - read-only roles (reviewer / debugger / hidden retrieval, i.e. any
|
|
168
168
|
// session resolving to permission 'read') -> 'readonly' bundle:
|
|
169
169
|
// read builtins (code_graph/find/glob/list/grep/read) + retrieval
|
|
170
|
-
// (explore/search/web_fetch/Skill)
|
|
171
|
-
//
|
|
170
|
+
// (explore/search/web_fetch/Skill) + shell/task for self-verification
|
|
171
|
+
// (agent-owned readonly bundle only), no apply_patch, no MCP-write.
|
|
172
|
+
// applyToolPermissionNarrowing('read') below trims the
|
|
172
173
|
// bundle to AGENT_STRING_PERMISSION_READ_ALLOW so the final surface is
|
|
173
174
|
// bit-identical across these roles regardless of MCP registry state.
|
|
174
175
|
// - write roles (worker / heavy-worker / maintainer / …) -> 'full'
|
|
@@ -188,7 +189,10 @@ export function createSession(opts) {
|
|
|
188
189
|
// fail closed (zero tools) rather than silently falling back to the full
|
|
189
190
|
// preset, which would grant the role more surface than declared.
|
|
190
191
|
if (ownerIsAgent) {
|
|
191
|
-
|
|
192
|
+
// Pass the RESOLVED agent (opts.agent || opts.role): narrowing keys
|
|
193
|
+
// retrieval/locator roles (explore etc.) off this name to strip
|
|
194
|
+
// shell/task; verifying read roles (reviewer/debugger) keep them.
|
|
195
|
+
toolsForRouting = applyToolPermissionNarrowing(toolsForRouting, toolPermission, resolvedAgent);
|
|
192
196
|
}
|
|
193
197
|
|
|
194
198
|
const { baseRules, stableSystemContext, sessionMarker, volatileTail } = composeSystemPrompt({
|
|
@@ -74,14 +74,39 @@ const AGENT_STRING_PERMISSION_READ_ALLOW = Object.freeze([
|
|
|
74
74
|
'list',
|
|
75
75
|
'grep',
|
|
76
76
|
'read',
|
|
77
|
+
// shell/task: read-role agents (reviewer/debugger) must run their own
|
|
78
|
+
// verification (tests/repro). task is required because shell's
|
|
79
|
+
// auto-background transition settles with a bare jobId — agent sessions
|
|
80
|
+
// get no completion notification, so `task wait/read` is the only way to
|
|
81
|
+
// collect a long-running result. apply_patch stays excluded: read roles
|
|
82
|
+
// keep the no-edit contract.
|
|
83
|
+
'shell',
|
|
84
|
+
'task',
|
|
77
85
|
'explore',
|
|
78
86
|
'search',
|
|
79
87
|
'web_fetch',
|
|
80
88
|
'Skill',
|
|
81
89
|
]);
|
|
82
90
|
|
|
83
|
-
|
|
84
|
-
|
|
91
|
+
// Retrieval/locator roles never self-verify, so they keep the historical
|
|
92
|
+
// no-shell read surface. Covers hidden retrieval agents (kind 'retrieval')
|
|
93
|
+
// AND public wrapper roles (explore + any invokedBy wrapper) — the same set
|
|
94
|
+
// pre-dispatch-deny treats as recursive wrappers. Only verifying read roles
|
|
95
|
+
// (reviewer/debugger) get shell/task.
|
|
96
|
+
const AGENT_STRING_PERMISSION_READ_RETRIEVAL_ALLOW = Object.freeze(
|
|
97
|
+
AGENT_STRING_PERMISSION_READ_ALLOW.filter((n) => n !== 'shell' && n !== 'task'),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
function isRetrievalToolRole(agent) {
|
|
101
|
+
if (!agent) return false;
|
|
102
|
+
if (getHiddenAgent(agent)?.kind === 'retrieval') return true;
|
|
103
|
+
return Boolean(recursiveWrapperToolNameForPublicAgent(agent));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function stringToolPermissionAllowList(toolPermission, { retrieval = false } = {}) {
|
|
107
|
+
if (toolPermission === 'read') {
|
|
108
|
+
return retrieval ? AGENT_STRING_PERMISSION_READ_RETRIEVAL_ALLOW : AGENT_STRING_PERMISSION_READ_ALLOW;
|
|
109
|
+
}
|
|
85
110
|
if (toolPermission === 'read-write') return AGENT_STRING_PERMISSION_READ_WRITE_ALLOW;
|
|
86
111
|
if (toolPermission === 'none') return [];
|
|
87
112
|
return null;
|
|
@@ -109,7 +134,7 @@ const AGENT_STRING_PERMISSION_READ_WRITE_ALLOW = Object.freeze([
|
|
|
109
134
|
|
|
110
135
|
export function applyToolPermissionNarrowing(tools, toolPermission, warnRole = null) {
|
|
111
136
|
if (toolPermission === 'none') return [];
|
|
112
|
-
const allowList = stringToolPermissionAllowList(toolPermission);
|
|
137
|
+
const allowList = stringToolPermissionAllowList(toolPermission, { retrieval: isRetrievalToolRole(warnRole) });
|
|
113
138
|
if (allowList) {
|
|
114
139
|
const allowSet = new Set(allowList.map((n) => String(n).toLowerCase()));
|
|
115
140
|
return tools.filter((t) => allowSet.has(String(t?.name || '').toLowerCase()));
|
|
@@ -203,7 +228,7 @@ export function resolveSessionTools(toolSpec, skills, { ownerIsAgentSession = fa
|
|
|
203
228
|
// time (loop.mjs), so the schema bytes stay bit-identical across roles /
|
|
204
229
|
// cwds and the provider cache shard does not fragment.
|
|
205
230
|
const skillTools = buildSkillToolDefs(skills, { ownerIsAgentSession });
|
|
206
|
-
return _computeBaseTools(toolSpec, mcp, skillTools);
|
|
231
|
+
return _computeBaseTools(toolSpec, mcp, skillTools, { ownerIsAgentSession });
|
|
207
232
|
}
|
|
208
233
|
|
|
209
234
|
export function previewSessionTools(toolSpec, skills = [], options = {}) {
|
|
@@ -230,7 +255,7 @@ function _dedupByName(tools) {
|
|
|
230
255
|
// Tools with agentHidden:true are stripped from agent sessions at schema
|
|
231
256
|
// build time (see deny filtering below). No code-level name list needed.
|
|
232
257
|
|
|
233
|
-
function _computeBaseTools(toolSpec, mcp, skillTools) {
|
|
258
|
+
function _computeBaseTools(toolSpec, mcp, skillTools, { ownerIsAgentSession = false } = {}) {
|
|
234
259
|
if (Array.isArray(toolSpec)) {
|
|
235
260
|
if (toolSpec.length === 0) {
|
|
236
261
|
// Explicit "no tools" — skill meta tools still travel so the model
|
|
@@ -285,7 +310,16 @@ function _computeBaseTools(toolSpec, mcp, skillTools) {
|
|
|
285
310
|
return _dedupByName([...mcp, ...skillTools]);
|
|
286
311
|
case 'readonly': {
|
|
287
312
|
const readTools = ALL_BUILTIN_SESSION_TOOLS.filter(t => READONLY_TOOL_NAMES.has(t.name));
|
|
288
|
-
|
|
313
|
+
// Read-ROLE agent sessions (reviewer/debugger) must self-verify, so
|
|
314
|
+
// their base bundle carries shell/task defs. The 'read' permission
|
|
315
|
+
// allowlist (AGENT_STRING_PERMISSION_READ_ALLOW) is a pure FILTER —
|
|
316
|
+
// it can only keep tools already assembled here, so without this the
|
|
317
|
+
// allowlist's shell/task entries were dead letters. Non-agent
|
|
318
|
+
// 'readonly' profiles stay strictly read-only.
|
|
319
|
+
const verifyTools = ownerIsAgentSession
|
|
320
|
+
? ALL_BUILTIN_SESSION_TOOLS.filter(t => t.name === 'shell' || t.name === 'task')
|
|
321
|
+
: [];
|
|
322
|
+
return _dedupByName([...readTools, ...verifyTools, ...mcp, ...skillTools]);
|
|
289
323
|
}
|
|
290
324
|
case 'full':
|
|
291
325
|
default:
|
|
@@ -11,6 +11,7 @@ import { takeApplyPatchUiDiff } from '../tools/patch.mjs';
|
|
|
11
11
|
import { compressToolResult } from '../tools/result-compression.mjs';
|
|
12
12
|
import { appendAgentTrace, traceAgentTool, traceAgentToolFailure } from '../agent-trace.mjs';
|
|
13
13
|
import { markSessionToolCall, updateSessionStage } from './manager.mjs';
|
|
14
|
+
import { resolveToolSelfDeadlineMs } from '../agent-runtime/agent-progress-watchdog.mjs';
|
|
14
15
|
import { classifyResultKind } from './result-classification.mjs';
|
|
15
16
|
import { normalizeToolEnvelope } from './tool-envelope.mjs';
|
|
16
17
|
import { maybeOffloadToolResult } from './tool-result-offload.mjs';
|
|
@@ -150,7 +151,7 @@ export async function processToolBatch(ctx) {
|
|
|
150
151
|
continue;
|
|
151
152
|
}
|
|
152
153
|
}
|
|
153
|
-
if (sessionId) markSessionToolCall(sessionId, call.name);
|
|
154
|
+
if (sessionId) markSessionToolCall(sessionId, call.name, resolveToolSelfDeadlineMs(call.name, call.arguments));
|
|
154
155
|
let result;
|
|
155
156
|
let toolStartedAt;
|
|
156
157
|
let toolEndedAt;
|