mixdog 0.9.35 → 0.9.37
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/explore-bench-tmp.mjs +19 -0
- package/scripts/explore-bench.mjs +36 -6
- package/scripts/explore-prompt-policy-test.mjs +27 -0
- package/scripts/ingest-pure-conversation-smoke.mjs +11 -11
- 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/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 +15 -7
- package/src/rules/lead/01-general.md +4 -0
- 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/mcp/client.mjs +7 -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 +4 -2
- 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 +26 -17
- 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/tool-batch.mjs +5 -2
- package/src/runtime/agent/orchestrator/stall-policy.mjs +18 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +35 -20
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
- 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/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/resource-api.mjs +22 -0
- package/src/session-runtime/runtime-core.mjs +13 -2
- package/src/session-runtime/settings-api.mjs +11 -2
- 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 +473 -155
- package/src/tui/engine/agent-job-feed.mjs +26 -6
- package/src/tui/engine/context-state.mjs +13 -4
- package/src/tui/engine/notification-plan.mjs +3 -3
- 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 +78 -28
- package/src/tui/engine/tool-card-results.mjs +28 -13
- package/src/tui/engine/turn.mjs +232 -156
- 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
|
@@ -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,9 @@ 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 are dropped whenever
|
|
199
|
+
// previous_response_id is present because the server resolves them from the
|
|
200
|
+
// chained response and rejects sending both fields together.
|
|
200
201
|
// Full/warmup frames pass the body unchanged and keep every key in place.
|
|
201
202
|
// omitTransportFields is used by wire-parity/prewarm helpers to drop stream/background.
|
|
202
203
|
export function _buildResponseCreateFrame(body, { previousResponseId = null, inputOverride, omitTransportFields = false } = {}) {
|
|
@@ -214,6 +215,7 @@ export function _buildResponseCreateFrame(body, { previousResponseId = null, inp
|
|
|
214
215
|
for (const key of Object.keys(src)) {
|
|
215
216
|
if (omitTransportFields && TRANSPORT_ONLY_FRAME_FIELDS.has(key)) continue;
|
|
216
217
|
if (key === 'instructions') {
|
|
218
|
+
if (previousResponseId != null) continue;
|
|
217
219
|
const instr = src.instructions;
|
|
218
220
|
if (typeof instr === 'string' && instr.length) frame.instructions = instr;
|
|
219
221
|
continue;
|
|
@@ -23,9 +23,9 @@ import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
|
|
|
23
23
|
import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
|
|
24
24
|
import {
|
|
25
25
|
PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS,
|
|
26
|
-
PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
|
|
27
26
|
PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
|
|
28
27
|
PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS,
|
|
28
|
+
PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS,
|
|
29
29
|
streamStalledError,
|
|
30
30
|
} from '../stall-policy.mjs';
|
|
31
31
|
import { customToolCallFromResponseItem } from './custom-tool-wire.mjs';
|
|
@@ -47,6 +47,10 @@ import {
|
|
|
47
47
|
_isMaxOutputIncompleteReason,
|
|
48
48
|
_httpStatusFromWsClose,
|
|
49
49
|
} from './openai-ws-events.mjs';
|
|
50
|
+
import {
|
|
51
|
+
createActiveToolItemTracker,
|
|
52
|
+
hasCompleteToolCall as sharedHasCompleteToolCall,
|
|
53
|
+
} from './tool-stream-state.mjs';
|
|
50
54
|
|
|
51
55
|
// Facade re-exports: the delta/matching helpers and usage/event helpers below
|
|
52
56
|
// were extracted to openai-ws-delta.mjs / openai-ws-events.mjs (no behavior
|
|
@@ -258,10 +262,24 @@ export async function _streamResponse({
|
|
|
258
262
|
const citations = [];
|
|
259
263
|
const citationKeys = new Set();
|
|
260
264
|
const pendingCalls = new Map();
|
|
265
|
+
// Active tool-item / alias tracking extracted to tool-stream-state.mjs.
|
|
266
|
+
// Keep the WS-local names bound to the tracker's Set + methods so the
|
|
267
|
+
// event-switch call sites (markActiveToolItem/clearActiveToolItem/
|
|
268
|
+
// activeToolItems.size) read unchanged.
|
|
269
|
+
const _toolTracker = createActiveToolItemTracker();
|
|
270
|
+
const activeToolItems = _toolTracker.items;
|
|
271
|
+
const markActiveToolItem = _toolTracker.mark;
|
|
272
|
+
const clearActiveToolItem = _toolTracker.clear;
|
|
261
273
|
// Tool-work-in-flight flag: set the moment a function/custom tool call's
|
|
262
274
|
// input starts streaming (before it lands in pendingCalls/toolCalls).
|
|
263
275
|
// Gates partial-final SUCCESS so a stall mid tool-input never looks text-only.
|
|
264
276
|
let _toolInFlight = false;
|
|
277
|
+
// Early tool-call settle latch. Set when a fully-formed tool call is
|
|
278
|
+
// available but the server never emits response.completed/response.done —
|
|
279
|
+
// we resolve successfully anyway and surface `closeSocket` so the pool
|
|
280
|
+
// discards (never reuses) the socket, which may still carry the missing
|
|
281
|
+
// terminal frames as orphans.
|
|
282
|
+
let _earlySettled = false;
|
|
265
283
|
// Fix 2: cross-path name+args dedupe. A text-leaked synthetic and an
|
|
266
284
|
// identical native function_call must fire onToolCall exactly once. Every
|
|
267
285
|
// dispatch site routes through emitToolCallDedupe.
|
|
@@ -461,7 +479,13 @@ export async function _streamResponse({
|
|
|
461
479
|
// only) trips a short, named terminal StreamStalledError instead of coasting
|
|
462
480
|
// to the 300s inter-chunk cap / 30-min agent watchdog.
|
|
463
481
|
let semanticIdleTimer = null;
|
|
464
|
-
|
|
482
|
+
// WS gets its own shorter semantic-idle window. The shared SSE semantic
|
|
483
|
+
// timeout is intentionally near the 300s stall warning to protect Anthropic
|
|
484
|
+
// effort-mode "silent thinking"; using that value here lets a wedged WS
|
|
485
|
+
// request race the TUI 300s watchdog. Keep websocket stalls provider-owned:
|
|
486
|
+
// fail/retry/fallback here first, then let the TUI watchdog remain a final
|
|
487
|
+
// safety net only.
|
|
488
|
+
const semanticIdleMs = PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS;
|
|
465
489
|
const semanticIdleEnabled = PROVIDER_SSE_IDLE_WATCHDOG_ENABLED && semanticIdleMs > 0;
|
|
466
490
|
// First-meaningful-frame watchdog timer + one-shot latch. Armed alongside
|
|
467
491
|
// the pre-stream watchdog; cleared exactly once by the first meaningful
|
|
@@ -575,6 +599,11 @@ export async function _streamResponse({
|
|
|
575
599
|
const resetInterChunk = () => {
|
|
576
600
|
if (interChunkTimer) clearTimeout(interChunkTimer);
|
|
577
601
|
interChunkTimer = setTimeout(() => {
|
|
602
|
+
// Early settle: a complete tool call is already captured with no
|
|
603
|
+
// deferred salvage pending, so silence here is the missing
|
|
604
|
+
// response.completed/done — resolve with the tool call instead
|
|
605
|
+
// of a stall error the loop would retry.
|
|
606
|
+
if (settleEarlyOnToolCall('inter_chunk')) return;
|
|
578
607
|
if (process.env.MIXDOG_DEBUG_AGENT) {
|
|
579
608
|
process.stderr.write(`[agent-trace] ws-timeout kind=inter-chunk afterMs=${interChunkMs}\n`);
|
|
580
609
|
}
|
|
@@ -593,6 +622,11 @@ export async function _streamResponse({
|
|
|
593
622
|
if (!semanticIdleEnabled) return;
|
|
594
623
|
if (semanticIdleTimer) clearTimeout(semanticIdleTimer);
|
|
595
624
|
semanticIdleTimer = setTimeout(() => {
|
|
625
|
+
// Early settle before treating tool-work silence as a stall: if
|
|
626
|
+
// a complete tool call is available and no deferred salvage
|
|
627
|
+
// remains, the turn result is already final even without
|
|
628
|
+
// response.completed/done.
|
|
629
|
+
if (settleEarlyOnToolCall('semantic_idle')) return;
|
|
596
630
|
traceWsTimeout('semantic_idle_timeout', semanticIdleMs);
|
|
597
631
|
terminalError = streamStalledError('Responses WS', semanticIdleMs, { emittedToolCall: !!midState?.emittedToolCall });
|
|
598
632
|
// Partial-final recovery: attach streamed partial state so
|
|
@@ -672,11 +706,43 @@ export async function _streamResponse({
|
|
|
672
706
|
// for the WS path (sendViaWebSocket spreads this result
|
|
673
707
|
// through to the provider caller unchanged).
|
|
674
708
|
...(stopReason === 'length' && content.length > 0 ? { truncated: true } : {}),
|
|
709
|
+
// Early tool-call settle: tell the caller to drop (not pool)
|
|
710
|
+
// this socket. The server may still emit the skipped
|
|
711
|
+
// response.completed/done as orphan frames; reusing the socket
|
|
712
|
+
// would read them as the next request's stream and wedge it.
|
|
713
|
+
...(_earlySettled ? { closeSocket: true } : {}),
|
|
675
714
|
incompleteReason: incompleteReason || undefined,
|
|
676
715
|
responseId: responseId || undefined,
|
|
677
716
|
serviceTier: responseServiceTier || undefined,
|
|
678
717
|
});
|
|
679
718
|
};
|
|
719
|
+
// Early tool-call settle guard. A fully-formed tool call (real call_id +
|
|
720
|
+
// name, not a deferred salvage placeholder) means the turn's actionable
|
|
721
|
+
// result is already complete. When the server then goes silent without
|
|
722
|
+
// response.completed/response.done, resolve successfully with that tool
|
|
723
|
+
// call rather than surfacing a stall error the loop gates out via
|
|
724
|
+
// pendingToolUse and retries. Returns true if it settled the stream.
|
|
725
|
+
const hasCompleteToolCall = () => sharedHasCompleteToolCall({
|
|
726
|
+
toolCalls,
|
|
727
|
+
pendingSize: pendingCalls.size,
|
|
728
|
+
activeSize: activeToolItems.size,
|
|
729
|
+
toolInFlight: _toolInFlight,
|
|
730
|
+
});
|
|
731
|
+
const settleEarlyOnToolCall = (reason) => {
|
|
732
|
+
if (done || terminalError) return false;
|
|
733
|
+
if (!hasCompleteToolCall()) return false;
|
|
734
|
+
_earlySettled = true;
|
|
735
|
+
// Treat as a normal completion for downstream state: we have the
|
|
736
|
+
// canonical tool call, only the terminal lifecycle frame is missing.
|
|
737
|
+
midState.sawCompleted = true;
|
|
738
|
+
midState.wsEarlySettle = reason;
|
|
739
|
+
done = true;
|
|
740
|
+
// Close and mark keep=false (via closeSocket in the resolve). The
|
|
741
|
+
// late/never response.completed cannot then leak onto a pooled reuse.
|
|
742
|
+
try { socket.close(4000, `early_settle_${reason}`); } catch {}
|
|
743
|
+
finish();
|
|
744
|
+
return true;
|
|
745
|
+
};
|
|
680
746
|
|
|
681
747
|
messageHandler = (data) => {
|
|
682
748
|
resetIdle();
|
|
@@ -759,14 +825,17 @@ export async function _streamResponse({
|
|
|
759
825
|
break;
|
|
760
826
|
case 'response.output_item.added':
|
|
761
827
|
if (event.item?.type === 'function_call') {
|
|
828
|
+
markActiveToolItem(event.item);
|
|
762
829
|
pendingCalls.set(event.item.id || '', {
|
|
763
830
|
name: event.item.name || '',
|
|
764
831
|
callId: event.item.call_id || '',
|
|
765
832
|
});
|
|
766
833
|
_toolInFlight = true;
|
|
767
834
|
} else if (event.item?.type === 'custom_tool_call') {
|
|
835
|
+
markActiveToolItem(event.item);
|
|
768
836
|
_toolInFlight = true;
|
|
769
837
|
} else if (event.item?.type === 'tool_search_call') {
|
|
838
|
+
markActiveToolItem(event.item);
|
|
770
839
|
// Mark tool_search in-flight at item-added time, same
|
|
771
840
|
// as function_call/custom_tool_call above, so the
|
|
772
841
|
// semantic-idle stall gate's pendingToolUse never
|
|
@@ -780,11 +849,13 @@ export async function _streamResponse({
|
|
|
780
849
|
resetSemanticIdle();
|
|
781
850
|
break;
|
|
782
851
|
case 'response.function_call_arguments.delta':
|
|
852
|
+
markActiveToolItem(null, event.item_id);
|
|
783
853
|
_toolInFlight = true;
|
|
784
854
|
try { onStreamDelta?.(); } catch {}
|
|
785
855
|
bumpSemanticIdle();
|
|
786
856
|
break;
|
|
787
857
|
case 'response.custom_tool_call_input.delta':
|
|
858
|
+
markActiveToolItem(null, event.item_id);
|
|
788
859
|
_toolInFlight = true;
|
|
789
860
|
try { onStreamDelta?.(); } catch {}
|
|
790
861
|
bumpSemanticIdle();
|
|
@@ -819,6 +890,11 @@ export async function _streamResponse({
|
|
|
819
890
|
const call = { id: pending.callId, name: pending.name, arguments: args };
|
|
820
891
|
toolCalls.push(call);
|
|
821
892
|
emitToolCallDedupe(call);
|
|
893
|
+
pendingCalls.delete(itemId);
|
|
894
|
+
// Keep the function item active until output_item.done:
|
|
895
|
+
// arguments.done completes args, but the lifecycle item
|
|
896
|
+
// itself may still be followed by item.done/final frames.
|
|
897
|
+
_toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
|
|
822
898
|
} else {
|
|
823
899
|
// Synthesizing a `tc_${Date.now()}` callId here would
|
|
824
900
|
// make the next turn fail to match the model's
|
|
@@ -850,11 +926,46 @@ export async function _streamResponse({
|
|
|
850
926
|
// server-side prompt cache prefix warm.
|
|
851
927
|
if (event.item?.type === 'reasoning') pushReasoningItem(event.item);
|
|
852
928
|
if (event.item?.type === 'web_search_call') pushWebSearchCall(event.item);
|
|
929
|
+
if (event.item?.type === 'function_call') {
|
|
930
|
+
const item = event.item;
|
|
931
|
+
const itemId = item.id || '';
|
|
932
|
+
const callId = item.call_id || '';
|
|
933
|
+
const name = item.name || '';
|
|
934
|
+
const argsText = typeof item.arguments === 'string' ? item.arguments : '';
|
|
935
|
+
let args = {};
|
|
936
|
+
if (argsText.trim() !== '') {
|
|
937
|
+
try {
|
|
938
|
+
args = JSON.parse(argsText);
|
|
939
|
+
} catch (err) {
|
|
940
|
+
args = makeInvalidToolArgsMarker(argsText, err instanceof Error ? err.message : String(err));
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
const deferred = toolCalls.find((tc) => tc?._deferred && (!itemId || tc._pendingItemId === itemId));
|
|
944
|
+
if (deferred && callId && name) {
|
|
945
|
+
deferred.id = callId;
|
|
946
|
+
deferred.name = name;
|
|
947
|
+
deferred.arguments = deferred.arguments && Object.keys(deferred.arguments).length > 0 ? deferred.arguments : args;
|
|
948
|
+
delete deferred._deferred;
|
|
949
|
+
delete deferred._pendingItemId;
|
|
950
|
+
emitToolCallDedupe(deferred);
|
|
951
|
+
} else if (callId && name && !toolCalls.some((tc) => tc?.id === callId)) {
|
|
952
|
+
const call = { id: callId, name, arguments: args };
|
|
953
|
+
toolCalls.push(call);
|
|
954
|
+
emitToolCallDedupe(call);
|
|
955
|
+
}
|
|
956
|
+
if (itemId) pendingCalls.delete(itemId);
|
|
957
|
+
clearActiveToolItem(item, itemId);
|
|
958
|
+
_toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
|
|
959
|
+
}
|
|
853
960
|
if (event.item?.type === 'tool_search_call') {
|
|
854
961
|
pushToolSearchCall(event.item);
|
|
962
|
+
clearActiveToolItem(event.item);
|
|
963
|
+
_toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
|
|
855
964
|
}
|
|
856
965
|
if (event.item?.type === 'custom_tool_call') {
|
|
857
966
|
pushCustomToolCall(event.item);
|
|
967
|
+
clearActiveToolItem(event.item);
|
|
968
|
+
_toolInFlight = pendingCalls.size > 0 || activeToolItems.size > 0;
|
|
858
969
|
}
|
|
859
970
|
// Item-done is genuine lifecycle progress — reset semantic
|
|
860
971
|
// idle so latency before the next item/args does not stall.
|
|
@@ -24,6 +24,15 @@ const signatures = new Map();
|
|
|
24
24
|
// strictly sequential at the registry level, independent of any caller gating,
|
|
25
25
|
// so two different config signatures can never interleave their rebuilds.
|
|
26
26
|
let _initChain = Promise.resolve();
|
|
27
|
+
// Singleflight state layered on top of the serial chain. Under simultaneous
|
|
28
|
+
// multi-agent launch, many callers hit initProviders() with a byte-identical
|
|
29
|
+
// config. `_inFlightPromise`/`_inFlightSig` coalesce those onto the pending
|
|
30
|
+
// init instead of queueing redundant clear()+rebuild passes behind it, and
|
|
31
|
+
// `_lastAppliedSig` lets a repeat call short-circuit entirely once the chain
|
|
32
|
+
// is idle. Differing signatures still serialize through _initChain as before.
|
|
33
|
+
let _inFlightPromise = null;
|
|
34
|
+
let _inFlightSig = null;
|
|
35
|
+
let _lastAppliedSig = null;
|
|
27
36
|
|
|
28
37
|
// Deterministic structural signature of a provider config. Recursively sorts
|
|
29
38
|
// object keys so signature equality reflects config-value equality regardless
|
|
@@ -80,6 +89,16 @@ function instantiateProvider(name, Ctor, cfg) {
|
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
export async function initProviders(config) {
|
|
92
|
+
const sig = configSignature(config);
|
|
93
|
+
// Coalesce: an identical config is already mid-init — attach to it.
|
|
94
|
+
if (sig !== null && _inFlightPromise && _inFlightSig === sig) {
|
|
95
|
+
return _inFlightPromise;
|
|
96
|
+
}
|
|
97
|
+
// Fast path: chain idle and the live registry already reflects this exact
|
|
98
|
+
// config — nothing to tear down or rebuild.
|
|
99
|
+
if (sig !== null && !_inFlightPromise && _lastAppliedSig === sig) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
83
102
|
// Serialize ALL inits through a single chain so two different config
|
|
84
103
|
// signatures can never run their clear()+rebuild concurrently, regardless
|
|
85
104
|
// of caller-side gating (agent-tool gateOnPrior may release a queued init
|
|
@@ -87,7 +106,19 @@ export async function initProviders(config) {
|
|
|
87
106
|
const run = () => _initProvidersUnsynchronized(config);
|
|
88
107
|
const next = _initChain.then(run, run);
|
|
89
108
|
_initChain = next.then(() => {}, () => {});
|
|
90
|
-
|
|
109
|
+
const settle = () => {
|
|
110
|
+
if (_inFlightPromise === tracked) {
|
|
111
|
+
_inFlightPromise = null;
|
|
112
|
+
_inFlightSig = null;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const tracked = next.then(
|
|
116
|
+
(v) => { _lastAppliedSig = sig; settle(); return v; },
|
|
117
|
+
(err) => { settle(); throw err; },
|
|
118
|
+
);
|
|
119
|
+
_inFlightSig = sig;
|
|
120
|
+
_inFlightPromise = tracked;
|
|
121
|
+
return tracked;
|
|
91
122
|
}
|
|
92
123
|
|
|
93
124
|
async function _initProvidersUnsynchronized(config) {
|
|
@@ -243,18 +274,23 @@ export function warmupCatalogs() {
|
|
|
243
274
|
// context metadata stays on its own 24h TTL. Fire-and-forget: never awaited,
|
|
244
275
|
// per-provider failures logged to stderr like warmupCatalogs.
|
|
245
276
|
export function refreshProviderCatalogsOnStartup() {
|
|
277
|
+
const pending = [];
|
|
246
278
|
for (const [name, provider] of providers) {
|
|
247
279
|
const refreshFn = typeof provider?._refreshModelCache === 'function'
|
|
248
280
|
? () => provider._refreshModelCache()
|
|
249
281
|
: (typeof provider?.listModels === 'function' ? () => provider.listModels() : null);
|
|
250
282
|
if (!refreshFn) continue;
|
|
251
|
-
Promise.resolve()
|
|
283
|
+
pending.push(Promise.resolve()
|
|
252
284
|
.then(() => refreshFn())
|
|
253
285
|
.catch((err) => {
|
|
254
286
|
const msg = err instanceof Error ? err.message : String(err);
|
|
255
287
|
process.stderr.write(`[provider:${name}] startup catalog refresh failed: ${msg}\n`);
|
|
256
|
-
});
|
|
288
|
+
}));
|
|
257
289
|
}
|
|
290
|
+
// Returns a completion promise so callers can invalidate stale model
|
|
291
|
+
// caches once the fresh catalogs land. Still fire-and-forget: unawaited
|
|
292
|
+
// callers keep the previous nonblocking startup behavior.
|
|
293
|
+
return Promise.allSettled(pending);
|
|
258
294
|
}
|
|
259
295
|
|
|
260
296
|
// Force-refresh provider catalogs after an operator changes model/provider
|
|
@@ -262,6 +298,7 @@ export function refreshProviderCatalogsOnStartup() {
|
|
|
262
298
|
// the shared LiteLLM metadata cache first so context/pricing metadata follows
|
|
263
299
|
// newly released models without waiting for the next process restart.
|
|
264
300
|
export function refreshCatalogs() {
|
|
301
|
+
const pending = [];
|
|
265
302
|
const metadataReady = Promise.resolve()
|
|
266
303
|
.then(() => refreshMetadataCatalog())
|
|
267
304
|
.catch((err) => {
|
|
@@ -274,12 +311,14 @@ export function refreshCatalogs() {
|
|
|
274
311
|
? () => provider._refreshModelCache()
|
|
275
312
|
: (typeof provider?.listModels === 'function' ? () => provider.listModels() : null);
|
|
276
313
|
if (!refreshFn) continue;
|
|
277
|
-
Promise.resolve()
|
|
314
|
+
pending.push(Promise.resolve()
|
|
278
315
|
.then(() => metadataReady)
|
|
279
316
|
.then(() => refreshFn())
|
|
280
317
|
.catch((err) => {
|
|
281
318
|
const msg = err instanceof Error ? err.message : String(err);
|
|
282
319
|
process.stderr.write(`[provider:${name}] catalog refresh failed: ${msg}\n`);
|
|
283
|
-
});
|
|
320
|
+
}));
|
|
284
321
|
}
|
|
322
|
+
// Completion promise: lets callers drop stale model caches after refresh.
|
|
323
|
+
return Promise.allSettled([metadataReady, ...pending]);
|
|
285
324
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-stream-state.mjs — shared active tool-item / alias tracking and the
|
|
3
|
+
* complete-tool early-settle predicate for the OpenAI Responses stream
|
|
4
|
+
* consumers.
|
|
5
|
+
*
|
|
6
|
+
* Extracted verbatim (no behavior change) from openai-ws-stream.mjs, where the
|
|
7
|
+
* activeToolItems Set + activeToolAliases Map (id/call_id/fallback-key alias
|
|
8
|
+
* union) and the hasCompleteToolCall gate were WS-local closures. Kept as a
|
|
9
|
+
* plain factory + pure predicate so any Responses-shaped stream can reuse the
|
|
10
|
+
* same lifecycle-tracking semantics.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Per-stream active tool-item tracker. Tracks in-flight function/custom/
|
|
15
|
+
* tool_search items by every key they surface under (id, call_id, and any
|
|
16
|
+
* fallback item_id from delta frames), unioning aliases so a mark under one key
|
|
17
|
+
* and a clear under another still resolve to the same item.
|
|
18
|
+
*/
|
|
19
|
+
export function createActiveToolItemTracker() {
|
|
20
|
+
const activeToolItems = new Set();
|
|
21
|
+
const activeToolAliases = new Map();
|
|
22
|
+
const activeToolKeys = (item, fallback = '') => {
|
|
23
|
+
const keys = [];
|
|
24
|
+
const add = (value) => {
|
|
25
|
+
const key = String(value || '');
|
|
26
|
+
if (key && !keys.includes(key)) keys.push(key);
|
|
27
|
+
};
|
|
28
|
+
add(fallback);
|
|
29
|
+
add(item?.id);
|
|
30
|
+
add(item?.call_id);
|
|
31
|
+
return keys;
|
|
32
|
+
};
|
|
33
|
+
const mark = (item, fallback = '') => {
|
|
34
|
+
const keys = new Set(activeToolKeys(item, fallback));
|
|
35
|
+
for (const key of [...keys]) {
|
|
36
|
+
const aliases = activeToolAliases.get(key);
|
|
37
|
+
if (aliases) for (const alias of aliases) keys.add(alias);
|
|
38
|
+
}
|
|
39
|
+
for (const key of keys) {
|
|
40
|
+
activeToolItems.add(key);
|
|
41
|
+
activeToolAliases.set(key, new Set(keys));
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const clear = (item, fallback = '') => {
|
|
45
|
+
const keys = new Set(activeToolKeys(item, fallback));
|
|
46
|
+
for (const key of [...keys]) {
|
|
47
|
+
const aliases = activeToolAliases.get(key);
|
|
48
|
+
if (aliases) for (const alias of aliases) keys.add(alias);
|
|
49
|
+
}
|
|
50
|
+
for (const key of keys) {
|
|
51
|
+
activeToolItems.delete(key);
|
|
52
|
+
activeToolAliases.delete(key);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
items: activeToolItems,
|
|
57
|
+
aliases: activeToolAliases,
|
|
58
|
+
keys: activeToolKeys,
|
|
59
|
+
mark,
|
|
60
|
+
clear,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Early tool-call settle predicate. True only when a fully-formed tool call
|
|
66
|
+
* (real id + name, not a deferred salvage placeholder) is captured and no tool
|
|
67
|
+
* work is still in flight — pendingCalls drained, no active lifecycle item, and
|
|
68
|
+
* the in-flight latch cleared. Callers pass the current sizes/flag rather than
|
|
69
|
+
* the live collections so the check stays a pure snapshot.
|
|
70
|
+
*/
|
|
71
|
+
export function hasCompleteToolCall({ toolCalls, pendingSize, activeSize, toolInFlight }) {
|
|
72
|
+
return toolCalls.length > 0
|
|
73
|
+
&& pendingSize === 0
|
|
74
|
+
&& activeSize === 0
|
|
75
|
+
&& toolInFlight !== true
|
|
76
|
+
&& !toolCalls.some((t) => t && t._deferred)
|
|
77
|
+
&& toolCalls.every((t) => t && t.id && t.name);
|
|
78
|
+
}
|
|
@@ -195,21 +195,32 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
195
195
|
const drainSteeringIntoMessages = (stage = 'mid-turn', options = {}) => {
|
|
196
196
|
if (typeof opts.drainSteering !== 'function') return false;
|
|
197
197
|
let steerMsgs = [];
|
|
198
|
-
try { steerMsgs = opts.drainSteering(sessionId) || []; }
|
|
198
|
+
try { steerMsgs = opts.drainSteering(sessionId, options) || []; }
|
|
199
199
|
catch { steerMsgs = []; }
|
|
200
|
-
const
|
|
201
|
-
|
|
200
|
+
const mergedMessages = [];
|
|
201
|
+
for (const entry of Array.isArray(steerMsgs) ? steerMsgs : []) {
|
|
202
|
+
const merged = mergeSteeringEntries([entry]);
|
|
203
|
+
if (merged) mergedMessages.push(merged);
|
|
204
|
+
}
|
|
205
|
+
if (mergedMessages.length === 0) return false;
|
|
202
206
|
if (typeof options.beforeAppend === 'function') {
|
|
203
207
|
try { options.beforeAppend(); } catch { /* best-effort hook */ }
|
|
204
208
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
let totalCount = 0;
|
|
210
|
+
let totalTextLen = 0;
|
|
211
|
+
for (const merged of mergedMessages) {
|
|
212
|
+
// Tag steering-origin user messages so provider lowering keeps them
|
|
213
|
+
// distinct from preceding tool results. Keep each queued command as
|
|
214
|
+
// its own user turn, matching Claude Code queued_command attachment
|
|
215
|
+
// semantics instead of collapsing priority/mode buckets together.
|
|
216
|
+
messages.push({ role: 'user', content: merged.content, meta: { source: 'steering' } });
|
|
217
|
+
const text = merged.text || steeringContentText(merged.content);
|
|
218
|
+
totalCount += Number(merged.count) || 1;
|
|
219
|
+
totalTextLen += String(text || '').length;
|
|
220
|
+
try { opts.onSteerMessage?.(text); } catch {}
|
|
221
|
+
}
|
|
211
222
|
if (sessionId) {
|
|
212
|
-
try { process.stderr.write(`[steer] sess=${sessionId} injected ${stage} user message (merged=${
|
|
223
|
+
try { process.stderr.write(`[steer] sess=${sessionId} injected ${stage} user message(s) (merged=${totalCount} len=${totalTextLen})\n`); } catch {}
|
|
213
224
|
}
|
|
214
225
|
return true;
|
|
215
226
|
};
|
|
@@ -259,6 +270,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
259
270
|
// the loop as an explicit empty termination instead.
|
|
260
271
|
let _emptyNudgeStreak = 0;
|
|
261
272
|
const EMPTY_NUDGE_MAX = 3;
|
|
273
|
+
// Claude Code parity: queued prompt/task notifications are attached after a
|
|
274
|
+
// tool batch, before the continuation provider send. Normal batches drain
|
|
275
|
+
// up to 'next'; a Sleep-like tool grants a 'later' flush.
|
|
276
|
+
let _toolBatchJustCompleted = false;
|
|
277
|
+
let _lastToolBatchHadSleep = false;
|
|
278
|
+
const isSleepLikeToolCall = (call) => {
|
|
279
|
+
const name = String(call?.name || call?.toolName || call?.function?.name || '').toLowerCase();
|
|
280
|
+
return name === 'sleep' || name.endsWith('/sleep') || name.endsWith('.sleep');
|
|
281
|
+
};
|
|
262
282
|
// Completion-first steering ladder controller. Owns the (cumulative) level-1
|
|
263
283
|
// fire count, the all-read-only / serial-single / same-file-grep streaks,
|
|
264
284
|
// and the level-2 latch. Threaded via live getters so it reads the loop's
|
|
@@ -336,11 +356,19 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
336
356
|
});
|
|
337
357
|
} catch { /* best-effort */ }
|
|
338
358
|
}
|
|
339
|
-
// Drain queued steering/prompts BEFORE the
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
// tool results
|
|
343
|
-
|
|
359
|
+
// Drain queued steering/prompts BEFORE the pre-send compact check, but
|
|
360
|
+
// only immediately after a tool batch has completed. This mirrors
|
|
361
|
+
// Claude Code's query.ts queued_command attachment drain: queued entries
|
|
362
|
+
// are attached after tool results are appended and before the recursive
|
|
363
|
+
// continuation, not on arbitrary non-tool continuations (empty nudges,
|
|
364
|
+
// iteration-cap final text turns, etc.).
|
|
365
|
+
if (_toolBatchJustCompleted) {
|
|
366
|
+
drainSteeringIntoMessages('pre-send', {
|
|
367
|
+
maxPriority: _lastToolBatchHadSleep ? 'later' : 'next',
|
|
368
|
+
});
|
|
369
|
+
_toolBatchJustCompleted = false;
|
|
370
|
+
_lastToolBatchHadSleep = false;
|
|
371
|
+
}
|
|
344
372
|
({
|
|
345
373
|
iterations,
|
|
346
374
|
lastUsage,
|
|
@@ -442,16 +470,23 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
442
470
|
// the stateless contract for providers that don't use continuation).
|
|
443
471
|
providerState = response?.providerState ?? undefined;
|
|
444
472
|
iterations = nextIteration;
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
// (
|
|
448
|
-
|
|
473
|
+
// Loop trace has two modes (both no-op on provider behavior):
|
|
474
|
+
// VERBOSE=1 → full row; pay the FULL messages+tools payload byte
|
|
475
|
+
// estimate (serializes the whole array).
|
|
476
|
+
// TIMING=1 → send-latency attribution only; skip the payload
|
|
477
|
+
// estimate so measuring send_ms does not itself add
|
|
478
|
+
// serialization cost during high-fanout bench runs.
|
|
479
|
+
const _traceVerbose = process.env.MIXDOG_AGENT_TRACE_VERBOSE === '1';
|
|
480
|
+
if (_traceVerbose || process.env.MIXDOG_AGENT_TRACE_TIMING === '1') {
|
|
449
481
|
traceAgentLoop({
|
|
450
482
|
sessionId,
|
|
451
483
|
iteration: iterations,
|
|
452
484
|
sendMs: Date.now() - sendStartedAt,
|
|
453
485
|
messageCount: Array.isArray(messages) ? messages.length : 0,
|
|
454
|
-
bodyBytesEst:
|
|
486
|
+
bodyBytesEst: _traceVerbose
|
|
487
|
+
? estimateProviderPayloadBytes(messages, model, sendTools)
|
|
488
|
+
: undefined,
|
|
489
|
+
agent: sessionAgent || null,
|
|
455
490
|
});
|
|
456
491
|
}
|
|
457
492
|
// Accumulate usage across iterations — every billable slot, not just
|
|
@@ -556,17 +591,6 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
556
591
|
const isHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
|
|
557
592
|
const stopReason = response.stopReason ?? response.stop_reason ?? null;
|
|
558
593
|
const isIncompleteStop = stopReason && INCOMPLETE_STOP_REASONS.has(stopReason);
|
|
559
|
-
// A user/schedule notification can arrive while provider.send() is
|
|
560
|
-
// returning a terminal no-tool response. Drain once before accepting
|
|
561
|
-
// it as final so the queued input is handled in the same active turn
|
|
562
|
-
// instead of waiting for post-turn TUI drain. If the model already
|
|
563
|
-
// produced assistant text, persist that as an intermediate assistant
|
|
564
|
-
// message before appending the steered user message.
|
|
565
|
-
if (drainSteeringIntoMessages('final-pre-send', {
|
|
566
|
-
beforeAppend: () => pushIntermediateAssistantResponse(response),
|
|
567
|
-
})) {
|
|
568
|
-
continue;
|
|
569
|
-
}
|
|
570
594
|
if (!hasContent && !isHidden) {
|
|
571
595
|
_emptyNudgeStreak += 1;
|
|
572
596
|
if (_emptyNudgeStreak > EMPTY_NUDGE_MAX) {
|
|
@@ -662,6 +686,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
662
686
|
pushToolResultMessage, throwIfAborted,
|
|
663
687
|
repeatFailLimit: REPEAT_FAIL_LIMIT,
|
|
664
688
|
}));
|
|
689
|
+
_toolBatchJustCompleted = true;
|
|
690
|
+
_lastToolBatchHadSleep = calls.some(isSleepLikeToolCall);
|
|
665
691
|
}
|
|
666
692
|
// Classify WHY the loop ended so agent-tool can promote an empty/abnormal
|
|
667
693
|
// finish to an explicit Lead-facing error instead of a silent empty
|