mixdog 0.9.22 → 0.9.23
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/build-runtime-windows.ps1 +242 -242
- package/scripts/channel-daemon-smoke.mjs +165 -0
- package/scripts/channel-daemon-stub.mjs +69 -0
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/tool-smoke.mjs +30 -17
- package/src/rules/lead/02-channels.md +3 -3
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +187 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +40 -3
- 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/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/session-lifecycle.mjs +7 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +24 -11
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +126 -167
- package/src/runtime/channels/lib/owner-heartbeat.mjs +11 -60
- 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/seat-lock.mjs +196 -0
- package/src/runtime/channels/lib/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +24 -8
- package/src/runtime/channels/lib/worker-main.mjs +42 -11
- 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/shared/atomic-file.mjs +20 -4
- package/src/runtime/shared/tool-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +23 -13
- package/src/session-runtime/config-lifecycle.mjs +48 -7
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +63 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +32 -2
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +24 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +306 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-daemon-client.mjs +224 -0
- package/src/standalone/channel-daemon-transport.mjs +351 -0
- package/src/standalone/channel-daemon.mjs +139 -0
- package/src/standalone/channel-worker.mjs +213 -4
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +105 -17
- 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/ToolExecution.jsx +2 -2
- package/src/tui/dist/index.mjs +635 -306
- package/src/tui/engine/session-api.mjs +17 -7
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine.mjs +8 -0
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline.mjs +45 -5
- 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
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Deferred-tool catalog: measured-usage ordering, kind/bucket classification,
|
|
2
2
|
// tool_search ranking + auto-selection, and the session tool-surface
|
|
3
3
|
// application/selection logic. Pure module (session objects passed in).
|
|
4
|
-
import { clean } from './session-text.mjs';
|
|
4
|
+
import { clean, LATE_TOOL_ANNOUNCEMENT_SENTINEL } from './session-text.mjs';
|
|
5
5
|
import { estimateToolSchemaTokens } from '../runtime/agent/orchestrator/session/context-utils.mjs';
|
|
6
|
-
import { applyInitialDeferredToolManifestToBp1 } from '../runtime/agent/orchestrator/context/collect.mjs';
|
|
6
|
+
import { applyInitialDeferredToolManifestToBp1, buildDeferredToolManifest } from '../runtime/agent/orchestrator/context/collect.mjs';
|
|
7
7
|
import { getMcpServerInstructionsMap } from '../runtime/agent/orchestrator/mcp/client.mjs';
|
|
8
8
|
import {
|
|
9
9
|
isResponsesFreeformTool,
|
|
@@ -37,7 +37,7 @@ export const DEFERRED_DEFAULT_FULL_TOOLS = Object.freeze([
|
|
|
37
37
|
'explore',
|
|
38
38
|
'apply_patch',
|
|
39
39
|
'Skill',
|
|
40
|
-
'
|
|
40
|
+
'load_tool',
|
|
41
41
|
]);
|
|
42
42
|
export const DEFERRED_DEFAULT_READONLY_TOOLS = Object.freeze([
|
|
43
43
|
'read',
|
|
@@ -48,7 +48,7 @@ export const DEFERRED_DEFAULT_READONLY_TOOLS = Object.freeze([
|
|
|
48
48
|
'list',
|
|
49
49
|
'explore',
|
|
50
50
|
'Skill',
|
|
51
|
-
'
|
|
51
|
+
'load_tool',
|
|
52
52
|
]);
|
|
53
53
|
export const DEFERRED_DEFAULT_LEAD_TOOLS = Object.freeze([
|
|
54
54
|
'read',
|
|
@@ -68,7 +68,7 @@ export const DEFERRED_DEFAULT_LEAD_TOOLS = Object.freeze([
|
|
|
68
68
|
'cwd',
|
|
69
69
|
'session_manage',
|
|
70
70
|
'Skill',
|
|
71
|
-
'
|
|
71
|
+
'load_tool',
|
|
72
72
|
]);
|
|
73
73
|
const READONLY_TOOL_NAMES = new Set([
|
|
74
74
|
'read',
|
|
@@ -120,7 +120,7 @@ export function toolSchemaBucket(tool) {
|
|
|
120
120
|
if (['shell', 'apply_patch'].includes(name)) return 'mutation';
|
|
121
121
|
if (name === 'agent' || name === 'delegate') return 'agents';
|
|
122
122
|
if (name.includes('channel') || name.includes('discord') || name.includes('webhook')) return 'channels';
|
|
123
|
-
if (name.includes('provider') || name === 'tool_search' || name === 'cwd') return 'setup';
|
|
123
|
+
if (name.includes('provider') || name === 'load_tool' || name === 'tool_search' || name === 'cwd') return 'setup';
|
|
124
124
|
return 'other';
|
|
125
125
|
}
|
|
126
126
|
|
|
@@ -227,6 +227,15 @@ export function compactToolSearchDescription(value, max = 220) {
|
|
|
227
227
|
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
// Late-announcement lines stay skill-manifest shaped but much tighter than the
|
|
231
|
+
// BP1 pool (first sentence, hard 80-char cap): the reminder is transient
|
|
232
|
+
// discovery only — the full description/schema arrives when the tool loads.
|
|
233
|
+
function lateAnnouncementDescription(value) {
|
|
234
|
+
const text = clean(value).replace(/\s+/g, ' ');
|
|
235
|
+
const sentence = text.split(/(?<=[.!?])\s+/, 1)[0] || text;
|
|
236
|
+
return sentence.length > 80 ? `${sentence.slice(0, 79)}…` : sentence;
|
|
237
|
+
}
|
|
238
|
+
|
|
230
239
|
export function toolRow(tool, activeNames = new Set()) {
|
|
231
240
|
const name = clean(tool?.name);
|
|
232
241
|
return {
|
|
@@ -345,6 +354,33 @@ export function deferredPoolToolNames(session) {
|
|
|
345
354
|
return sortedNamesByMeasuredUsage(out);
|
|
346
355
|
}
|
|
347
356
|
|
|
357
|
+
// Union of the boot-frozen deferred catalog and the late-connected MCP catalog.
|
|
358
|
+
// The boot catalog (session.deferredToolCatalog) is what the Anthropic providers
|
|
359
|
+
// serialize as defer_loading tools, so it MUST stay byte-identical after boot;
|
|
360
|
+
// tools whose MCP servers connected after boot live in
|
|
361
|
+
// session.deferredLateToolCatalog and are merged in ONLY for lookup/selection
|
|
362
|
+
// (never for provider serialization) so the request tools param — and its cache
|
|
363
|
+
// hash — is unchanged until a late tool is actually loaded.
|
|
364
|
+
export function deferredCatalogUnion(session) {
|
|
365
|
+
const boot = Array.isArray(session?.deferredToolCatalog) ? session.deferredToolCatalog : [];
|
|
366
|
+
const late = Array.isArray(session?.deferredLateToolCatalog) ? session.deferredLateToolCatalog : [];
|
|
367
|
+
if (!late.length) return boot;
|
|
368
|
+
const byName = new Map();
|
|
369
|
+
// On a same-name collision (a boot MCP tool whose server reconnected with a
|
|
370
|
+
// possibly fresher schema also lives in the late pool) prefer the LATE entry
|
|
371
|
+
// for lookup/load resolution. The boot-catalog ARRAY itself is never mutated,
|
|
372
|
+
// so provider defer_loading serialization stays byte-identical.
|
|
373
|
+
for (const tool of boot) {
|
|
374
|
+
const name = clean(tool?.name);
|
|
375
|
+
if (name && !byName.has(name)) byName.set(name, tool);
|
|
376
|
+
}
|
|
377
|
+
for (const tool of late) {
|
|
378
|
+
const name = clean(tool?.name);
|
|
379
|
+
if (name) byName.set(name, tool);
|
|
380
|
+
}
|
|
381
|
+
return [...byName.values()];
|
|
382
|
+
}
|
|
383
|
+
|
|
348
384
|
export function isReadonlySelectable(tool) {
|
|
349
385
|
const name = clean(tool?.name);
|
|
350
386
|
if (READONLY_TOOL_NAMES.has(name)) return true;
|
|
@@ -400,13 +436,188 @@ export function applyDeferredToolSurface(session, mode, extraTools = [], options
|
|
|
400
436
|
}
|
|
401
437
|
applyInitialDeferredToolManifestToBp1(session, deferredPoolToolNames(session));
|
|
402
438
|
}
|
|
439
|
+
if (!Array.isArray(session.deferredAnnouncedTools) && session.deferredToolBp1Applied) {
|
|
440
|
+
// Seed the announced set with everything already advertised in the BP1
|
|
441
|
+
// manifest, so the turn-boundary MCP delta (reconcileDeferredMcpToolCatalog)
|
|
442
|
+
// only announces genuinely new, late-connecting tools and never re-announces
|
|
443
|
+
// the startup pool.
|
|
444
|
+
session.deferredAnnouncedTools = deferredPoolToolNames(session);
|
|
445
|
+
}
|
|
403
446
|
return session;
|
|
404
447
|
}
|
|
405
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Turn-boundary reconciliation (Codex-style snapshot + CC-style delta).
|
|
451
|
+
* Merge currently-connected MCP tools into session.deferredLateToolCatalog (a
|
|
452
|
+
* SEPARATE pool from the boot-frozen session.deferredToolCatalog) so tools from
|
|
453
|
+
* servers that finished their handshake AFTER this session was created become
|
|
454
|
+
* reachable (deferred-call-through / load_tool resolve against the union of both
|
|
455
|
+
* catalogs and auto-load them on first direct call). The boot catalog — the only
|
|
456
|
+
* one the Anthropic providers serialize as defer_loading tools — is never
|
|
457
|
+
* touched, so the tools request parameter (and its cache hash) is byte-identical
|
|
458
|
+
* until a late tool is actually loaded (promoted onto session.tools).
|
|
459
|
+
* When the late pool gains MCP names not yet advertised, deliver ONE persistent
|
|
460
|
+
* <system-reminder> through the pending-message queue (options.enqueue) so it
|
|
461
|
+
* rides inside the next real user turn; if that queue is unreachable, fall back
|
|
462
|
+
* to a tail append ONLY when the transcript tail is an assistant turn, else defer
|
|
463
|
+
* the announcement (names stay un-announced and are retried next turn). No filler
|
|
464
|
+
* assistant messages are ever appended.
|
|
465
|
+
* A disconnected server's unloaded tools leave the late pool; a loaded (active)
|
|
466
|
+
* tool stays on session.tools, is never announced as removed, and is re-linked to
|
|
467
|
+
* the fresh server tool on reconnect.
|
|
468
|
+
* Returns the announced names, or null when nothing was announced.
|
|
469
|
+
*/
|
|
470
|
+
export function reconcileDeferredMcpToolCatalog(session, liveMcpTools, options = {}) {
|
|
471
|
+
if (!session || !Array.isArray(session.messages)) return null;
|
|
472
|
+
if (session.deferredProviderMode === 'full') return null;
|
|
473
|
+
const isMcp = (name) => typeof name === 'string' && name.startsWith('mcp__');
|
|
474
|
+
const live = Array.isArray(liveMcpTools) ? liveMcpTools : [];
|
|
475
|
+
const lateCatalog = Array.isArray(session.deferredLateToolCatalog) ? session.deferredLateToolCatalog : [];
|
|
476
|
+
const active = new Set((session.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
|
|
477
|
+
|
|
478
|
+
const liveMcpByName = new Map();
|
|
479
|
+
for (const tool of live) {
|
|
480
|
+
const name = clean(tool?.name);
|
|
481
|
+
if (!name || !isMcp(name) || liveMcpByName.has(name)) continue;
|
|
482
|
+
liveMcpByName.set(name, activeToolForSurface(tool));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Rebuild the LATE pool only (boot catalog stays frozen). It holds live MCP
|
|
486
|
+
// tools — INCLUDING ones whose name also exists in the boot catalog, so a
|
|
487
|
+
// reconnect's fresher schema is reachable via deferredCatalogUnion (which
|
|
488
|
+
// prefers the late entry). Keep an entry only while its server is still
|
|
489
|
+
// connected OR the tool is already loaded (active). A disconnected server's
|
|
490
|
+
// unloaded tool drops out; a loaded one stays on session.tools.
|
|
491
|
+
const nextByName = new Map();
|
|
492
|
+
for (const tool of lateCatalog) {
|
|
493
|
+
const name = clean(tool?.name);
|
|
494
|
+
if (!name || nextByName.has(name)) continue;
|
|
495
|
+
if (!liveMcpByName.has(name) && !active.has(name)) continue;
|
|
496
|
+
nextByName.set(name, tool);
|
|
497
|
+
}
|
|
498
|
+
for (const [name, tool] of liveMcpByName) nextByName.set(name, tool);
|
|
499
|
+
session.deferredLateToolCatalog = sortedCatalogByMeasuredUsage([...nextByName.values()]);
|
|
500
|
+
|
|
501
|
+
// A promoted (active) MCP tool survives its server disconnecting; on reconnect
|
|
502
|
+
// re-link the active session.tools entry to the fresh server tool so its
|
|
503
|
+
// schema/handler track the live connection. Swap ONLY when the serialized
|
|
504
|
+
// surface actually changed, so a steady reconnect never perturbs the tools
|
|
505
|
+
// request param or its cache hash.
|
|
506
|
+
if (Array.isArray(session.tools)) {
|
|
507
|
+
for (let i = 0; i < session.tools.length; i += 1) {
|
|
508
|
+
const name = clean(session.tools[i]?.name);
|
|
509
|
+
if (!name || !isMcp(name) || !liveMcpByName.has(name)) continue;
|
|
510
|
+
const freshTool = liveMcpByName.get(name);
|
|
511
|
+
if (JSON.stringify(freshTool) !== JSON.stringify(session.tools[i])) session.tools[i] = freshTool;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const announced = new Set(Array.isArray(session.deferredAnnouncedTools) ? session.deferredAnnouncedTools : []);
|
|
516
|
+
// Commit-based dedupe: a late tool counts as announced only once its reminder
|
|
517
|
+
// actually LANDED in session.messages (the tail-append path lands immediately;
|
|
518
|
+
// the enqueue path lands when the pending queue drains into the next user
|
|
519
|
+
// turn). Fold those committed names in now and persist them so a later
|
|
520
|
+
// transcript trim can never resurrect an already-delivered announcement. A
|
|
521
|
+
// crash BETWEEN enqueue and drain leaves the name uncommitted, so the next
|
|
522
|
+
// reconcile re-announces instead of silently dropping it; the scan is
|
|
523
|
+
// idempotent, so a double-drain never double-marks.
|
|
524
|
+
const lateMcpNames = session.deferredLateToolCatalog
|
|
525
|
+
.map((tool) => clean(tool?.name))
|
|
526
|
+
.filter((name) => name && isMcp(name));
|
|
527
|
+
let announcedChanged = false;
|
|
528
|
+
for (const name of committedAnnouncedLateTools(session, lateMcpNames)) {
|
|
529
|
+
if (!announced.has(name)) { announced.add(name); announcedChanged = true; }
|
|
530
|
+
}
|
|
531
|
+
if (announcedChanged) session.deferredAnnouncedTools = sortedNamesByMeasuredUsage([...announced]);
|
|
532
|
+
const fresh = [];
|
|
533
|
+
for (const tool of session.deferredLateToolCatalog) {
|
|
534
|
+
const name = clean(tool?.name);
|
|
535
|
+
if (!name || !isMcp(name)) continue;
|
|
536
|
+
if (active.has(name) || announced.has(name)) continue;
|
|
537
|
+
fresh.push({ name, description: lateAnnouncementDescription(tool?.description) });
|
|
538
|
+
}
|
|
539
|
+
if (!fresh.length) return null;
|
|
540
|
+
|
|
541
|
+
const manifest = buildDeferredToolManifest(fresh);
|
|
542
|
+
if (!manifest) return null;
|
|
543
|
+
const reminder = `<system-reminder>\nTools from MCP servers that ${LATE_TOOL_ANNOUNCEMENT_SENTINEL} are now available. Call any tool listed below directly by name; it loads on first use.\n\n${manifest}\n</system-reminder>`;
|
|
544
|
+
const delivery = deliverDeferredAnnouncement(session, reminder, options);
|
|
545
|
+
if (!delivery) return null;
|
|
546
|
+
// Only the tail-append path commits into session.messages synchronously, so
|
|
547
|
+
// mark it announced now. Enqueued reminders are marked on the next reconcile's
|
|
548
|
+
// transcript scan (post-drain) — never at enqueue time (crash-safety above).
|
|
549
|
+
if (delivery === 'committed') {
|
|
550
|
+
for (const entry of fresh) announced.add(entry.name);
|
|
551
|
+
session.deferredAnnouncedTools = sortedNamesByMeasuredUsage([...announced]);
|
|
552
|
+
}
|
|
553
|
+
session.updatedAt = Date.now();
|
|
554
|
+
return fresh.map((entry) => entry.name);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Sentinel phrase carried in every late-tool reminder; used to recognise a
|
|
558
|
+
// delivered (committed) announcement inside session.messages regardless of
|
|
559
|
+
// whether the pending drain merged it with other queued user content.
|
|
560
|
+
// Single source of truth lives in session-text.mjs (imported above) so the
|
|
561
|
+
// hide-from-UI detection can never drift from the emitted reminder text.
|
|
562
|
+
const LATE_TOOL_REMINDER_SENTINEL = LATE_TOOL_ANNOUNCEMENT_SENTINEL;
|
|
563
|
+
|
|
564
|
+
function reminderMessageText(content) {
|
|
565
|
+
if (typeof content === 'string') return content;
|
|
566
|
+
if (Array.isArray(content)) {
|
|
567
|
+
return content.map((b) => (typeof b === 'string' ? b : String(b?.text || ''))).join('\n');
|
|
568
|
+
}
|
|
569
|
+
return '';
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Late-tool names whose reminder is already present in session.messages (either
|
|
573
|
+
// tail-appended directly or drained in from the pending queue). Idempotent.
|
|
574
|
+
function committedAnnouncedLateTools(session, lateNames) {
|
|
575
|
+
const names = Array.isArray(lateNames) ? lateNames.filter(Boolean) : [];
|
|
576
|
+
if (!names.length) return [];
|
|
577
|
+
const msgs = Array.isArray(session?.messages) ? session.messages : [];
|
|
578
|
+
const blob = msgs
|
|
579
|
+
.filter((m) => m && m.role === 'user')
|
|
580
|
+
.map((m) => reminderMessageText(m.content))
|
|
581
|
+
.filter((t) => t.includes(LATE_TOOL_REMINDER_SENTINEL))
|
|
582
|
+
.join('\n');
|
|
583
|
+
if (!blob) return [];
|
|
584
|
+
return names.filter((name) => blob.includes(name));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Deliver the late-tool <system-reminder> without any '.' filler turn. Primary
|
|
588
|
+
// path: the pending-message queue (options.enqueue) — the SAME mechanism that
|
|
589
|
+
// carries tool-completion notifications. The queue drains AFTER the current
|
|
590
|
+
// turn's assistant terminal response (ask-session.mjs), so the reminder rides a
|
|
591
|
+
// fresh follow-up user turn that always follows an assistant turn; strict
|
|
592
|
+
// role-alternation holds and no wire-level same-role merge is needed (the
|
|
593
|
+
// Anthropic/OpenAI lowering never merges two plain user turns, and none occurs
|
|
594
|
+
// here). Returns 'enqueued' (commit deferred to drain) on that path.
|
|
595
|
+
// Fallback (queue unreachable, e.g. unit tests): append a user reminder ONLY
|
|
596
|
+
// when the transcript tail is an assistant turn — that commits synchronously
|
|
597
|
+
// ('committed'); otherwise defer to the next turn (null).
|
|
598
|
+
function deliverDeferredAnnouncement(session, reminder, options) {
|
|
599
|
+
const enqueue = typeof options?.enqueue === 'function' ? options.enqueue : null;
|
|
600
|
+
if (enqueue) {
|
|
601
|
+
try { if (enqueue(reminder) === true) return 'enqueued'; }
|
|
602
|
+
catch { /* fall through to the tail-append fallback */ }
|
|
603
|
+
}
|
|
604
|
+
const messages = session.messages;
|
|
605
|
+
const tail = messages[messages.length - 1];
|
|
606
|
+
if (tail && tail.role === 'assistant') {
|
|
607
|
+
messages.push({ role: 'user', meta: 'hook', content: reminder });
|
|
608
|
+
return 'committed';
|
|
609
|
+
}
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
|
|
406
613
|
export function selectDeferredTools(session, names, mode, options = {}) {
|
|
407
614
|
const promoteToActive = options?.promoteToActive === true;
|
|
408
|
-
|
|
409
|
-
|
|
615
|
+
// Resolve against the union of the boot-frozen catalog and the late-connected
|
|
616
|
+
// MCP catalog so load_tool can load a late tool; loading promotes it onto
|
|
617
|
+
// session.tools where the providers serialize it as a real (non-deferred) tool.
|
|
618
|
+
const union = deferredCatalogUnion(session);
|
|
619
|
+
const catalog = union.length
|
|
620
|
+
? union
|
|
410
621
|
: (Array.isArray(session?.tools) ? session.tools : []);
|
|
411
622
|
const active = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
|
|
412
623
|
const native = session?.deferredProviderMode === 'native' || session?.deferredNativeTools === true;
|
|
@@ -459,44 +670,101 @@ export function selectDeferredTools(session, names, mode, options = {}) {
|
|
|
459
670
|
return { added, already, blocked, missing, native };
|
|
460
671
|
}
|
|
461
672
|
|
|
462
|
-
|
|
673
|
+
// Collect the exact deferred-tool names to load, honoring back-compat inputs:
|
|
674
|
+
// names[] → primary loader input
|
|
675
|
+
// select / "select:" → legacy alias (parseToolSelection strips the prefix)
|
|
676
|
+
// query "select:a,b" → legacy query-side loader
|
|
677
|
+
// A free-text `query` is NOT a search anymore: it yields no names, and the
|
|
678
|
+
// caller is steered back to names[].
|
|
679
|
+
export function parseLoadToolNames(args = {}) {
|
|
680
|
+
const fromNames = parseToolSelection(args.names);
|
|
681
|
+
if (fromNames.length) return fromNames;
|
|
682
|
+
const fromSelect = parseToolSelection(args.select);
|
|
683
|
+
if (fromSelect.length) return fromSelect;
|
|
684
|
+
return parseToolSearchQuerySelection(args.query);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Split live MCP servers into "still connecting" (pending) and "failed" so the
|
|
688
|
+
// loader can tell the model to retry next turn instead of treating a missing
|
|
689
|
+
// tool as a permanent zero result. `mcpStatus` is a getter plumbed from the
|
|
690
|
+
// runtime; absent in unit tests, where these lists are simply empty.
|
|
691
|
+
function pendingAndFailedMcpServers(mcpStatus) {
|
|
692
|
+
const empty = { pending: [], failed: [] };
|
|
693
|
+
let status = null;
|
|
694
|
+
try {
|
|
695
|
+
status = typeof mcpStatus === 'function' ? mcpStatus() : mcpStatus;
|
|
696
|
+
} catch {
|
|
697
|
+
return empty;
|
|
698
|
+
}
|
|
699
|
+
const servers = Array.isArray(status?.servers) ? status.servers : [];
|
|
700
|
+
const pending = [];
|
|
701
|
+
const failed = [];
|
|
702
|
+
for (const row of servers) {
|
|
703
|
+
const name = clean(row?.name);
|
|
704
|
+
if (!name) continue;
|
|
705
|
+
if (row?.status === 'disconnected') pending.push(name);
|
|
706
|
+
else if (row?.status === 'failed') failed.push(name);
|
|
707
|
+
}
|
|
708
|
+
return { pending: [...new Set(pending)].sort(), failed: [...new Set(failed)].sort() };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Pure loader (formerly a keyword search). Input is exact deferred-tool
|
|
712
|
+
// names/aliases; output reports loaded / already-active / missing / blocked
|
|
713
|
+
// tools PLUS pending/failed MCP servers. No listing, no ranking, no substring
|
|
714
|
+
// filter. `options.mcpStatus` is the runtime getter for per-server status.
|
|
715
|
+
export function renderToolSearch(args = {}, session, mode = 'full', options = {}) {
|
|
463
716
|
const catalog = Array.isArray(session?.deferredToolCatalog)
|
|
464
717
|
? session.deferredToolCatalog
|
|
465
718
|
: (Array.isArray(session?.tools) ? session.tools : []);
|
|
466
|
-
const
|
|
467
|
-
const
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
719
|
+
const requestedNames = parseLoadToolNames(args);
|
|
720
|
+
const { pending: pendingMcpServers, failed: failedMcpServers } = pendingAndFailedMcpServers(options?.mcpStatus);
|
|
721
|
+
const mcpFields = {
|
|
722
|
+
...(pendingMcpServers.length ? { pendingMcpServers } : {}),
|
|
723
|
+
...(failedMcpServers.length ? { failedMcpServers } : {}),
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
if (!requestedNames.length) {
|
|
727
|
+
const strayQuery = clean(args.query || args.q || args.text);
|
|
728
|
+
return JSON.stringify({
|
|
729
|
+
error: strayQuery
|
|
730
|
+
? `load_tool is a loader, not a search: "${strayQuery}" is not an exact tool name. Pass names:["exact_tool_name", ...] (deferred tool names/aliases). No keyword search.`
|
|
731
|
+
: 'load_tool requires names:["exact_tool_name", ...] (deferred tool names/aliases).',
|
|
732
|
+
loaded: [],
|
|
733
|
+
alreadyActive: [],
|
|
734
|
+
missing: [],
|
|
735
|
+
...mcpFields,
|
|
736
|
+
activeTools: sortedNamesByMeasuredUsage((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean)),
|
|
737
|
+
discoveredTools: sortedNamesByMeasuredUsage(session?.deferredDiscoveredTools || []),
|
|
738
|
+
}, null, 2);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const toolSelection = selectDeferredTools(session, requestedNames, mode);
|
|
481
742
|
const nextActiveNames = new Set((session?.tools || []).map((tool) => clean(tool?.name)).filter(Boolean));
|
|
482
|
-
const
|
|
483
|
-
const
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
}
|
|
489
|
-
: null;
|
|
490
|
-
const nativeToolSearch = toolSelection?.native
|
|
491
|
-
? toolSearchNativePayload(catalog, [...new Set([...toolSelection.added, ...(toolSelection.already || [])])], session?.provider)
|
|
743
|
+
const loaded = toolSelection.added || [];
|
|
744
|
+
const alreadyActive = toolSelection.already || [];
|
|
745
|
+
const missing = toolSelection.missing || [];
|
|
746
|
+
const blocked = toolSelection.blocked || [];
|
|
747
|
+
const nativeToolSearch = toolSelection.native
|
|
748
|
+
? toolSearchNativePayload(catalog, [...new Set([...loaded, ...alreadyActive])], session?.provider)
|
|
492
749
|
: null;
|
|
750
|
+
const notes = [];
|
|
751
|
+
if (missing.length && pendingMcpServers.length) {
|
|
752
|
+
notes.push('Some requested names may belong to an MCP server still connecting — retry next turn.');
|
|
753
|
+
}
|
|
754
|
+
if (missing.length && failedMcpServers.length) {
|
|
755
|
+
notes.push('Some requested names may belong to a failed MCP server; those tools are unavailable.');
|
|
756
|
+
}
|
|
493
757
|
return JSON.stringify({
|
|
494
|
-
selected
|
|
758
|
+
// `selected` retained for back-compat consumers (mode is always 'select').
|
|
759
|
+
selected: { mode: 'select', tools: toolSelection },
|
|
495
760
|
...(nativeToolSearch ? { nativeToolSearch } : {}),
|
|
496
|
-
|
|
497
|
-
|
|
761
|
+
loaded,
|
|
762
|
+
alreadyActive,
|
|
763
|
+
missing,
|
|
764
|
+
...(blocked.length ? { blocked } : {}),
|
|
765
|
+
...mcpFields,
|
|
498
766
|
activeTools: sortedNamesByMeasuredUsage(nextActiveNames),
|
|
499
767
|
discoveredTools: sortedNamesByMeasuredUsage(session?.deferredDiscoveredTools || []),
|
|
500
|
-
|
|
768
|
+
...(notes.length ? { note: notes.join(' ') } : {}),
|
|
501
769
|
}, null, 2);
|
|
502
770
|
}
|
|
@@ -3,23 +3,23 @@
|
|
|
3
3
|
// from mixdog-session-runtime.mjs; no runtime closure dependencies.
|
|
4
4
|
|
|
5
5
|
export const TOOL_SEARCH_TOOL = {
|
|
6
|
-
name: '
|
|
7
|
-
title: '
|
|
6
|
+
name: 'load_tool',
|
|
7
|
+
title: 'load_tool',
|
|
8
8
|
annotations: {
|
|
9
|
-
title: '
|
|
9
|
+
title: 'load_tool',
|
|
10
10
|
readOnlyHint: true,
|
|
11
11
|
destructiveHint: false,
|
|
12
12
|
idempotentHint: true,
|
|
13
13
|
openWorldHint: false,
|
|
14
14
|
agentHidden: true,
|
|
15
15
|
},
|
|
16
|
-
description: '
|
|
16
|
+
description: 'load_tool: pure loader for deferred tools. Pass names:["exact_tool_name", ...] (deferred tool names/aliases) to load them; returns loaded / already-active / missing plus any MCP servers still connecting (retry next turn) or failed. No keyword search, no ranking, no listing. Deferred tools can also be called directly by name (they auto-load on first call).',
|
|
17
17
|
inputSchema: {
|
|
18
18
|
type: 'object',
|
|
19
19
|
properties: {
|
|
20
|
-
|
|
21
|
-
select: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], description: '
|
|
22
|
-
|
|
20
|
+
names: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], description: 'Exact deferred tool names/aliases to load.' },
|
|
21
|
+
select: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], description: 'Legacy alias for names (accepts "select:a,b").' },
|
|
22
|
+
query: { type: 'string', description: 'Legacy: only "select:a,b" is honored (mapped to names); free-text keyword queries are rejected — this tool does not search.' },
|
|
23
23
|
},
|
|
24
24
|
additionalProperties: false,
|
|
25
25
|
},
|
|
@@ -101,6 +101,7 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
|
|
|
101
101
|
name: clean(fm.name) || id,
|
|
102
102
|
description: clean(fm.description),
|
|
103
103
|
entry,
|
|
104
|
+
hidden: String(fm.hidden ?? '').trim().toLowerCase() === 'true',
|
|
104
105
|
agentsConfigured,
|
|
105
106
|
agents: agentsConfigured
|
|
106
107
|
? String(fm.agents || '')
|
|
@@ -124,7 +125,7 @@ export function createWorkflowHelpers({ rootDir, dataDir, readMarkdownDocument,
|
|
|
124
125
|
const d = join(root, entry.name);
|
|
125
126
|
if (!existsSync(join(d, 'WORKFLOW.md'))) continue;
|
|
126
127
|
const pack = readWorkflowPackFromDir(d, source, entry.name);
|
|
127
|
-
if (pack) byId.set(pack.id, pack);
|
|
128
|
+
if (pack && !pack.hidden) byId.set(pack.id, pack);
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
return [...byId.values()].sort((a, b) => {
|