mixdog 0.9.46 → 0.9.49
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 +14 -4
- package/scripts/agent-parallel-smoke.mjs +4 -1
- package/scripts/agent-route-batch-test.mjs +40 -0
- package/scripts/code-graph-aggregate-cwd-test.mjs +154 -0
- package/scripts/code-graph-description-contract.mjs +185 -0
- package/scripts/code-graph-disk-hit-test.mjs +55 -0
- package/scripts/code-graph-dispatch-test.mjs +96 -0
- package/scripts/compact-pressure-test.mjs +40 -0
- package/scripts/deferred-tool-loading-test.mjs +233 -0
- package/scripts/execution-completion-dedup-test.mjs +48 -0
- package/scripts/explore-prompt-policy-test.mjs +88 -3
- package/scripts/live-worker-smoke.mjs +68 -16
- package/scripts/memory-core-input-test.mjs +33 -13
- package/scripts/native-edit-wire-test.mjs +152 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +294 -16
- package/scripts/patch-binary-cache-test.mjs +181 -0
- package/scripts/prompt-immediate-render-test.mjs +89 -0
- package/scripts/provider-toolcall-test.mjs +280 -15
- package/scripts/shell-failure-diagnostics-test.mjs +211 -0
- package/scripts/statusline-quota-hysteresis-test.mjs +26 -1
- package/scripts/streaming-tail-window-test.mjs +29 -0
- package/scripts/tool-failures.mjs +21 -3
- package/scripts/tool-smoke.mjs +263 -38
- package/scripts/tool-tui-presentation-test.mjs +17 -1
- package/scripts/tui-perf-run.ps1 +26 -0
- package/scripts/tui-transcript-perf-test.mjs +7 -1
- package/scripts/verify-release-assets-test.mjs +647 -0
- package/scripts/verify-release-assets.mjs +293 -0
- package/scripts/windows-hide-spawn-options-test.mjs +19 -0
- package/src/cli.mjs +1 -0
- package/src/defaults/cycle3-review-prompt.md +10 -4
- package/src/defaults/memory-promote-prompt.md +4 -3
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +16 -5
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +35 -11
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +35 -11
- package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +28 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +34 -34
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +45 -38
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -11
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +4 -4
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +10 -6
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +16 -1
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +0 -1
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +13 -8
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +30 -16
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +25 -8
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +69 -4
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +5 -4
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +12 -12
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +12 -7
- package/src/runtime/agent/orchestrator/tools/patch-binary-fetcher.mjs +77 -18
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +99 -24
- package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -11
- package/src/runtime/memory/lib/memory-cycle2-gate.mjs +13 -5
- package/src/runtime/memory/lib/memory-cycle2.mjs +8 -5
- package/src/runtime/memory/lib/memory-cycle3.mjs +41 -6
- package/src/runtime/memory/lib/query-handlers.mjs +49 -6
- package/src/runtime/memory/lib/recall-format.mjs +21 -6
- package/src/runtime/memory/tool-defs.mjs +2 -3
- package/src/session-runtime/context-status.mjs +25 -16
- package/src/session-runtime/model-route-api.mjs +2 -0
- package/src/session-runtime/runtime-core.mjs +2 -2
- package/src/session-runtime/session-turn-api.mjs +4 -1
- package/src/session-runtime/tool-catalog.mjs +113 -19
- package/src/session-runtime/tool-defs.mjs +1 -0
- package/src/standalone/agent-tool/helpers.mjs +25 -6
- package/src/standalone/agent-tool.mjs +75 -41
- package/src/tui/App.jsx +4 -0
- package/src/tui/app/input-parsers.mjs +8 -9
- package/src/tui/components/Markdown.jsx +6 -1
- package/src/tui/components/Message.jsx +11 -2
- package/src/tui/components/PromptInput.jsx +19 -21
- package/src/tui/components/Spinner.jsx +4 -4
- package/src/tui/components/StatusLine.jsx +6 -6
- package/src/tui/components/TranscriptItem.jsx +2 -2
- package/src/tui/components/prompt-input/immediate-render.mjs +47 -0
- package/src/tui/components/tool-execution/surface-detail.mjs +14 -9
- package/src/tui/dist/index.mjs +130 -45
- package/src/tui/engine/agent-job-feed.mjs +21 -2
- package/src/tui/engine/turn.mjs +12 -1
- package/src/tui/markdown/measure-rendered-rows.mjs +1 -1
- package/src/tui/markdown/streaming-markdown.mjs +20 -0
- package/src/ui/statusline.mjs +5 -5
- package/src/vendor/statusline/src/gateway/session-routes.mjs +22 -10
|
@@ -570,6 +570,7 @@ export async function _acquireWithRetry({
|
|
|
570
570
|
if (err && typeof err === 'object') {
|
|
571
571
|
try { err.attempts = attempt; } catch {}
|
|
572
572
|
try { err.retryClassifier = classifier; } catch {}
|
|
573
|
+
try { err.wsRetriesExhausted = true; } catch {}
|
|
573
574
|
}
|
|
574
575
|
try {
|
|
575
576
|
if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(
|
|
@@ -580,15 +581,10 @@ export async function _acquireWithRetry({
|
|
|
580
581
|
}
|
|
581
582
|
// Schedule backoff and emit progress.
|
|
582
583
|
const backoff = _backoffFor(attempt);
|
|
583
|
-
try {
|
|
584
|
-
if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) process.stderr.write(
|
|
585
|
-
`[openai-oauth-ws] worker retry ${attempt}/${attemptCap} (transient: ${classifier}, backoff ${backoff}ms)\n`,
|
|
586
|
-
);
|
|
587
|
-
} catch {}
|
|
588
584
|
try {
|
|
589
585
|
onRetry?.({
|
|
590
586
|
attempt,
|
|
591
|
-
max: attemptCap,
|
|
587
|
+
max: attemptCap - 1,
|
|
592
588
|
classifier,
|
|
593
589
|
backoffMs: backoff,
|
|
594
590
|
error: err,
|
|
@@ -652,12 +648,6 @@ export async function sendViaWebSocket({
|
|
|
652
648
|
traceProvider = 'openai-oauth',
|
|
653
649
|
logSuppressedReasoningDeltas = true,
|
|
654
650
|
warmupBody = null,
|
|
655
|
-
// Fast-fallback: when the caller has HTTP/SSE fallback enabled, cap the
|
|
656
|
-
// handshake acquire retry loop to a single attempt so a first
|
|
657
|
-
// acquire/first-byte failure surfaces immediately instead of burning the
|
|
658
|
-
// full exponential-backoff budget (measured 16.4s) before the caller can
|
|
659
|
-
// race HTTP. WS-only callers leave this false → full HANDSHAKE_MAX_ATTEMPTS.
|
|
660
|
-
fastFallback = false,
|
|
661
651
|
// Test seams (undefined in production). Let the unit test drive the
|
|
662
652
|
// retry state machine without opening real sockets or touching the
|
|
663
653
|
// handshake-retry layer.
|
|
@@ -714,7 +704,6 @@ export async function sendViaWebSocket({
|
|
|
714
704
|
// warmed. openai-oauth / openai-direct anchor by per-socket session_id, where
|
|
715
705
|
// this carry-forward would not help and is therefore gated to xAI.
|
|
716
706
|
let carryForwardCache = null;
|
|
717
|
-
const emittedProgress = [];
|
|
718
707
|
const useCodexWsClientMetadata = traceProvider === 'openai-oauth';
|
|
719
708
|
const codexMetadataContext = { poolKey, cacheKey, sendOpts };
|
|
720
709
|
const codexHandshakeHeaders = useCodexWsClientMetadata
|
|
@@ -760,6 +749,22 @@ export async function sendViaWebSocket({
|
|
|
760
749
|
});
|
|
761
750
|
} catch {}
|
|
762
751
|
};
|
|
752
|
+
// Single caller-visible recovery path for both handshake/acquire retries
|
|
753
|
+
// and retryable stream failures. The session/TUI stage bridge renders this
|
|
754
|
+
// as non-terminal reconnect progress; transport code must not also print it
|
|
755
|
+
// to stderr.
|
|
756
|
+
const emitReconnectProgress = ({ attempt, max, classifier }) => {
|
|
757
|
+
const retryAttempt = Number(attempt) || 1;
|
|
758
|
+
const retryMax = Number(max) || 1;
|
|
759
|
+
try {
|
|
760
|
+
onStageChange?.('reconnecting', {
|
|
761
|
+
attempt: retryAttempt,
|
|
762
|
+
max: retryMax,
|
|
763
|
+
classifier: classifier || null,
|
|
764
|
+
message: `Reconnecting... ${retryAttempt}/${retryMax}`,
|
|
765
|
+
});
|
|
766
|
+
} catch {}
|
|
767
|
+
};
|
|
763
768
|
|
|
764
769
|
for (let attemptIndex = 0; attemptIndex <= MAX_MIDSTREAM_RETRIES; attemptIndex++) {
|
|
765
770
|
const handshakeStart = performance.now();
|
|
@@ -778,16 +783,14 @@ export async function sendViaWebSocket({
|
|
|
778
783
|
// one is either torn down or in an unknown state.
|
|
779
784
|
forceFresh: forceFresh || attemptIndex > 0,
|
|
780
785
|
externalSignal,
|
|
781
|
-
|
|
782
|
-
// the caller can race HTTP immediately instead of waiting out
|
|
783
|
-
// the full backoff budget. Only the FIRST midstream attempt is
|
|
784
|
-
// capped — a midstream retry (attemptIndex>0) is already past
|
|
785
|
-
// the fallback decision and keeps the normal budget.
|
|
786
|
-
maxAttempts: (fastFallback && attemptIndex === 0) ? 1 : HANDSHAKE_MAX_ATTEMPTS,
|
|
786
|
+
maxAttempts: HANDSHAKE_MAX_ATTEMPTS,
|
|
787
787
|
onRetry: (info) => {
|
|
788
788
|
handshakeRetries += 1;
|
|
789
789
|
sendSpan.handshakeRetries += 1;
|
|
790
790
|
if (info?.classifier) handshakeRetryClassifiers.push(info.classifier);
|
|
791
|
+
const attempt = Number(info?.attempt) || handshakeRetries;
|
|
792
|
+
const max = Number(info?.max) || Math.max(HANDSHAKE_MAX_ATTEMPTS - 1, 1);
|
|
793
|
+
emitReconnectProgress({ attempt, max, classifier: info?.classifier });
|
|
791
794
|
},
|
|
792
795
|
onBackoffSlept: (ms) => { sendSpan.retryBackoffMs += ms; },
|
|
793
796
|
});
|
|
@@ -813,6 +816,9 @@ export async function sendViaWebSocket({
|
|
|
813
816
|
// the caller's turn actually tripped on).
|
|
814
817
|
if (attemptIndex > 0 && firstAttemptError) {
|
|
815
818
|
try { firstAttemptError.midstreamRetries = attemptIndex; } catch {}
|
|
819
|
+
if (err?.wsRetriesExhausted === true) {
|
|
820
|
+
try { firstAttemptError.wsRetriesExhausted = true; } catch {}
|
|
821
|
+
}
|
|
816
822
|
emitSendSpan('error');
|
|
817
823
|
throw _stampTool(_stampLiveText(firstAttemptError));
|
|
818
824
|
}
|
|
@@ -1109,16 +1115,7 @@ export async function sendViaWebSocket({
|
|
|
1109
1115
|
_stampTool(err);
|
|
1110
1116
|
const classifier = _classifyMidstreamError(err, midState);
|
|
1111
1117
|
const retryLimit = classifier ? _midstreamRetryLimit(classifier) : 0;
|
|
1112
|
-
|
|
1113
|
-
// socket opened but the server never ACKed — race HTTP now instead
|
|
1114
|
-
// of burning a midstream retry (fresh acquire + first-byte window).
|
|
1115
|
-
// Only suppresses the first_byte_timeout bucket on attemptIndex 0;
|
|
1116
|
-
// ws_1006/1011 mid-stream drops still retry (a live socket that died
|
|
1117
|
-
// is cheaper to re-acquire than to cold-fall-back).
|
|
1118
|
-
const fastFallbackSkipRetry = fastFallback
|
|
1119
|
-
&& attemptIndex === 0
|
|
1120
|
-
&& (classifier === 'first_byte_timeout' || err?.firstByteTimeout === true);
|
|
1121
|
-
if (classifier && attemptIndex < retryLimit && !fastFallbackSkipRetry) {
|
|
1118
|
+
if (classifier && attemptIndex < retryLimit) {
|
|
1122
1119
|
// Retry-eligible: stash the first-attempt error, emit progress,
|
|
1123
1120
|
// and loop. The subsequent acquire uses forceFresh so no socket
|
|
1124
1121
|
// is shared between attempts.
|
|
@@ -1127,11 +1124,11 @@ export async function sendViaWebSocket({
|
|
|
1127
1124
|
try { err.midstreamClassifier = classifier; } catch {}
|
|
1128
1125
|
const retryNumber = attemptIndex + 1;
|
|
1129
1126
|
const backoff = _midstreamBackoffFor(retryNumber);
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
}
|
|
1127
|
+
emitReconnectProgress({
|
|
1128
|
+
attempt: retryNumber,
|
|
1129
|
+
max: retryLimit,
|
|
1130
|
+
classifier,
|
|
1131
|
+
});
|
|
1135
1132
|
const sleepStart = performance.now();
|
|
1136
1133
|
try {
|
|
1137
1134
|
await _sleepWithAbort(backoff, externalSignal, _sleepFn);
|
|
@@ -1149,6 +1146,9 @@ export async function sendViaWebSocket({
|
|
|
1149
1146
|
// the user's turn actually tripped on), tag actual retry count.
|
|
1150
1147
|
try { firstAttemptError.midstreamRetries = attemptIndex; } catch {}
|
|
1151
1148
|
try { firstAttemptError.midstreamClassifier = firstAttemptClassifier; } catch {}
|
|
1149
|
+
if (attemptIndex >= _midstreamRetryLimit(firstAttemptClassifier)) {
|
|
1150
|
+
try { firstAttemptError.wsRetriesExhausted = true; } catch {}
|
|
1151
|
+
}
|
|
1152
1152
|
// Attach the retry attempt's error so post-mortem diagnostics
|
|
1153
1153
|
// can see WHY the retry also failed instead of silently
|
|
1154
1154
|
// dropping it. Use `cause` if free, else `suppressed`.
|
|
@@ -51,6 +51,8 @@ import {
|
|
|
51
51
|
customToolInputFromArguments,
|
|
52
52
|
isCustomToolCallRecord,
|
|
53
53
|
isResponsesFreeformTool,
|
|
54
|
+
nativeToolSearchCallInput,
|
|
55
|
+
nativeToolSearchOutputInput,
|
|
54
56
|
toResponsesCustomTool,
|
|
55
57
|
} from './custom-tool-wire.mjs';
|
|
56
58
|
import {
|
|
@@ -401,6 +403,15 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
|
|
|
401
403
|
if (mediaContent) pendingToolMedia.push(...mediaContent);
|
|
402
404
|
continue;
|
|
403
405
|
}
|
|
406
|
+
const nativeSearchOutput = nativeToolSearchOutputInput(
|
|
407
|
+
m,
|
|
408
|
+
opts.nativeToolSearchProvider || 'openai-oauth',
|
|
409
|
+
);
|
|
410
|
+
if (nativeSearchOutput) {
|
|
411
|
+
out.push(nativeSearchOutput);
|
|
412
|
+
if (mediaContent) pendingToolMedia.push(...mediaContent);
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
404
415
|
out.push({
|
|
405
416
|
type: 'function_call_output',
|
|
406
417
|
call_id: m.toolCallId || '',
|
|
@@ -419,7 +430,10 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
|
|
|
419
430
|
// reasoning in `input` triggers "Duplicate item".
|
|
420
431
|
if (m.content) out.push(wireMessage('assistant', normalizeContentForOpenAIResponses(m.content, { role: 'assistant' })));
|
|
421
432
|
for (const tc of m.toolCalls) {
|
|
422
|
-
|
|
433
|
+
const nativeSearchCall = nativeToolSearchCallInput(tc);
|
|
434
|
+
if (nativeSearchCall) {
|
|
435
|
+
out.push(nativeSearchCall);
|
|
436
|
+
} else if (isCustomToolCallRecord(tc)) {
|
|
423
437
|
if (tc.id) customToolCallNameById.set(tc.id, tc.name || '');
|
|
424
438
|
out.push({
|
|
425
439
|
type: 'custom_tool_call',
|
|
@@ -450,8 +464,8 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
|
|
|
450
464
|
function toOpenAIResponsesTool(t) {
|
|
451
465
|
if (t?.name === 'load_tool' || t?.name === 'tool_search') {
|
|
452
466
|
return {
|
|
453
|
-
type: '
|
|
454
|
-
|
|
467
|
+
type: 'tool_search',
|
|
468
|
+
execution: 'client',
|
|
455
469
|
description: t.description,
|
|
456
470
|
parameters: t.inputSchema,
|
|
457
471
|
};
|
|
@@ -465,6 +479,8 @@ function toOpenAIResponsesTool(t) {
|
|
|
465
479
|
};
|
|
466
480
|
}
|
|
467
481
|
|
|
482
|
+
export const _convertMessagesToResponsesInputForTest = convertMessagesToResponsesInput;
|
|
483
|
+
|
|
468
484
|
// codex build_reasoning() (core/src/client.rs:785-805) only attaches the
|
|
469
485
|
// reasoning object when model_info.supports_reasoning_summaries; models
|
|
470
486
|
// without summary support get NO reasoning field at all. Mirror that via the
|
|
@@ -501,6 +517,7 @@ export function buildRequestBody(messages, model, tools, sendOpts) {
|
|
|
501
517
|
const input = convertMessagesToResponsesInput(messages, {
|
|
502
518
|
providerState: opts.providerState,
|
|
503
519
|
model,
|
|
520
|
+
nativeToolSearchProvider: opts.promptCacheProvider || 'openai-oauth',
|
|
504
521
|
});
|
|
505
522
|
// Match the request body shape the OAuth backend expects so the
|
|
506
523
|
// server-side auto-cache routes correctly. text.verbosity / include /
|
|
@@ -625,8 +642,11 @@ export class OpenAIOAuthProvider {
|
|
|
625
642
|
name = 'openai-oauth';
|
|
626
643
|
tokens = null;
|
|
627
644
|
_refreshFallbackUntil = 0;
|
|
628
|
-
|
|
629
|
-
|
|
645
|
+
// Sticky transport fallback is isolated by the WS pool/session key. A
|
|
646
|
+
// provider instance is shared across concurrent sessions, so singleton
|
|
647
|
+
// booleans here would let one unhealthy session force every other session
|
|
648
|
+
// onto HTTP.
|
|
649
|
+
_httpFallbackUntilByPoolKey = new Map();
|
|
630
650
|
config;
|
|
631
651
|
constructor(config) {
|
|
632
652
|
this.config = config || {};
|
|
@@ -801,17 +821,17 @@ export class OpenAIOAuthProvider {
|
|
|
801
821
|
return result;
|
|
802
822
|
};
|
|
803
823
|
const httpFallbackActive = () => {
|
|
804
|
-
if (
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
this.
|
|
824
|
+
if (!poolKey) return false;
|
|
825
|
+
const now = Date.now();
|
|
826
|
+
for (const [key, expiresAt] of this._httpFallbackUntilByPoolKey) {
|
|
827
|
+
if (!(expiresAt > now)) this._httpFallbackUntilByPoolKey.delete(key);
|
|
808
828
|
}
|
|
809
|
-
return
|
|
829
|
+
return (this._httpFallbackUntilByPoolKey.get(poolKey) || 0) > now;
|
|
810
830
|
};
|
|
811
831
|
const markStickyHttpFallback = () => {
|
|
832
|
+
if (!poolKey) return;
|
|
812
833
|
const ttlMs = _envPositiveInt('MIXDOG_OPENAI_OAUTH_HTTP_FALLBACK_STICKY_MS', 60_000);
|
|
813
|
-
this.
|
|
814
|
-
this._forceHttpFallbackUntil = Date.now() + ttlMs;
|
|
834
|
+
this._httpFallbackUntilByPoolKey.set(poolKey, Date.now() + ttlMs);
|
|
815
835
|
};
|
|
816
836
|
const traceWsError = (err, stage = 'primary') => {
|
|
817
837
|
try {
|
|
@@ -898,9 +918,8 @@ export class OpenAIOAuthProvider {
|
|
|
898
918
|
// a fresh WS entry; only after the bounded WS retry budget is
|
|
899
919
|
// exhausted does openai-oauth fall back to HTTP/SSE. This preserves
|
|
900
920
|
// the hot WS/cache path for temporary blips while still preventing
|
|
901
|
-
// TUI-level hangs.
|
|
902
|
-
//
|
|
903
|
-
fastFallback: httpFallbackEnabled && _envFlag('MIXDOG_OPENAI_OAUTH_FAST_HTTP_FALLBACK', false),
|
|
921
|
+
// TUI-level hangs. Sticky HTTP fallback is only armed after this
|
|
922
|
+
// bounded reconnect budget is exhausted.
|
|
904
923
|
// codex-parity prewarm (generate:false full frame on a fresh
|
|
905
924
|
// socket, wire-verified 2026-07-03). DEFAULT ON: R19(off) vs
|
|
906
925
|
// R20(on) A/B shows prewarm removes ALL early-session zero-cache
|
|
@@ -951,7 +970,9 @@ export class OpenAIOAuthProvider {
|
|
|
951
970
|
return recordLiveModel(result);
|
|
952
971
|
} catch (retryErr) {
|
|
953
972
|
traceWsError(retryErr, 'auth_retry');
|
|
954
|
-
if (httpFallbackEnabled
|
|
973
|
+
if (httpFallbackEnabled
|
|
974
|
+
&& retryErr?.wsRetriesExhausted === true
|
|
975
|
+
&& _shouldUseOpenAIHttpFallback(retryErr, externalSignal)) {
|
|
955
976
|
try {
|
|
956
977
|
return await dispatchHttp(
|
|
957
978
|
retryErr?.retryClassifier || retryErr?.code || retryErr?.message || 'ws_auth_retry_failed',
|
|
@@ -973,32 +994,18 @@ export class OpenAIOAuthProvider {
|
|
|
973
994
|
const msg = err?.message || '';
|
|
974
995
|
const isUnknownModel = status === 404
|
|
975
996
|
|| /unknown[_\s-]?model|model[_\s-]?not[_\s-]?found/i.test(msg);
|
|
976
|
-
|
|
997
|
+
// Catalog recovery reissues the full turn. Once any text/tool
|
|
998
|
+
// output has escaped, that replay can duplicate rendered output or
|
|
999
|
+
// a dispatched side effect, so honor the same unsafe gate as auth
|
|
1000
|
+
// retry and transport fallback.
|
|
1001
|
+
if (isUnknownModel && !opts._modelRetry && !liveTextEmitted) {
|
|
977
1002
|
process.stderr.write(`[openai-oauth-ws] unknown model — refreshing catalog + 1 retry\n`);
|
|
978
1003
|
await this._refreshModelCache();
|
|
979
1004
|
return this.send(messages, model, tools, { ...opts, _modelRetry: true });
|
|
980
1005
|
}
|
|
981
|
-
if (httpFallbackEnabled
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
// remaining backoff retries were skipped and HTTP starts now.
|
|
985
|
-
// err.attempts===1 means the capped single-attempt path fired.
|
|
986
|
-
if (httpFallbackEnabled && Number(err?.attempts) === 1) {
|
|
987
|
-
appendAgentTrace({
|
|
988
|
-
sessionId: poolKey,
|
|
989
|
-
iteration,
|
|
990
|
-
kind: 'ws_fallback_fast',
|
|
991
|
-
provider: 'openai-oauth',
|
|
992
|
-
model: useModel,
|
|
993
|
-
transport: 'websocket',
|
|
994
|
-
elapsed_ms: Date.now() - _t1,
|
|
995
|
-
payload: {
|
|
996
|
-
elapsed_ms: Date.now() - _t1,
|
|
997
|
-
classifier: err?.retryClassifier || err?.midstreamClassifier || null,
|
|
998
|
-
code: err?.code || null,
|
|
999
|
-
},
|
|
1000
|
-
});
|
|
1001
|
-
}
|
|
1006
|
+
if (httpFallbackEnabled
|
|
1007
|
+
&& err?.wsRetriesExhausted === true
|
|
1008
|
+
&& _shouldUseOpenAIHttpFallback(err, externalSignal)) {
|
|
1002
1009
|
try {
|
|
1003
1010
|
return await dispatchHttp(
|
|
1004
1011
|
err?.retryClassifier || err?.midstreamClassifier || err?.code || err?.message || 'ws_failed',
|
|
@@ -41,6 +41,11 @@ export const WS_IDLE_MS = resolveTimeoutMs(
|
|
|
41
41
|
);
|
|
42
42
|
const WS_HANDSHAKE_TIMEOUT_MS = PROVIDER_WS_HANDSHAKE_TIMEOUT_MS;
|
|
43
43
|
const WS_ACQUIRE_TIMEOUT_MS = PROVIDER_WS_ACQUIRE_TIMEOUT_MS;
|
|
44
|
+
// A write that never reaches the ws callback is indistinguishable from a
|
|
45
|
+
// wedged transport. Keep it under the same short bound as socket acquisition
|
|
46
|
+
// so the caller can discard the entry and reconnect before arming stream
|
|
47
|
+
// watchdogs.
|
|
48
|
+
const WS_SEND_TIMEOUT_MS = WS_ACQUIRE_TIMEOUT_MS;
|
|
44
49
|
const WS_PING_INTERVAL_MS = PROVIDER_WS_PING_INTERVAL_MS;
|
|
45
50
|
const WS_PONG_TIMEOUT_MS = PROVIDER_WS_PONG_TIMEOUT_MS;
|
|
46
51
|
const WS_LIVENESS_STALE_MS = PROVIDER_WS_LIVENESS_STALE_MS;
|
|
@@ -386,7 +391,7 @@ function _armLiveness(poolKey, entry) {
|
|
|
386
391
|
// for a server event that will never arrive. Tag any failure with
|
|
387
392
|
// `wsSendFailed=true` so _classifyMidstreamError routes the next attempt
|
|
388
393
|
// through a fresh socket.
|
|
389
|
-
export function _sendFrame(entry, frame, sendSpan = null) {
|
|
394
|
+
export function _sendFrame(entry, frame, sendSpan = null, timeoutMs = WS_SEND_TIMEOUT_MS) {
|
|
390
395
|
return new Promise((resolve, reject) => {
|
|
391
396
|
const socket = entry?.socket;
|
|
392
397
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
@@ -409,18 +414,38 @@ export function _sendFrame(entry, frame, sendSpan = null) {
|
|
|
409
414
|
+ (performance.now() - serializeStart);
|
|
410
415
|
}
|
|
411
416
|
_dumpFrame(payload);
|
|
417
|
+
let settled = false;
|
|
418
|
+
let timer = null;
|
|
419
|
+
const finish = (err = null) => {
|
|
420
|
+
if (settled) return;
|
|
421
|
+
settled = true;
|
|
422
|
+
if (timer) clearTimeout(timer);
|
|
423
|
+
if (!err) {
|
|
424
|
+
resolve();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
const sendErr = err instanceof Error ? err : new Error(String(err));
|
|
428
|
+
sendErr.wsSendFailed = true;
|
|
429
|
+
// A callback error/timeout leaves the transport state unknown.
|
|
430
|
+
// Drop it immediately; releaseWebSocket will also remove the entry
|
|
431
|
+
// from its pool, and the retry path force-acquires a fresh socket.
|
|
432
|
+
try { entry.closing = true; } catch {}
|
|
433
|
+
try { socket.terminate?.(); } catch {}
|
|
434
|
+
reject(sendErr);
|
|
435
|
+
};
|
|
436
|
+
const boundedTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
437
|
+
? timeoutMs
|
|
438
|
+
: WS_SEND_TIMEOUT_MS;
|
|
439
|
+
timer = setTimeout(() => {
|
|
440
|
+
finish(Object.assign(
|
|
441
|
+
new Error(`WS send callback timed out after ${boundedTimeoutMs}ms`),
|
|
442
|
+
{ code: 'EWSSENDTIMEOUT', wsSendTimeoutMs: boundedTimeoutMs },
|
|
443
|
+
));
|
|
444
|
+
}, boundedTimeoutMs);
|
|
412
445
|
try {
|
|
413
|
-
|
|
414
|
-
// ws write callback may never fire, which would hang this Promise
|
|
415
|
-
// before _streamResponse arms its first-byte watchdog. Fire and
|
|
416
|
-
// resolve immediately; transport failures surface via the socket
|
|
417
|
-
// 'error'/'close' handlers and the first-byte watchdog.
|
|
418
|
-
socket.send(payload, () => {});
|
|
419
|
-
resolve();
|
|
446
|
+
socket.send(payload, (err) => finish(err || null));
|
|
420
447
|
} catch (e) {
|
|
421
|
-
|
|
422
|
-
err.wsSendFailed = true;
|
|
423
|
-
reject(err);
|
|
448
|
+
finish(e);
|
|
424
449
|
}
|
|
425
450
|
});
|
|
426
451
|
}
|
|
@@ -292,10 +292,10 @@ export function classifyMidstreamError(err, signals, policy = {}) {
|
|
|
292
292
|
// userAbort / watchdogAbort / responseFailedPayload exactly as before.
|
|
293
293
|
function _classifyMidstreamWs(err, state, attemptIndex, policy) {
|
|
294
294
|
if (state.sawCompleted) return null
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
295
|
+
// Once a tool call has been dispatched, no transport outcome is replay-safe.
|
|
296
|
+
// This includes a nominal close-1000 before response.completed: the tool may
|
|
297
|
+
// already be executing, so retry/fallback could duplicate its side effect.
|
|
298
|
+
if (state.emittedToolCall) return null
|
|
299
299
|
if (state.emittedText || err?.liveTextEmitted) return null
|
|
300
300
|
if (state.firstByteTimeout || err?.firstByteTimeout) {
|
|
301
301
|
return _allowMidstream('first_byte_timeout', attemptIndex, policy)
|
|
@@ -11,11 +11,12 @@ import { tryReadCached, tryScopedToolCached } from './read-dedup.mjs';
|
|
|
11
11
|
import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
|
|
12
12
|
import { executeTool } from './loop/tool-exec.mjs';
|
|
13
13
|
import { crossTurnSignature } from './loop/completion-guards.mjs';
|
|
14
|
-
import { getToolKind, isEagerDispatchable } from './loop/tool-helpers.mjs';
|
|
14
|
+
import { getToolKind, isEagerDispatchable, isToolCallDedupEligible } from './loop/tool-helpers.mjs';
|
|
15
15
|
|
|
16
16
|
export function createEagerDispatcher({
|
|
17
17
|
tools, cwd, sessionId, sessionRef, signal, opts,
|
|
18
18
|
crossTurnCalls, getIterations, getNextIteration, repeatFailLimit,
|
|
19
|
+
executeToolFn = executeTool,
|
|
19
20
|
}) {
|
|
20
21
|
const pending = new Map();
|
|
21
22
|
// Streaming-time intra-turn dedup. When the LLM emits two
|
|
@@ -24,7 +25,9 @@ export function createEagerDispatcher({
|
|
|
24
25
|
// the iter for-body runs, so the batch-level pre-pass would be
|
|
25
26
|
// too late to prevent the eager dispatch of the second one.
|
|
26
27
|
// Track signatures of in-flight eager calls and skip starting a
|
|
27
|
-
// second one for the same sig.
|
|
28
|
+
// second one for the same sig. Loader calls are the narrow exception:
|
|
29
|
+
// each invocation must run and report loaded vs already-active state.
|
|
30
|
+
// Every other duplicate's executeTool is
|
|
28
31
|
// never invoked; the for-body's pre-pass marks it as a duplicate
|
|
29
32
|
// and emits a stub tool_result. The sig is NOT cleared when the
|
|
30
33
|
// eager promise settles (see finally below): a streaming onToolCall
|
|
@@ -43,7 +46,8 @@ export function createEagerDispatcher({
|
|
|
43
46
|
// body handles it via the invalid-args feedback path.
|
|
44
47
|
if (isInvalidToolArgsMarker(call.arguments)) return null;
|
|
45
48
|
const _sig = _intraTurnSig(call.name, call.arguments);
|
|
46
|
-
|
|
49
|
+
const _dedupEligible = isToolCallDedupEligible(call.name, tools);
|
|
50
|
+
if (_dedupEligible && _eagerInFlightSigs.has(_sig)) return null;
|
|
47
51
|
// Repeat-failure guard also gates eager dispatch (reviewer-flagged):
|
|
48
52
|
// streaming onToolCall / startEagerRun would otherwise re-run an
|
|
49
53
|
// identical read-only call that already failed repeatFailLimit
|
|
@@ -59,7 +63,7 @@ export function createEagerDispatcher({
|
|
|
59
63
|
// re-executed — the serial for-body pushes the [cross-turn-dedup]
|
|
60
64
|
// stub instead. Without this gate startEagerRun/onToolCall would
|
|
61
65
|
// re-run the call before the serial dedup check ever sees it.
|
|
62
|
-
{
|
|
66
|
+
if (isToolCallDedupEligible(call.name, tools)) {
|
|
63
67
|
const _ctSig = crossTurnSignature(call.name, call.arguments);
|
|
64
68
|
const _prior = crossTurnCalls.get(_ctSig);
|
|
65
69
|
if (_prior && _prior.firstIteration < getIterations()) return null;
|
|
@@ -87,10 +91,10 @@ export function createEagerDispatcher({
|
|
|
87
91
|
// this call there, never start it eagerly here.
|
|
88
92
|
if (preDispatchDenyForSession(sessionRef, call, toolKind) !== null) return null;
|
|
89
93
|
const entry = { startedAt: Date.now(), endedAt: null, mutationEpoch: epoch.mutation };
|
|
90
|
-
_eagerInFlightSigs.set(_sig, call.id);
|
|
94
|
+
if (_dedupEligible) _eagerInFlightSigs.set(_sig, call.id);
|
|
91
95
|
entry.promise = (async () => {
|
|
92
96
|
try {
|
|
93
|
-
return { ok: true, value: await
|
|
97
|
+
return { ok: true, value: await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: getNextIteration() }) };
|
|
94
98
|
} catch (error) {
|
|
95
99
|
return { ok: false, error };
|
|
96
100
|
}
|
|
@@ -15,7 +15,7 @@ function isActiveSessionTool(session, name) {
|
|
|
15
15
|
function lookupDeferredCatalogTool(session, name) {
|
|
16
16
|
// Union of the boot-frozen catalog and the late-connected MCP catalog so a
|
|
17
17
|
// direct call to a tool whose server connected after boot resolves and
|
|
18
|
-
//
|
|
18
|
+
// records it in the independent callable registry.
|
|
19
19
|
const catalog = deferredCatalogUnion(session);
|
|
20
20
|
const key = clean(name);
|
|
21
21
|
if (!key) return null;
|
|
@@ -54,7 +54,8 @@ function denyDeferredCallThrough(message) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
* Inactive deferred catalog hits: readonly/mode gate +
|
|
57
|
+
* Inactive deferred catalog hits: readonly/mode gate + callable registration,
|
|
58
|
+
* or deny. Native provider schema arrays are never mutated here.
|
|
58
59
|
* Returns null when not applicable (not in catalog, already active, infra allowlist).
|
|
59
60
|
*/
|
|
60
61
|
export function prepareDeferredToolCallThrough(sessionRef, name, _args) {
|
|
@@ -77,6 +78,6 @@ export function prepareDeferredToolCallThrough(sessionRef, name, _args) {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
const selectMode = resolvedMode === null ? 'readonly' : resolvedMode;
|
|
80
|
-
selectDeferredTools(sessionRef, [toolLabel], selectMode
|
|
81
|
+
selectDeferredTools(sessionRef, [toolLabel], selectMode);
|
|
81
82
|
return null;
|
|
82
83
|
}
|
|
@@ -42,15 +42,17 @@ export function truncateToKb(text, maxKb) {
|
|
|
42
42
|
const s = String(text || '');
|
|
43
43
|
if (Buffer.byteLength(s, 'utf8') <= maxBytes) return s;
|
|
44
44
|
const lines = s.split('\n');
|
|
45
|
+
const marker = '[digest truncated at ' + maxKb + 'KB]';
|
|
46
|
+
const contentBudget = maxBytes - Buffer.byteLength(marker, 'utf8');
|
|
45
47
|
const out = [];
|
|
46
48
|
let used = 0;
|
|
47
49
|
for (const line of lines) {
|
|
48
50
|
const cost = Buffer.byteLength(line, 'utf8') + 1;
|
|
49
|
-
if (used + cost >
|
|
51
|
+
if (used + cost > contentBudget) break;
|
|
50
52
|
out.push(line);
|
|
51
53
|
used += cost;
|
|
52
54
|
}
|
|
53
|
-
return out.join('\n') + '\n
|
|
55
|
+
return out.length ? out.join('\n') + '\n' + marker : marker;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
function buildRecallDigestText(sessionId, digestBody, maxKb) {
|
|
@@ -61,7 +63,6 @@ function buildRecallDigestText(sessionId, digestBody, maxKb) {
|
|
|
61
63
|
// the model needs for a scoped recall.
|
|
62
64
|
return [
|
|
63
65
|
`[context compacted — session ${sessionId}]`,
|
|
64
|
-
`Full history is in memory — use the recall tool for details beyond this digest.`,
|
|
65
66
|
`Recent digest (newest first):`,
|
|
66
67
|
truncateToKb(digestBody, maxKb),
|
|
67
68
|
].join('\n');
|
|
@@ -29,12 +29,25 @@ export function isEagerDispatchable(name, tools) {
|
|
|
29
29
|
for (const t of tools) {
|
|
30
30
|
if (t?.annotations?.readOnlyHint === true && typeof t.name === 'string') {
|
|
31
31
|
set.add(t.name);
|
|
32
|
+
// `tool_search` is the legacy provider/runtime call name for the
|
|
33
|
+
// active `load_tool` schema; both must share eager semantics.
|
|
34
|
+
if (t.name === 'load_tool') set.add('tool_search');
|
|
35
|
+
else if (t.name === 'tool_search') set.add('load_tool');
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
38
|
_eagerNameSetByTools.set(tools, set);
|
|
35
39
|
}
|
|
36
40
|
return set.has(name);
|
|
37
41
|
}
|
|
42
|
+
|
|
43
|
+
// Read-only is necessary but not sufficient for result deduplication. Loader
|
|
44
|
+
// calls mutate the active session tool surface and must execute on every
|
|
45
|
+
// explicit invocation so repeats can truthfully report `alreadyActive`.
|
|
46
|
+
export function isToolCallDedupEligible(name, tools) {
|
|
47
|
+
return name !== 'load_tool'
|
|
48
|
+
&& name !== 'tool_search'
|
|
49
|
+
&& isEagerDispatchable(name, tools);
|
|
50
|
+
}
|
|
38
51
|
export function messagesArrayChanged(before, after) {
|
|
39
52
|
if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
|
|
40
53
|
if (before.length !== after.length) return true;
|
|
@@ -114,7 +127,8 @@ export function parseNativeToolSearchPayload(toolName, result) {
|
|
|
114
127
|
&& tool.name.trim()
|
|
115
128
|
&& (tool.type === undefined || typeof tool.type === 'string')
|
|
116
129
|
));
|
|
117
|
-
|
|
130
|
+
const provider = typeof native.provider === 'string' ? native.provider.trim().toLowerCase() : '';
|
|
131
|
+
if (!toolReferences.length && !openaiTools.length && !provider) return null;
|
|
118
132
|
const baseSummary = typeof native.summary === 'string' && native.summary
|
|
119
133
|
? native.summary
|
|
120
134
|
: `Loaded deferred tools: ${toolReferences.join(', ') || openaiTools.map((tool) => tool.name).filter(Boolean).join(', ')}`;
|
|
@@ -140,6 +154,7 @@ export function parseNativeToolSearchPayload(toolName, result) {
|
|
|
140
154
|
if (blocked.length) extraLines.push(`blocked: ${blocked.join(', ')}`);
|
|
141
155
|
const summary = extraLines.length ? `${baseSummary}\n${extraLines.join('; ')}` : baseSummary;
|
|
142
156
|
return {
|
|
157
|
+
provider,
|
|
143
158
|
toolReferences,
|
|
144
159
|
openaiTools,
|
|
145
160
|
summary,
|
|
@@ -397,9 +397,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
|
|
|
397
397
|
// don't get overridden by defaults. When session has no profile,
|
|
398
398
|
// providerCacheOpts is null and this spread is a no-op.
|
|
399
399
|
...(session.providerCacheOpts || {}),
|
|
400
|
-
onStageChange: (stage) => {
|
|
400
|
+
onStageChange: (stage, detail) => {
|
|
401
401
|
updateSessionStage(sessionId, stage);
|
|
402
|
-
try { askOpts?.onStageChange?.(stage); } catch {}
|
|
402
|
+
try { askOpts?.onStageChange?.(stage, detail); } catch {}
|
|
403
403
|
},
|
|
404
404
|
onStreamDelta: (kind = 'semantic') => {
|
|
405
405
|
markSessionStreamDelta(sessionId, kind).catch(() => {});
|
|
@@ -213,7 +213,6 @@ async function runRecallFastTrackForSession(session, messages, opts = {}) {
|
|
|
213
213
|
querySha,
|
|
214
214
|
recallText: [
|
|
215
215
|
`session_id=${sessionId}`,
|
|
216
|
-
`Full history is in memory — use the recall tool for details beyond this digest.`,
|
|
217
216
|
// Same byte cap as the loop digest path (recallDigestMaxKb,
|
|
218
217
|
// default = shared tool-output limit) — without it the memory
|
|
219
218
|
// renderer bounds the browse at ~200 rows × 1000 chars, letting a
|
|
@@ -29,7 +29,12 @@ import {
|
|
|
29
29
|
import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
|
|
30
30
|
import { executeTool } from './loop/tool-exec.mjs';
|
|
31
31
|
import { crossTurnSignature, crossTurnDedupStub, isEditProgressTool } from './loop/completion-guards.mjs';
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
getToolKind,
|
|
34
|
+
isEagerDispatchable,
|
|
35
|
+
isToolCallDedupEligible,
|
|
36
|
+
parseNativeToolSearchPayload,
|
|
37
|
+
} from './loop/tool-helpers.mjs';
|
|
33
38
|
import { restoreToolCallBodyForId } from './loop/stored-tool-args.mjs';
|
|
34
39
|
|
|
35
40
|
function classifyToolReturn(value) {
|
|
@@ -51,8 +56,8 @@ export async function processToolBatch(ctx) {
|
|
|
51
56
|
// Intra-turn duplicate suppression: when an LLM emits two tool_use
|
|
52
57
|
// blocks with identical (name, args) inside the SAME assistant turn,
|
|
53
58
|
// re-executing wastes tokens. Restricted to tools with
|
|
54
|
-
// `readOnlyHint:true`
|
|
55
|
-
//
|
|
59
|
+
// `readOnlyHint:true` plus result-dedup eligibility — loader calls
|
|
60
|
+
// are read-only-dispatchable but must execute to report state.
|
|
56
61
|
// Pre-pass identifies duplicates BEFORE startEagerRun so eager
|
|
57
62
|
// dispatch also skips them, not just the for-body.
|
|
58
63
|
const _duplicateCallIds = new Set();
|
|
@@ -61,7 +66,7 @@ export async function processToolBatch(ctx) {
|
|
|
61
66
|
const _firstIdBySig = new Map();
|
|
62
67
|
for (const c of calls) {
|
|
63
68
|
if (!c?.id) continue;
|
|
64
|
-
if (!
|
|
69
|
+
if (!isToolCallDedupEligible(c.name, tools)) {
|
|
65
70
|
_firstIdBySig.clear();
|
|
66
71
|
continue;
|
|
67
72
|
}
|
|
@@ -115,12 +120,12 @@ export async function processToolBatch(ctx) {
|
|
|
115
120
|
continue;
|
|
116
121
|
}
|
|
117
122
|
// Cross-turn identical-call stub (Step 2): a SUCCESSFUL read-only
|
|
118
|
-
//
|
|
123
|
+
// dedup-eligible call whose (name,args) signature already ran
|
|
119
124
|
// in an EARLIER turn is not re-executed — its result is unchanged and
|
|
120
125
|
// already in context. Warn at the 2nd occurrence; append the "stuck"
|
|
121
126
|
// escalation tail once the session has emitted 5+ dedup stubs total.
|
|
122
127
|
// Never applies to write/bash/MCP/skill tools (not eager-dispatchable).
|
|
123
|
-
if (
|
|
128
|
+
if (isToolCallDedupEligible(call.name, tools)) {
|
|
124
129
|
_ctSig = crossTurnSignature(call.name, call.arguments);
|
|
125
130
|
const _prior = crossTurnCalls.get(_ctSig);
|
|
126
131
|
if (_prior && _prior.firstIteration < iterations) {
|
|
@@ -582,7 +587,7 @@ export async function processToolBatch(ctx) {
|
|
|
582
587
|
// Read-only successful calls seed the cross-turn dedup map.
|
|
583
588
|
if (_executeOk) {
|
|
584
589
|
const _isEager = isEagerDispatchable(call.name, tools);
|
|
585
|
-
if (
|
|
590
|
+
if (isToolCallDedupEligible(call.name, tools)) {
|
|
586
591
|
if (_ctSig === null) _ctSig = crossTurnSignature(call.name, call.arguments);
|
|
587
592
|
if (!crossTurnCalls.has(_ctSig)) {
|
|
588
593
|
crossTurnCalls.set(_ctSig, { count: 1, firstIteration: iterations });
|
|
@@ -591,7 +596,7 @@ export async function processToolBatch(ctx) {
|
|
|
591
596
|
crossTurnCalls.delete(_oldest);
|
|
592
597
|
}
|
|
593
598
|
}
|
|
594
|
-
} else {
|
|
599
|
+
} else if (!_isEager) {
|
|
595
600
|
// A successful mutating (non-eager) tool invalidates the
|
|
596
601
|
// cross-turn dedup map wholesale: any prior read/grep may
|
|
597
602
|
// now return different content, so a post-edit
|