mixdog 0.9.22 → 0.9.24
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/README.md +1 -4
- package/package.json +1 -1
- package/scripts/boot-smoke.mjs +1 -1
- package/scripts/channel-daemon-smoke.mjs +483 -0
- package/scripts/channel-daemon-stub.mjs +80 -0
- 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/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-smoke.mjs +68 -47
- 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 +226 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
- 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/providers/openai-compat-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- 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-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- 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 +126 -22
- package/src/runtime/channels/lib/crash-log.mjs +4 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
- package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
- package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
- package/src/runtime/channels/lib/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +42 -30
- package/src/runtime/memory/index.mjs +54 -7
- package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
- package/src/runtime/memory/lib/pg/process.mjs +85 -40
- package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
- 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 +150 -6
- 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-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +42 -13
- 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 +221 -20
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +93 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +118 -4
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +50 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +350 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +226 -0
- package/src/standalone/channel-daemon-transport.mjs +545 -0
- package/src/standalone/channel-daemon.mjs +176 -0
- package/src/standalone/channel-worker.mjs +224 -4
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +107 -19
- package/src/tui/app/clipboard.mjs +39 -19
- package/src/tui/app/doctor.mjs +57 -0
- package/src/tui/app/extension-pickers.mjs +53 -9
- package/src/tui/app/maintenance-pickers.mjs +0 -5
- package/src/tui/app/slash-dispatch.mjs +4 -4
- package/src/tui/app/text-layout.mjs +11 -0
- package/src/tui/app/use-mouse-input.mjs +235 -51
- package/src/tui/app/use-prompt-handlers.mjs +49 -30
- package/src/tui/app/use-transcript-scroll.mjs +124 -27
- package/src/tui/app/use-transcript-window.mjs +55 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +3 -1
- package/src/tui/components/QueuedCommands.jsx +21 -10
- package/src/tui/components/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +16 -4
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +820 -326
- package/src/tui/engine/agent-job-feed.mjs +5 -0
- package/src/tui/engine/notification-plan.mjs +5 -0
- package/src/tui/engine/session-api.mjs +23 -8
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +32 -5
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +60 -10
- package/src/ui/tool-card.mjs +8 -1
- package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
- package/src/workflows/bench/WORKFLOW.md +46 -0
- package/src/workflows/default/WORKFLOW.md +6 -0
- package/src/workflows/solo/WORKFLOW.md +5 -0
- package/vendor/ink/build/ink.js +23 -1
- package/vendor/ink/build/output.js +154 -71
- package/vendor/ink/build/render-node-to-output.js +44 -2
- package/vendor/ink/build/render.js +4 -0
- package/vendor/ink/build/renderer.js +4 -1
|
@@ -4,6 +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, killStdioChildTreeFast } from './child-tree.mjs';
|
|
7
8
|
// --- Types ---
|
|
8
9
|
/** Known auto-detect targets: port file path relative to tmpdir.
|
|
9
10
|
* Note: `mixdog` used to self-loopback via active-instance.json's
|
|
@@ -15,6 +16,8 @@ const AUTO_DETECT_PORTS = {
|
|
|
15
16
|
'mixdog-memory': { dir: 'mixdog', file: 'active-instance.json', portField: 'memory_port', endpoint: '/mcp' },
|
|
16
17
|
};
|
|
17
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;
|
|
18
21
|
// --- State ---
|
|
19
22
|
const servers = new Map();
|
|
20
23
|
let mcpSdkPromise = null;
|
|
@@ -96,21 +99,31 @@ export function resolveMcpTransportKind(cfg) {
|
|
|
96
99
|
* Supports stdio (child process) and http (Streamable HTTP) transports.
|
|
97
100
|
*/
|
|
98
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;
|
|
99
107
|
const failures = [];
|
|
100
|
-
|
|
108
|
+
const entries = Object.entries(config).filter(([name, cfg]) => {
|
|
101
109
|
if (cfg?.enabled === false) {
|
|
102
110
|
mcpLog(`[mcp-client] Skipping disabled server "${name}"\n`);
|
|
103
|
-
|
|
111
|
+
return false;
|
|
104
112
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
});
|
|
114
127
|
if (failures.length > 0) {
|
|
115
128
|
const detail = failures.map(f => `${f.name}: ${f.msg}`).join('; ');
|
|
116
129
|
const err = new Error(`[mcp-client] ${failures.length} MCP server(s) failed to connect — ${detail}`);
|
|
@@ -182,7 +195,7 @@ export async function executeMcpTool(name, args) {
|
|
|
182
195
|
mcpLog(`[mcp-client] Tool call failed, attempting reconnect...\n`);
|
|
183
196
|
await new Promise(r => setTimeout(r, 500));
|
|
184
197
|
try {
|
|
185
|
-
await server
|
|
198
|
+
await _closeServer(server);
|
|
186
199
|
} catch { /* ignore close error */ }
|
|
187
200
|
try {
|
|
188
201
|
await connectServer(serverName, server.cfg);
|
|
@@ -233,6 +246,26 @@ export function isMcpToolCallTimeoutError(err) {
|
|
|
233
246
|
return err?.code === 'EMCPTOOLTIMEOUT';
|
|
234
247
|
}
|
|
235
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
|
+
|
|
236
269
|
async function _callToolWithTimeout(server, toolName, args) {
|
|
237
270
|
let timer;
|
|
238
271
|
const timeoutMs = resolveMcpCallTimeoutMs(server?.cfg);
|
|
@@ -241,7 +274,9 @@ async function _callToolWithTimeout(server, toolName, args) {
|
|
|
241
274
|
}
|
|
242
275
|
const timeout = new Promise((_, rej) => {
|
|
243
276
|
timer = setTimeout(() => {
|
|
244
|
-
|
|
277
|
+
// Route through the full tree-shutdown path so a timed-out stdio
|
|
278
|
+
// server never orphans grandchildren. Fire-and-forget.
|
|
279
|
+
try { _closeServer(server).catch(() => {}); } catch { /* ignore */ }
|
|
245
280
|
const err = new Error(`MCP tool call timed out after ${timeoutMs}ms (server="${server.name}", tool="${toolName}")`);
|
|
246
281
|
err.code = 'EMCPTOOLTIMEOUT';
|
|
247
282
|
err.serverName = server.name;
|
|
@@ -336,16 +371,72 @@ export function mcpToolHasField(name, field) {
|
|
|
336
371
|
* Disconnect all MCP servers.
|
|
337
372
|
*/
|
|
338
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
|
+
}
|
|
339
390
|
for (const [name, server] of servers) {
|
|
340
391
|
try {
|
|
341
|
-
await server
|
|
392
|
+
await _closeServer(server);
|
|
342
393
|
}
|
|
343
394
|
catch { /* ignore */ }
|
|
344
395
|
servers.delete(name);
|
|
345
396
|
}
|
|
346
397
|
_invalidateMcpToolFieldMemo();
|
|
347
398
|
}
|
|
348
|
-
|
|
399
|
+
/**
|
|
400
|
+
* Disconnect a single MCP server by name. No-op (returns false) when the
|
|
401
|
+
* server is not in the live registry; otherwise closes its transport, removes
|
|
402
|
+
* it, and invalidates the tool-field memo. Lets callers toggle one server
|
|
403
|
+
* without a full disconnectAll()/reconnect cycle.
|
|
404
|
+
*/
|
|
405
|
+
export async function disconnectMcpServer(name) {
|
|
406
|
+
const server = servers.get(name);
|
|
407
|
+
if (!server) return false;
|
|
408
|
+
try {
|
|
409
|
+
await _closeServer(server);
|
|
410
|
+
}
|
|
411
|
+
catch { /* ignore */ }
|
|
412
|
+
servers.delete(name);
|
|
413
|
+
_invalidateMcpToolFieldMemo();
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Close a single server: for stdio transports first shut down the full child
|
|
418
|
+
* process tree (close stdin -> grace -> tree kill) so wrapper chains such as
|
|
419
|
+
* uvx/npx/uv never orphan grandchildren, then release the SDK client. The
|
|
420
|
+
* tree teardown runs before client.close() because the SDK's own close()
|
|
421
|
+
* only kills the direct child and discards the pid we need to walk the tree.
|
|
422
|
+
*/
|
|
423
|
+
async function _closeServer(server) {
|
|
424
|
+
const transport = server?.transport;
|
|
425
|
+
// Live stdio transports expose the spawned ChildProcess on _process.
|
|
426
|
+
if (transport && transport._process) {
|
|
427
|
+
try { await shutdownStdioChild(transport); }
|
|
428
|
+
catch { /* ignore */ }
|
|
429
|
+
}
|
|
430
|
+
try { await server.client.close(); }
|
|
431
|
+
catch { /* ignore */ }
|
|
432
|
+
}
|
|
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) {
|
|
349
440
|
const {
|
|
350
441
|
Client,
|
|
351
442
|
StdioClientTransport,
|
|
@@ -353,6 +444,11 @@ async function connectServer(name, cfg) {
|
|
|
353
444
|
SSEClientTransport,
|
|
354
445
|
WebSocketClientTransport,
|
|
355
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
|
+
}
|
|
356
452
|
const client = new Client({ name: `mixdog-agent/${name}`, version: '1.0.0' });
|
|
357
453
|
let transport;
|
|
358
454
|
const kind = resolveMcpTransportKind(cfg);
|
|
@@ -435,23 +531,78 @@ async function connectServer(name, cfg) {
|
|
|
435
531
|
else {
|
|
436
532
|
throw new Error(`Invalid config for "${name}": need autoDetect, type (stdio/http/sse/ws), url (http), or command (stdio)`);
|
|
437
533
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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);
|
|
446
607
|
}
|
|
447
|
-
const tools = toolsResult.tools.map((t) => ({
|
|
448
|
-
name: `mcp__${name}__${t.name}`,
|
|
449
|
-
description: t.description || '',
|
|
450
|
-
inputSchema: (t.inputSchema || { type: 'object', properties: {} }),
|
|
451
|
-
...(t.annotations && typeof t.annotations === 'object' ? { annotations: t.annotations } : {}),
|
|
452
|
-
}));
|
|
453
|
-
const toolNames = tools.map(t => t.name);
|
|
454
|
-
servers.set(name, { name, client, transport, tools, cfg, instructions });
|
|
455
|
-
_invalidateMcpToolFieldMemo();
|
|
456
|
-
mcpLog(`[mcp] connected: ${tools.length} tools — ${toolNames.join(', ')}\n`);
|
|
457
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.
|
|
@@ -514,7 +514,7 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
|
|
|
514
514
|
: parseCompletedToolCallArgumentsJson(item.arguments || '{}', label, { id: callId, name: 'tool_search', finishReason: 'done' });
|
|
515
515
|
const call = {
|
|
516
516
|
id: callId,
|
|
517
|
-
name: '
|
|
517
|
+
name: 'load_tool',
|
|
518
518
|
// Schema is a plain object ({query,select,limit}); an array must
|
|
519
519
|
// never pass through as args.
|
|
520
520
|
arguments: (_tsArgs && typeof _tsArgs === 'object' && !Array.isArray(_tsArgs)) ? _tsArgs : {},
|
|
@@ -107,7 +107,9 @@ export function toOpenAITools(tools) {
|
|
|
107
107
|
|
|
108
108
|
export function toResponsesTools(tools) {
|
|
109
109
|
return tools.map((t) => {
|
|
110
|
-
|
|
110
|
+
// load_tool advertises as the OpenAI-native `tool_search` wire type
|
|
111
|
+
// (legacy 'tool_search' name still accepted for back-compat).
|
|
112
|
+
if (t?.name === 'load_tool' || t?.name === 'tool_search') {
|
|
111
113
|
return {
|
|
112
114
|
type: 'tool_search',
|
|
113
115
|
execution: 'client',
|
|
@@ -193,7 +195,7 @@ export function parseResponsesToolCalls(response, label) {
|
|
|
193
195
|
: parseCompletedToolCallArgumentsJson(item.arguments || '{}', label, { id: item.call_id || item.id, name: 'tool_search', finishReason });
|
|
194
196
|
out.push({
|
|
195
197
|
id: item.call_id || item.id,
|
|
196
|
-
name: '
|
|
198
|
+
name: 'load_tool',
|
|
197
199
|
// Schema is a plain object ({query,select,limit}); an array
|
|
198
200
|
// (parsed JSON or passthrough) must never pass through as args.
|
|
199
201
|
arguments: (_tsArgs && typeof _tsArgs === 'object' && !Array.isArray(_tsArgs)) ? _tsArgs : {},
|
|
@@ -294,7 +296,7 @@ export function toResponsesInputMessage(m, pendingToolMedia = null, customToolCa
|
|
|
294
296
|
const items = [];
|
|
295
297
|
if (m.content) items.push({ role: 'assistant', content: normalizeContentForOpenAIResponses(m.content, { role: 'assistant' }) });
|
|
296
298
|
for (const tc of m.toolCalls) {
|
|
297
|
-
if (tc.nativeType === 'tool_search_call' || tc.name === 'tool_search') {
|
|
299
|
+
if (tc.nativeType === 'tool_search_call' || tc.name === 'load_tool' || tc.name === 'tool_search') {
|
|
298
300
|
items.push({
|
|
299
301
|
type: 'tool_search_call',
|
|
300
302
|
call_id: tc.id,
|
|
@@ -451,7 +451,7 @@ export async function sendViaHttpSse({
|
|
|
451
451
|
}
|
|
452
452
|
const call = {
|
|
453
453
|
id: callId,
|
|
454
|
-
name: '
|
|
454
|
+
name: 'load_tool',
|
|
455
455
|
arguments: args,
|
|
456
456
|
nativeType: 'tool_search_call',
|
|
457
457
|
};
|
|
@@ -501,7 +501,7 @@ export async function sendViaHttpSse({
|
|
|
501
501
|
// mistakes it for a function call by id collision/empty id.
|
|
502
502
|
if (event.item.id) {
|
|
503
503
|
pendingCalls.set(event.item.id, {
|
|
504
|
-
name: '
|
|
504
|
+
name: 'load_tool',
|
|
505
505
|
callId: event.item.call_id || '',
|
|
506
506
|
kind: 'tool_search',
|
|
507
507
|
});
|
|
@@ -444,7 +444,7 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
|
|
|
444
444
|
// reasoning in `input` triggers "Duplicate item".
|
|
445
445
|
if (m.content) out.push({ role: 'assistant', content: normalizeContentForOpenAIResponses(m.content, { role: 'assistant' }) });
|
|
446
446
|
for (const tc of m.toolCalls) {
|
|
447
|
-
if (tc.nativeType === 'tool_search_call' || tc.name === 'tool_search') {
|
|
447
|
+
if (tc.nativeType === 'tool_search_call' || tc.name === 'load_tool' || tc.name === 'tool_search') {
|
|
448
448
|
out.push({
|
|
449
449
|
type: 'tool_search_call',
|
|
450
450
|
call_id: tc.id,
|
|
@@ -480,7 +480,7 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
|
|
|
480
480
|
}
|
|
481
481
|
|
|
482
482
|
function toOpenAIResponsesTool(t) {
|
|
483
|
-
if (t?.name === 'tool_search') {
|
|
483
|
+
if (t?.name === 'load_tool' || t?.name === 'tool_search') {
|
|
484
484
|
return {
|
|
485
485
|
type: 'tool_search',
|
|
486
486
|
execution: 'client',
|
|
@@ -355,7 +355,7 @@ export async function _streamResponse({
|
|
|
355
355
|
if (!callId || toolCalls.some((call) => call.id === callId)) return;
|
|
356
356
|
const call = {
|
|
357
357
|
id: callId,
|
|
358
|
-
name: '
|
|
358
|
+
name: 'load_tool',
|
|
359
359
|
arguments: parseToolSearchArgs(item.arguments),
|
|
360
360
|
nativeType: 'tool_search_call',
|
|
361
361
|
};
|
|
@@ -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);
|
|
@@ -24,8 +24,9 @@ export function crossTurnSignature(name, args) {
|
|
|
24
24
|
|
|
25
25
|
// Tool names that are non-eager (no readOnlyHint) but are NOT edits/progress —
|
|
26
26
|
// they must not reset the escalation ladder's "zero edit" condition. Skill /
|
|
27
|
-
// recall / agent / task / cwd /
|
|
28
|
-
|
|
27
|
+
// recall / agent / task / cwd / load_tool are exploration/meta plumbing.
|
|
28
|
+
// (tool_search kept as legacy alias for old transcripts.)
|
|
29
|
+
const NON_PROGRESS_TOOLS = new Set(['Skill', 'recall', 'agent', 'task', 'cwd', 'load_tool', 'tool_search']);
|
|
29
30
|
|
|
30
31
|
// True when a successfully-executed tool represents real edit/progress. A tool
|
|
31
32
|
// counts as progress only if its def lacks readOnlyHint (not eager) AND it is
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Deferred catalog call-through: inactive catalog tool_use → discovery bookkeeping
|
|
2
2
|
// then normal executeTool routing (runtime errors only; no pre-dispatch schema).
|
|
3
3
|
import { clean } from '../../../../../session-runtime/session-text.mjs';
|
|
4
|
-
import { isReadonlySelectable, selectDeferredTools } from '../../../../../session-runtime/tool-catalog.mjs';
|
|
4
|
+
import { deferredCatalogUnion, isReadonlySelectable, selectDeferredTools } from '../../../../../session-runtime/tool-catalog.mjs';
|
|
5
5
|
|
|
6
6
|
/** Skill-list plumbing only; mutation/MCP/builtins must use the catalog+mode gate. */
|
|
7
7
|
const INACTIVE_INFRA_BYPASS = new Set(['skills_list', 'skill_view']);
|
|
@@ -13,7 +13,10 @@ function isActiveSessionTool(session, name) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
function lookupDeferredCatalogTool(session, name) {
|
|
16
|
-
|
|
16
|
+
// Union of the boot-frozen catalog and the late-connected MCP catalog so a
|
|
17
|
+
// direct call to a tool whose server connected after boot resolves and
|
|
18
|
+
// auto-loads (selectDeferredTools promotes it onto session.tools).
|
|
19
|
+
const catalog = deferredCatalogUnion(session);
|
|
17
20
|
const key = clean(name);
|
|
18
21
|
if (!key) return null;
|
|
19
22
|
for (const tool of catalog) {
|
|
@@ -66,7 +69,7 @@ export function prepareDeferredToolCallThrough(sessionRef, name, _args) {
|
|
|
66
69
|
if (resolvedMode === null) {
|
|
67
70
|
if (!isReadonlySelectable(tool)) {
|
|
68
71
|
return denyDeferredCallThrough(
|
|
69
|
-
`Error: tool "${toolLabel}" is deferred and cannot be auto-loaded without a resolved tool mode;
|
|
72
|
+
`Error: tool "${toolLabel}" is deferred and cannot be auto-loaded without a resolved tool mode; load it with load_tool names:["${toolLabel}"].`,
|
|
70
73
|
);
|
|
71
74
|
}
|
|
72
75
|
} else if (resolvedMode === 'readonly' && !isReadonlySelectable(tool)) {
|
|
@@ -81,7 +81,7 @@ export function resolveToolResultAfterHook(originalResult, hookResult) {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
export function parseNativeToolSearchPayload(toolName, result) {
|
|
84
|
-
if (toolName !== 'tool_search' || typeof result !== 'string') return null;
|
|
84
|
+
if ((toolName !== 'load_tool' && toolName !== 'tool_search') || typeof result !== 'string') return null;
|
|
85
85
|
try {
|
|
86
86
|
const parsed = JSON.parse(result);
|
|
87
87
|
const native = parsed?.nativeToolSearch;
|