mixdog 0.9.23 → 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/package.json +1 -1
- package/scripts/boot-smoke.mjs +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- 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/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/tool-smoke.mjs +38 -30
- package/src/rules/agent/30-explorer.md +6 -0
- package/src/rules/lead/02-channels.md +3 -3
- 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/lib/crash-log.mjs +4 -2
- 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/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 +209 -44
- 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 +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/runtime/channels/lib/seat-lock.mjs +0 -196
|
@@ -33,6 +33,62 @@ export function isAgentProgressWatchdogAbortError(err) {
|
|
|
33
33
|
return typeof msg === 'string' && WATCHDOG_ABORT_RE.test(msg);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// Tools that enforce their own execution deadline: 'shell' kills the process
|
|
37
|
+
// at its configured timeout; 'task' wait is bounded by its own wait budget.
|
|
38
|
+
// These are NOT blanket-exempted from the tool-running watchdog — if their own
|
|
39
|
+
// deadline timer dies the session would otherwise hang forever. Instead the
|
|
40
|
+
// watchdog raises the tool-running ceiling to their self-deadline + a grace
|
|
41
|
+
// window (below), so normal long runs are allowed but a dead timer is still
|
|
42
|
+
// caught. Unknown/missing self-deadline falls back to the plain toolRunningMs.
|
|
43
|
+
const SELF_DEADLINE_TOOLS = new Set(['shell', 'task']);
|
|
44
|
+
// Grace added on top of a tool's own deadline before the watchdog steps in, so
|
|
45
|
+
// the tool's in-process kill always fires first under normal operation.
|
|
46
|
+
const TOOL_SELF_DEADLINE_GRACE_MS = 60_000;
|
|
47
|
+
// Fallback deadlines matching the tool implementations (bash-tool.mjs default
|
|
48
|
+
// 120s foreground timeout; task/shell-job wait default 30s budget).
|
|
49
|
+
const SHELL_DEFAULT_TIMEOUT_MS = 120_000;
|
|
50
|
+
const TASK_DEFAULT_WAIT_MS = 30_000;
|
|
51
|
+
|
|
52
|
+
function bareToolName(toolName) {
|
|
53
|
+
if (typeof toolName !== 'string' || !toolName) return '';
|
|
54
|
+
// Strip any MCP/server prefix (e.g. 'server__shell' or 'server.shell').
|
|
55
|
+
return toolName.split(/[.]|__/).pop();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isSelfDeadlineTool(toolName) {
|
|
59
|
+
const bare = bareToolName(toolName);
|
|
60
|
+
return SELF_DEADLINE_TOOLS.has(bare) || SELF_DEADLINE_TOOLS.has(toolName);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the self-enforced deadline (ms) for a tool call from its arguments,
|
|
65
|
+
* recorded into the progress snapshot at dispatch time. Returns a positive
|
|
66
|
+
* number when the tool enforces its own deadline, or null when unknown/missing
|
|
67
|
+
* (caller then falls back to the plain toolRunningMs ceiling).
|
|
68
|
+
* - shell: explicit `timeout` (ms) if positive, else the 120s default.
|
|
69
|
+
* - task: explicit `timeout_ms` if positive, else 30s for the `wait` action
|
|
70
|
+
* (other actions carry no wait budget -> null).
|
|
71
|
+
*/
|
|
72
|
+
export function resolveToolSelfDeadlineMs(toolName, args) {
|
|
73
|
+
if (!isSelfDeadlineTool(toolName)) return null;
|
|
74
|
+
const bare = bareToolName(toolName);
|
|
75
|
+
const a = (args && typeof args === 'object') ? args : {};
|
|
76
|
+
if (bare === 'shell') {
|
|
77
|
+
const t = Number(a.timeout);
|
|
78
|
+
if (Number.isFinite(t) && t > 0) return t;
|
|
79
|
+
const envDefault = parseInt(process.env.BASH_DEFAULT_TIMEOUT_MS ?? '', 10);
|
|
80
|
+
return envDefault > 0 ? envDefault : SHELL_DEFAULT_TIMEOUT_MS;
|
|
81
|
+
}
|
|
82
|
+
if (bare === 'task') {
|
|
83
|
+
const t = Number(a.timeout_ms);
|
|
84
|
+
if (Number.isFinite(t) && t > 0) return t;
|
|
85
|
+
const action = typeof a.action === 'string' ? a.action : '';
|
|
86
|
+
if (action === 'wait') return TASK_DEFAULT_WAIT_MS;
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
36
92
|
function assistantMessageText(content) {
|
|
37
93
|
if (typeof content === 'string') return content;
|
|
38
94
|
if (!Array.isArray(content)) return '';
|
|
@@ -209,7 +265,26 @@ export function evaluateAgentWatchdogAbort(snapshot, now, policy) {
|
|
|
209
265
|
&& policy.toolRunningMs > 0
|
|
210
266
|
&& now - snapshot.toolStartedAt > policy.toolRunningMs
|
|
211
267
|
) {
|
|
212
|
-
|
|
268
|
+
// Deadline-aware ceiling for tools that self-enforce their own deadline
|
|
269
|
+
// ('shell'/'task'). Rather than a blanket exemption (which would hang
|
|
270
|
+
// forever if the tool's own timer died), raise the tool-running ceiling
|
|
271
|
+
// to max(toolRunningMs, selfDeadlineMs + grace): normal long runs are
|
|
272
|
+
// allowed because the tool kills itself first, but a dead deadline timer
|
|
273
|
+
// is still caught after the grace window. Unknown/missing self-deadline
|
|
274
|
+
// (selfDeadlineMs <= 0) keeps the plain toolRunningMs behavior. All
|
|
275
|
+
// other tools are unaffected.
|
|
276
|
+
let ceilingMs = policy.toolRunningMs;
|
|
277
|
+
const selfDeadlineMs = Number(snapshot.toolSelfDeadlineMs);
|
|
278
|
+
if (
|
|
279
|
+
isSelfDeadlineTool(snapshot.currentTool)
|
|
280
|
+
&& Number.isFinite(selfDeadlineMs)
|
|
281
|
+
&& selfDeadlineMs > 0
|
|
282
|
+
) {
|
|
283
|
+
ceilingMs = Math.max(policy.toolRunningMs, selfDeadlineMs + TOOL_SELF_DEADLINE_GRACE_MS);
|
|
284
|
+
}
|
|
285
|
+
if (now - snapshot.toolStartedAt > ceilingMs) {
|
|
286
|
+
return new AgentStallAbortError(`agent tool running stale (${ceilingMs}ms)`);
|
|
287
|
+
}
|
|
213
288
|
}
|
|
214
289
|
|
|
215
290
|
return null;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolvePluginData } from '../../shared/plugin-paths.mjs';
|
|
2
|
-
import { readSection, updateSection, getAgentApiKey, AGENT_PROVIDER_ENV } from '../../shared/config.mjs';
|
|
2
|
+
import { readSection, updateSection, updateSectionAsync, getAgentApiKey, AGENT_PROVIDER_ENV } from '../../shared/config.mjs';
|
|
3
3
|
import { OPENAI_COMPAT_PRESETS } from './providers/openai-compat-presets.mjs';
|
|
4
4
|
import {
|
|
5
5
|
hasAnthropicOAuthCredentials,
|
|
@@ -241,6 +241,14 @@ function persistAgentConfig(build) {
|
|
|
241
241
|
updateSection('agent', (current) => build(hasKeys(current) ? current : {}));
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
// Async twin of persistAgentConfig: same in-lock rebase-on-current semantics,
|
|
245
|
+
// but the whole-section RMW runs through updateSectionAsync so a debounced
|
|
246
|
+
// timer flush never blocks the event loop on icacls/backup. Reuses the same
|
|
247
|
+
// config lock file, so it stays linearizable with the sync writers.
|
|
248
|
+
async function persistAgentConfigAsync(build) {
|
|
249
|
+
await updateSectionAsync('agent', (current) => build(hasKeys(current) ? current : {}));
|
|
250
|
+
}
|
|
251
|
+
|
|
244
252
|
// Recap toggle (recap.enabled, default true) gates ONLY the background memory
|
|
245
253
|
// cycles. The memory module itself is always-on. On load we fold a legacy
|
|
246
254
|
// `modules.memory === false` flag into recap.enabled=false one time; the caller
|
|
@@ -506,23 +514,34 @@ export function loadConfig(options = {}) {
|
|
|
506
514
|
* concurrent instance's edits are not reverted.
|
|
507
515
|
*/
|
|
508
516
|
/** In-lock patch of `skills.disabled` only (avoids whole-config lost-update). */
|
|
509
|
-
|
|
517
|
+
function buildSkillsDisabledPatch(disabledNames) {
|
|
510
518
|
const names = disabledNames instanceof Set
|
|
511
519
|
? [...disabledNames]
|
|
512
520
|
: (Array.isArray(disabledNames) ? disabledNames : []);
|
|
513
521
|
const nextSkills = normalizeSkillsConfig({ disabled: names });
|
|
514
|
-
|
|
522
|
+
const build = (current) => {
|
|
515
523
|
const cur = { ...current };
|
|
516
524
|
const target = (cur.agent && cur.agent.providers)
|
|
517
525
|
? (cur.agent = { ...cur.agent })
|
|
518
526
|
: cur;
|
|
519
527
|
target.skills = nextSkills;
|
|
520
528
|
return cur;
|
|
521
|
-
}
|
|
529
|
+
};
|
|
530
|
+
return { build, nextSkills };
|
|
531
|
+
}
|
|
532
|
+
export function patchSkillsDisabled(disabledNames) {
|
|
533
|
+
const { build, nextSkills } = buildSkillsDisabledPatch(disabledNames);
|
|
534
|
+
persistAgentConfig(build);
|
|
535
|
+
return nextSkills;
|
|
536
|
+
}
|
|
537
|
+
// Async twin used by the debounced skills flush timer.
|
|
538
|
+
export async function patchSkillsDisabledAsync(disabledNames) {
|
|
539
|
+
const { build, nextSkills } = buildSkillsDisabledPatch(disabledNames);
|
|
540
|
+
await persistAgentConfigAsync(build);
|
|
522
541
|
return nextSkills;
|
|
523
542
|
}
|
|
524
543
|
|
|
525
|
-
|
|
544
|
+
function buildAgentSaveBuilder(config) {
|
|
526
545
|
// Strip ephemeral defaults from providers but preserve any unknown
|
|
527
546
|
// per-provider subkey so future schema additions round-trip through the
|
|
528
547
|
// setup UI without changes here. apiKey is intentionally omitted —
|
|
@@ -556,7 +575,7 @@ export function saveConfig(config) {
|
|
|
556
575
|
// Build the replacement from `existingRaw` — the section read INSIDE the
|
|
557
576
|
// file lock — not a snapshot taken before it, so unmanaged keys written by
|
|
558
577
|
// a concurrent instance survive the save (lost-update guard).
|
|
559
|
-
|
|
578
|
+
return (existingRaw) => ({
|
|
560
579
|
...existingRaw,
|
|
561
580
|
guide: config.guide || existingRaw.guide || undefined,
|
|
562
581
|
providers: persistedProviders,
|
|
@@ -582,7 +601,14 @@ export function saveConfig(config) {
|
|
|
582
601
|
update: config.update || {},
|
|
583
602
|
recap: config.recap || {},
|
|
584
603
|
modules: config.modules || existingRaw.modules || {},
|
|
585
|
-
})
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
export function saveConfig(config) {
|
|
607
|
+
persistAgentConfig(buildAgentSaveBuilder(config));
|
|
608
|
+
}
|
|
609
|
+
// Async twin used by the debounced config-save flush timer.
|
|
610
|
+
export async function saveConfigAsync(config) {
|
|
611
|
+
await persistAgentConfigAsync(buildAgentSaveBuilder(config));
|
|
586
612
|
}
|
|
587
613
|
// --- Preset helpers ---
|
|
588
614
|
// preset shape: { id, name, type: 'agent', provider, model, effort?, fast?, tools? }
|
|
@@ -407,9 +407,38 @@ export function stripDeferredToolManifestBlock(text) {
|
|
|
407
407
|
.trimEnd();
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
410
|
+
// Rebuild path: replace the FIRST previously-injected <available-deferred-tools>
|
|
411
|
+
// block (with its leading `---` separator) with the fresh manifest IN PLACE, so
|
|
412
|
+
// the block keeps its original position and no sibling BP1 block (skills
|
|
413
|
+
// manifest, agent rules, …) is reordered or dropped. The fresh manifest already
|
|
414
|
+
// carries the mcp-instructions companion, so any pre-existing standalone one is
|
|
415
|
+
// removed first to avoid duplication.
|
|
416
|
+
export function rebuildDeferredToolManifestBlock(text, manifest) {
|
|
417
|
+
let out = String(text || '').replace(MCP_INSTRUCTIONS_BLOCK_RE, '');
|
|
418
|
+
let replaced = false;
|
|
419
|
+
out = out.replace(DEFERRED_TOOLS_BLOCK_RE, (match, sep) => {
|
|
420
|
+
if (replaced) return '';
|
|
421
|
+
replaced = true;
|
|
422
|
+
return `${sep || ''}${manifest}`;
|
|
423
|
+
});
|
|
424
|
+
if (!replaced) {
|
|
425
|
+
const base = out.trimEnd();
|
|
426
|
+
out = base ? `${base}\n\n---\n\n${manifest}` : manifest;
|
|
427
|
+
}
|
|
428
|
+
return out;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Inject the skill-style deferred pool (name + description) into BP1 at session
|
|
433
|
+
* start. Normally once; with `{ rebuild: true }` it strips any existing
|
|
434
|
+
* <available-deferred-tools>/<mcp-instructions> block and re-injects the fresh
|
|
435
|
+
* pool in place (used by the first-turn MCP refresh, before the prompt renders,
|
|
436
|
+
* so late-connected MCP tools land in the INITIAL manifest — never duplicated).
|
|
437
|
+
*/
|
|
438
|
+
export function applyInitialDeferredToolManifestToBp1(session, poolNames, options = {}) {
|
|
439
|
+
const rebuild = options?.rebuild === true;
|
|
440
|
+
if (!session || !Array.isArray(session.messages)) return false;
|
|
441
|
+
if (session.deferredToolBp1Applied && !rebuild) return false;
|
|
413
442
|
const pool = Array.isArray(poolNames) ? poolNames : [];
|
|
414
443
|
const descByName = new Map();
|
|
415
444
|
for (const tool of Array.isArray(session?.deferredToolCatalog) ? session.deferredToolCatalog : []) {
|
|
@@ -432,15 +461,21 @@ export function applyInitialDeferredToolManifestToBp1(session, poolNames) {
|
|
|
432
461
|
}
|
|
433
462
|
if (idx === -1) return false;
|
|
434
463
|
const raw = typeof session.messages[idx].content === 'string' ? session.messages[idx].content : '';
|
|
435
|
-
if (bp1HasDeferredToolManifestBlock(raw)) {
|
|
464
|
+
if (bp1HasDeferredToolManifestBlock(raw) && !rebuild) {
|
|
436
465
|
session.deferredToolBp1Applied = true;
|
|
437
466
|
return true;
|
|
438
467
|
}
|
|
439
468
|
if (manifest) {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
469
|
+
if (rebuild && bp1HasDeferredToolManifestBlock(raw)) {
|
|
470
|
+
// Anchored in-place rebuild: swap the previously injected manifest
|
|
471
|
+
// block for the fresh one at its EXISTING position.
|
|
472
|
+
session.messages[idx].content = rebuildDeferredToolManifestBlock(raw, manifest);
|
|
473
|
+
} else {
|
|
474
|
+
const base = stripDeferredToolManifestBlock(raw);
|
|
475
|
+
session.messages[idx].content = base
|
|
476
|
+
? `${base}\n\n---\n\n${manifest}`
|
|
477
|
+
: manifest;
|
|
478
|
+
}
|
|
444
479
|
}
|
|
445
480
|
session.deferredToolBp1Applied = true;
|
|
446
481
|
session.updatedAt = Date.now();
|
|
@@ -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.
|