mixdog 0.9.9 → 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/bench/lead-review-tasks-r3.json +1 -1
- package/scripts/bench/r4-mixed-tasks.json +1 -1
- package/scripts/bench/review-tasks.json +1 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- 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/rules/agent/30-explorer.md +18 -20
- package/src/rules/lead/02-channels.md +3 -3
- 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/providers/openai-oauth-ws.mjs +9 -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/explore-tool.mjs +2 -2
- 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
|
@@ -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,
|
|
@@ -66,11 +66,30 @@ export function xaiResponsesCacheRouting(opts, params, rawTools, model) {
|
|
|
66
66
|
const providerKey = resolveProviderCacheKey(opts, 'xai');
|
|
67
67
|
const prefixSeed = xaiPrefixSeed({ opts, params, rawTools, model });
|
|
68
68
|
const prefixHash = traceHash(prefixSeed);
|
|
69
|
+
// Optional lane sharding (OpenAI prompt-caching guidance: keep each
|
|
70
|
+
// prefix+prompt_cache_key combo under ~15 RPM). When lane shards are
|
|
71
|
+
// enabled (MIXDOG_XAI_RESPONSES_CACHE_LANE_SHARDS=N or lane auto), mix the
|
|
72
|
+
// assigned slot into the routing seed so sessions spread across N server
|
|
73
|
+
// cache keys instead of overflowing one. Slot is derived from a hash of
|
|
74
|
+
// the session id (NOT in-process round-robin: headless workers run one
|
|
75
|
+
// session per process, where round-robin degenerates to slot 0 for
|
|
76
|
+
// everyone). Default stays a single shared key (lane disabled) —
|
|
77
|
+
// identical seed/key to before.
|
|
78
|
+
const lane = xaiResponsesPromptCacheLane(opts, null, { prefixHash, ownerSessionHash: sessionId ? traceHash(sessionId) : null });
|
|
79
|
+
const laneEnabled = lane?.enabled === true;
|
|
80
|
+
const laneShards = Number.isFinite(Number(lane?.shards)) && Number(lane.shards) > 0 ? Number(lane.shards) : 0;
|
|
81
|
+
const explicitSlot = Number(opts?.promptCacheLaneSlot ?? opts?.xaiCacheLaneSlot);
|
|
82
|
+
const laneSlot = Number.isFinite(explicitSlot) && explicitSlot >= 0
|
|
83
|
+
? (laneShards > 0 ? Math.floor(explicitSlot) % laneShards : Math.floor(explicitSlot))
|
|
84
|
+
: (laneShards > 0
|
|
85
|
+
? createHash('sha256').update(sessionId || String(process.pid)).digest().readUInt32BE(0) % laneShards
|
|
86
|
+
: Number.isFinite(Number(lane?.slot)) ? Number(lane.slot) : 0);
|
|
69
87
|
const routingSeed = stableTraceStringify({
|
|
70
88
|
scope: 'xai-responses-prefix-v1',
|
|
71
89
|
providerKey: String(providerKey),
|
|
72
90
|
model: model || null,
|
|
73
91
|
prefixHash,
|
|
92
|
+
...(laneEnabled ? { laneSlot } : {}),
|
|
74
93
|
});
|
|
75
94
|
return {
|
|
76
95
|
key: deterministicUuidFromKey(routingSeed),
|
|
@@ -78,6 +97,10 @@ export function xaiResponsesCacheRouting(opts, params, rawTools, model) {
|
|
|
78
97
|
seedHash: traceHash(routingSeed),
|
|
79
98
|
prefixHash,
|
|
80
99
|
ownerSessionHash: sessionId ? traceHash(sessionId) : null,
|
|
100
|
+
...(laneEnabled ? {
|
|
101
|
+
laneIndex: laneSlot,
|
|
102
|
+
activeLanes: Number.isFinite(Number(lane?.shards)) && Number(lane.shards) > 0 ? Number(lane.shards) : null,
|
|
103
|
+
} : {}),
|
|
81
104
|
};
|
|
82
105
|
}
|
|
83
106
|
|
|
@@ -254,6 +254,15 @@ function _codexMetadataBase(entry, { poolKey, cacheKey, sendOpts } = {}) {
|
|
|
254
254
|
turn_id: turnId,
|
|
255
255
|
window_id: windowId,
|
|
256
256
|
request_kind: requestKind,
|
|
257
|
+
// Richer codex turn-metadata fields (responses_metadata.rs:264-280:
|
|
258
|
+
// thread_source "user" per protocol.rs:2751-2765, sandbox label).
|
|
259
|
+
// A/B 2026-07-04 (rvA/rvB interleaved, 24 sessions/arm): no effect
|
|
260
|
+
// (it2 full 3 vs 2, miss 2 vs 3 — noise). Default OFF; keep the knob
|
|
261
|
+
// for future probes if the backend starts gating on payload richness.
|
|
262
|
+
...(process.env.MIXDOG_OAI_TURN_METADATA_RICH === '1' ? {
|
|
263
|
+
thread_source: 'user',
|
|
264
|
+
sandbox: 'read-only',
|
|
265
|
+
} : {}),
|
|
257
266
|
turn_started_at_unix_ms: startedAt,
|
|
258
267
|
};
|
|
259
268
|
const metadata = {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Deferred catalog call-through: inactive catalog tool_use → discovery bookkeeping
|
|
2
|
+
// then normal executeTool routing (runtime errors only; no pre-dispatch schema).
|
|
3
|
+
import { clean } from '../../../../../session-runtime/session-text.mjs';
|
|
4
|
+
import { isReadonlySelectable, selectDeferredTools } from '../../../../../session-runtime/tool-catalog.mjs';
|
|
5
|
+
|
|
6
|
+
/** Skill-list plumbing only; mutation/MCP/builtins must use the catalog+mode gate. */
|
|
7
|
+
const INACTIVE_INFRA_BYPASS = new Set(['skills_list', 'skill_view']);
|
|
8
|
+
|
|
9
|
+
function isActiveSessionTool(session, name) {
|
|
10
|
+
const key = clean(name);
|
|
11
|
+
if (!key || !Array.isArray(session?.tools)) return false;
|
|
12
|
+
return session.tools.some((tool) => clean(tool?.name) === key);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function lookupDeferredCatalogTool(session, name) {
|
|
16
|
+
const catalog = Array.isArray(session?.deferredToolCatalog) ? session.deferredToolCatalog : [];
|
|
17
|
+
const key = clean(name);
|
|
18
|
+
if (!key) return null;
|
|
19
|
+
for (const tool of catalog) {
|
|
20
|
+
const n = clean(tool?.name);
|
|
21
|
+
if (n === key || n.toLowerCase() === key.toLowerCase()) return tool;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isOnDeferredToolSurface(session, name) {
|
|
27
|
+
if (!session) return false;
|
|
28
|
+
return isActiveSessionTool(session, name) || lookupDeferredCatalogTool(session, name) !== null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function toolExecutesWhenInactive(name) {
|
|
32
|
+
return INACTIVE_INFRA_BYPASS.has(name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveDeferredSelectMode(session) {
|
|
36
|
+
const spec = session?.toolSpec;
|
|
37
|
+
if (spec == null || spec === '') return null;
|
|
38
|
+
if (spec === 'readonly') return 'readonly';
|
|
39
|
+
if (spec === 'full' || spec === 'mcp') return 'full';
|
|
40
|
+
if (Array.isArray(spec)) {
|
|
41
|
+
if (!spec.length) return null;
|
|
42
|
+
if (spec.includes('full')) return 'full';
|
|
43
|
+
if (spec.includes('tools:readonly')) return 'readonly';
|
|
44
|
+
return 'full';
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function denyDeferredCallThrough(message) {
|
|
50
|
+
return { deny: message };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Inactive deferred catalog hits: readonly/mode gate + promoteToActive, or deny.
|
|
55
|
+
* Returns null when not applicable (not in catalog, already active, infra allowlist).
|
|
56
|
+
*/
|
|
57
|
+
export function prepareDeferredToolCallThrough(sessionRef, name, _args) {
|
|
58
|
+
if (!sessionRef) return null;
|
|
59
|
+
const tool = lookupDeferredCatalogTool(sessionRef, name);
|
|
60
|
+
if (!tool) return null;
|
|
61
|
+
if (isActiveSessionTool(sessionRef, name)) return null;
|
|
62
|
+
if (toolExecutesWhenInactive(name)) return null;
|
|
63
|
+
|
|
64
|
+
const toolLabel = clean(tool.name) || clean(name) || 'tool';
|
|
65
|
+
const resolvedMode = resolveDeferredSelectMode(sessionRef);
|
|
66
|
+
if (resolvedMode === null) {
|
|
67
|
+
if (!isReadonlySelectable(tool)) {
|
|
68
|
+
return denyDeferredCallThrough(
|
|
69
|
+
`Error: tool "${toolLabel}" is deferred and cannot be auto-loaded without a resolved tool mode; use tool_search or load it explicitly.`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
} else if (resolvedMode === 'readonly' && !isReadonlySelectable(tool)) {
|
|
73
|
+
return denyDeferredCallThrough(`Error: tool "${toolLabel}" is not available in readonly mode`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const selectMode = resolvedMode === null ? 'readonly' : resolvedMode;
|
|
77
|
+
selectDeferredTools(sessionRef, [toolLabel], selectMode, { promoteToActive: true });
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// shell/apply_patch/builtin/external-adapter) through before/after hooks and the
|
|
4
4
|
// scoped-cache outcome bookkeeping. No behavior change: bodies are verbatim from
|
|
5
5
|
// loop.mjs, re-exported via the facade so existing importers keep working.
|
|
6
|
-
import { executeMcpTool, isMcpTool, mcpToolHasField } from '../../mcp/client.mjs';
|
|
6
|
+
import { executeMcpTool, isMcpTool, isRegisteredMcpTool, mcpToolHasField } from '../../mcp/client.mjs';
|
|
7
7
|
import { executeBuiltinTool, formatUnknownBuiltinToolMessage, isBuiltinTool, isExternalAdapterTool } from '../../tools/builtin.mjs';
|
|
8
8
|
import { executeBashSessionTool } from '../../tools/bash-session.mjs';
|
|
9
9
|
import { executePatchTool } from '../../tools/patch.mjs';
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
buildAgentBashSessionArgs,
|
|
22
22
|
resolvePreToolAskApproval,
|
|
23
23
|
} from './tool-helpers.mjs';
|
|
24
|
+
import { isOnDeferredToolSurface, prepareDeferredToolCallThrough } from './deferred-call-through.mjs';
|
|
24
25
|
|
|
25
26
|
let codeGraphRuntimePromise = null;
|
|
26
27
|
async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options = {}) {
|
|
@@ -125,6 +126,8 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
|
|
|
125
126
|
const afterToolHook = typeof executeOpts.afterToolHook === 'function'
|
|
126
127
|
? executeOpts.afterToolHook
|
|
127
128
|
: sessionRef?.afterToolHook;
|
|
129
|
+
const deferredPrep = prepareDeferredToolCallThrough(sessionRef, name, args);
|
|
130
|
+
if (deferredPrep?.deny) return deferredPrep.deny;
|
|
128
131
|
const __result = await (async () => {
|
|
129
132
|
if (name === 'Skill') {
|
|
130
133
|
return viewSkill(cwd, args?.name);
|
|
@@ -136,6 +139,9 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
|
|
|
136
139
|
return viewSkill(cwd, args?.name);
|
|
137
140
|
}
|
|
138
141
|
if (isMcpTool(name)) {
|
|
142
|
+
if (!isOnDeferredToolSurface(sessionRef, name) && !isRegisteredMcpTool(name)) {
|
|
143
|
+
return formatUnknownBuiltinToolMessage(name, args, 'tool');
|
|
144
|
+
}
|
|
139
145
|
// 24h trace data shows ~24% of external MCP calls are cwd-sensitive
|
|
140
146
|
// (bash / grep / read / list / glob etc.) but the worker session's
|
|
141
147
|
// cwd was previously dropped here. Inject cwd only when the tool's
|
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
import { isMcpTool } from '../../mcp/client.mjs';
|
|
5
5
|
import { isBuiltinTool } from '../../tools/builtin.mjs';
|
|
6
6
|
import { isInternalTool } from '../../internal-tools.mjs';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
collectSkillsCached,
|
|
9
|
+
loadSkillResource,
|
|
10
|
+
buildSkillToolEnvelope,
|
|
11
|
+
filterSkillsExcludingDisabled,
|
|
12
|
+
isSkillDisabled,
|
|
13
|
+
} from '../../context/collect.mjs';
|
|
8
14
|
import { isAgentOwner } from '../../agent-owner.mjs';
|
|
9
15
|
|
|
10
16
|
// Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
|
|
@@ -37,12 +43,13 @@ export function getToolKind(name) {
|
|
|
37
43
|
return 'builtin';
|
|
38
44
|
}
|
|
39
45
|
export function buildSkillsListResponse(cwd) {
|
|
40
|
-
const skills = collectSkillsCached(cwd);
|
|
46
|
+
const skills = filterSkillsExcludingDisabled(collectSkillsCached(cwd));
|
|
41
47
|
const entries = skills.map(s => ({ name: s.name, description: s.description || '' }));
|
|
42
48
|
return JSON.stringify({ skills: entries });
|
|
43
49
|
}
|
|
44
50
|
export function viewSkill(cwd, name) {
|
|
45
51
|
if (!name) return 'Error: skill name is required';
|
|
52
|
+
if (isSkillDisabled(name)) return `Error: skill "${name}" is disabled`;
|
|
46
53
|
const res = loadSkillResource(name, cwd);
|
|
47
54
|
if (!res) return `Error: skill "${name}" not found`;
|
|
48
55
|
// Return the general tool envelope: the model-visible tool_result is the
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from './compact.mjs';
|
|
14
14
|
import { estimateMessagesTokens, estimateTranscriptContextUsage } from './context-utils.mjs';
|
|
15
15
|
import { executeInternalTool } from '../internal-tools.mjs';
|
|
16
|
-
import {
|
|
16
|
+
import { collectPromptSkillsCached, buildSkillManifest, composeSystemPrompt } from '../context/collect.mjs';
|
|
17
17
|
import { saveSession, saveSessionAsync, loadSession, listStoredSessionSummaries, sweepStaleSessions, markSessionClosed, bumpSessionGeneration, setLiveSession } from './store.mjs';
|
|
18
18
|
import { clearReadDedupSession, tryPrefetchCached, setPrefetchCached } from './read-dedup.mjs';
|
|
19
19
|
import { clearOffloadSession } from './tool-result-offload.mjs';
|
|
@@ -636,7 +636,7 @@ export function createSession(opts) {
|
|
|
636
636
|
// maintenance roles are deliberately narrowed away from the Skill tool.
|
|
637
637
|
// Do not leak a Skill manifest into those hidden prompts when no Skill()
|
|
638
638
|
// loader is available.
|
|
639
|
-
const skills = (opts.skipSkills || hiddenAgent) ? [] :
|
|
639
|
+
const skills = (opts.skipSkills || hiddenAgent) ? [] : collectPromptSkillsCached(opts.cwd);
|
|
640
640
|
|
|
641
641
|
// BP1 is shared tool policy (+ compact skill manifest in compose). BP2 is
|
|
642
642
|
// role/system rules. User-defined schedules/webhooks ride as normal user
|
|
@@ -1785,7 +1785,7 @@ export async function resumeSession(sessionId, preset) {
|
|
|
1785
1785
|
// createSession so resume and spawn produce identical BP_1 shapes.
|
|
1786
1786
|
const oldTools = session.tools || [];
|
|
1787
1787
|
const ownerIsAgent = isAgentOwner(session);
|
|
1788
|
-
const skills = ownerIsAgent ? [] :
|
|
1788
|
+
const skills = ownerIsAgent ? [] : collectPromptSkillsCached(session.cwd);
|
|
1789
1789
|
let toolSpec = ownerIsAgent ? 'full' : (preset || session.preset || 'full');
|
|
1790
1790
|
if (session.profileId && _agentRuntimeApi?.getProfile) {
|
|
1791
1791
|
try {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// callers may pass either spelling (e.g. `glob` alias for grep, or
|
|
10
10
|
// `file_path` alias for read.path).
|
|
11
11
|
|
|
12
|
-
import { coerceShapeFlex, hasGlobMagic } from './path-utils.mjs';
|
|
12
|
+
import { coerceReadFamilyPathArg, coerceShapeFlex, hasGlobMagic } from './path-utils.mjs';
|
|
13
13
|
|
|
14
14
|
const MAX_INT = 100000;
|
|
15
15
|
// Explicit grep context should be large enough to frame a function/block without
|
|
@@ -368,11 +368,11 @@ function guardRead(a) {
|
|
|
368
368
|
return 'Error: read requires "path" (or alias file_path).';
|
|
369
369
|
}
|
|
370
370
|
// Some providers/models send a batched path array as a JSON string despite
|
|
371
|
-
// the schema. The executor
|
|
372
|
-
//
|
|
373
|
-
//
|
|
371
|
+
// the schema (or path:"[]" meaning cwd). The executor coerces via
|
|
372
|
+
// coerceReadFamilyPathArg(); mirror that here so validation does not reject
|
|
373
|
+
// shapes the executor would absorb.
|
|
374
374
|
if (hasOwn(a, 'path')) {
|
|
375
|
-
a.path =
|
|
375
|
+
a.path = coerceReadFamilyPathArg(a.path);
|
|
376
376
|
}
|
|
377
377
|
// path can be string | string[] | object[]; file_path is string
|
|
378
378
|
if (hasOwn(a, 'path')) {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// adapter expects — the caller (builtin.mjs default: case) falls back to
|
|
13
13
|
// the existing EXTERNAL_TOOL_REDIRECTS guidance message in that case.
|
|
14
14
|
import { readFileSync, mkdirSync, existsSync, lstatSync, realpathSync, statSync } from 'node:fs';
|
|
15
|
-
import { dirname
|
|
15
|
+
import { dirname } from 'node:path';
|
|
16
16
|
import { atomicWrite } from './atomic-write.mjs';
|
|
17
17
|
import { assertPathsReachable } from './fs-reachability.mjs';
|
|
18
18
|
import { normalizeInputPath, resolveAgainstCwd, normalizeOutputPath } from './path-utils.mjs';
|
|
@@ -102,25 +102,6 @@ function guardRealTarget(fullPath) {
|
|
|
102
102
|
return null;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
// Write containment — mirrors apply_patch's realBase check: the REAL resolved
|
|
106
|
-
// target (symlinks flattened, create-mode suffix re-attached lexically) must
|
|
107
|
-
// stay inside the REAL workDir. A lexically-inside path whose ancestor
|
|
108
|
-
// symlink/junction lands outside the project must not be writable through
|
|
109
|
-
// this adapter surface (apply_patch refuses the same shape).
|
|
110
|
-
function guardBaseContainment(fullPath, workDir) {
|
|
111
|
-
let realBase;
|
|
112
|
-
try { realBase = realpathSync(workDir); } catch { return null; }
|
|
113
|
-
const nearest = realpathNearestExisting(fullPath);
|
|
114
|
-
if (!nearest) return null;
|
|
115
|
-
const realResolved = nearest.real + fullPath.slice(nearest.probe.length);
|
|
116
|
-
const fold = process.platform === 'win32' ? (s) => s.toLowerCase() : (s) => s;
|
|
117
|
-
const baseWithSep = realBase.endsWith(sep) ? realBase : realBase + sep;
|
|
118
|
-
if (fold(realResolved) !== fold(realBase) && !fold(realResolved).startsWith(fold(baseWithSep))) {
|
|
119
|
-
return `Error: cannot write outside the working directory: ${normalizeOutputPath(realResolved)} escapes ${normalizeOutputPath(realBase)}`;
|
|
120
|
-
}
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
105
|
async function resolveTargetPath(args, workDir) {
|
|
125
106
|
const raw = args?.path ?? args?.file_path;
|
|
126
107
|
if (typeof raw !== 'string' || raw.length === 0) return null;
|
|
@@ -138,8 +119,6 @@ async function resolveTargetPath(args, workDir) {
|
|
|
138
119
|
catch (e) { return { error: `Error: ${e?.message || e}` }; }
|
|
139
120
|
const realGuardErr = guardRealTarget(full);
|
|
140
121
|
if (realGuardErr) return { error: realGuardErr };
|
|
141
|
-
const containErr = guardBaseContainment(full, workDir);
|
|
142
|
-
if (containErr) return { error: containErr };
|
|
143
122
|
return { full };
|
|
144
123
|
}
|
|
145
124
|
|