mixdog 0.9.109 → 0.9.110
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 +1 -1
- package/scripts/run-suite.mjs +2 -0
- package/src/rules/shared/01-tool.md +36 -35
- package/src/runtime/agent/orchestrator/agent-runtime/title-completion.mjs +7 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +67 -20
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +7 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +31 -18
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +81 -0
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +64 -0
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +44 -9
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +6 -7
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +24 -0
- package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +13 -6
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -1
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +37 -14
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +33 -61
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -4
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +6 -37
- package/src/runtime/shared/pristine-execution-contract.json +4 -1
- package/src/session-runtime/session-title.mjs +14 -1
- package/src/standalone/agent-tool/spawn-flow.mjs +18 -0
- package/src/workflows/default/WORKFLOW.md +2 -1
package/package.json
CHANGED
package/scripts/run-suite.mjs
CHANGED
|
@@ -51,6 +51,7 @@ export const SUITES = {
|
|
|
51
51
|
'pretool-ask-runtime-test.mjs',
|
|
52
52
|
'prompt-input-parity-test.mjs',
|
|
53
53
|
'reactive-compact-persist-smoke.mjs',
|
|
54
|
+
'reasoning-replay-policy-test.mjs',
|
|
54
55
|
'repl-stream-finalize-test.mjs',
|
|
55
56
|
'result-classification-test.mjs',
|
|
56
57
|
'rg-runner-test.mjs',
|
|
@@ -60,6 +61,7 @@ export const SUITES = {
|
|
|
60
61
|
'session-title-controller-test.mjs',
|
|
61
62
|
'set-effort-config-test.mjs',
|
|
62
63
|
'shell-jobs-windows-hide-test.mjs',
|
|
64
|
+
'spawn-ws-prewarm-test.mjs',
|
|
63
65
|
'spinner-meta-test.mjs',
|
|
64
66
|
'statusline-agents-test.mjs',
|
|
65
67
|
'statusline-quota-hysteresis-test.mjs',
|
|
@@ -2,51 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
- Baseline routing assigns each facet directly by the evidence needed to
|
|
4
4
|
determine the complete edit:
|
|
5
|
-
path/name only→`find`; wildcard/recursive paths→`glob
|
|
6
|
-
entries→`list`;
|
|
5
|
+
path/name only→`find`; wildcard/recursive paths→`glob` (including known-root
|
|
6
|
+
unknown descendants); exact directory entries→`list`;
|
|
7
7
|
source content/value/`path:line`→`grep`; exact symbol/relation→`code_graph`;
|
|
8
8
|
known file/range→`read`;
|
|
9
|
-
web/current→`search`; returned URL body→`web_fetch`; prior work→`recall
|
|
9
|
+
web/current→`search`; returned URL body→`web_fetch`; prior work→`recall`
|
|
10
|
+
(history only, never current local state);
|
|
10
11
|
durable compact English memory→`memory`; explicit project change→`cwd`;
|
|
11
12
|
explicit user-requested conversation reset→`session_manage`.
|
|
12
13
|
Use only named tools present in the current tool surface.
|
|
13
14
|
`explore`, when exposed, is a fast path only for facets whose repository
|
|
14
15
|
coordinates remain unknown: call it first once for all such independent
|
|
15
|
-
facets in one query array. It returns
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
facets in one query array. It returns direct `path:line` anchors, not
|
|
17
|
+
analysis or solutions; anchors are tool-verified coordinates that END
|
|
18
|
+
their location facets — never re-locate or second-guess them; resume
|
|
19
|
+
baseline routing from them.
|
|
18
20
|
- Act only on verified identities (cwd/project/user/tool-returned) — paths,
|
|
19
21
|
module specifiers, symbols, data/record shapes alike; a guessed identity is
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
verified by one lookup or sample only when the next call or edit references
|
|
23
|
+
it. Within the current project, pass
|
|
22
24
|
project-relative paths and omit optional scopes equal to its root; explicit
|
|
23
25
|
paths may be outside cwd only for targets outside the project.
|
|
24
|
-
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
independent facet, batched with the rest.
|
|
26
|
+
- Plan the fewest dependent rounds, then the fewest calls. A conclusive
|
|
27
|
+
result ends its facet, and known state — task/brief-supplied facts,
|
|
28
|
+
returned content, your own successful calls' effects — is never
|
|
29
|
+
re-acquired, broadened, or reconfirmed. Batch calls iff none needs
|
|
30
|
+
another's output or can change another's inputs/state; otherwise
|
|
31
|
+
serialize. Before each batch, deduplicate the facets still required by the request,
|
|
32
|
+
route each once to the cheapest sufficient tool with all required
|
|
33
|
+
variants/scopes, and launch every independent call together — never
|
|
34
|
+
split or duplicate a facet across tools, mutate merely to widen
|
|
35
|
+
retrieval, reserve known work, or cap fanout. Symbol relations end at
|
|
36
|
+
`code_graph`; values/locations end at the context grep returns; `read`
|
|
37
|
+
covers only what returned spans cannot, as an anchored offset/limit
|
|
38
|
+
window. The moment evidence determines the answer, edit, or deliverable,
|
|
39
|
+
stop retrieving; patch if needed.
|
|
40
|
+
- Once the edit or deliverable is determined, finish in one assistant turn:
|
|
41
|
+
one `apply_patch` per file or cohesive unit, all patches first, then one
|
|
42
|
+
batched verification `shell` that runs the real required postconditions
|
|
43
|
+
on every changed file and produced artifact, never echoes a claim;
|
|
44
|
+
runtime waits for every patch and skips the shell
|
|
45
|
+
if any fails. Retry only failed envelopes; rerun a failed check only
|
|
46
|
+
after a fix that can change its result, else report it unresolved.
|
|
47
|
+
Hand-authored text is edited only with `apply_patch`; computed artifacts
|
|
48
|
+
(data/reports/derived values) come from `shell` computation, never
|
|
49
|
+
hand-transcribed numbers. Earlier `shell` is only for runtime/state
|
|
50
|
+
evidence unavailable to file tools—an independent facet, batched with
|
|
51
|
+
the rest.
|
|
51
52
|
- A background `task_id` ends the turn; completion resumes work. Never poll;
|
|
52
53
|
use task control only for recovery or a required blocking result.
|
|
@@ -39,7 +39,13 @@ export function createTitleCompletion(deps = {}) {
|
|
|
39
39
|
config,
|
|
40
40
|
});
|
|
41
41
|
if (!route || typeof route !== 'object') {
|
|
42
|
-
|
|
42
|
+
const error = new Error('Session title maintenance route is unresolved.');
|
|
43
|
+
// Machine-readable marker: callers (session-title controller)
|
|
44
|
+
// downgrade this to a one-shot "titling disabled" skip instead of
|
|
45
|
+
// logging a stack per session (e.g. bench profiles without a
|
|
46
|
+
// maintainer/default route).
|
|
47
|
+
error.code = 'MAINTENANCE_ROUTE_UNRESOLVED';
|
|
48
|
+
throw error;
|
|
43
49
|
}
|
|
44
50
|
const providerName = String(route.provider || '').trim();
|
|
45
51
|
const model = String(route.model || '').trim();
|
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
} from './anthropic-oauth-credentials.mjs';
|
|
42
42
|
import {
|
|
43
43
|
PROVIDER_FIRST_BYTE_TIMEOUT_MS,
|
|
44
|
+
PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS,
|
|
45
|
+
createTimeoutSignal,
|
|
44
46
|
createPassthroughSignal,
|
|
45
47
|
} from '../stall-policy.mjs';
|
|
46
48
|
import {
|
|
@@ -740,7 +742,11 @@ export class AnthropicOAuthProvider {
|
|
|
740
742
|
// Test seam: injectable request factory for retry-path tests.
|
|
741
743
|
const doRequestImpl = typeof opts._doRequestFn === 'function' ? opts._doRequestFn : doRequest;
|
|
742
744
|
|
|
743
|
-
const requestWithRetry = async (
|
|
745
|
+
const requestWithRetry = async (
|
|
746
|
+
accessToken,
|
|
747
|
+
requestBody = body,
|
|
748
|
+
retrySignal = totalSignal,
|
|
749
|
+
) => withRetry(async ({ signal: attemptSignal }) => {
|
|
744
750
|
const result = await doRequestImpl(accessToken, attemptSignal, requestBody);
|
|
745
751
|
const status = Number(result?.response?.status || 0);
|
|
746
752
|
const transientStatus = classifyError({ httpStatus: status }) === 'transient';
|
|
@@ -772,7 +778,7 @@ export class AnthropicOAuthProvider {
|
|
|
772
778
|
}
|
|
773
779
|
return result;
|
|
774
780
|
}, {
|
|
775
|
-
signal:
|
|
781
|
+
signal: retrySignal,
|
|
776
782
|
maxAttempts: anthropicMaxAttempts(),
|
|
777
783
|
backoffMs: ANTHROPIC_RETRY_BACKOFF_MS,
|
|
778
784
|
retryJitterRatio: ANTHROPIC_RETRY_JITTER_RATIO,
|
|
@@ -816,28 +822,69 @@ export class AnthropicOAuthProvider {
|
|
|
816
822
|
const issueNonStreamingFallback = async (controller, abortReason) => {
|
|
817
823
|
try { controller?.abort?.(abortReason); } catch {}
|
|
818
824
|
try { onStageChange?.('requesting', { transport: 'non-streaming-fallback' }); } catch {}
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
825
|
+
const timeoutMs = Number(opts._nonStreamingTimeoutMs) > 0
|
|
826
|
+
? Number(opts._nonStreamingTimeoutMs)
|
|
827
|
+
: PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS;
|
|
828
|
+
const lifetime = createTimeoutSignal(
|
|
829
|
+
totalSignal,
|
|
830
|
+
timeoutMs,
|
|
831
|
+
'Anthropic OAuth non-streaming fallback',
|
|
832
|
+
);
|
|
833
|
+
let fallback = null;
|
|
834
|
+
let lifetimeAbortHandler = null;
|
|
835
|
+
const releaseFallback = (reason) => {
|
|
836
|
+
if (lifetimeAbortHandler) {
|
|
837
|
+
try { lifetime.signal.removeEventListener('abort', lifetimeAbortHandler); } catch {}
|
|
838
|
+
lifetimeAbortHandler = null;
|
|
839
|
+
}
|
|
840
|
+
cleanupCancelHandler(fallback?.cancelHandler);
|
|
841
|
+
try { fallback?.controller?.abort?.(reason); } catch {}
|
|
842
|
+
fallback = null;
|
|
843
|
+
};
|
|
844
|
+
const requestFallback = async (accessToken) => {
|
|
845
|
+
const result = await requestWithRetry(
|
|
846
|
+
accessToken,
|
|
847
|
+
{ ...body, stream: false },
|
|
848
|
+
lifetime.signal,
|
|
849
|
+
);
|
|
850
|
+
fallback = result;
|
|
851
|
+
lifetimeAbortHandler = () => {
|
|
852
|
+
try { result.controller?.abort?.(lifetime.signal.reason); } catch {}
|
|
853
|
+
};
|
|
854
|
+
if (lifetime.signal.aborted) {
|
|
855
|
+
lifetimeAbortHandler();
|
|
856
|
+
const reason = lifetime.signal.reason;
|
|
857
|
+
throw reason instanceof Error
|
|
858
|
+
? reason
|
|
859
|
+
: new Error('Anthropic OAuth non-streaming fallback aborted');
|
|
860
|
+
}
|
|
861
|
+
lifetime.signal.addEventListener('abort', lifetimeAbortHandler, { once: true });
|
|
862
|
+
return result;
|
|
863
|
+
};
|
|
835
864
|
try {
|
|
865
|
+
fallback = await requestFallback(creds.accessToken);
|
|
866
|
+
if (fallback.response.status === 401) {
|
|
867
|
+
releaseFallback('Anthropic OAuth non-streaming fallback refreshing auth');
|
|
868
|
+
creds = await this.ensureAuth({ forceRefresh: true, reason: '401' });
|
|
869
|
+
fallback = await requestFallback(creds.accessToken);
|
|
870
|
+
}
|
|
871
|
+
if (!fallback.response.ok) {
|
|
872
|
+
const text = await fallback.response.text().catch(() => '');
|
|
873
|
+
const fallbackError = new Error(`Anthropic OAuth API ${fallback.response.status}: ${this.scrubTokens(text).slice(0, 200)}`);
|
|
874
|
+
fallbackError.status = fallback.response.status;
|
|
875
|
+
fallbackError.httpStatus = fallback.response.status;
|
|
876
|
+
throw fallbackError;
|
|
877
|
+
}
|
|
836
878
|
const message = await fallback.response.json();
|
|
837
879
|
return normalizeAnthropicNonStreamingResponse(message, useModel);
|
|
880
|
+
} catch (err) {
|
|
881
|
+
if (lifetime.signal.aborted && lifetime.signal.reason instanceof Error) {
|
|
882
|
+
throw lifetime.signal.reason;
|
|
883
|
+
}
|
|
884
|
+
throw err;
|
|
838
885
|
} finally {
|
|
839
|
-
|
|
840
|
-
|
|
886
|
+
releaseFallback('Anthropic non-streaming fallback complete');
|
|
887
|
+
lifetime.cleanup();
|
|
841
888
|
}
|
|
842
889
|
};
|
|
843
890
|
|
|
@@ -47,7 +47,13 @@ function _captureMidstreamAbort(state, reason) {
|
|
|
47
47
|
const reasonName = reason?.name || '';
|
|
48
48
|
if (reasonName === 'AgentStallAbortError' || reasonName === 'StreamStalledAbortError') {
|
|
49
49
|
state.watchdogAbort = reasonName;
|
|
50
|
-
} else {
|
|
50
|
+
} else if (reasonName !== 'ProviderTimeoutError' && reasonName !== 'StreamStalledError') {
|
|
51
|
+
// Internal timeout/stall abort reasons are transport symptoms, not a
|
|
52
|
+
// caller decision. Recording them as userAbort silently vetoed the
|
|
53
|
+
// mid-stream retry ladder (_classifyMidstreamSse returns null on
|
|
54
|
+
// userAbort → the turn surfaced as an instant unlogged failure).
|
|
55
|
+
// Leaving state untouched keeps them classifiable by the thrown error
|
|
56
|
+
// itself (EPROVIDERTIMEOUT/ESTREAMSTALL → transient/stall retry).
|
|
51
57
|
state.userAbort = true;
|
|
52
58
|
}
|
|
53
59
|
}
|
|
@@ -19,6 +19,7 @@ import { readStreamOutcome } from './lib/stream-outcome.mjs';
|
|
|
19
19
|
import { traceAgentUsage } from '../agent-trace.mjs';
|
|
20
20
|
import {
|
|
21
21
|
PROVIDER_FIRST_BYTE_TIMEOUT_MS,
|
|
22
|
+
PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS,
|
|
22
23
|
createTimeoutSignal,
|
|
23
24
|
createPassthroughSignal,
|
|
24
25
|
} from '../stall-policy.mjs';
|
|
@@ -327,25 +328,37 @@ export class AnthropicProvider {
|
|
|
327
328
|
try { streamController.abort?.(abortReason); } catch {}
|
|
328
329
|
try { onStageChange?.('requesting', { transport: 'non-streaming-fallback' }); } catch {}
|
|
329
330
|
const nonStreamingParams = { ...params, stream: false };
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
maxAttempts: anthropicMaxAttempts(),
|
|
338
|
-
backoffMs: ANTHROPIC_RETRY_BACKOFF_MS,
|
|
339
|
-
retryJitterRatio: ANTHROPIC_RETRY_JITTER_RATIO,
|
|
340
|
-
retryJitterMode: 'positive',
|
|
341
|
-
perAttemptTimeoutMs: anthropicRequestTimeoutMs(),
|
|
342
|
-
perAttemptLabel: `${this.name} Anthropic non-streaming fallback`,
|
|
343
|
-
provider: 'anthropic',
|
|
344
|
-
model: useModel,
|
|
345
|
-
fallbackModel: opts._fallbackTriggered ? undefined : opts.fallbackModel,
|
|
346
|
-
},
|
|
331
|
+
const timeoutMs = Number(opts._nonStreamingTimeoutMs) > 0
|
|
332
|
+
? Number(opts._nonStreamingTimeoutMs)
|
|
333
|
+
: PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS;
|
|
334
|
+
const lifetime = createTimeoutSignal(
|
|
335
|
+
totalSignal,
|
|
336
|
+
timeoutMs,
|
|
337
|
+
`${this.name} Anthropic non-streaming fallback`,
|
|
347
338
|
);
|
|
348
|
-
|
|
339
|
+
try {
|
|
340
|
+
const message = await withRetry(
|
|
341
|
+
async ({ signal: attemptSignal }) => this.client.messages.create(nonStreamingParams, {
|
|
342
|
+
signal: attemptSignal,
|
|
343
|
+
...(betaHeaders ? { headers: betaHeaders } : {}),
|
|
344
|
+
}),
|
|
345
|
+
{
|
|
346
|
+
signal: lifetime.signal,
|
|
347
|
+
maxAttempts: anthropicMaxAttempts(),
|
|
348
|
+
backoffMs: ANTHROPIC_RETRY_BACKOFF_MS,
|
|
349
|
+
retryJitterRatio: ANTHROPIC_RETRY_JITTER_RATIO,
|
|
350
|
+
retryJitterMode: 'positive',
|
|
351
|
+
perAttemptTimeoutMs: anthropicRequestTimeoutMs(),
|
|
352
|
+
perAttemptLabel: `${this.name} Anthropic non-streaming fallback`,
|
|
353
|
+
provider: 'anthropic',
|
|
354
|
+
model: useModel,
|
|
355
|
+
fallbackModel: opts._fallbackTriggered ? undefined : opts.fallbackModel,
|
|
356
|
+
},
|
|
357
|
+
);
|
|
358
|
+
return buildReturnFromParse(normalizeAnthropicNonStreamingResponse(message, useModel));
|
|
359
|
+
} finally {
|
|
360
|
+
lifetime.cleanup();
|
|
361
|
+
}
|
|
349
362
|
};
|
|
350
363
|
|
|
351
364
|
const recoverNonStreaming = async (midState, streamingError, streamController) => {
|
|
@@ -167,7 +167,7 @@ function _pushOutputTextAnnotations(part, citations, citationKeys) {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
function _buildOpenAIHttpFallbackHeaders({ auth, cacheKey }) {
|
|
170
|
+
function _buildOpenAIHttpFallbackHeaders({ auth, cacheKey, statelessConversation = false }) {
|
|
171
171
|
if (auth?.type === 'openai-direct') {
|
|
172
172
|
// Public API-key auth: Bearer <OPENAI_API_KEY>, no chatgpt-account-id /
|
|
173
173
|
// originator (mirrors openai-ws-pool _buildHandshakeHeaders' direct
|
|
@@ -189,7 +189,7 @@ function _buildOpenAIHttpFallbackHeaders({ auth, cacheKey }) {
|
|
|
189
189
|
'chatgpt-account-id': auth.account_id || '',
|
|
190
190
|
'x-client-request-id': randomBytes(16).toString('hex'),
|
|
191
191
|
};
|
|
192
|
-
if (cacheKey) {
|
|
192
|
+
if (cacheKey && !statelessConversation) {
|
|
193
193
|
const sid = String(cacheKey);
|
|
194
194
|
// Backend-native anchors (see openai-ws-pool _buildHandshakeHeaders):
|
|
195
195
|
// the hyphenated `session-id`/`thread-id` pair; legacy underscore
|
|
@@ -252,7 +252,9 @@ export async function sendViaHttpSse({
|
|
|
252
252
|
// one still aborts, and
|
|
253
253
|
// (c) externalSignal (client disconnect / replaced-by-newer-request).
|
|
254
254
|
const totalTimeout = createPassthroughSignal(externalSignal);
|
|
255
|
-
const
|
|
255
|
+
const statelessConversation = opts?.statelessConversation === true
|
|
256
|
+
|| _envFlag('MIXDOG_OAI_STATELESS_HTTP', false);
|
|
257
|
+
const headers = _buildOpenAIHttpFallbackHeaders({ auth, cacheKey, statelessConversation });
|
|
256
258
|
const fetchStartedAt = Date.now();
|
|
257
259
|
const responsesUrl = auth?.type === 'openai-direct'
|
|
258
260
|
? OPENAI_DIRECT_RESPONSES_URL
|
|
@@ -313,6 +313,57 @@ function _warmupContinuityTrace({
|
|
|
313
313
|
* err.attempts — 1..HANDSHAKE_MAX_ATTEMPTS
|
|
314
314
|
* err.retryClassifier — final classifier string, or null for permanent
|
|
315
315
|
*/
|
|
316
|
+
/**
|
|
317
|
+
* Recovery-only encrypted-reasoning replay policy (codex full-frame parity).
|
|
318
|
+
*
|
|
319
|
+
* The request body may carry retained `reasoning` items
|
|
320
|
+
* (opts.replayEncryptedReasoning; default ON for openai-oauth, kill switch
|
|
321
|
+
* MIXDOG_OAI_DISABLE_REASONING_REPLAY=1).
|
|
322
|
+
* Whether they actually go on the wire is decided PER SOCKET ENTRY, once, at
|
|
323
|
+
* the first frame that entry sends, and stays sticky for its lifetime so the
|
|
324
|
+
* append-only prefix bookkeeping (entry.lastRequestInput) never mixes
|
|
325
|
+
* conventions:
|
|
326
|
+
*
|
|
327
|
+
* - fresh entry + reasoning items present → mid-session reconnect/recovery:
|
|
328
|
+
* KEEP them. This is the codex behavior on a broken delta chain — the full
|
|
329
|
+
* frame replays retained reasoning so the model does not re-reason the
|
|
330
|
+
* whole transcript (client.rs full-request path).
|
|
331
|
+
* - fresh entry + no reasoning items → virgin session: nothing to
|
|
332
|
+
* replay; the chain locks to today's wire (subsequent frames strip).
|
|
333
|
+
* - entry with prior chain state (pooled reuse / completed warmup) → STRIP:
|
|
334
|
+
* re-sending an rs_* item a live server chain already saw is rejected as a
|
|
335
|
+
* duplicate.
|
|
336
|
+
*
|
|
337
|
+
* `suppress` (rejection safety net) forces the strip convention for the rest
|
|
338
|
+
* of the send. Returns the body unchanged when nothing needs stripping.
|
|
339
|
+
*/
|
|
340
|
+
export function _applyReasoningReplayPolicy(entry, body, { suppress = false } = {}) {
|
|
341
|
+
const input = Array.isArray(body?.input) ? body.input : null;
|
|
342
|
+
if (!entry || !input) return body;
|
|
343
|
+
const hasReasoning = input.some((item) => item?.type === 'reasoning');
|
|
344
|
+
if (suppress) {
|
|
345
|
+
entry.replayReasoning = false;
|
|
346
|
+
} else if (entry.replayReasoning == null) {
|
|
347
|
+
entry.replayReasoning = hasReasoning
|
|
348
|
+
&& entry.lastResponseId == null
|
|
349
|
+
&& entry.lastRequestInput == null;
|
|
350
|
+
}
|
|
351
|
+
if (!hasReasoning || entry.replayReasoning === true) return body;
|
|
352
|
+
return { ...body, input: input.filter((item) => item?.type !== 'reasoning') };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Server rejection of a replayed reasoning item (duplicate rs_* inside a
|
|
357
|
+
* stateful chain). Deliberately narrow: generic transport/5xx errors must not
|
|
358
|
+
* trip the replay-suppression retry.
|
|
359
|
+
*/
|
|
360
|
+
export function _isReasoningReplayRejection(err) {
|
|
361
|
+
const msg = String(err?.payload?.message || err?.message || '');
|
|
362
|
+
if (!msg) return false;
|
|
363
|
+
if (/\brs_[A-Za-z0-9]/i.test(msg) && /duplicate|already|exists|repeated/i.test(msg)) return true;
|
|
364
|
+
return /reasoning/i.test(msg) && /duplicate|already exists|repeated|invalid item/i.test(msg);
|
|
365
|
+
}
|
|
366
|
+
|
|
316
367
|
export async function _acquireWithRetry({
|
|
317
368
|
auth,
|
|
318
369
|
poolKey,
|
|
@@ -577,6 +628,10 @@ export async function sendViaWebSocket({
|
|
|
577
628
|
// backs xAI, whose existing 429 handshake retry behavior must remain.
|
|
578
629
|
const retry429 = traceProvider !== 'openai-oauth';
|
|
579
630
|
|
|
631
|
+
// Armed by the rejection safety net below: once a server rejects a
|
|
632
|
+
// replayed reasoning item, every later attempt of THIS send strips them.
|
|
633
|
+
let suppressReasoningReplay = false;
|
|
634
|
+
|
|
580
635
|
for (let attemptIndex = 0; attemptIndex <= MAX_MIDSTREAM_RETRIES; attemptIndex++) {
|
|
581
636
|
const handshakeStart = performance.now();
|
|
582
637
|
let acquired;
|
|
@@ -739,6 +794,11 @@ export async function sendViaWebSocket({
|
|
|
739
794
|
if (carryForwardCache && auth?.type === 'xai' && attemptIndex > 0 && !body.previous_response_id) {
|
|
740
795
|
requestBody = { ...body, previous_response_id: carryForwardCache.lastResponseId };
|
|
741
796
|
}
|
|
797
|
+
// Recovery-only reasoning replay: decide per entry whether retained
|
|
798
|
+
// reasoning items ride this chain (see _applyReasoningReplayPolicy).
|
|
799
|
+
// Must run BEFORE _computeDelta and the post-send bookkeeping so
|
|
800
|
+
// entry.lastRequestInput always records the post-policy input.
|
|
801
|
+
requestBody = _applyReasoningReplayPolicy(entry, requestBody, { suppress: suppressReasoningReplay });
|
|
742
802
|
let warmupResult = null;
|
|
743
803
|
// midState is shared between warmup and the main stream so warmup
|
|
744
804
|
// failures (first-byte timeout, send-failure, ws_4000) flow through
|
|
@@ -1008,6 +1068,27 @@ export async function sendViaWebSocket({
|
|
|
1008
1068
|
continuation: midState.sawCompleted !== true,
|
|
1009
1069
|
});
|
|
1010
1070
|
} catch { /* stamping is best-effort */ }
|
|
1071
|
+
// Reasoning-replay rejection safety net: a duplicate-rs_ rejection
|
|
1072
|
+
// on a frame that carried replayed reasoning gets ONE strip-and-
|
|
1073
|
+
// retry within the existing attempt budget instead of failing the
|
|
1074
|
+
// recovery turn outright. Unsafe outcomes (live text/tool output)
|
|
1075
|
+
// keep their normal no-replay handling.
|
|
1076
|
+
if (!suppressReasoningReplay
|
|
1077
|
+
&& entry.replayReasoning === true
|
|
1078
|
+
&& err?.unsafeToRetry !== true
|
|
1079
|
+
&& attemptIndex < MAX_MIDSTREAM_RETRIES
|
|
1080
|
+
&& _isReasoningReplayRejection(err)) {
|
|
1081
|
+
suppressReasoningReplay = true;
|
|
1082
|
+
firstAttemptError = err;
|
|
1083
|
+
firstAttemptClassifier = 'reasoning_replay_rejected';
|
|
1084
|
+
try { err.midstreamClassifier = 'reasoning_replay_rejected'; } catch {}
|
|
1085
|
+
emitReconnectProgress({
|
|
1086
|
+
attempt: attemptIndex + 1,
|
|
1087
|
+
max: MAX_MIDSTREAM_RETRIES,
|
|
1088
|
+
classifier: 'reasoning_replay_rejected',
|
|
1089
|
+
});
|
|
1090
|
+
continue;
|
|
1091
|
+
}
|
|
1011
1092
|
const classifier = err?.unsafeToRetry === true
|
|
1012
1093
|
? null
|
|
1013
1094
|
: _classifyMidstreamError(err, midState);
|
|
@@ -19,6 +19,8 @@ import { makeModelCache } from './model-cache.mjs';
|
|
|
19
19
|
|
|
20
20
|
import { sendViaWebSocket } from './openai-oauth-ws.mjs';
|
|
21
21
|
import { _combineUsageWithWarmup } from './openai-ws-events.mjs';
|
|
22
|
+
import { acquireWebSocket, releaseWebSocket, hasPooledWebSocket } from './openai-ws-pool.mjs';
|
|
23
|
+
import { _codexWsCompatibilityHeaders } from './openai-codex-metadata.mjs';
|
|
22
24
|
import { resolveOpenAiTransportPolicy } from './openai-transport-policy.mjs';
|
|
23
25
|
import {
|
|
24
26
|
buildStableProviderPromptCacheKey,
|
|
@@ -769,6 +771,68 @@ export class OpenAIOAuthProvider {
|
|
|
769
771
|
throw err;
|
|
770
772
|
}
|
|
771
773
|
}
|
|
774
|
+
/**
|
|
775
|
+
* Spawn-time transport prewarm (codex prewarm_websocket parity): open the
|
|
776
|
+
* session's WS socket while the caller is still assembling the session /
|
|
777
|
+
* first prompt, so the first real request reuses an already-open pooled
|
|
778
|
+
* socket instead of paying TLS + upgrade on its critical path.
|
|
779
|
+
*
|
|
780
|
+
* Best-effort by contract: every failure returns false and leaves the
|
|
781
|
+
* lazy per-send handshake untouched. Only runs when the thread-scoped
|
|
782
|
+
* prompt_cache_key derivation is active — with it disabled the real
|
|
783
|
+
* cacheKey depends on the built request body (instructions/tools hash),
|
|
784
|
+
* which does not exist yet at spawn prep, and a mismatched socket would
|
|
785
|
+
* just be evicted as incompatible on first acquire.
|
|
786
|
+
*/
|
|
787
|
+
async prewarmWsTransportForSession(opts = {}, seams = {}) {
|
|
788
|
+
const _acquire = seams._acquire || acquireWebSocket;
|
|
789
|
+
const _release = seams._release || releaseWebSocket;
|
|
790
|
+
const _hasPooled = seams._hasPooled || hasPooledWebSocket;
|
|
791
|
+
const _warmVersion = seams._warmVersion || warmCodexClientVersion;
|
|
792
|
+
const poolKey = opts.sessionId || null;
|
|
793
|
+
if (!poolKey) return false;
|
|
794
|
+
try {
|
|
795
|
+
const transportPolicy = resolveOpenAiTransportPolicy();
|
|
796
|
+
if (transportPolicy.transport === 'http'
|
|
797
|
+
|| _envFlag('MIXDOG_OPENAI_OAUTH_FORCE_HTTP_FALLBACK', false)) return false;
|
|
798
|
+
const threadKeyGate = String(process.env.MIXDOG_OAI_CODEX_THREAD_CACHE_KEY || '').toLowerCase();
|
|
799
|
+
if (threadKeyGate === '0' || threadKeyGate === 'false') return false;
|
|
800
|
+
if (_hasPooled(poolKey)) return true;
|
|
801
|
+
// Identical derivation to buildRequestBody's prompt_cache_key
|
|
802
|
+
// (thread-scoped branch), so the prewarmed socket's handshake
|
|
803
|
+
// session_id and pool-compatibility key match the first real send.
|
|
804
|
+
const cacheKey = buildStableProviderPromptCacheKey('openai-oauth', opts);
|
|
805
|
+
const [auth] = await Promise.all([this.ensureAuth(), _warmVersion()]);
|
|
806
|
+
const codexHeaders = _codexWsCompatibilityHeaders({ poolKey, cacheKey, sendOpts: opts, handshake: true });
|
|
807
|
+
const _t0 = Date.now();
|
|
808
|
+
const acquired = await _acquire({
|
|
809
|
+
auth,
|
|
810
|
+
poolKey,
|
|
811
|
+
cacheKey,
|
|
812
|
+
codexHeaders,
|
|
813
|
+
externalSignal: opts.signal || null,
|
|
814
|
+
});
|
|
815
|
+
_release({ entry: acquired.entry, poolKey, keep: true });
|
|
816
|
+
try {
|
|
817
|
+
appendAgentTrace({
|
|
818
|
+
sessionId: poolKey,
|
|
819
|
+
kind: 'spawn_ws_prewarm',
|
|
820
|
+
provider: 'openai-oauth',
|
|
821
|
+
transport: 'websocket',
|
|
822
|
+
payload: { elapsed_ms: Date.now() - _t0, reused: acquired.reused === true },
|
|
823
|
+
});
|
|
824
|
+
} catch {}
|
|
825
|
+
if (process.env.MIXDOG_DEBUG_AGENT) {
|
|
826
|
+
process.stderr.write(`[agent-trace] spawn-ws-prewarm ok poolKey=${createHash('sha256').update(String(poolKey)).digest('hex').slice(0, 8)} elapsed=${Date.now() - _t0}ms\n`);
|
|
827
|
+
}
|
|
828
|
+
return true;
|
|
829
|
+
} catch (err) {
|
|
830
|
+
if (process.env.MIXDOG_DEBUG_AGENT) {
|
|
831
|
+
process.stderr.write(`[agent-trace] spawn-ws-prewarm failed err=${String(err?.message || err).slice(0, 160)}\n`);
|
|
832
|
+
}
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
772
836
|
async listModels() {
|
|
773
837
|
// Dynamic lookup via /backend-api/codex/models. Cached 24h.
|
|
774
838
|
// Endpoint returns rich metadata (context_window, reasoning levels,
|