mixdog 0.9.47 → 0.9.50
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 +19 -9
- 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/embedding-worker-exit-test.mjs +76 -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/smoke-loop.mjs +9 -3
- package/scripts/smoke.mjs +5 -106
- 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 +281 -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/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/embedding-provider.mjs +3 -2
- package/src/runtime/memory/lib/memory-action-handlers.mjs +21 -11
- package/src/runtime/memory/tool-defs.mjs +1 -2
- 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
|
@@ -246,7 +246,7 @@ function _sanitizeInputSchema(schema, toolName) {
|
|
|
246
246
|
// None of the branches' required lists are hoisted — callers that relied
|
|
247
247
|
// on discriminated-union semantics will still function; the model simply
|
|
248
248
|
// receives a union of the property surface with no hard-required constraint.
|
|
249
|
-
const mergedProps = {};
|
|
249
|
+
const mergedProps = { ...(schema.properties && typeof schema.properties === 'object' ? schema.properties : {}) };
|
|
250
250
|
const branchDescs = [];
|
|
251
251
|
for (const branch of Array.isArray(compound) ? compound : []) {
|
|
252
252
|
if (branch && typeof branch === 'object' && branch.properties) {
|
|
@@ -317,7 +317,26 @@ function nativeAnthropicTools(opts) {
|
|
|
317
317
|
function toAnthropicToolChoice(toolChoice) {
|
|
318
318
|
return toolChoice === 'none' ? { type: 'none' } : undefined;
|
|
319
319
|
}
|
|
320
|
-
function
|
|
320
|
+
function discoveredAnthropicToolNames(messages, opts, provider) {
|
|
321
|
+
const anthropicNative = new Set(['anthropic', 'anthropic-oauth']);
|
|
322
|
+
const discovered = new Set(
|
|
323
|
+
Array.isArray(opts?.session?.deferredDiscoveredTools)
|
|
324
|
+
? opts.session.deferredDiscoveredTools.map((name) => String(name || '').trim()).filter(Boolean)
|
|
325
|
+
: [],
|
|
326
|
+
);
|
|
327
|
+
for (const message of Array.isArray(messages) ? messages : []) {
|
|
328
|
+
const native = message?.nativeToolSearch;
|
|
329
|
+
const source = String(native?.provider || '').toLowerCase();
|
|
330
|
+
if (source && source !== provider
|
|
331
|
+
&& !(anthropicNative.has(source) && anthropicNative.has(provider))) continue;
|
|
332
|
+
for (const name of Array.isArray(native?.toolReferences) ? native.toolReferences : []) {
|
|
333
|
+
const key = String(name || '').trim();
|
|
334
|
+
if (key) discovered.add(key);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return discovered;
|
|
338
|
+
}
|
|
339
|
+
function deferredAnthropicTools(activeTools, messages, opts) {
|
|
321
340
|
if (opts?.session?.deferredNativeTools !== true) return [];
|
|
322
341
|
// A request whose ONLY tools are deferred is rejected by the API with
|
|
323
342
|
// `400: At least one tool must have defer_loading=false` — happens on the
|
|
@@ -325,9 +344,10 @@ function deferredAnthropicTools(activeTools, opts) {
|
|
|
325
344
|
// No active tools ⇒ send no deferred catalog either.
|
|
326
345
|
if (!Array.isArray(activeTools) || activeTools.length === 0) return [];
|
|
327
346
|
const active = new Set((activeTools || []).map((tool) => String(tool?.name || '').trim()).filter(Boolean));
|
|
347
|
+
const discovered = discoveredAnthropicToolNames(messages, opts, 'anthropic-oauth');
|
|
328
348
|
const catalog = Array.isArray(opts.session.deferredToolCatalog) ? opts.session.deferredToolCatalog : [];
|
|
329
349
|
return catalog
|
|
330
|
-
.filter((tool) => tool?.name && !active.has(String(tool.name)))
|
|
350
|
+
.filter((tool) => tool?.name && discovered.has(String(tool.name)) && !active.has(String(tool.name)))
|
|
331
351
|
.map((tool) => ({ ...tool, deferLoading: true }));
|
|
332
352
|
}
|
|
333
353
|
|
|
@@ -374,15 +394,19 @@ function toAnthropicMessages(messages) {
|
|
|
374
394
|
|
|
375
395
|
if (m.role === 'tool') {
|
|
376
396
|
const last = result[result.length - 1];
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
397
|
+
const native = m.nativeToolSearch;
|
|
398
|
+
const nativeProvider = String(native?.provider || '').toLowerCase();
|
|
399
|
+
const anthropicNative = new Set(['anthropic', 'anthropic-oauth']);
|
|
400
|
+
const references = (!nativeProvider || anthropicNative.has(nativeProvider))
|
|
401
|
+
&& Array.isArray(native?.toolReferences)
|
|
402
|
+
? native.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
|
|
403
|
+
: [];
|
|
382
404
|
const block = {
|
|
383
405
|
type: 'tool_result',
|
|
384
406
|
tool_use_id: m.toolCallId || '',
|
|
385
|
-
content:
|
|
407
|
+
content: references.length
|
|
408
|
+
? references.map((tool_name) => ({ type: 'tool_reference', tool_name }))
|
|
409
|
+
: normalizeContentForAnthropic(m.content),
|
|
386
410
|
};
|
|
387
411
|
if (last?.role === 'user' && Array.isArray(last.content)) {
|
|
388
412
|
last.content.push(block);
|
|
@@ -627,7 +651,7 @@ function buildRequestBody(messages, model, tools, sendOpts) {
|
|
|
627
651
|
if (systemBlocks.length) body.system = systemBlocks;
|
|
628
652
|
|
|
629
653
|
const nativeTools = nativeAnthropicTools(opts);
|
|
630
|
-
const deferredTools = deferredAnthropicTools(tools || [], opts);
|
|
654
|
+
const deferredTools = deferredAnthropicTools(tools || [], chatMsgs, opts);
|
|
631
655
|
if (tools?.length || nativeTools.length || deferredTools.length) {
|
|
632
656
|
// No cache_control on tools — the systemBase BP already covers the
|
|
633
657
|
// tools prefix via Anthropic's prompt cache prefix semantics (order:
|
|
@@ -1329,4 +1353,4 @@ export { parseSSEStream, _classifyMidstreamError, ANTHROPIC_MAX_MIDSTREAM_RETRIE
|
|
|
1329
1353
|
|
|
1330
1354
|
// Test-only escape hatch for scripts/tool-smoke.mjs to verify the
|
|
1331
1355
|
// catalog-driven max-tokens resolution without duplicating its logic.
|
|
1332
|
-
export const _test = { resolveMaxTokens };
|
|
1356
|
+
export const _test = { resolveMaxTokens, deferredAnthropicTools, sanitizeInputSchema: _sanitizeInputSchema };
|
|
@@ -231,7 +231,7 @@ function resolveMaxTokens(model) {
|
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
// Test-only escape hatch for scripts/anthropic-maxtokens-test.mjs.
|
|
234
|
-
export const _test = { resolveMaxTokens };
|
|
234
|
+
export const _test = { resolveMaxTokens, deferredAnthropicTools, sanitizeInputSchema: _sanitizeInputSchema };
|
|
235
235
|
|
|
236
236
|
const MIN_THINKING_BUDGET = 1024;
|
|
237
237
|
const THINKING_OUTPUT_RESERVE = 1024;
|
|
@@ -252,7 +252,7 @@ function _sanitizeInputSchema(schema, toolName) {
|
|
|
252
252
|
}
|
|
253
253
|
const compound = schema.oneOf || schema.anyOf || schema.allOf;
|
|
254
254
|
if (!compound) return structuredClone(schema);
|
|
255
|
-
const mergedProps = {};
|
|
255
|
+
const mergedProps = { ...(schema.properties && typeof schema.properties === 'object' ? schema.properties : {}) };
|
|
256
256
|
const branchDescs = [];
|
|
257
257
|
for (const branch of Array.isArray(compound) ? compound : []) {
|
|
258
258
|
if (branch && typeof branch === 'object' && branch.properties) {
|
|
@@ -324,16 +324,36 @@ function nativeAnthropicTools(opts) {
|
|
|
324
324
|
function toAnthropicToolChoice(toolChoice) {
|
|
325
325
|
return toolChoice === 'none' ? { type: 'none' } : undefined;
|
|
326
326
|
}
|
|
327
|
-
function
|
|
327
|
+
function discoveredAnthropicToolNames(messages, opts, provider) {
|
|
328
|
+
const anthropicNative = new Set(['anthropic', 'anthropic-oauth']);
|
|
329
|
+
const discovered = new Set(
|
|
330
|
+
Array.isArray(opts?.session?.deferredDiscoveredTools)
|
|
331
|
+
? opts.session.deferredDiscoveredTools.map((name) => String(name || '').trim()).filter(Boolean)
|
|
332
|
+
: [],
|
|
333
|
+
);
|
|
334
|
+
for (const message of Array.isArray(messages) ? messages : []) {
|
|
335
|
+
const native = message?.nativeToolSearch;
|
|
336
|
+
const source = String(native?.provider || '').toLowerCase();
|
|
337
|
+
if (source && source !== provider
|
|
338
|
+
&& !(anthropicNative.has(source) && anthropicNative.has(provider))) continue;
|
|
339
|
+
for (const name of Array.isArray(native?.toolReferences) ? native.toolReferences : []) {
|
|
340
|
+
const key = String(name || '').trim();
|
|
341
|
+
if (key) discovered.add(key);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return discovered;
|
|
345
|
+
}
|
|
346
|
+
function deferredAnthropicTools(activeTools, messages, opts) {
|
|
328
347
|
if (opts?.session?.deferredNativeTools !== true) return [];
|
|
329
348
|
// Guard against an all-deferred tools array — the API rejects it with
|
|
330
349
|
// `400: At least one tool must have defer_loading=false` (iteration-cap
|
|
331
350
|
// final turn sends tools: []). See anthropic-oauth.mjs counterpart.
|
|
332
351
|
if (!Array.isArray(activeTools) || activeTools.length === 0) return [];
|
|
333
352
|
const active = new Set((activeTools || []).map((tool) => String(tool?.name || '').trim()).filter(Boolean));
|
|
353
|
+
const discovered = discoveredAnthropicToolNames(messages, opts, 'anthropic');
|
|
334
354
|
const catalog = Array.isArray(opts.session.deferredToolCatalog) ? opts.session.deferredToolCatalog : [];
|
|
335
355
|
return catalog
|
|
336
|
-
.filter((tool) => tool?.name && !active.has(String(tool.name)))
|
|
356
|
+
.filter((tool) => tool?.name && discovered.has(String(tool.name)) && !active.has(String(tool.name)))
|
|
337
357
|
.map((tool) => ({ ...tool, deferLoading: true }));
|
|
338
358
|
}
|
|
339
359
|
function toAnthropicMessages(messages) {
|
|
@@ -376,15 +396,19 @@ function toAnthropicMessages(messages) {
|
|
|
376
396
|
}
|
|
377
397
|
if (m.role === 'tool') {
|
|
378
398
|
const last = result[result.length - 1];
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
399
|
+
const native = m.nativeToolSearch;
|
|
400
|
+
const nativeProvider = String(native?.provider || '').toLowerCase();
|
|
401
|
+
const anthropicNative = new Set(['anthropic', 'anthropic-oauth']);
|
|
402
|
+
const references = (!nativeProvider || anthropicNative.has(nativeProvider))
|
|
403
|
+
&& Array.isArray(native?.toolReferences)
|
|
404
|
+
? native.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
|
|
405
|
+
: [];
|
|
384
406
|
const block = {
|
|
385
407
|
type: 'tool_result',
|
|
386
408
|
tool_use_id: m.toolCallId || '',
|
|
387
|
-
content:
|
|
409
|
+
content: references.length
|
|
410
|
+
? references.map((tool_name) => ({ type: 'tool_reference', tool_name }))
|
|
411
|
+
: normalizeContentForAnthropic(m.content),
|
|
388
412
|
};
|
|
389
413
|
if (last?.role === 'user' && Array.isArray(last.content)) {
|
|
390
414
|
last.content.push(block);
|
|
@@ -620,7 +644,7 @@ export class AnthropicProvider {
|
|
|
620
644
|
if (tools?.length || nativeTools.length) {
|
|
621
645
|
// No cache_control on tools — the system BP covers tools via
|
|
622
646
|
// Anthropic prefix semantics (order: tools → system → messages).
|
|
623
|
-
params.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredAnthropicTools(tools || [], opts)])];
|
|
647
|
+
params.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredAnthropicTools(tools || [], chatMsgs, opts)])];
|
|
624
648
|
}
|
|
625
649
|
// tool_choice only when tools are actually present (Anthropic rejects
|
|
626
650
|
// tool_choice without tools). 'none' rides the hard-cap final turn to
|
|
@@ -47,3 +47,31 @@ export function customToolCallFromResponseItem(item) {
|
|
|
47
47
|
export function isCustomToolCallRecord(call) {
|
|
48
48
|
return call?.nativeType === 'custom_tool_call';
|
|
49
49
|
}
|
|
50
|
+
|
|
51
|
+
export function nativeToolSearchCallInput(call) {
|
|
52
|
+
if (call?.nativeType !== 'tool_search_call') return null;
|
|
53
|
+
return {
|
|
54
|
+
type: 'tool_search_call',
|
|
55
|
+
call_id: call.id || '',
|
|
56
|
+
execution: 'client',
|
|
57
|
+
arguments: call.arguments && typeof call.arguments === 'object' ? call.arguments : {},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function nativeToolSearchOutputInput(message, provider) {
|
|
62
|
+
const native = message?.nativeToolSearch;
|
|
63
|
+
const source = String(native?.provider || '').toLowerCase();
|
|
64
|
+
const target = String(provider || '').toLowerCase();
|
|
65
|
+
const openaiNative = new Set(['openai', 'openai-oauth']);
|
|
66
|
+
const sameNativeFamily = source === target
|
|
67
|
+
|| (openaiNative.has(source) && openaiNative.has(target));
|
|
68
|
+
if (!native || (source && !sameNativeFamily)) return null;
|
|
69
|
+
if (!Array.isArray(native.openaiTools)) return null;
|
|
70
|
+
return {
|
|
71
|
+
type: 'tool_search_output',
|
|
72
|
+
call_id: message.toolCallId || '',
|
|
73
|
+
status: 'completed',
|
|
74
|
+
execution: 'client',
|
|
75
|
+
tools: native.openaiTools,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -117,7 +117,7 @@ function functionToolFromSessionTool(t, name = t?.name) {
|
|
|
117
117
|
export function toResponsesTools(tools, options = {}) {
|
|
118
118
|
const provider = String(options?.provider || '').toLowerCase();
|
|
119
119
|
const allowNativeToolSearch = options?.nativeToolSearch === true
|
|
120
|
-
|| (options?.nativeToolSearch !== false && provider
|
|
120
|
+
|| (options?.nativeToolSearch !== false && (provider === 'openai' || provider === 'openai-oauth'));
|
|
121
121
|
return tools.map((t) => {
|
|
122
122
|
// load_tool advertises as the OpenAI-native `tool_search` wire type
|
|
123
123
|
// (legacy 'tool_search' name still accepted for back-compat). xAI/Grok
|
|
@@ -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)
|