mixdog 0.9.36 → 0.9.38
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 +3 -2
- package/scripts/abort-recovery-test.mjs +118 -0
- package/scripts/compact-smoke.mjs +7 -7
- package/scripts/dispatch-persist-recovery-test.mjs +141 -0
- package/scripts/explore-bench-tmp.mjs +19 -0
- package/scripts/explore-bench.mjs +101 -14
- package/scripts/explore-prompt-policy-test.mjs +31 -0
- package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
- package/scripts/notify-completion-mirror-test.mjs +73 -0
- package/scripts/openai-ws-early-settle-test.mjs +176 -0
- package/scripts/output-style-smoke.mjs +17 -17
- package/scripts/provider-toolcall-test.mjs +333 -14
- package/scripts/recall-bench-cases.json +3 -3
- package/scripts/recall-bench.mjs +1 -1
- package/scripts/recall-quality-cases.json +4 -4
- package/scripts/recall-usecase-cases.json +2 -2
- package/scripts/session-bench.mjs +13 -13
- package/scripts/session-sweep.mjs +266 -0
- package/scripts/steering-drain-buckets-test.mjs +72 -11
- package/scripts/submit-commandbusy-race-test.mjs +114 -0
- package/scripts/tool-smoke.mjs +83 -10
- package/src/app.mjs +2 -1
- package/src/defaults/cycle3-review-prompt.md +9 -0
- package/src/defaults/skills/setup/SKILL.md +93 -293
- package/src/lib/rules-builder.cjs +3 -2
- package/src/output-styles/default.md +2 -2
- package/src/output-styles/extreme-minimal.md +1 -1
- package/src/output-styles/minimal.md +1 -1
- package/src/output-styles/simple.md +2 -3
- package/src/rules/agent/30-explorer.md +40 -14
- package/src/rules/lead/01-general.md +4 -0
- package/src/rules/shared/01-tool.md +7 -3
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +15 -3
- package/src/runtime/agent/orchestrator/config.mjs +1 -1
- package/src/runtime/agent/orchestrator/dispatch-persist.mjs +31 -8
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +13 -7
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +6 -6
- package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +35 -5
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +27 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +36 -37
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +72 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +26 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +12 -28
- package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +18 -15
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +5 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +119 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +113 -2
- package/src/runtime/agent/orchestrator/providers/registry.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +78 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +57 -31
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +8 -6
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +28 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +1 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +15 -2
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +30 -55
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +24 -16
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +116 -21
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +5 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -9
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +77 -31
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +25 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +73 -25
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +17 -7
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +14 -12
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +5 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +56 -6
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
- package/src/runtime/channels/lib/worker-main.mjs +5 -0
- package/src/runtime/memory/lib/core-memory-store.mjs +104 -0
- package/src/runtime/memory/lib/ko-morph.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle3.mjs +64 -4
- package/src/runtime/memory/lib/memory-text-utils.mjs +4 -4
- package/src/runtime/memory/lib/recall-format.mjs +3 -3
- package/src/runtime/shared/child-spawn-gate.mjs +15 -0
- package/src/runtime/shared/config.mjs +98 -12
- package/src/runtime/shared/process-listener-headroom.mjs +12 -0
- package/src/session-runtime/cwd-plugins.mjs +6 -3
- package/src/session-runtime/model-route-api.mjs +50 -2
- package/src/session-runtime/provider-auth-api.mjs +8 -1
- package/src/session-runtime/provider-models.mjs +3 -3
- package/src/session-runtime/resource-api.mjs +22 -0
- package/src/session-runtime/runtime-core.mjs +56 -10
- package/src/session-runtime/settings-api.mjs +11 -2
- package/src/session-runtime/warmup-schedulers.mjs +7 -1
- package/src/standalone/agent-tool.mjs +15 -9
- package/src/standalone/explore-tool.mjs +4 -1
- package/src/tui/App.jsx +6 -5
- package/src/tui/app/transcript-window.mjs +60 -10
- package/src/tui/app/use-transcript-window.mjs +2 -1
- package/src/tui/components/ContextPanel.jsx +4 -1
- package/src/tui/components/ToolExecution.jsx +15 -12
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +8 -8
- package/src/tui/dist/index.mjs +482 -151
- package/src/tui/engine/agent-job-feed.mjs +36 -6
- package/src/tui/engine/notification-plan.mjs +3 -3
- package/src/tui/engine/queue-helpers.mjs +8 -0
- package/src/tui/engine/render-timing.mjs +104 -8
- package/src/tui/engine/session-api.mjs +58 -3
- package/src/tui/engine/session-flow.mjs +93 -29
- package/src/tui/engine/tool-card-results.mjs +28 -13
- package/src/tui/engine/turn.mjs +238 -157
- package/src/tui/engine.mjs +17 -8
- package/src/tui/index.jsx +10 -1
- package/src/workflows/default/WORKFLOW.md +8 -4
- package/src/workflows/solo/WORKFLOW.md +8 -4
|
@@ -26,6 +26,7 @@ import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
|
|
|
26
26
|
import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
|
|
27
27
|
import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
|
|
28
28
|
import { CODEX_OAUTH_ORIGINATOR, CODEX_RESPONSES_URL, _displayCodexModel } from './openai-oauth.mjs';
|
|
29
|
+
import { createActiveToolItemTracker } from './tool-stream-state.mjs';
|
|
29
30
|
|
|
30
31
|
// Public OpenAI Responses API endpoint for the api-key `openai` provider.
|
|
31
32
|
// The openai-direct WS transport hits the same origin (openai-ws-pool
|
|
@@ -297,7 +298,15 @@ export async function sendViaHttpSse({
|
|
|
297
298
|
try {
|
|
298
299
|
_streamAbortReason.partialContent = content;
|
|
299
300
|
_streamAbortReason.partialToolCalls = toolCalls.length ? toolCalls.slice() : undefined;
|
|
300
|
-
|
|
301
|
+
// Shared active tool-item tracker (tool-stream-state.mjs) closes
|
|
302
|
+
// the custom-tool gap: a stall mid custom_tool_call_input.delta
|
|
303
|
+
// never lands in pendingCalls, so without activeToolItems/_toolInFlight
|
|
304
|
+
// a tool-bearing turn would look text-only and be wrongly accepted
|
|
305
|
+
// as a partial-final.
|
|
306
|
+
_streamAbortReason.pendingToolUse = pendingCalls.size > 0
|
|
307
|
+
|| emittedToolCallIds.size > 0
|
|
308
|
+
|| activeToolItems.size > 0
|
|
309
|
+
|| _toolInFlight === true;
|
|
301
310
|
_streamAbortReason.partialModel = model || undefined;
|
|
302
311
|
} catch { /* best-effort enrichment */ }
|
|
303
312
|
try { reader.cancel(_streamAbortReason).catch(() => {}); } catch {}
|
|
@@ -313,6 +322,15 @@ export async function sendViaHttpSse({
|
|
|
313
322
|
let ttftMs = null;
|
|
314
323
|
const toolCalls = [];
|
|
315
324
|
const pendingCalls = new Map();
|
|
325
|
+
// Active tool-item / alias tracking shared with the WS + compat Responses
|
|
326
|
+
// streams (tool-stream-state.mjs). Mark on output_item.added / arg-input
|
|
327
|
+
// deltas, clear on output_item.done; _toolInFlight latches tool work the
|
|
328
|
+
// moment a call's input starts streaming (before it lands in pendingCalls).
|
|
329
|
+
const _toolTracker = createActiveToolItemTracker();
|
|
330
|
+
const activeToolItems = _toolTracker.items;
|
|
331
|
+
const markActiveToolItem = _toolTracker.mark;
|
|
332
|
+
const clearActiveToolItem = _toolTracker.clear;
|
|
333
|
+
let _toolInFlight = false;
|
|
316
334
|
const reasoningItems = [];
|
|
317
335
|
const citations = [];
|
|
318
336
|
const citationKeys = new Set();
|
|
@@ -486,10 +504,12 @@ export async function sendViaHttpSse({
|
|
|
486
504
|
break;
|
|
487
505
|
case 'response.output_item.added':
|
|
488
506
|
if (event.item?.type === 'function_call') {
|
|
507
|
+
markActiveToolItem(event.item);
|
|
489
508
|
pendingCalls.set(event.item.id || '', {
|
|
490
509
|
name: event.item.name || '',
|
|
491
510
|
callId: event.item.call_id || '',
|
|
492
511
|
});
|
|
512
|
+
_toolInFlight = true;
|
|
493
513
|
} else if (event.item?.type === 'tool_search_call') {
|
|
494
514
|
// Mark tool_search as in-flight the moment the item is
|
|
495
515
|
// added, mirroring function_call above, so the semantic
|
|
@@ -506,9 +526,21 @@ export async function sendViaHttpSse({
|
|
|
506
526
|
kind: 'tool_search',
|
|
507
527
|
});
|
|
508
528
|
}
|
|
529
|
+
markActiveToolItem(event.item);
|
|
530
|
+
_toolInFlight = true;
|
|
531
|
+
} else if (event.item?.type === 'custom_tool_call') {
|
|
532
|
+
// Custom tool calls surface no pendingCalls entry, so mark
|
|
533
|
+
// the item active at added-time (mirroring function_call /
|
|
534
|
+
// tool_search_call above). The later custom_tool_call_input.delta
|
|
535
|
+
// still marks too, but a stall between added and the first
|
|
536
|
+
// input delta must already read as pendingToolUse.
|
|
537
|
+
markActiveToolItem(event.item);
|
|
538
|
+
_toolInFlight = true;
|
|
509
539
|
}
|
|
510
540
|
break;
|
|
511
541
|
case 'response.function_call_arguments.delta':
|
|
542
|
+
markActiveToolItem(null, event.item_id);
|
|
543
|
+
_toolInFlight = true;
|
|
512
544
|
meaningful();
|
|
513
545
|
break;
|
|
514
546
|
case 'response.function_call_arguments.done': {
|
|
@@ -530,6 +562,8 @@ export async function sendViaHttpSse({
|
|
|
530
562
|
break;
|
|
531
563
|
}
|
|
532
564
|
case 'response.custom_tool_call_input.delta':
|
|
565
|
+
markActiveToolItem(null, event.item_id);
|
|
566
|
+
_toolInFlight = true;
|
|
533
567
|
meaningful();
|
|
534
568
|
break;
|
|
535
569
|
case 'response.output_item.done': {
|
|
@@ -546,11 +580,23 @@ export async function sendViaHttpSse({
|
|
|
546
580
|
emitToolCall(tc);
|
|
547
581
|
}
|
|
548
582
|
}
|
|
583
|
+
// Drop the resolved function item from pendingCalls before
|
|
584
|
+
// recomputing _toolInFlight (mirrors tool_search_call below
|
|
585
|
+
// and the compat path) — otherwise a completed call keeps
|
|
586
|
+
// pendingCalls.size > 0 and the latch never clears, so a
|
|
587
|
+
// later max-output cutoff is misread as a tool in flight.
|
|
588
|
+
pendingCalls.delete(item.id || '');
|
|
589
|
+
clearActiveToolItem(item, item.id || '');
|
|
590
|
+
_toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
|
|
549
591
|
} else if (item.type === 'tool_search_call') {
|
|
550
592
|
pendingCalls.delete(item.id || '');
|
|
551
593
|
pushToolSearchCall(item);
|
|
594
|
+
clearActiveToolItem(item, item.id || '');
|
|
595
|
+
_toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
|
|
552
596
|
} else if (item.type === 'custom_tool_call') {
|
|
553
597
|
pushCustomToolCall(item);
|
|
598
|
+
clearActiveToolItem(item, item.id || '');
|
|
599
|
+
_toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
|
|
554
600
|
meaningful();
|
|
555
601
|
}
|
|
556
602
|
break;
|
|
@@ -636,6 +682,20 @@ export async function sendViaHttpSse({
|
|
|
636
682
|
} else if (event.response.status === 'incomplete') {
|
|
637
683
|
const reason = _incompleteReasonFromEvent(event);
|
|
638
684
|
if (_isMaxOutputIncompleteReason(reason)) {
|
|
685
|
+
// Max-output cutoff with a function/custom/tool_search
|
|
686
|
+
// still in flight means the tool arguments were
|
|
687
|
+
// truncated — do NOT mark a clean completion (mirrors
|
|
688
|
+
// the compat Responses path), or partial args surface as
|
|
689
|
+
// a successful tool call. Throw a stream-stalled
|
|
690
|
+
// pendingToolUse error so the loop gates/retries.
|
|
691
|
+
if (pendingCalls.size > 0 || activeToolItems.size > 0 || _toolInFlight === true) {
|
|
692
|
+
const err = _stampToolSafety(new Error('OpenAI OAuth HTTP fallback response.done incomplete (max_output_tokens) with tool call in flight'));
|
|
693
|
+
err.streamStalled = true;
|
|
694
|
+
err.pendingToolUse = true;
|
|
695
|
+
err.partialContent = content;
|
|
696
|
+
err.partialModel = model || undefined;
|
|
697
|
+
throw err;
|
|
698
|
+
}
|
|
639
699
|
completed = true;
|
|
640
700
|
stopReason = 'length';
|
|
641
701
|
break;
|
|
@@ -652,6 +712,17 @@ export async function sendViaHttpSse({
|
|
|
652
712
|
case 'response.incomplete': {
|
|
653
713
|
const reason = _incompleteReasonFromEvent(event);
|
|
654
714
|
if (_isMaxOutputIncompleteReason(reason)) {
|
|
715
|
+
// See response.done incomplete above: a max-output cutoff
|
|
716
|
+
// while a tool is in flight is a truncated tool call, not a
|
|
717
|
+
// clean length completion.
|
|
718
|
+
if (pendingCalls.size > 0 || activeToolItems.size > 0 || _toolInFlight === true) {
|
|
719
|
+
const err = _stampToolSafety(new Error('OpenAI OAuth HTTP fallback response.incomplete (max_output_tokens) with tool call in flight'));
|
|
720
|
+
err.streamStalled = true;
|
|
721
|
+
err.pendingToolUse = true;
|
|
722
|
+
err.partialContent = content;
|
|
723
|
+
err.partialModel = model || undefined;
|
|
724
|
+
throw err;
|
|
725
|
+
}
|
|
655
726
|
completed = true;
|
|
656
727
|
stopReason = 'length';
|
|
657
728
|
break;
|
|
@@ -895,7 +895,26 @@ export async function sendViaWebSocket({
|
|
|
895
895
|
// Delta opt-in still chains via entry.lastResponseId above.
|
|
896
896
|
}
|
|
897
897
|
|
|
898
|
-
|
|
898
|
+
// Warmup writes the same prefix with generate:false, but the first
|
|
899
|
+
// real response must still be a FULL generating frame. Reusing the
|
|
900
|
+
// warmup response_id here would make _computeDelta reduce the frame
|
|
901
|
+
// input to [] when the warmup input matches, and a generate:false
|
|
902
|
+
// warmup is not a chainable response to continue from — so the first
|
|
903
|
+
// real turn would generate from an empty frame. Keep the warmup state
|
|
904
|
+
// for cache/trace, but compute the main frame as cold (full input +
|
|
905
|
+
// instructions).
|
|
906
|
+
const deltaEntry = warmupResult
|
|
907
|
+
? {
|
|
908
|
+
...entry,
|
|
909
|
+
lastResponseId: null,
|
|
910
|
+
lastRequestSansInput: null,
|
|
911
|
+
lastRequestInput: null,
|
|
912
|
+
lastResponseItems: null,
|
|
913
|
+
lastInputLen: 0,
|
|
914
|
+
lastInputPrefixHash: null,
|
|
915
|
+
}
|
|
916
|
+
: entry;
|
|
917
|
+
const delta = _computeDelta({ entry: deltaEntry, body: requestBody, traceProvider });
|
|
899
918
|
({ mode, frame } = delta);
|
|
900
919
|
deltaReason = delta.reason || null;
|
|
901
920
|
strippedResponseItems = delta.strippedResponseItems || 0;
|
|
@@ -1063,7 +1082,11 @@ export async function sendViaWebSocket({
|
|
|
1063
1082
|
// own, so retaining the anchor cannot corrupt the cache — it only adds
|
|
1064
1083
|
// a delta fast-path when the items DO match.
|
|
1065
1084
|
const keepResponseChain = !!result.responseId;
|
|
1066
|
-
|
|
1085
|
+
// Normally the socket is pooled for reuse. But an early tool-call settle
|
|
1086
|
+
// (result.closeSocket) means the stream resolved before
|
|
1087
|
+
// response.completed/done arrived: the server may still emit those as
|
|
1088
|
+
// orphan frames, so the socket must be discarded, not reused.
|
|
1089
|
+
const keepSocket = !result.closeSocket;
|
|
1067
1090
|
|
|
1068
1091
|
// Update cache state for the next iteration in this session. openai-oauth
|
|
1069
1092
|
// keeps the previous response anchor even when the model emitted tool
|
|
@@ -1300,7 +1323,7 @@ export async function sendViaWebSocket({
|
|
|
1300
1323
|
} catch {}
|
|
1301
1324
|
|
|
1302
1325
|
releaseWebSocket({ entry, poolKey, keep: keepSocket });
|
|
1303
|
-
const { responseId: _ignored, responseItems: _responseItemsIgnored, ...out } = result;
|
|
1326
|
+
const { responseId: _ignored, responseItems: _responseItemsIgnored, closeSocket: _closeSocketIgnored, ...out } = result;
|
|
1304
1327
|
if (includeResponseId && result.responseId) out.responseId = result.responseId;
|
|
1305
1328
|
if (warmupResult) {
|
|
1306
1329
|
try {
|
|
@@ -410,16 +410,6 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
|
|
|
410
410
|
for (const m of messages) {
|
|
411
411
|
if (!m || m.role === 'system') continue;
|
|
412
412
|
if (m.role === 'tool') {
|
|
413
|
-
if (Array.isArray(m.nativeToolSearch?.openaiTools)) {
|
|
414
|
-
out.push({
|
|
415
|
-
type: 'tool_search_output',
|
|
416
|
-
call_id: m.toolCallId || '',
|
|
417
|
-
status: 'completed',
|
|
418
|
-
execution: 'client',
|
|
419
|
-
tools: m.nativeToolSearch.openaiTools,
|
|
420
|
-
});
|
|
421
|
-
continue;
|
|
422
|
-
}
|
|
423
413
|
const { output, mediaContent } = splitToolContentForOpenAIResponses(m.content);
|
|
424
414
|
if (customToolCallNameById.has(m.toolCallId || '')) {
|
|
425
415
|
out.push({
|
|
@@ -449,14 +439,7 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
|
|
|
449
439
|
// reasoning in `input` triggers "Duplicate item".
|
|
450
440
|
if (m.content) out.push(wireMessage('assistant', normalizeContentForOpenAIResponses(m.content, { role: 'assistant' })));
|
|
451
441
|
for (const tc of m.toolCalls) {
|
|
452
|
-
if (tc
|
|
453
|
-
out.push({
|
|
454
|
-
type: 'tool_search_call',
|
|
455
|
-
call_id: tc.id,
|
|
456
|
-
execution: 'client',
|
|
457
|
-
arguments: tc.arguments || {},
|
|
458
|
-
});
|
|
459
|
-
} else if (isCustomToolCallRecord(tc)) {
|
|
442
|
+
if (isCustomToolCallRecord(tc)) {
|
|
460
443
|
if (tc.id) customToolCallNameById.set(tc.id, tc.name || '');
|
|
461
444
|
out.push({
|
|
462
445
|
type: 'custom_tool_call',
|
|
@@ -468,7 +451,7 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
|
|
|
468
451
|
out.push({
|
|
469
452
|
type: 'function_call',
|
|
470
453
|
call_id: tc.id,
|
|
471
|
-
name: tc.name,
|
|
454
|
+
name: tc.name === 'tool_search' ? 'load_tool' : tc.name,
|
|
472
455
|
arguments: JSON.stringify(tc.arguments),
|
|
473
456
|
});
|
|
474
457
|
}
|
|
@@ -487,8 +470,8 @@ function convertMessagesToResponsesInput(messages, opts = {}) {
|
|
|
487
470
|
function toOpenAIResponsesTool(t) {
|
|
488
471
|
if (t?.name === 'load_tool' || t?.name === 'tool_search') {
|
|
489
472
|
return {
|
|
490
|
-
type: '
|
|
491
|
-
|
|
473
|
+
type: 'function',
|
|
474
|
+
name: 'load_tool',
|
|
492
475
|
description: t.description,
|
|
493
476
|
parameters: t.inputSchema,
|
|
494
477
|
};
|
|
@@ -923,13 +906,14 @@ export class OpenAIOAuthProvider {
|
|
|
923
906
|
useModel,
|
|
924
907
|
displayModel: _displayCodexModel,
|
|
925
908
|
forceFresh,
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
|
|
909
|
+
// Default refs-style recovery: keep using WS first. A transient
|
|
910
|
+
// first-byte / mid-stream stall closes the bad socket and retries on
|
|
911
|
+
// a fresh WS entry; only after the bounded WS retry budget is
|
|
912
|
+
// exhausted does openai-oauth fall back to HTTP/SSE. This preserves
|
|
913
|
+
// the hot WS/cache path for temporary blips while still preventing
|
|
914
|
+
// TUI-level hangs. Operators can opt into immediate HTTP fallback
|
|
915
|
+
// for diagnostics with MIXDOG_OPENAI_OAUTH_FAST_HTTP_FALLBACK=1.
|
|
916
|
+
fastFallback: httpFallbackEnabled && _envFlag('MIXDOG_OPENAI_OAUTH_FAST_HTTP_FALLBACK', false),
|
|
933
917
|
// codex-parity prewarm (generate:false full frame on a fresh
|
|
934
918
|
// socket, wire-verified 2026-07-03). DEFAULT ON: R19(off) vs
|
|
935
919
|
// R20(on) A/B shows prewarm removes ALL early-session zero-cache
|
|
@@ -3,20 +3,20 @@
|
|
|
3
3
|
* Responses-API transport policy switch (OpenAI OAuth/direct + xAI/compat).
|
|
4
4
|
*
|
|
5
5
|
* One env knob, MIXDOG_OAI_TRANSPORT, selects among the transport modes:
|
|
6
|
-
* - 'ws-delta'
|
|
6
|
+
* - 'ws-delta' WS-only transport, refs-compatible delta ON.
|
|
7
7
|
* - 'ws-full' WS transport, delta OFF (always full frames).
|
|
8
8
|
* - 'http-sse' force the HTTP/SSE transport directly (delta is WS-only, so
|
|
9
9
|
* it stays off).
|
|
10
|
-
* - 'auto'
|
|
10
|
+
* - 'auto' WS-first route with HTTP/SSE fallback on websocket failure.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* Default/unset behaves like 'auto': prefer WS/delta for cache hotness, but
|
|
13
|
+
* fall back to HTTP/SSE after bounded websocket failures. Explicit ws-* modes
|
|
14
|
+
* pin WS for experiments; http-sse pins HTTP.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
// Normalize the transport mode token. Underscores/spaces and a few common
|
|
18
18
|
// spellings collapse to the canonical modes. Unknown/empty → null so the
|
|
19
|
-
// caller falls back to the default '
|
|
19
|
+
// caller falls back to the default 'auto'.
|
|
20
20
|
export function _normalizeTransportMode(raw) {
|
|
21
21
|
const v = String(raw || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
|
|
22
22
|
switch (v) {
|
|
@@ -27,7 +27,7 @@ export function _normalizeTransportMode(raw) {
|
|
|
27
27
|
case 'http-sse': case 'httpsse': case 'http': case 'sse': case 'http/sse':
|
|
28
28
|
return 'http-sse';
|
|
29
29
|
case 'auto':
|
|
30
|
-
return '
|
|
30
|
+
return 'auto';
|
|
31
31
|
default:
|
|
32
32
|
return null;
|
|
33
33
|
}
|
|
@@ -63,9 +63,9 @@ export const RESPONSES_TRANSPORT_CAPABILITIES = Object.freeze({
|
|
|
63
63
|
export function _gateTransportMode(mode, caps) {
|
|
64
64
|
let m = mode;
|
|
65
65
|
// Delta unsupported → keep WS transport but force full frames.
|
|
66
|
-
if (m === 'ws-delta' && !caps.delta) m = 'ws-full';
|
|
66
|
+
if ((m === 'auto' || m === 'ws-delta') && !caps.delta) m = caps.ws ? 'ws-full' : (caps.http ? 'http-sse' : 'auto');
|
|
67
67
|
// WS unsupported → prefer HTTP, else defer to auto.
|
|
68
|
-
if ((m === 'ws-full' || m === 'ws-delta') && !caps.ws) m = caps.http ? 'http-sse' : '
|
|
68
|
+
if ((m === 'auto' || m === 'ws-full' || m === 'ws-delta') && !caps.ws) m = caps.http ? 'http-sse' : 'auto';
|
|
69
69
|
// HTTP unsupported → prefer full-frame WS, else defer to auto.
|
|
70
70
|
if (m === 'http-sse' && !caps.http) m = caps.ws ? 'ws-full' : 'auto';
|
|
71
71
|
return m;
|
|
@@ -76,8 +76,8 @@ export function _gateTransportMode(mode, caps) {
|
|
|
76
76
|
* per-provider capabilities.
|
|
77
77
|
* @param {Record<string,string|undefined>} [env=process.env]
|
|
78
78
|
* @param {{ws?:boolean,http?:boolean,delta?:boolean}} [capabilities=FULL_RESPONSES_TRANSPORT_CAPS]
|
|
79
|
-
* @returns {{ mode: 'ws-full'|'ws-delta'|'http-sse',
|
|
80
|
-
* requestedMode: 'ws-full'|'ws-delta'|'http-sse',
|
|
79
|
+
* @returns {{ mode: 'auto'|'ws-full'|'ws-delta'|'http-sse',
|
|
80
|
+
* requestedMode: 'auto'|'ws-full'|'ws-delta'|'http-sse',
|
|
81
81
|
* transport: 'auto'|'ws'|'http',
|
|
82
82
|
* allowHttpFallback: boolean,
|
|
83
83
|
* delta: { force: boolean, refs: boolean, optIn: boolean },
|
|
@@ -85,7 +85,7 @@ export function _gateTransportMode(mode, caps) {
|
|
|
85
85
|
*/
|
|
86
86
|
export function resolveResponsesTransportPolicy(env = process.env, capabilities = FULL_RESPONSES_TRANSPORT_CAPS) {
|
|
87
87
|
const caps = { ...FULL_RESPONSES_TRANSPORT_CAPS, ...(capabilities || {}) };
|
|
88
|
-
const requestedMode = _normalizeTransportMode(env?.MIXDOG_OAI_TRANSPORT) || '
|
|
88
|
+
const requestedMode = _normalizeTransportMode(env?.MIXDOG_OAI_TRANSPORT) || 'auto';
|
|
89
89
|
const mode = _gateTransportMode(requestedMode, caps);
|
|
90
90
|
let transport;
|
|
91
91
|
let delta;
|
|
@@ -98,6 +98,7 @@ export function resolveResponsesTransportPolicy(env = process.env, capabilities
|
|
|
98
98
|
transport = 'ws';
|
|
99
99
|
delta = DELTA_OFF; // explicit full frames
|
|
100
100
|
break;
|
|
101
|
+
case 'auto':
|
|
101
102
|
case 'ws-delta':
|
|
102
103
|
transport = 'ws';
|
|
103
104
|
// Reachable only when caps.delta is true (else gated to ws-full).
|
|
@@ -112,9 +113,11 @@ export function resolveResponsesTransportPolicy(env = process.env, capabilities
|
|
|
112
113
|
mode,
|
|
113
114
|
requestedMode,
|
|
114
115
|
transport,
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
116
|
+
// Codex refs behavior: default/auto is WS-first but not WS-only. If the
|
|
117
|
+
// websocket path stalls/fails before emitting live output, callers may
|
|
118
|
+
// replay the request over HTTP/SSE. Explicit ws-* modes remain pinned
|
|
119
|
+
// for transport experiments; explicit http-sse bypasses WS entirely.
|
|
120
|
+
allowHttpFallback: requestedMode === 'auto' && caps.http,
|
|
118
121
|
delta,
|
|
119
122
|
capabilities: caps,
|
|
120
123
|
};
|
|
@@ -195,8 +195,11 @@ const TRANSPORT_ONLY_FRAME_FIELDS = new Set(['stream', 'background']);
|
|
|
195
195
|
// identical byte-for-byte: `type` always leads, then the body's codex
|
|
196
196
|
// struct-order keys follow verbatim. A delta send passes previousResponseId
|
|
197
197
|
// (inserted immediately before `input`, matching codex's refs position) and
|
|
198
|
-
// inputOverride (the stripped tail)
|
|
199
|
-
//
|
|
198
|
+
// inputOverride (the stripped tail). `instructions` MUST still be resent on
|
|
199
|
+
// previous_response_id frames: per the OpenAI Responses API, the previous
|
|
200
|
+
// response's top-level instructions are NOT carried over to the chained
|
|
201
|
+
// response, so dropping them here strips the system/lead prompt from every
|
|
202
|
+
// continuation turn. Only an empty instructions string is omitted.
|
|
200
203
|
// Full/warmup frames pass the body unchanged and keep every key in place.
|
|
201
204
|
// omitTransportFields is used by wire-parity/prewarm helpers to drop stream/background.
|
|
202
205
|
export function _buildResponseCreateFrame(body, { previousResponseId = null, inputOverride, omitTransportFields = false } = {}) {
|
|
@@ -15,6 +15,10 @@ import { codexOriginator, codexUserAgent, codexVersionHeader } from './codex-cli
|
|
|
15
15
|
import {
|
|
16
16
|
PROVIDER_WS_ACQUIRE_TIMEOUT_MS,
|
|
17
17
|
PROVIDER_WS_HANDSHAKE_TIMEOUT_MS,
|
|
18
|
+
PROVIDER_WS_PING_ENABLED,
|
|
19
|
+
PROVIDER_WS_PING_INTERVAL_MS,
|
|
20
|
+
PROVIDER_WS_PONG_TIMEOUT_MS,
|
|
21
|
+
PROVIDER_WS_LIVENESS_STALE_MS,
|
|
18
22
|
resolveTimeoutMs,
|
|
19
23
|
} from '../stall-policy.mjs';
|
|
20
24
|
|
|
@@ -36,6 +40,10 @@ export const WS_IDLE_MS = resolveTimeoutMs(
|
|
|
36
40
|
);
|
|
37
41
|
const WS_HANDSHAKE_TIMEOUT_MS = PROVIDER_WS_HANDSHAKE_TIMEOUT_MS;
|
|
38
42
|
const WS_ACQUIRE_TIMEOUT_MS = PROVIDER_WS_ACQUIRE_TIMEOUT_MS;
|
|
43
|
+
const WS_PING_INTERVAL_MS = PROVIDER_WS_PING_INTERVAL_MS;
|
|
44
|
+
const WS_PONG_TIMEOUT_MS = PROVIDER_WS_PONG_TIMEOUT_MS;
|
|
45
|
+
const WS_LIVENESS_STALE_MS = PROVIDER_WS_LIVENESS_STALE_MS;
|
|
46
|
+
const WS_PING_ENABLED = PROVIDER_WS_PING_ENABLED;
|
|
39
47
|
|
|
40
48
|
// WS socket pool buckets are keyed by `poolKey` (the per-call sessionId)
|
|
41
49
|
// to isolate parallel agent invocations — each gets its own socket so
|
|
@@ -263,6 +271,10 @@ function _getPoolArr(poolKey) {
|
|
|
263
271
|
}
|
|
264
272
|
|
|
265
273
|
function _removeFromPool(poolKey, entry) {
|
|
274
|
+
// Always tear down per-entry timers so evicting a socket never leaks an
|
|
275
|
+
// idle-close or liveness-ping interval.
|
|
276
|
+
_clearIdle(entry);
|
|
277
|
+
_clearLiveness(entry);
|
|
266
278
|
if (!poolKey) return;
|
|
267
279
|
const arr = _wsPool.get(poolKey);
|
|
268
280
|
if (!arr) return;
|
|
@@ -293,6 +305,70 @@ function _isOpen(entry) {
|
|
|
293
305
|
return entry?.socket?.readyState === WebSocket.OPEN;
|
|
294
306
|
}
|
|
295
307
|
|
|
308
|
+
function _clearLiveness(entry) {
|
|
309
|
+
if (entry?.pingTimer) {
|
|
310
|
+
clearInterval(entry.pingTimer);
|
|
311
|
+
entry.pingTimer = null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Force a dead/half-open socket out of the pool. close() alone can hang on a
|
|
316
|
+
// wedged socket, so follow with terminate() to guarantee FD release.
|
|
317
|
+
function _evictDead(poolKey, entry) {
|
|
318
|
+
try { entry.socket.close(1000, 'ws_liveness_dead'); } catch {}
|
|
319
|
+
try { entry.socket.terminate?.(); } catch {}
|
|
320
|
+
_removeFromPool(poolKey, entry);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Send one ws-level ping and resolve true iff a pong lands within timeoutMs.
|
|
324
|
+
// Never rejects. On any success it refreshes lastAliveAt so the caller/loop
|
|
325
|
+
// treats the socket as fresh.
|
|
326
|
+
function _pingProbe(entry, timeoutMs) {
|
|
327
|
+
return new Promise((resolve) => {
|
|
328
|
+
const socket = entry?.socket;
|
|
329
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) { resolve(false); return; }
|
|
330
|
+
let done = false;
|
|
331
|
+
const finish = (alive) => {
|
|
332
|
+
if (done) return;
|
|
333
|
+
done = true;
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
try { socket.removeListener('pong', onPong); } catch {}
|
|
336
|
+
resolve(alive);
|
|
337
|
+
};
|
|
338
|
+
const onPong = () => { entry.lastAliveAt = Date.now(); finish(true); };
|
|
339
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
340
|
+
try { timer.unref?.(); } catch {}
|
|
341
|
+
try {
|
|
342
|
+
socket.on('pong', onPong);
|
|
343
|
+
socket.ping();
|
|
344
|
+
} catch {
|
|
345
|
+
finish(false);
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// While an entry sits idle in the pool, ping it every WS_PING_INTERVAL_MS.
|
|
351
|
+
// A missed pong (or a socket that is no longer OPEN) evicts the entry so it can
|
|
352
|
+
// never be handed out dead. Busy entries are skipped — an in-flight turn has
|
|
353
|
+
// its own inter-chunk/semantic-idle watchdogs.
|
|
354
|
+
function _armLiveness(poolKey, entry) {
|
|
355
|
+
_clearLiveness(entry);
|
|
356
|
+
entry.pingTimer = setInterval(async () => {
|
|
357
|
+
if (entry.busy || entry.closing || entry.probing) return;
|
|
358
|
+
if (!_isOpen(entry)) { _evictDead(poolKey, entry); return; }
|
|
359
|
+
// Recent activity ⇒ assume live, skip the probe this tick.
|
|
360
|
+
if (Date.now() - (entry.lastAliveAt || 0) < WS_LIVENESS_STALE_MS) return;
|
|
361
|
+
entry.probing = true;
|
|
362
|
+
try {
|
|
363
|
+
const alive = await _pingProbe(entry, WS_PONG_TIMEOUT_MS);
|
|
364
|
+
if (!alive && !entry.busy) _evictDead(poolKey, entry);
|
|
365
|
+
} finally {
|
|
366
|
+
entry.probing = false;
|
|
367
|
+
}
|
|
368
|
+
}, WS_PING_INTERVAL_MS);
|
|
369
|
+
try { entry.pingTimer.unref?.(); } catch {}
|
|
370
|
+
}
|
|
371
|
+
|
|
296
372
|
// Awaited frame send. Asserts the socket is OPEN and resolves only after
|
|
297
373
|
// the underlying transport reports the buffered write succeeded (or fails)
|
|
298
374
|
// via the WebSocket send callback. Raw `socket.send(JSON.stringify(...))`
|
|
@@ -596,15 +672,36 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
|
|
|
596
672
|
for (let i = arr.length - 1; i >= 0; i--) {
|
|
597
673
|
if (!_isOpen(arr[i]) || arr[i].closing) {
|
|
598
674
|
_clearIdle(arr[i]);
|
|
675
|
+
_clearLiveness(arr[i]);
|
|
599
676
|
arr.splice(i, 1);
|
|
600
677
|
}
|
|
601
678
|
}
|
|
602
679
|
if (arr.length === 0) _wsPool.delete(poolKey);
|
|
603
|
-
// Reuse
|
|
604
|
-
|
|
605
|
-
|
|
680
|
+
// Reuse an idle open entry (cache-warm path). An entry with no observed
|
|
681
|
+
// activity within the freshness window is ping-probed under a short
|
|
682
|
+
// bound before hand-out; a dead one is evicted and the scan retries the
|
|
683
|
+
// next idle entry so a busy caller is never handed a wedged socket.
|
|
684
|
+
let idle;
|
|
685
|
+
while ((idle = arr.find(e => !e.busy))) {
|
|
606
686
|
_clearIdle(idle);
|
|
687
|
+
_clearLiveness(idle);
|
|
688
|
+
// Reserve the entry BEFORE awaiting the probe: _pingProbe yields the
|
|
689
|
+
// event loop, so without this a second concurrent acquire could scan
|
|
690
|
+
// the same still-idle entry and both would take it. Marking busy up
|
|
691
|
+
// front makes the find() above skip it; on probe failure it is
|
|
692
|
+
// evicted (removed from arr) so the loop continues cleanly.
|
|
607
693
|
idle.busy = true;
|
|
694
|
+
if (WS_PING_ENABLED && Date.now() - (idle.lastAliveAt || 0) >= WS_LIVENESS_STALE_MS) {
|
|
695
|
+
const alive = await _pingProbe(idle, WS_PONG_TIMEOUT_MS);
|
|
696
|
+
if (!alive) {
|
|
697
|
+
if (process.env.MIXDOG_DEBUG_AGENT) {
|
|
698
|
+
process.stderr.write(`[agent-trace] acquire-evict-dead poolKey=${poolKey} reason=missed_pong elapsed=${Date.now() - _acqStart}ms\n`);
|
|
699
|
+
}
|
|
700
|
+
_evictDead(poolKey, idle);
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
idle.lastAliveAt = Date.now();
|
|
608
705
|
// Defensive: pre-existing pooled entries created before the
|
|
609
706
|
// prefix-hash field was introduced may not have it set. Normalize
|
|
610
707
|
// to null so the first delta check reads a deterministic value
|
|
@@ -646,6 +743,11 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
|
|
|
646
743
|
ephemeral: true,
|
|
647
744
|
sessionToken: ephSessionToken,
|
|
648
745
|
};
|
|
746
|
+
entry.lastAliveAt = Date.now();
|
|
747
|
+
entry.pingTimer = null;
|
|
748
|
+
entry.probing = false;
|
|
749
|
+
socket.on('pong', () => { entry.lastAliveAt = Date.now(); });
|
|
750
|
+
socket.on('message', () => { entry.lastAliveAt = Date.now(); });
|
|
649
751
|
socket.on('close', () => { entry.closing = true; });
|
|
650
752
|
return { entry, reused: false };
|
|
651
753
|
}
|
|
@@ -674,6 +776,11 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
|
|
|
674
776
|
ephemeral: false,
|
|
675
777
|
sessionToken,
|
|
676
778
|
};
|
|
779
|
+
entry.lastAliveAt = Date.now();
|
|
780
|
+
entry.pingTimer = null;
|
|
781
|
+
entry.probing = false;
|
|
782
|
+
socket.on('pong', () => { entry.lastAliveAt = Date.now(); });
|
|
783
|
+
socket.on('message', () => { entry.lastAliveAt = Date.now(); });
|
|
677
784
|
if (poolKey && !forceFresh) _getPoolArr(poolKey).push(entry);
|
|
678
785
|
socket.on('close', () => {
|
|
679
786
|
entry.closing = true;
|
|
@@ -690,7 +797,12 @@ export function releaseWebSocket({ entry, poolKey, keep }) {
|
|
|
690
797
|
_removeFromPool(poolKey, entry);
|
|
691
798
|
return;
|
|
692
799
|
}
|
|
800
|
+
// Mark activity at release, then arm both the idle-close timer and the
|
|
801
|
+
// periodic liveness ping so a socket that dies while pooled is evicted
|
|
802
|
+
// before the next acquire can hand it out.
|
|
803
|
+
entry.lastAliveAt = Date.now();
|
|
693
804
|
_scheduleIdleClose(poolKey, entry);
|
|
805
|
+
if (WS_PING_ENABLED) _armLiveness(poolKey, entry);
|
|
694
806
|
}
|
|
695
807
|
|
|
696
808
|
// Drain-complete fence — set true once _closeAllPooledSockets runs so any
|
|
@@ -706,6 +818,10 @@ export function _closeAllPooledSockets(reason = 'shutdown') {
|
|
|
706
818
|
_drainComplete = true;
|
|
707
819
|
for (const arr of _wsPool.values()) {
|
|
708
820
|
for (const entry of arr) {
|
|
821
|
+
// Tear down per-entry timers before dropping the map, otherwise the
|
|
822
|
+
// idle-close and liveness-ping intervals outlive the drained pool.
|
|
823
|
+
_clearIdle(entry);
|
|
824
|
+
_clearLiveness(entry);
|
|
709
825
|
try { entry.socket.close(1000, reason); } catch {}
|
|
710
826
|
}
|
|
711
827
|
}
|