mixdog 0.9.10 → 0.9.11
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/agent-tag-reuse-smoke.mjs +12 -8
- package/scripts/bench/cache-probe-tasks.json +8 -0
- package/scripts/tool-smoke.mjs +109 -0
- package/src/defaults/mixdog-config.template.json +1 -0
- package/src/mixdog-session-runtime.mjs +8 -0
- package/src/runtime/agent/orchestrator/config.mjs +30 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +160 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +25 -1
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +24 -9
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +18 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -0
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +79 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +7 -1
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +9 -2
- package/src/runtime/agent/orchestrator/session/manager.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +5 -5
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +1 -22
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +26 -3
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +29 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +17 -16
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +4 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +30 -4
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +333 -14
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +129 -2
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +58 -18
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +56 -83
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +26 -56
- package/src/runtime/channels/lib/webhook/deliveries.mjs +4 -1
- package/src/session-runtime/settings-api.mjs +13 -0
- package/src/session-runtime/tool-catalog.mjs +34 -7
- package/src/standalone/agent-tool/render.mjs +2 -0
- package/src/standalone/agent-tool.mjs +28 -6
- package/src/standalone/memory-runtime-proxy.mjs +85 -21
- package/src/tui/App.jsx +20 -1
- package/src/tui/app/extension-pickers.mjs +6 -3
- package/src/tui/dist/index.mjs +28 -4
- package/src/tui/engine.mjs +2 -0
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// 1) live + idle -> spawn reuses the existing session (reused:true, send path)
|
|
6
6
|
// 2) live + busy -> spawn queues the prompt instead of throwing
|
|
7
7
|
// 3) lingering terminal trace (no live session) -> spawn/send error, no respawn
|
|
8
|
+
// 3) lingering terminal trace -> spawn reaps tag; dead-tag send auto-respawns
|
|
8
9
|
// 4) genuinely new tag -> a fresh spawn (no reused/respawned flag)
|
|
9
10
|
//
|
|
10
11
|
// No network: a fake askSession drives status transitions. Uses the REAL
|
|
@@ -150,19 +151,22 @@ async function main() {
|
|
|
150
151
|
holdBusy = false;
|
|
151
152
|
await waitJob(busyStart, /ack: stay busy please/, 'busy worker finish');
|
|
152
153
|
|
|
153
|
-
// --- tier 3: terminal trace
|
|
154
|
-
// --- tier 3: lingering trace without live session must not cold-respawn ---
|
|
154
|
+
// --- tier 3: terminal trace -> reap and reuse tag (spawn) or auto-respawn (send) ---
|
|
155
155
|
closeSession(sid, 'smoke-session-closed-trace');
|
|
156
156
|
const traceSpawnOut = await agent.execute({
|
|
157
|
-
type: 'spawn', agent: 'reviewer', tag: 'reviewerA', prompt: '
|
|
157
|
+
type: 'spawn', agent: 'reviewer', tag: 'reviewerA', prompt: 'trace reap respawn', cwd: root,
|
|
158
158
|
}, ctx);
|
|
159
|
-
assert(
|
|
160
|
-
|
|
159
|
+
assert(!/^Error:/.test(traceSpawnOut), `terminal-trace spawn must reap and reuse tag: ${traceSpawnOut}`);
|
|
160
|
+
await waitJob(traceSpawnOut, /ack: trace reap respawn/, 'trace reap spawn');
|
|
161
|
+
const traceLive = await waitSessionForTag('reviewerA', 'trace reap spawn live');
|
|
162
|
+
closeSession(traceLive.id, 'smoke-kill-for-send-respawn');
|
|
161
163
|
const traceSendOut = await agent.execute({
|
|
162
|
-
type: 'send', tag: 'reviewerA', message: '
|
|
164
|
+
type: 'send', tag: 'reviewerA', message: 'auto-respawn follow-up', cwd: root,
|
|
163
165
|
}, ctx);
|
|
164
|
-
assert(
|
|
165
|
-
assert(/
|
|
166
|
+
assert(!/^Error:/.test(traceSendOut), `dead-tag send must auto-respawn: ${traceSendOut}`);
|
|
167
|
+
assert(/respawned: true/.test(traceSendOut), `send auto-respawn must flag respawned: ${traceSendOut}`);
|
|
168
|
+
assert(!/reused: true/.test(traceSendOut), `dead-tag send must not be a live reuse: ${traceSendOut}`);
|
|
169
|
+
await waitJob(traceSendOut, /ack: auto-respawn follow-up/, 'trace send respawn');
|
|
166
170
|
|
|
167
171
|
// agent close clears the trace; same tag can spawn fresh again
|
|
168
172
|
await agent.execute({ type: 'close', tag: 'reviewerA' }, ctx);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "cache-probe-multiturn",
|
|
4
|
+
"agent": "worker",
|
|
5
|
+
"cwd": "C:/Project/mixdog",
|
|
6
|
+
"prompt": "Investigation only, no edits. Step by step, one file per turn: 1) read README.md 2) read package.json 3) read scripts/smoke.mjs first 40 lines 4) read src/cli.mjs first 40 lines 5) read scripts/build-tui.mjs first 40 lines 6) read scripts/boot-smoke.mjs first 40 lines. After each read, state one sentence about the file. Finally give a 3-line synthesis. Use exactly one read tool call per step, sequentially."
|
|
7
|
+
}
|
|
8
|
+
]
|
package/scripts/tool-smoke.mjs
CHANGED
|
@@ -345,6 +345,23 @@ const grepOut = await executeBuiltinTool('grep', {
|
|
|
345
345
|
}, root);
|
|
346
346
|
assertOk('grep', grepOut, /smoke\.mjs/);
|
|
347
347
|
|
|
348
|
+
const grepBracketPathOut = await executeBuiltinTool('grep', {
|
|
349
|
+
pattern: 'tool-smoke',
|
|
350
|
+
path: '[]',
|
|
351
|
+
glob: '*.mjs',
|
|
352
|
+
head_limit: 5,
|
|
353
|
+
}, root);
|
|
354
|
+
assertOk('grep path [] coerces to cwd', grepBracketPathOut, /tool-smoke\.mjs/);
|
|
355
|
+
|
|
356
|
+
const grepRedirectOut = await executeBuiltinTool('grep', {
|
|
357
|
+
pattern: 'assertOk',
|
|
358
|
+
path: 'bogus/wrong/prefix/scripts/tool-smoke.mjs',
|
|
359
|
+
head_limit: 3,
|
|
360
|
+
}, root);
|
|
361
|
+
if (!/^\[redirected from/.test(grepRedirectOut) || !/assertOk/.test(grepRedirectOut)) {
|
|
362
|
+
throw new Error(`grep ENOENT should auto-redirect on unique suffix hit:\n${grepRedirectOut.slice(0, 800)}`);
|
|
363
|
+
}
|
|
364
|
+
|
|
348
365
|
const redundantAllFilesGlobGrepOut = await executeBuiltinTool('grep', {
|
|
349
366
|
pattern: 'standalone mixdog CLI/TUI coding agent',
|
|
350
367
|
glob: '**/*',
|
|
@@ -367,6 +384,98 @@ const explicitSrcGlobOut = await executeBuiltinTool('glob', {
|
|
|
367
384
|
}, root);
|
|
368
385
|
assertOk('glob explicit src', explicitSrcGlobOut, /src[\\/].*engine\.mjs/i);
|
|
369
386
|
|
|
387
|
+
const globPathOnlyOut = await executeBuiltinTool('glob', {
|
|
388
|
+
path: 'scripts',
|
|
389
|
+
head_limit: 8,
|
|
390
|
+
}, root);
|
|
391
|
+
assertOk('glob path-only default *', globPathOnlyOut, /tool-smoke\.mjs/i);
|
|
392
|
+
|
|
393
|
+
const grepNoPatternGlobOut = await executeBuiltinTool('grep', {
|
|
394
|
+
path: 'scripts',
|
|
395
|
+
glob: 'tool-smoke.mjs',
|
|
396
|
+
head_limit: 5,
|
|
397
|
+
}, root);
|
|
398
|
+
assertOk('grep without pattern routes to glob', grepNoPatternGlobOut, /tool-smoke\.mjs/i);
|
|
399
|
+
|
|
400
|
+
const grepManyPatterns = [
|
|
401
|
+
...Array.from({ length: 20 }, (_, i) => `__tool_smoke_miss_${i}__`),
|
|
402
|
+
'tool-smoke',
|
|
403
|
+
];
|
|
404
|
+
const grepManyPatternsOut = await executeBuiltinTool('grep', {
|
|
405
|
+
pattern: grepManyPatterns,
|
|
406
|
+
path: 'scripts',
|
|
407
|
+
glob: '*.mjs',
|
|
408
|
+
head_limit: 5,
|
|
409
|
+
}, root);
|
|
410
|
+
if (/exceeds the 20-pattern cap/i.test(String(grepManyPatternsOut))) {
|
|
411
|
+
throw new Error(`grep should chunk >20 patterns instead of error:\n${grepManyPatternsOut.slice(0, 400)}`);
|
|
412
|
+
}
|
|
413
|
+
assertOk('grep >20 pattern chunk merge', grepManyPatternsOut, /tool-smoke\.mjs/i);
|
|
414
|
+
if (!/\[pattern set split into 2 chunks\]/.test(String(grepManyPatternsOut))) {
|
|
415
|
+
throw new Error(`grep >20 patterns should emit chunk advisory:\n${grepManyPatternsOut.slice(0, 400)}`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function grepCountTotalMatches(body) {
|
|
419
|
+
const m = String(body).match(/\[total (\d+) match/i);
|
|
420
|
+
return m ? Number(m[1]) : null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const grepCountSingleOut = await executeBuiltinTool('grep', {
|
|
424
|
+
pattern: 'assertOk',
|
|
425
|
+
path: 'scripts/tool-smoke.mjs',
|
|
426
|
+
output_mode: 'count',
|
|
427
|
+
}, root);
|
|
428
|
+
const singleCountTotal = grepCountTotalMatches(grepCountSingleOut);
|
|
429
|
+
if (singleCountTotal == null || singleCountTotal < 1) {
|
|
430
|
+
throw new Error(`grep count baseline failed:\n${grepCountSingleOut.slice(0, 400)}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const grepCountOverlapPatterns = [
|
|
434
|
+
...Array.from({ length: 19 }, (_, i) => `__count_overlap_a_${i}__`),
|
|
435
|
+
'assertOk',
|
|
436
|
+
...Array.from({ length: 19 }, (_, i) => `__count_overlap_b_${i}__`),
|
|
437
|
+
'assertOk',
|
|
438
|
+
];
|
|
439
|
+
const grepCountOverlapOut = await executeBuiltinTool('grep', {
|
|
440
|
+
pattern: grepCountOverlapPatterns,
|
|
441
|
+
path: 'scripts/tool-smoke.mjs',
|
|
442
|
+
output_mode: 'count',
|
|
443
|
+
}, root);
|
|
444
|
+
const overlapCountTotal = grepCountTotalMatches(grepCountOverlapOut);
|
|
445
|
+
if (overlapCountTotal !== singleCountTotal) {
|
|
446
|
+
throw new Error(
|
|
447
|
+
`chunked count must not double-count overlapping lines (single=${singleCountTotal} overlap=${overlapCountTotal}):\n${grepCountOverlapOut.slice(0, 600)}`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const grepChunkContextPatterns = [
|
|
452
|
+
...Array.from({ length: 20 }, (_, i) => `__ctx_chunk_miss_${i}__`),
|
|
453
|
+
'grepCountTotalMatches',
|
|
454
|
+
];
|
|
455
|
+
const grepChunkContextOut = await executeBuiltinTool('grep', {
|
|
456
|
+
pattern: grepChunkContextPatterns,
|
|
457
|
+
path: 'scripts/tool-smoke.mjs',
|
|
458
|
+
'-C': 1,
|
|
459
|
+
head_limit: 30,
|
|
460
|
+
}, root);
|
|
461
|
+
if (!/\[pattern set split into 2 chunks\]/.test(String(grepChunkContextOut))) {
|
|
462
|
+
throw new Error(`chunked -C grep should split patterns:\n${grepChunkContextOut.slice(0, 500)}`);
|
|
463
|
+
}
|
|
464
|
+
if (!/tool-smoke\.mjs:\d+:/.test(String(grepChunkContextOut))) {
|
|
465
|
+
throw new Error(`chunked -C must emit path-prefixed match lines:\n${grepChunkContextOut.slice(0, 800)}`);
|
|
466
|
+
}
|
|
467
|
+
if (!/tool-smoke\.mjs-\d+-/.test(String(grepChunkContextOut))) {
|
|
468
|
+
throw new Error(`chunked -C must keep path-prefixed context lines:\n${grepChunkContextOut.slice(0, 800)}`);
|
|
469
|
+
}
|
|
470
|
+
const ctxBodyLines = String(grepChunkContextOut).split('\n').filter((l) => l && !/^\[/.test(l) && !/^\(no matches\)/.test(l));
|
|
471
|
+
const orphanLineOnlyContext = ctxBodyLines.some((l) => /^\d+-/.test(l));
|
|
472
|
+
if (orphanLineOnlyContext) {
|
|
473
|
+
throw new Error(`chunked -C must not leave line-only context orphans:\n${grepChunkContextOut.slice(0, 800)}`);
|
|
474
|
+
}
|
|
475
|
+
if (!/function grepCountTotalMatches/.test(String(grepChunkContextOut))) {
|
|
476
|
+
throw new Error(`chunked -C should include match span:\n${grepChunkContextOut.slice(0, 800)}`);
|
|
477
|
+
}
|
|
478
|
+
|
|
370
479
|
const findOut = await executeBuiltinTool('find', {
|
|
371
480
|
query: 'tool smoke',
|
|
372
481
|
path: '.',
|
|
@@ -818,6 +818,10 @@ export async function createMixdogSessionRuntime({
|
|
|
818
818
|
}
|
|
819
819
|
|
|
820
820
|
function skillToolContent(name) {
|
|
821
|
+
if (typeof contextMod.isSkillDisabled === 'function' && contextMod.isSkillDisabled(name)) {
|
|
822
|
+
const label = String(name || '').trim() || 'skill';
|
|
823
|
+
return `Error: skill "${label}" is disabled`;
|
|
824
|
+
}
|
|
821
825
|
const skill = skillContent(name);
|
|
822
826
|
// Return the general tool envelope so the main/Lead session behaves the
|
|
823
827
|
// same as agent-loop sessions: the model-visible tool_result is the short
|
|
@@ -1596,6 +1600,9 @@ export async function createMixdogSessionRuntime({
|
|
|
1596
1600
|
// Channels are opt-in: only boot the worker when this session started in (or
|
|
1597
1601
|
// was toggled into) remote mode. Non-remote sessions never contend for the
|
|
1598
1602
|
// channel; see startRemote()/stopRemote() and the `/remote` toggle.
|
|
1603
|
+
// `remote.autoStart` in mixdog-config.json makes every session claim remote
|
|
1604
|
+
// at boot (same force-takeover semantics as `mixdog --remote` / `/remote`).
|
|
1605
|
+
if (!remoteEnabled && config?.remote?.autoStart === true) remoteEnabled = true;
|
|
1599
1606
|
if (remoteEnabled) startRemote();
|
|
1600
1607
|
|
|
1601
1608
|
function contextStatusCacheKeyFor({ messages, tools }) {
|
|
@@ -1657,6 +1664,7 @@ export async function createMixdogSessionRuntime({
|
|
|
1657
1664
|
getRoute: () => route,
|
|
1658
1665
|
getSession: () => session,
|
|
1659
1666
|
getRemoteEnabled: () => remoteEnabled,
|
|
1667
|
+
adoptConfig,
|
|
1660
1668
|
saveConfigAndAdopt,
|
|
1661
1669
|
scheduleBackendSave,
|
|
1662
1670
|
cfgMod,
|
|
@@ -81,6 +81,16 @@ export function normalizeProfileConfig(value = {}) {
|
|
|
81
81
|
return { title, language };
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** Persisted `skills.disabled` name list (deduped, trimmed, sorted). */
|
|
85
|
+
export function normalizeSkillsConfig(value = {}) {
|
|
86
|
+
const raw = value && typeof value === 'object' ? value : {};
|
|
87
|
+
const disabled = Array.isArray(raw.disabled)
|
|
88
|
+
? [...new Set(raw.disabled.map((n) => String(n).trim()).filter(Boolean))]
|
|
89
|
+
: [];
|
|
90
|
+
disabled.sort((a, b) => a.localeCompare(b));
|
|
91
|
+
return { disabled };
|
|
92
|
+
}
|
|
93
|
+
|
|
84
94
|
// Look up the catalog entry for a stored language id (defaults to 'system').
|
|
85
95
|
export function profileLanguageEntry(languageId) {
|
|
86
96
|
const id = String(languageId || 'system');
|
|
@@ -435,6 +445,7 @@ export function loadConfig(options = {}) {
|
|
|
435
445
|
workflow: raw.workflow && typeof raw.workflow === 'object' ? { active: String(raw.workflow.active || 'default') } : { active: 'default' },
|
|
436
446
|
agentMaintenance: { enabled: true, interval: '1h', ...raw.agentMaintenance },
|
|
437
447
|
profile: normalizeProfileConfig(raw.profile),
|
|
448
|
+
skills: normalizeSkillsConfig(raw.skills),
|
|
438
449
|
// No idleMs default here: absent idleMs means "provider default"
|
|
439
450
|
// (config-helpers normalizeAutoClearConfig custom:false path).
|
|
440
451
|
// Injecting a fixed 1h would mark every config custom:true and
|
|
@@ -467,6 +478,7 @@ export function loadConfig(options = {}) {
|
|
|
467
478
|
workflow: { active: 'default' },
|
|
468
479
|
agentMaintenance: { enabled: true, interval: '1h' },
|
|
469
480
|
profile: normalizeProfileConfig(null),
|
|
481
|
+
skills: normalizeSkillsConfig(null),
|
|
470
482
|
autoClear: { enabled: true },
|
|
471
483
|
compaction: {},
|
|
472
484
|
trajectory: { enabled: true },
|
|
@@ -493,6 +505,23 @@ export function loadConfig(options = {}) {
|
|
|
493
505
|
* via persistAgentConfig((current) => ({ ...current, <field> })) so a
|
|
494
506
|
* concurrent instance's edits are not reverted.
|
|
495
507
|
*/
|
|
508
|
+
/** In-lock patch of `skills.disabled` only (avoids whole-config lost-update). */
|
|
509
|
+
export function patchSkillsDisabled(disabledNames) {
|
|
510
|
+
const names = disabledNames instanceof Set
|
|
511
|
+
? [...disabledNames]
|
|
512
|
+
: (Array.isArray(disabledNames) ? disabledNames : []);
|
|
513
|
+
const nextSkills = normalizeSkillsConfig({ disabled: names });
|
|
514
|
+
persistAgentConfig((current) => {
|
|
515
|
+
const cur = { ...current };
|
|
516
|
+
const target = (cur.agent && cur.agent.providers)
|
|
517
|
+
? (cur.agent = { ...cur.agent })
|
|
518
|
+
: cur;
|
|
519
|
+
target.skills = nextSkills;
|
|
520
|
+
return cur;
|
|
521
|
+
});
|
|
522
|
+
return nextSkills;
|
|
523
|
+
}
|
|
524
|
+
|
|
496
525
|
export function saveConfig(config) {
|
|
497
526
|
// Strip ephemeral defaults from providers but preserve any unknown
|
|
498
527
|
// per-provider subkey so future schema additions round-trip through the
|
|
@@ -544,6 +573,7 @@ export function saveConfig(config) {
|
|
|
544
573
|
workflow: config.workflow || { active: 'default' },
|
|
545
574
|
agentMaintenance: config.agentMaintenance || {},
|
|
546
575
|
profile: normalizeProfileConfig(config.profile),
|
|
576
|
+
skills: normalizeSkillsConfig(config.skills),
|
|
547
577
|
autoClear: config.autoClear || {},
|
|
548
578
|
compaction: config.compaction || {},
|
|
549
579
|
trajectory: config.trajectory || {},
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
parseMarkdownFrontmatter,
|
|
8
8
|
readMarkdownDocument,
|
|
9
9
|
} from '../../../shared/markdown-frontmatter.mjs';
|
|
10
|
+
import { loadConfig, normalizeSkillsConfig } from '../config.mjs';
|
|
10
11
|
|
|
11
12
|
// --- mixdog asset roots (standalone CLI owns its own paths; never .claude) ---
|
|
12
13
|
// Project-local: <cwd>/.mixdog/<kind>
|
|
@@ -123,6 +124,37 @@ export function collectSkills(cwd) {
|
|
|
123
124
|
}
|
|
124
125
|
return skills;
|
|
125
126
|
}
|
|
127
|
+
|
|
128
|
+
function normalizeSkillNameKey(name) {
|
|
129
|
+
return String(name || '').trim().toLowerCase();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function getDisabledSkillNameSet(config = null) {
|
|
133
|
+
const cfg = config || loadConfig({ secrets: false });
|
|
134
|
+
const keys = normalizeSkillsConfig(cfg.skills).disabled
|
|
135
|
+
.map((n) => normalizeSkillNameKey(n))
|
|
136
|
+
.filter(Boolean);
|
|
137
|
+
return new Set(keys);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function isSkillDisabled(name, config = null) {
|
|
141
|
+
const n = normalizeSkillNameKey(name);
|
|
142
|
+
if (!n) return false;
|
|
143
|
+
return getDisabledSkillNameSet(config).has(n);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function filterSkillsExcludingDisabled(skills, config = null) {
|
|
147
|
+
const disabled = getDisabledSkillNameSet(config);
|
|
148
|
+
if (!disabled.size) return Array.isArray(skills) ? skills : [];
|
|
149
|
+
return (Array.isArray(skills) ? skills : []).filter((s) => {
|
|
150
|
+
const key = normalizeSkillNameKey(s?.name);
|
|
151
|
+
return key && !disabled.has(key);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function collectPromptSkillsCached(cwd, config = null) {
|
|
156
|
+
return filterSkillsExcludingDisabled(collectSkillsCached(cwd), config);
|
|
157
|
+
}
|
|
126
158
|
// --- Skill cache (mtime-based, keyed by cwd) ---
|
|
127
159
|
const _skillsCache = new Map();
|
|
128
160
|
const _mtimeCache = new Map();
|
|
@@ -267,6 +299,130 @@ export function buildSkillManifest(skills, { limit = 80 } = {}) {
|
|
|
267
299
|
return lines.join('\n');
|
|
268
300
|
}
|
|
269
301
|
|
|
302
|
+
const DEFERRED_TOOLS_BLOCK_RE = /(\n\n---\n*)?<available-deferred-tools>[\s\S]*?<\/available-deferred-tools>\s*/gi;
|
|
303
|
+
const MCP_INSTRUCTIONS_BLOCK_RE = /(\n\n---\n*)?<mcp-instructions>[\s\S]*?<\/mcp-instructions>\s*/gi;
|
|
304
|
+
const DEFERRED_TOOL_NAME_SAFE_RE = /^[A-Za-z0-9_.:-]+$/;
|
|
305
|
+
const MCP_SERVER_NAME_SAFE_RE = /^[A-Za-z0-9_.:-]+$/;
|
|
306
|
+
const MCP_INSTRUCTION_MAX_CHARS = 600;
|
|
307
|
+
|
|
308
|
+
function sanitizeDeferredToolManifestName(name) {
|
|
309
|
+
const text = String(name || '').trim();
|
|
310
|
+
if (!text || text.includes('<') || text.includes('>')) return '';
|
|
311
|
+
if (!DEFERRED_TOOL_NAME_SAFE_RE.test(text)) return '';
|
|
312
|
+
return text;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function bp1HasDeferredToolManifestBlock(text) {
|
|
316
|
+
const raw = String(text || '');
|
|
317
|
+
return /<available-deferred-tools>[\s\S]*?<\/available-deferred-tools>/i.test(raw)
|
|
318
|
+
|| /<mcp-instructions>[\s\S]*?<\/mcp-instructions>/i.test(raw);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Names-only manifest for tools in the deferred pool (catalog minus active wire tools).
|
|
323
|
+
* Empty pool → '' (caller omits the block).
|
|
324
|
+
*/
|
|
325
|
+
export function buildDeferredToolManifest(names) {
|
|
326
|
+
const list = [...new Set((Array.isArray(names) ? names : [])
|
|
327
|
+
.map((name) => sanitizeDeferredToolManifestName(name))
|
|
328
|
+
.filter(Boolean))]
|
|
329
|
+
.sort((a, b) => a.localeCompare(b));
|
|
330
|
+
if (!list.length) return '';
|
|
331
|
+
return ['<available-deferred-tools>', ...list, '</available-deferred-tools>'].join('\n');
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function sanitizeMcpManifestServerName(name) {
|
|
335
|
+
const text = String(name || '').trim();
|
|
336
|
+
if (!text || text.includes('<') || text.includes('>')) return '';
|
|
337
|
+
if (!MCP_SERVER_NAME_SAFE_RE.test(text)) return '';
|
|
338
|
+
return text;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function sanitizeMcpInstructionText(text, max = MCP_INSTRUCTION_MAX_CHARS) {
|
|
342
|
+
const stripped = String(text || '').replace(/[<>]/g, '').trim();
|
|
343
|
+
if (!stripped) return '';
|
|
344
|
+
const cap = Math.max(1, Number(max) || MCP_INSTRUCTION_MAX_CHARS);
|
|
345
|
+
return stripped.length > cap ? `${stripped.slice(0, Math.max(1, cap - 3))}...` : stripped;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Per-server MCP initialize instructions for deferred-pool tools only.
|
|
350
|
+
* Empty when no instructions or no matching deferred MCP tools → omit block.
|
|
351
|
+
*/
|
|
352
|
+
export function buildMcpInstructionsManifest(mcpServerInstructions, poolNames) {
|
|
353
|
+
const map = mcpServerInstructions && typeof mcpServerInstructions === 'object'
|
|
354
|
+
? mcpServerInstructions
|
|
355
|
+
: {};
|
|
356
|
+
const pool = [...new Set((Array.isArray(poolNames) ? poolNames : [])
|
|
357
|
+
.map((name) => sanitizeDeferredToolManifestName(name))
|
|
358
|
+
.filter(Boolean))];
|
|
359
|
+
const toolsByServer = new Map();
|
|
360
|
+
for (const name of pool) {
|
|
361
|
+
const match = name.match(/^mcp__(.+?)__(.+)$/);
|
|
362
|
+
if (!match) continue;
|
|
363
|
+
const server = match[1];
|
|
364
|
+
if (!toolsByServer.has(server)) toolsByServer.set(server, []);
|
|
365
|
+
toolsByServer.get(server).push(name);
|
|
366
|
+
}
|
|
367
|
+
const servers = [...toolsByServer.keys()]
|
|
368
|
+
.filter((server) => sanitizeMcpManifestServerName(server)
|
|
369
|
+
&& sanitizeMcpInstructionText(map[server])
|
|
370
|
+
&& toolsByServer.get(server).length)
|
|
371
|
+
.sort((a, b) => a.localeCompare(b));
|
|
372
|
+
if (!servers.length) return '';
|
|
373
|
+
const lines = ['<mcp-instructions>'];
|
|
374
|
+
for (const server of servers) {
|
|
375
|
+
const safeServer = sanitizeMcpManifestServerName(server);
|
|
376
|
+
const body = sanitizeMcpInstructionText(map[server]);
|
|
377
|
+
const tools = [...toolsByServer.get(server)].sort((a, b) => a.localeCompare(b));
|
|
378
|
+
lines.push(`## ${safeServer}`, body, ...tools.map((tool) => `- ${tool}`));
|
|
379
|
+
}
|
|
380
|
+
lines.push('</mcp-instructions>');
|
|
381
|
+
return lines.join('\n');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function stripDeferredToolManifestBlock(text) {
|
|
385
|
+
return String(text || '')
|
|
386
|
+
.replace(DEFERRED_TOOLS_BLOCK_RE, '')
|
|
387
|
+
.replace(MCP_INSTRUCTIONS_BLOCK_RE, '')
|
|
388
|
+
.replace(/\n\n---\n*$/,'')
|
|
389
|
+
.trimEnd();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Inject names-only deferred pool into BP1 once at session start; never rewrite BP1 after. */
|
|
393
|
+
export function applyInitialDeferredToolManifestToBp1(session, poolNames) {
|
|
394
|
+
if (!session || !Array.isArray(session.messages) || session.deferredToolBp1Applied) return false;
|
|
395
|
+
const pool = Array.isArray(poolNames) ? poolNames : [];
|
|
396
|
+
const parts = [];
|
|
397
|
+
const deferredManifest = buildDeferredToolManifest(pool);
|
|
398
|
+
if (deferredManifest) parts.push(deferredManifest);
|
|
399
|
+
const mcpManifest = buildMcpInstructionsManifest(session.mcpServerInstructions, pool);
|
|
400
|
+
if (mcpManifest) parts.push(mcpManifest);
|
|
401
|
+
const manifest = parts.join('\n\n');
|
|
402
|
+
let idx = -1;
|
|
403
|
+
for (let i = 0; i < session.messages.length; i++) {
|
|
404
|
+
if (session.messages[i]?.role === 'system') {
|
|
405
|
+
idx = i;
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (idx === -1) return false;
|
|
410
|
+
const raw = typeof session.messages[idx].content === 'string' ? session.messages[idx].content : '';
|
|
411
|
+
if (bp1HasDeferredToolManifestBlock(raw)) {
|
|
412
|
+
session.deferredToolBp1Applied = true;
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
if (manifest) {
|
|
416
|
+
const base = stripDeferredToolManifestBlock(raw);
|
|
417
|
+
session.messages[idx].content = base
|
|
418
|
+
? `${base}\n\n---\n\n${manifest}`
|
|
419
|
+
: manifest;
|
|
420
|
+
}
|
|
421
|
+
session.deferredToolBp1Applied = true;
|
|
422
|
+
session.updatedAt = Date.now();
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
|
|
270
426
|
/**
|
|
271
427
|
* Build the fixed skill loader meta-tool.
|
|
272
428
|
* A tiny stable schema keeps provider cache keys steady; concrete skill
|
|
@@ -558,6 +714,10 @@ export function composeSystemPrompt(opts) {
|
|
|
558
714
|
if (!_skip.skills && opts.skillManifest && typeof opts.skillManifest === 'string' && opts.skillManifest.trim()) {
|
|
559
715
|
baseParts.push(opts.skillManifest.trim());
|
|
560
716
|
}
|
|
717
|
+
// deferredToolManifest: optional BP1 slice; production path is applyInitialDeferredToolManifestToBp1 once after applyDeferredToolSurface.
|
|
718
|
+
if (opts.deferredToolManifest && typeof opts.deferredToolManifest === 'string' && opts.deferredToolManifest.trim()) {
|
|
719
|
+
baseParts.push(opts.deferredToolManifest.trim());
|
|
720
|
+
}
|
|
561
721
|
const baseRules = baseParts.join('\n\n---\n\n');
|
|
562
722
|
|
|
563
723
|
// ── BP2: role/system layer ─────────────────────────────────────────
|
|
@@ -147,6 +147,16 @@ export function getMcpServerStatus() {
|
|
|
147
147
|
})(),
|
|
148
148
|
}));
|
|
149
149
|
}
|
|
150
|
+
|
|
151
|
+
/** Snapshot of MCP initialize `instructions` per connected server (handshake time). */
|
|
152
|
+
export function getMcpServerInstructionsMap() {
|
|
153
|
+
const out = {};
|
|
154
|
+
for (const server of servers.values()) {
|
|
155
|
+
const text = typeof server.instructions === 'string' ? server.instructions.trim() : '';
|
|
156
|
+
if (text) out[server.name] = text;
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
150
160
|
/**
|
|
151
161
|
* Execute an MCP tool call.
|
|
152
162
|
* Name format: `mcp__{serverName}__{toolName}`
|
|
@@ -290,6 +300,16 @@ function capMcpOutput(content) {
|
|
|
290
300
|
export function isMcpTool(name) {
|
|
291
301
|
return name.startsWith('mcp__');
|
|
292
302
|
}
|
|
303
|
+
/** True when the prefixed name exists on a connected MCP server. */
|
|
304
|
+
export function isRegisteredMcpTool(name) {
|
|
305
|
+
if (!isMcpTool(name)) return false;
|
|
306
|
+
const match = name.match(/^mcp__(.+?)__(.+)$/);
|
|
307
|
+
if (!match) return false;
|
|
308
|
+
const [, serverName] = match;
|
|
309
|
+
const server = servers.get(serverName);
|
|
310
|
+
if (!server || !Array.isArray(server.tools)) return false;
|
|
311
|
+
return server.tools.some((t) => t?.name === name);
|
|
312
|
+
}
|
|
293
313
|
/**
|
|
294
314
|
* Check whether the inputSchema for an MCP tool declares the given top-level
|
|
295
315
|
* property. Used to decide if the orchestrator should auto-inject context
|
|
@@ -416,6 +436,10 @@ async function connectServer(name, cfg) {
|
|
|
416
436
|
throw new Error(`Invalid config for "${name}": need autoDetect, type (stdio/http/sse/ws), url (http), or command (stdio)`);
|
|
417
437
|
}
|
|
418
438
|
await client.connect(transport);
|
|
439
|
+
const instructionsRaw = typeof client.getInstructions === 'function'
|
|
440
|
+
? client.getInstructions()
|
|
441
|
+
: undefined;
|
|
442
|
+
const instructions = typeof instructionsRaw === 'string' ? instructionsRaw.trim() : '';
|
|
419
443
|
const toolsResult = await client.listTools();
|
|
420
444
|
if (!toolsResult || !Array.isArray(toolsResult.tools)) {
|
|
421
445
|
throw new Error(`[mcp-client] ListTools returned invalid shape for "${name}": missing or non-array tools field`);
|
|
@@ -427,7 +451,7 @@ async function connectServer(name, cfg) {
|
|
|
427
451
|
...(t.annotations && typeof t.annotations === 'object' ? { annotations: t.annotations } : {}),
|
|
428
452
|
}));
|
|
429
453
|
const toolNames = tools.map(t => t.name);
|
|
430
|
-
servers.set(name, { name, client, transport, tools, cfg });
|
|
454
|
+
servers.set(name, { name, client, transport, tools, cfg, instructions });
|
|
431
455
|
_invalidateMcpToolFieldMemo();
|
|
432
456
|
mcpLog(`[mcp] connected: ${tools.length} tools — ${toolNames.join(', ')}\n`);
|
|
433
457
|
}
|
|
@@ -425,6 +425,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
|
|
|
425
425
|
try {
|
|
426
426
|
armFirstByteTimer();
|
|
427
427
|
resetIdleTimer();
|
|
428
|
+
var collectedChunks = [];
|
|
428
429
|
while (true) {
|
|
429
430
|
if (idleTimedOut) {
|
|
430
431
|
throw geminiTimeoutError(`${label} SSE idle`, PROVIDER_SSE_IDLE_TIMEOUT_MS);
|
|
@@ -463,6 +464,7 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
|
|
|
463
464
|
clearFirstByteTimer();
|
|
464
465
|
}
|
|
465
466
|
resetIdleTimer();
|
|
467
|
+
if (step.value) collectedChunks.push(step.value);
|
|
466
468
|
try { onStreamDelta?.(); } catch {}
|
|
467
469
|
if (onTextDelta || textLeakGuard) {
|
|
468
470
|
const t = geminiChunkText(step.value);
|
|
@@ -488,17 +490,30 @@ export async function consumeGeminiSdkStream(streamResult, { signal, onStreamDel
|
|
|
488
490
|
try { textLeakGuard?.finalize(); } catch {}
|
|
489
491
|
}
|
|
490
492
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
493
|
+
// Aggregate the raw wire chunks locally instead of awaiting the SDK's
|
|
494
|
+
// streamResult.response: @google/generative-ai 0.24.1 aggregateResponses()
|
|
495
|
+
// predates Gemini 3 thinking and silently drops part.thoughtSignature.
|
|
496
|
+
// Losing the signature breaks the mandatory echo-back on the next turn
|
|
497
|
+
// (400 "Function call is missing a thought_signature in functionCall
|
|
498
|
+
// parts"). aggregateGeminiStreamChunks() preserves it (see part copy
|
|
499
|
+
// above). Fall back to the SDK aggregate only if we somehow collected no
|
|
500
|
+
// usable chunks.
|
|
501
|
+
let raw;
|
|
502
|
+
if (collectedChunks.length > 0) {
|
|
503
|
+
raw = aggregateGeminiStreamChunks(collectedChunks);
|
|
504
|
+
} else {
|
|
505
|
+
let response;
|
|
506
|
+
try {
|
|
507
|
+
response = await streamResult.response;
|
|
508
|
+
} catch (err) {
|
|
509
|
+
if (signal?.aborted) {
|
|
510
|
+
const reason = signal.reason;
|
|
511
|
+
throw reason instanceof Error ? reason : new Error(`${label} aborted`);
|
|
512
|
+
}
|
|
513
|
+
throw err;
|
|
498
514
|
}
|
|
499
|
-
|
|
515
|
+
raw = response?.candidates ? response : (response?.response || response);
|
|
500
516
|
}
|
|
501
|
-
const raw = response?.candidates ? response : (response?.response || response);
|
|
502
517
|
const finishReason = raw?.candidates?.[0]?.finishReason || null;
|
|
503
518
|
assertGeminiStreamCompleted({ sawStreamChunk, finishReason, label });
|
|
504
519
|
return raw;
|
|
@@ -174,12 +174,29 @@ export class GeminiProvider {
|
|
|
174
174
|
// a few iterations.
|
|
175
175
|
async _ensureGeminiCache({ apiKey, model, systemInstruction, geminiTools, contents, opts }) {
|
|
176
176
|
if (Array.isArray(opts?.nativeTools) && opts.nativeTools.length) return null;
|
|
177
|
+
// Kill-switch: MIXDOG_GEMINI_EXPLICIT_CACHE=0 skips cachedContents
|
|
178
|
+
// entirely and relies on Gemini's implicit prefix caching (2.5+/3.x
|
|
179
|
+
// default, same 90% discount, no storage fee). A/B probe knob.
|
|
180
|
+
const explicitMode = String(process.env.MIXDOG_GEMINI_EXPLICIT_CACHE || '').trim().toLowerCase();
|
|
181
|
+
if (['0', 'false', 'off', 'no'].includes(explicitMode)) return null;
|
|
177
182
|
const state = opts.providerState?.gemini || null;
|
|
178
183
|
const now = Date.now();
|
|
179
184
|
const currentIter = Number.isFinite(Number(opts.iteration)) ? Number(opts.iteration) : 1;
|
|
180
185
|
const refreshEveryN = Number(process.env.MIXDOG_GEMINI_CACHE_REFRESH_EVERY) > 0
|
|
181
186
|
? Number(process.env.MIXDOG_GEMINI_CACHE_REFRESH_EVERY)
|
|
182
187
|
: 4;
|
|
188
|
+
// Cache TTL (storage is billed per token-hour, so shorter is cheaper).
|
|
189
|
+
// Default 5m: agent tool loops re-request within seconds, and the
|
|
190
|
+
// refresh-every-4-iterations rebuild re-arms the TTL well before
|
|
191
|
+
// expiry. Long-idle sessions just pay one cold rebuild on resume.
|
|
192
|
+
const ttlSeconds = Number(process.env.MIXDOG_GEMINI_CACHE_TTL_SECONDS) > 0
|
|
193
|
+
? Number(process.env.MIXDOG_GEMINI_CACHE_TTL_SECONDS)
|
|
194
|
+
: 300;
|
|
195
|
+
// Reuse guard: require some remaining TTL headroom so we never attach
|
|
196
|
+
// a cache that expires mid-request. Scale with TTL (25%, clamped to
|
|
197
|
+
// 10s..6m) — the old fixed 6-minute floor silently disabled reuse for
|
|
198
|
+
// any TTL <= 6m, forcing a full-price rebuild every turn.
|
|
199
|
+
const reuseHeadroomMs = Math.min(6 * 60 * 1000, Math.max(10 * 1000, ttlSeconds * 250));
|
|
183
200
|
const cacheLiveMs = state?.cacheExpiresAt ? state.cacheExpiresAt - now : 0;
|
|
184
201
|
const itersSinceCreate = state?.cacheCreatedAtIter != null
|
|
185
202
|
? currentIter - state.cacheCreatedAtIter
|
|
@@ -203,7 +220,7 @@ export class GeminiProvider {
|
|
|
203
220
|
&& !!state?.cachePrefixHash
|
|
204
221
|
&& state.cachePrefixHash === currentStatePrefixHash;
|
|
205
222
|
const canAttachState = !!state?.cacheName && cacheLiveMs > 0 && modelMatches && prefixMatches;
|
|
206
|
-
const canReuseState = canAttachState && cacheLiveMs >
|
|
223
|
+
const canReuseState = canAttachState && cacheLiveMs > reuseHeadroomMs && itersSinceCreate < refreshEveryN;
|
|
207
224
|
try {
|
|
208
225
|
appendAgentTrace({
|
|
209
226
|
sessionId: opts.sessionId || opts.session?.id || null,
|
|
@@ -253,7 +270,6 @@ export class GeminiProvider {
|
|
|
253
270
|
} catch {}
|
|
254
271
|
return canAttachState ? state.cacheName : null;
|
|
255
272
|
}
|
|
256
|
-
const ttlSeconds = 3600;
|
|
257
273
|
const cachePrefixContentCount = _geminiCachePrefixCount(contents);
|
|
258
274
|
const cachePrefixHash = _geminiCachePrefixHash({
|
|
259
275
|
model,
|