mixdog 0.9.49 → 0.9.51

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.
Files changed (39) hide show
  1. package/README.md +6 -6
  2. package/package.json +7 -7
  3. package/scripts/embedding-worker-exit-test.mjs +76 -0
  4. package/scripts/hook-bus-test.mjs +23 -0
  5. package/scripts/internal-tools-normalization-test.mjs +10 -0
  6. package/scripts/openai-oauth-ws-1006-retry-test.mjs +67 -1
  7. package/scripts/provider-toolcall-test.mjs +200 -2
  8. package/scripts/session-bench-cache-break-test.mjs +102 -0
  9. package/scripts/session-bench.mjs +101 -42
  10. package/scripts/shell-failure-diagnostics-test.mjs +73 -4
  11. package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
  12. package/scripts/smoke-loop-failure-summary.mjs +16 -0
  13. package/scripts/smoke-loop.mjs +4 -3
  14. package/scripts/smoke.mjs +5 -106
  15. package/scripts/tool-failures.mjs +15 -1
  16. package/scripts/tui-transcript-perf-test.mjs +38 -0
  17. package/scripts/verify-release-assets-test.mjs +15 -381
  18. package/scripts/web-fetch-routing-test.mjs +158 -0
  19. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
  20. package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +14 -4
  22. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
  23. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +75 -3
  24. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -0
  25. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
  26. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
  27. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
  28. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +4 -0
  29. package/src/runtime/memory/lib/embedding-provider.mjs +3 -2
  30. package/src/runtime/search/index.mjs +41 -0
  31. package/src/runtime/search/lib/http-fetch.mjs +154 -0
  32. package/src/runtime/search/tool-defs.mjs +23 -0
  33. package/src/session-runtime/runtime-core.mjs +37 -15
  34. package/src/standalone/hook-bus/handlers.mjs +4 -0
  35. package/src/standalone/hook-bus/rules.mjs +7 -1
  36. package/src/standalone/hook-bus.mjs +10 -2
  37. package/src/tui/App.jsx +17 -11
  38. package/src/tui/app/live-spinner-visibility.mjs +20 -0
  39. package/src/tui/dist/index.mjs +28 -3
@@ -656,6 +656,7 @@ export async function sendViaWebSocket({
656
656
  _sendFrameFn = _sendFrame,
657
657
  _sleepFn = _defaultSleep,
658
658
  _sendSpanTraceFn = appendAgentTrace,
659
+ _agentTraceFn = appendAgentTrace,
659
660
  }) {
660
661
  // Bounded mid-stream retry: if an attempt's stream dies after
661
662
  // response.created but before response.completed from a transient cause
@@ -885,6 +886,7 @@ export async function sendViaWebSocket({
885
886
  let deltaReason = null;
886
887
  let strippedResponseItems = 0;
887
888
  let skippedResponseItems = 0;
889
+ let responseOutputMismatch = null;
888
890
  let wireFrameHadTurnState = false;
889
891
  let wireFrameMetadataTrace = _metadataTrace(null);
890
892
  let framePrefixHash = null;
@@ -976,7 +978,7 @@ export async function sendViaWebSocket({
976
978
  output_tokens: warmupResult.usage?.outputTokens || 0,
977
979
  prompt_tokens: warmupResult.usage?.promptTokens || 0,
978
980
  };
979
- appendAgentTrace({
981
+ _agentTraceFn({
980
982
  sessionId: poolKey,
981
983
  iteration,
982
984
  kind: 'cache_warmup',
@@ -1020,6 +1022,7 @@ export async function sendViaWebSocket({
1020
1022
  deltaReason = delta.reason || null;
1021
1023
  strippedResponseItems = delta.strippedResponseItems || 0;
1022
1024
  skippedResponseItems = delta.skippedResponseItems || 0;
1025
+ responseOutputMismatch = delta.responseOutputMismatch || null;
1023
1026
  const wireFrame = _withCodexWsClientMetadata(frame, entry, useCodexWsClientMetadata, codexMetadataContext);
1024
1027
  wireFrameHadTurnState = !!wireFrame?.client_metadata?.['x-codex-turn-state'];
1025
1028
  wireFrameMetadataTrace = _metadataTrace(wireFrame?.client_metadata);
@@ -1292,7 +1295,7 @@ export async function sendViaWebSocket({
1292
1295
  : null;
1293
1296
  if (cacheObservation.actualMiss) {
1294
1297
  try {
1295
- appendAgentTrace({
1298
+ _agentTraceFn({
1296
1299
  sessionId: poolKey,
1297
1300
  iteration,
1298
1301
  kind: 'cache_miss',
@@ -1389,6 +1392,7 @@ export async function sendViaWebSocket({
1389
1392
  chain_delta_reason: mode === 'delta' ? null : deltaReason,
1390
1393
  chain_stripped_response_items: strippedResponseItems,
1391
1394
  chain_skipped_response_items: skippedResponseItems,
1395
+ ...(responseOutputMismatch || {}),
1392
1396
  chain_response_items: Array.isArray(result.responseItems) ? result.responseItems.length : 0,
1393
1397
  body_input_items: Array.isArray(requestBody.input) ? requestBody.input.length : null,
1394
1398
  frame_input_items: Array.isArray(frame.input) ? frame.input.length : null,
@@ -1400,7 +1404,7 @@ export async function sendViaWebSocket({
1400
1404
  keep_socket: keepSocket,
1401
1405
  keep_response_chain: keepResponseChain,
1402
1406
  };
1403
- appendAgentTrace({
1407
+ _agentTraceFn({
1404
1408
  sessionId: poolKey,
1405
1409
  iteration,
1406
1410
  kind: 'transport',
@@ -1411,7 +1415,10 @@ export async function sendViaWebSocket({
1411
1415
  && deltaReason
1412
1416
  && !['no_anchor', 'full_forced', 'full_default', 'delta_missing_turn_state'].includes(deltaReason);
1413
1417
  if (chainFallback || (mode === 'delta' && deltaReason)) {
1414
- appendAgentTrace({
1418
+ const intentionalTransition = typeof sendOpts?.cacheBreakIntent === 'string'
1419
+ ? sendOpts.cacheBreakIntent
1420
+ : null;
1421
+ _agentTraceFn({
1415
1422
  sessionId: poolKey,
1416
1423
  iteration,
1417
1424
  kind: 'cache_break',
@@ -1423,6 +1430,8 @@ export async function sendViaWebSocket({
1423
1430
  transport: 'websocket',
1424
1431
  ws_mode: mode,
1425
1432
  reason: mode === 'delta' ? deltaReason : (deltaReason || 'full_frame'),
1433
+ intentional_transition: intentionalTransition,
1434
+ request_tool_choice: requestBody.tool_choice ?? null,
1426
1435
  cache_key_hash: transportCacheKeyHash,
1427
1436
  cached_tokens: cacheObservation.cachedTokens,
1428
1437
  prompt_tokens: cacheObservation.promptTokens,
@@ -1432,6 +1441,7 @@ export async function sendViaWebSocket({
1432
1441
  request_has_previous_response_id: transportPayload.request_has_previous_response_id,
1433
1442
  chain_stripped_response_items: strippedResponseItems,
1434
1443
  chain_skipped_response_items: skippedResponseItems,
1444
+ ...(responseOutputMismatch || {}),
1435
1445
  chain_response_items: Array.isArray(result.responseItems) ? result.responseItems.length : 0,
1436
1446
  body_input_items: Array.isArray(requestBody.input) ? requestBody.input.length : null,
1437
1447
  frame_input_items: Array.isArray(frame.input) ? frame.input.length : null,
@@ -15,6 +15,7 @@ import {
15
15
  RESPONSES_TRANSPORT_CAPABILITIES,
16
16
  FULL_RESPONSES_TRANSPORT_CAPS,
17
17
  } from './openai-transport-policy.mjs';
18
+ import { createHash } from 'node:crypto';
18
19
 
19
20
  // If the cached request (sans input) matches the current one and the current
20
21
  // input starts with the cached input, return only the tail. Otherwise return
@@ -157,6 +158,67 @@ function _isReplayLikeHead(item, responseItem) {
157
158
  return inputType === responseType;
158
159
  }
159
160
 
161
+ // The hash input follows the same per-item normalizations used by
162
+ // _logicalResponseItemMatch, but is never emitted itself. This retains a
163
+ // stable comparison fingerprint after history compaction without putting
164
+ // message content, tool arguments, or custom-tool input into traces.
165
+ function _normalizedResponseItemDiagnosticShape(item) {
166
+ if (!item || typeof item !== 'object') return item || null;
167
+ const type = item.type || (item.role === 'assistant' ? 'message' : '');
168
+ if (type === 'function_call' || type === 'tool_search_call') {
169
+ return {
170
+ type,
171
+ call_id: String(item.call_id || ''),
172
+ name: type === 'function_call' ? String(item.name || '') : undefined,
173
+ arguments: _normalizeArguments(item.arguments),
174
+ };
175
+ }
176
+ if (type === 'custom_tool_call') {
177
+ return {
178
+ type,
179
+ call_id: String(item.call_id || ''),
180
+ name: String(item.name || ''),
181
+ input: String(item.input || ''),
182
+ };
183
+ }
184
+ if (type === 'message') {
185
+ return {
186
+ type,
187
+ role: item.role || 'assistant',
188
+ content: Array.isArray(item.content) ? item.content.map(_normalizeContentPart) : [],
189
+ };
190
+ }
191
+ if (type === 'reasoning') {
192
+ return {
193
+ type,
194
+ id: item.id || null,
195
+ encrypted_content: item.encrypted_content || null,
196
+ };
197
+ }
198
+ if (type === 'web_search_call') {
199
+ return { type, id: item.id || null, action: item.action || null };
200
+ }
201
+ const { id: _id, status: _status, ...rest } = item;
202
+ return { type, ...rest };
203
+ }
204
+
205
+ function _normalizedResponseItemDiagnosticHash(item) {
206
+ return createHash('sha256')
207
+ .update(_stableStringify(_normalizedResponseItemDiagnosticShape(item)))
208
+ .digest('hex');
209
+ }
210
+
211
+ function _responseOutputMismatchDiagnostics(inputItem, responseItem, replayItemCount, responseItemCount) {
212
+ return {
213
+ response_output_mismatch_expected_type: responseItem?.type || 'unknown',
214
+ response_output_mismatch_expected_hash: _normalizedResponseItemDiagnosticHash(responseItem),
215
+ response_output_mismatch_expected_response_item_count: responseItemCount,
216
+ response_output_mismatch_actual_type: inputItem?.type || (inputItem?.role === 'assistant' ? 'message' : 'unknown'),
217
+ response_output_mismatch_actual_hash: _normalizedResponseItemDiagnosticHash(inputItem),
218
+ response_output_mismatch_actual_replayed_input_item_count: replayItemCount,
219
+ };
220
+ }
221
+
160
222
  export function _stripResponseItemsFromHead(items, responseItems) {
161
223
  const tail = Array.isArray(items) ? items : [];
162
224
  const outputs = Array.isArray(responseItems) ? responseItems : [];
@@ -177,6 +239,12 @@ export function _stripResponseItemsFromHead(items, responseItems) {
177
239
  tail,
178
240
  stripped,
179
241
  skipped,
242
+ responseOutputMismatch: _responseOutputMismatchDiagnostics(
243
+ tail[cursor],
244
+ output,
245
+ tail.length,
246
+ outputs.length,
247
+ ),
180
248
  };
181
249
  }
182
250
  skipped += 1;
@@ -291,7 +359,12 @@ export function _computeDelta({ entry, body, traceProvider }) {
291
359
  }
292
360
  const stripped = _stripResponseItemsFromHead(afterPreviousInput, entry.lastResponseItems);
293
361
  if (!stripped.ok) {
294
- return { mode: 'full', reason: stripped.reason, frame: buildFrame(body) };
362
+ return {
363
+ mode: 'full',
364
+ reason: stripped.reason,
365
+ responseOutputMismatch: stripped.responseOutputMismatch,
366
+ frame: buildFrame(body),
367
+ };
295
368
  }
296
369
  return {
297
370
  mode: 'delta',
@@ -67,6 +67,44 @@ const MAX_POOLED_SOCKETS_PER_KEY = 8;
67
67
  // lastRequestInput, lastResponseItems, lastInputLen, turnState,
68
68
  // closing, ephemeral }
69
69
  const _wsPool = new Map();
70
+ let _releaseSequence = 0;
71
+
72
+ function _poolCompatibility(auth, cacheKey) {
73
+ const type = String(auth?.type || 'openai-oauth');
74
+ // Prefer the stable OAuth account id so an access-token refresh can keep
75
+ // using the same connection. If it is unavailable, fail closed to the
76
+ // credential itself; a refresh then opens a new socket rather than risking
77
+ // cross-account continuation.
78
+ const credentialIdentity = auth?.account_id || auth?.apiKey || auth?.access_token || '';
79
+ return {
80
+ authIdentity: createHash('sha256')
81
+ .update(`${type}\0${String(credentialIdentity)}`)
82
+ .digest('hex'),
83
+ cacheKeyIdentity: String(cacheKey || ''),
84
+ };
85
+ }
86
+
87
+ function _entryCompatible(entry, compatibility) {
88
+ return entry?.authIdentity === compatibility.authIdentity
89
+ && entry?.cacheKeyIdentity === compatibility.cacheKeyIdentity;
90
+ }
91
+
92
+ // A previous_response_id is valid on the connection that produced it. When
93
+ // concurrency temporarily creates sibling sockets for one pool key, continue
94
+ // on the most recently completed sibling instead of whichever socket happened
95
+ // to be inserted first. This is the closest representation of Codex's
96
+ // turn-local single-connection ownership without weakening the existing
97
+ // full-frame fallback checks.
98
+ function _selectIdleEntry(entries, compatibility) {
99
+ let selected;
100
+ for (const entry of entries || []) {
101
+ if (entry?.busy || !_entryCompatible(entry, compatibility)) continue;
102
+ if (!selected || (entry.releaseSequence || 0) > (selected.releaseSequence || 0)) {
103
+ selected = entry;
104
+ }
105
+ }
106
+ return selected;
107
+ }
70
108
 
71
109
  // --- Cache-route probe state (2026-07-04 hunt) -----------------------------
72
110
  // CF cookie stickiness (codex chatgpt_cloudflare_cookies.rs:22-55 persists
@@ -707,9 +745,16 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
707
745
  }
708
746
  if (poolKey && !forceFresh) {
709
747
  const arr = _wsPool.get(poolKey) || [];
710
- // Prune dead entries first.
748
+ const compatibility = _poolCompatibility(auth, cacheKey);
749
+ // Prune dead entries and idle sockets from an obsolete auth/cache
750
+ // boundary. Busy incompatible entries are left alone until their owner
751
+ // releases them, but can never be selected by this acquire.
711
752
  for (let i = arr.length - 1; i >= 0; i--) {
712
- if (!_isOpen(arr[i]) || arr[i].closing) {
753
+ const incompatibleIdle = !arr[i].busy && !_entryCompatible(arr[i], compatibility);
754
+ if (!_isOpen(arr[i]) || arr[i].closing || incompatibleIdle) {
755
+ if (incompatibleIdle) {
756
+ try { arr[i].socket.close(1000, 'pool_boundary_changed'); } catch {}
757
+ }
713
758
  _clearIdle(arr[i]);
714
759
  _clearLiveness(arr[i]);
715
760
  arr.splice(i, 1);
@@ -721,7 +766,7 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
721
766
  // bound before hand-out; a dead one is evicted and the scan retries the
722
767
  // next idle entry so a busy caller is never handed a wedged socket.
723
768
  let idle;
724
- while ((idle = arr.find(e => !e.busy))) {
769
+ while ((idle = _selectIdleEntry(arr, compatibility))) {
725
770
  _clearIdle(idle);
726
771
  _clearLiveness(idle);
727
772
  // Reserve the entry BEFORE awaiting the probe: _pingProbe yields the
@@ -778,6 +823,8 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
778
823
  lastResponseItems: null,
779
824
  lastInputLen: 0,
780
825
  lastInputPrefixHash: null,
826
+ releaseSequence: 0,
827
+ ...compatibility,
781
828
  turnState: turnState || null,
782
829
  closing: false,
783
830
  ephemeral: true,
@@ -797,6 +844,7 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
797
844
  // returns "No tool output found for function call …". turnState only
798
845
  // propagates within a single entry across its own iterations.
799
846
  const sessionToken = _mintSessionToken(cacheKey, auth);
847
+ const compatibility = _poolCompatibility(auth, cacheKey);
800
848
  if (process.env.MIXDOG_DEBUG_AGENT) {
801
849
  process.stderr.write(`[agent-trace] acquire-new tokenHash=${createHash('sha256').update(String(sessionToken)).digest('hex').slice(0, 8)} elapsed=${Date.now() - _acqStart}ms\n`);
802
850
  }
@@ -811,6 +859,8 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
811
859
  lastResponseItems: null,
812
860
  lastInputLen: 0,
813
861
  lastInputPrefixHash: null,
862
+ releaseSequence: 0,
863
+ ...compatibility,
814
864
  turnState: turnState || null,
815
865
  closing: false,
816
866
  ephemeral: false,
@@ -840,6 +890,7 @@ export function releaseWebSocket({ entry, poolKey, keep }) {
840
890
  // Mark activity at release, then arm both the idle-close timer and the
841
891
  // periodic liveness ping so a socket that dies while pooled is evicted
842
892
  // before the next acquire can hand it out.
893
+ entry.releaseSequence = ++_releaseSequence;
843
894
  entry.lastAliveAt = Date.now();
844
895
  _scheduleIdleClose(poolKey, entry);
845
896
  if (WS_PING_ENABLED) _armLiveness(poolKey, entry);
@@ -848,6 +899,27 @@ export function releaseWebSocket({ entry, poolKey, keep }) {
848
899
  _setTransportReferenced(entry, false);
849
900
  }
850
901
 
902
+ // Focused pool lifecycle test seam. Entries still pass through the production
903
+ // release/acquire code; this only avoids a live provider handshake.
904
+ export function _seedWebSocketEntryForTest({ poolKey, auth, cacheKey, entry }) {
905
+ Object.assign(entry, _poolCompatibility(auth, cacheKey));
906
+ if (entry.releaseSequence === undefined) entry.releaseSequence = 0;
907
+ _getPoolArr(poolKey).push(entry);
908
+ return entry;
909
+ }
910
+
911
+ export function _clearWebSocketPoolForTest() {
912
+ for (const arr of _wsPool.values()) {
913
+ for (const entry of arr) {
914
+ _clearIdle(entry);
915
+ _clearLiveness(entry);
916
+ try { entry.socket.close(1000, 'test_cleanup'); } catch {}
917
+ }
918
+ }
919
+ _wsPool.clear();
920
+ _releaseSequence = 0;
921
+ }
922
+
851
923
  // Drain-complete fence — set true once _closeAllPooledSockets runs so any
852
924
  // in-flight acquire that resumes after drain throws instead of pushing a
853
925
  // fresh socket into the cleared pool. Single-set, process-lifetime invariant.
@@ -429,6 +429,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
429
429
  // holds the prefix byte-stable; 'none' makes the model emit text
430
430
  // only. Overrides the forced-first-tool path below.
431
431
  opts.toolChoice = 'none';
432
+ if (sessionAgent === 'explorer') {
433
+ opts.cacheBreakIntent = 'explorer_hard_cap_final_tool_choice_none';
434
+ }
432
435
  } else if (forcedFirstTool && toolCallsTotal === 0) {
433
436
  opts.toolChoice = 'required';
434
437
  } else {
@@ -464,7 +467,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
464
467
  opts.onToolCall = _capFinalToolsDisabled ? undefined : eager.onToolCall;
465
468
  // Reattach separated tool results, then drop only truly dangling
466
469
  // assistant/orphan pairs before the provider sees the transcript.
470
+ const messagesBeforeTranscriptRepair = messages.slice();
467
471
  repairTranscriptBeforeProviderSend(messages, sessionId);
472
+ if (!opts.cacheBreakIntent
473
+ && (messages.length !== messagesBeforeTranscriptRepair.length
474
+ || messages.some((message, index) => message !== messagesBeforeTranscriptRepair[index]))) {
475
+ // Pairing repair deliberately rebuilds the provider transcript;
476
+ // record that intent without broadening the mismatch hash scope.
477
+ opts.cacheBreakIntent = 'transcript_rebuild';
478
+ }
468
479
  // Strip soft-warn markers from prior tool results before the next
469
480
  // send. Marker bytes (Tool-budget(xN), Same-file reads(xN), etc.)
470
481
  // mutate every turn with dynamic counters, so leaving them in the
@@ -484,12 +495,14 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
484
495
  sessionId, sessionRef, nextIteration, contextOverflowRetryUsed,
485
496
  });
486
497
  if (_sendResult.action === 'retry') {
498
+ delete opts.cacheBreakIntent;
487
499
  contextOverflowRetryUsed = true;
488
500
  reactiveOverflowRetryPending = true;
489
501
  continue;
490
502
  }
491
503
  response = _sendResult.response;
492
504
  opts.onToolCall = undefined;
505
+ delete opts.cacheBreakIntent;
493
506
  contextOverflowRetryUsed = false;
494
507
  // Capture opaque state for the next turn (may be undefined — that's
495
508
  // the stateless contract for providers that don't use continuation).
@@ -26,6 +26,44 @@ const WORKER_DENIED_TOOLS = new Set([
26
26
  'inject_input',
27
27
  ]);
28
28
 
29
+ const IMAGE_PATH_RE = /\.(?:png|jpe?g|gif|webp)(?:$|[?#])/i;
30
+
31
+ function callUrls(call) {
32
+ const value = call?.arguments?.url;
33
+ if (typeof value === 'string' && value.trim()) return [value.trim()];
34
+ if (Array.isArray(value) && value.length && value.every((item) => typeof item === 'string' && item.trim())) {
35
+ return value.map((item) => item.trim());
36
+ }
37
+ return [];
38
+ }
39
+
40
+ export function isLoopbackHttpUrl(value) {
41
+ try {
42
+ const url = new URL(value);
43
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
44
+ const host = url.hostname.toLowerCase();
45
+ if (host === 'localhost' || host === '[::1]' || host === '::1') return true;
46
+ const match = host.match(/^(\d{1,3})(?:\.(\d{1,3})){3}$/);
47
+ return Boolean(match && Number(match[1]) === 127);
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Rewrite only calls whose complete URL set has one unambiguous transport.
55
+ * Mixed document/image or public/loopback batches stay on web_fetch, whose
56
+ * existing SSRF checks fail closed rather than broadening local_fetch.
57
+ */
58
+ export function routeWebFetchCall(call) {
59
+ if (call?.name !== 'web_fetch') return call;
60
+ const urls = callUrls(call);
61
+ if (!urls.length) return call;
62
+ if (urls.every(isLoopbackHttpUrl)) call.name = 'local_fetch';
63
+ else if (urls.every((value) => IMAGE_PATH_RE.test(value))) call.name = 'image_fetch';
64
+ return call;
65
+ }
66
+
29
67
  function _preDispatchDeny(call, toolKind, sessionRef) {
30
68
  const name = call?.name;
31
69
  if (typeof name !== 'string' || !name) return null;
@@ -22,6 +22,7 @@ import {
22
22
  resolvePreToolAskApproval,
23
23
  } from './tool-helpers.mjs';
24
24
  import { isOnDeferredToolSurface, prepareDeferredToolCallThrough } from './deferred-call-through.mjs';
25
+ import { preDispatchDenyForSession, routeWebFetchCall } from './pre-dispatch-deny.mjs';
25
26
 
26
27
  let codeGraphRuntimePromise = null;
27
28
  async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options = {}) {
@@ -121,10 +122,24 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
121
122
  if ((action === 'modify' || action === 'rewrite') && decision?.args && typeof decision.args === 'object' && !Array.isArray(decision.args)) {
122
123
  args = decision.args;
123
124
  }
125
+ if ((action === 'modify' || action === 'rewrite') && typeof decision?.name === 'string' && decision.name.trim()) {
126
+ name = decision.name.trim();
127
+ }
124
128
  } catch {
125
129
  // Hooks are policy extensions. A broken hook must not wedge the agent loop.
126
130
  }
127
131
  }
132
+ // A hook may replace the tool name, so pass the final call through the
133
+ // same eager/serial boundary again. This prevents a rename from bypassing
134
+ // role scoping and also applies built-in web_fetch transport routing.
135
+ {
136
+ const finalCall = { name, arguments: args };
137
+ routeWebFetchCall(finalCall);
138
+ const denial = preDispatchDenyForSession(sessionRef, finalCall);
139
+ if (denial !== null) return denial;
140
+ name = finalCall.name;
141
+ args = finalCall.arguments;
142
+ }
128
143
  const afterToolHook = typeof executeOpts.afterToolHook === 'function'
129
144
  ? executeOpts.afterToolHook
130
145
  : sessionRef?.afterToolHook;
@@ -20,7 +20,11 @@ import { getHiddenAgent, listHiddenAgentNames } from '../../internal-agents.mjs'
20
20
  // the sort overhead on ~30 tools is negligible.
21
21
  function _getMcpTools() {
22
22
  const mcp = getMcpTools() || [];
23
- const internalRaw = getInternalTools() || [];
23
+ // `public:false` tools stay registered in internal-tools for runtime
24
+ // rewrites/dispatch, but must never enter any model-visible schema (Lead
25
+ // full/mcp included). Filter before mapping because the projection below
26
+ // intentionally drops module-private metadata such as `public`.
27
+ const internalRaw = (getInternalTools() || []).filter(t => t?.public !== false);
24
28
  const internal = internalRaw.map(t => ({
25
29
  name: t.name,
26
30
  description: typeof t.description === 'string' ? t.description : '',
@@ -395,6 +395,10 @@ export async function runPreSendCompactPass(state) {
395
395
  if (compactChanged) {
396
396
  messages.length = 0;
397
397
  messages.push(...compacted);
398
+ // The next provider send intentionally starts a new
399
+ // transcript shape. Keep this one-shot observability tag
400
+ // separate from an unexplained prefix mismatch.
401
+ opts.cacheBreakIntent = 'automatic_compaction';
398
402
  // Compacting/pruning the transcript invalidates the
399
403
  // server-side conversation anchor (xAI Responses / openai-oauth
400
404
  // WS rely on previous_response_id which points at a
@@ -104,14 +104,15 @@ function ensureWorker() {
104
104
  _restartCount++
105
105
  })
106
106
  worker.on('exit', (code) => {
107
+ const exitError = new Error(`Worker exited with code ${code}`)
107
108
  if (code !== 0) {
108
109
  __mixdogMemoryLog(`[embed] worker exited with code ${code}\n`)
109
- for (const [, p] of _pending) p.reject(new Error(`Worker exited with code ${code}`))
110
- _pending.clear()
111
110
  _restartCount++
112
111
  } else {
113
112
  _restartCount = 0
114
113
  }
114
+ for (const [, p] of _pending) p.reject(exitError)
115
+ _pending.clear()
115
116
  worker = null
116
117
  _modelReady = false
117
118
  })
@@ -32,6 +32,7 @@ import {
32
32
  updateProviderState,
33
33
  } from './lib/state.mjs'
34
34
  import { getScrapeCapabilities, scrapeUrls } from './lib/web-tools.mjs'
35
+ import { fetchLoopbackText, fetchPublicImage } from './lib/http-fetch.mjs'
35
36
  import { formatResponse } from './lib/formatter.mjs'
36
37
  ensureDataDir()
37
38
 
@@ -538,6 +539,46 @@ async function handleToolCall(name, rawArgs, options = {}) {
538
539
  return { content: [{ type: 'text', text: `Fetch failed: ${_cleanErr}` }], isError: true }
539
540
  }
540
541
  }
542
+ case 'local_fetch':
543
+ case 'image_fetch': {
544
+ let urlArgs
545
+ try {
546
+ urlArgs = searchUrlArgsSchema.parse(normalizeSearchUrlArgs(rawArgs || {}))
547
+ } catch (e) {
548
+ if (e instanceof z.ZodError) {
549
+ return { content: [{ type: 'text', text: JSON.stringify({ error: 'Invalid arguments', details: e.errors }) }], isError: true }
550
+ }
551
+ throw e
552
+ }
553
+ const urls = Array.isArray(urlArgs.url) ? urlArgs.url : [urlArgs.url]
554
+ if (urls.length > 8) return { content: [{ type: 'text', text: 'Error: fetch batch exceeds maximum of 8 URLs.' }], isError: true }
555
+ const fetchSignal = signal
556
+ ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
557
+ : AbortSignal.timeout(timeoutMs)
558
+ try {
559
+ if (name === 'local_fetch') {
560
+ const parts = []
561
+ for (const url of urls) {
562
+ const text = await fetchLoopbackText(url, { signal: fetchSignal })
563
+ const start = urlArgs.startIndex || 0
564
+ const max = urlArgs.maxLength == null ? 50_000 : urlArgs.maxLength
565
+ const body = max === 0 ? text.slice(start) : text.slice(start, start + max)
566
+ parts.push({ type: 'text', text: `${url}\n\n${body}` })
567
+ }
568
+ return { content: parts }
569
+ }
570
+ const parts = []
571
+ for (const url of urls) {
572
+ const image = await fetchPublicImage(url, { signal: fetchSignal })
573
+ parts.push({ type: 'text', text: `Downloaded image: ${url} (${image.mimeType}, ${image.bytes} bytes)` })
574
+ parts.push({ type: 'image', source: { type: 'base64', media_type: image.mimeType, data: image.data } })
575
+ }
576
+ return { content: parts }
577
+ } catch (error) {
578
+ const message = presentErrorText(normalizeErrorMessage(error instanceof Error ? error.message : String(error)), { surface: name })
579
+ return { content: [{ type: 'text', text: `Fetch failed: ${message}` }], isError: true }
580
+ }
581
+ }
541
582
  default:
542
583
  throw new Error(`Unknown tool: ${name}`)
543
584
  }