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.
- package/README.md +6 -6
- package/package.json +7 -7
- package/scripts/embedding-worker-exit-test.mjs +76 -0
- package/scripts/hook-bus-test.mjs +23 -0
- package/scripts/internal-tools-normalization-test.mjs +10 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +67 -1
- package/scripts/provider-toolcall-test.mjs +200 -2
- package/scripts/session-bench-cache-break-test.mjs +102 -0
- package/scripts/session-bench.mjs +101 -42
- package/scripts/shell-failure-diagnostics-test.mjs +73 -4
- package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
- package/scripts/smoke-loop-failure-summary.mjs +16 -0
- package/scripts/smoke-loop.mjs +4 -3
- package/scripts/smoke.mjs +5 -106
- package/scripts/tool-failures.mjs +15 -1
- package/scripts/tui-transcript-perf-test.mjs +38 -0
- package/scripts/verify-release-assets-test.mjs +15 -381
- package/scripts/web-fetch-routing-test.mjs +158 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
- package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +14 -4
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +75 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +4 -0
- package/src/runtime/memory/lib/embedding-provider.mjs +3 -2
- package/src/runtime/search/index.mjs +41 -0
- package/src/runtime/search/lib/http-fetch.mjs +154 -0
- package/src/runtime/search/tool-defs.mjs +23 -0
- package/src/session-runtime/runtime-core.mjs +37 -15
- package/src/standalone/hook-bus/handlers.mjs +4 -0
- package/src/standalone/hook-bus/rules.mjs +7 -1
- package/src/standalone/hook-bus.mjs +10 -2
- package/src/tui/App.jsx +17 -11
- package/src/tui/app/live-spinner-visibility.mjs +20 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|