mixdog 0.9.40 → 0.9.41
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 +2 -1
- package/scripts/anthropic-oauth-refresh-race-test.mjs +338 -0
- package/scripts/compact-pressure-test.mjs +128 -0
- package/scripts/compact-trigger-migration-smoke.mjs +8 -8
- package/scripts/internal-comms-smoke.mjs +66 -25
- package/scripts/max-output-recovery-persist-test.mjs +81 -0
- package/scripts/max-output-recovery-test.mjs +222 -0
- package/scripts/provider-toolcall-test.mjs +32 -0
- package/src/agents/reviewer/AGENT.md +4 -0
- package/src/rules/lead/lead-brief.md +11 -3
- package/src/rules/lead/lead-tool.md +0 -2
- package/src/rules/shared/01-tool.md +4 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +20 -2
- package/src/runtime/agent/orchestrator/context/collect.mjs +7 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +144 -7
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +15 -2
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +57 -25
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +54 -12
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +106 -8
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +12 -1
- package/src/runtime/agent/orchestrator/session/loop/termination.mjs +22 -7
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +26 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +31 -4
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +11 -2
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +45 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -21
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -10
- package/src/workflows/bench/WORKFLOW.md +51 -27
- package/src/workflows/default/WORKFLOW.md +29 -24
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* the public functions so external callers keep their existing import path.
|
|
8
8
|
*/
|
|
9
9
|
import { readFileSync, existsSync, statSync } from 'fs';
|
|
10
|
-
import { join } from 'path';
|
|
10
|
+
import { join, resolve } from 'path';
|
|
11
11
|
import { createServer } from 'http';
|
|
12
12
|
import { randomBytes, createHash } from 'crypto';
|
|
13
|
-
import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
|
|
13
|
+
import { writeJsonAtomicSync, withFileLock } from '../../../shared/atomic-file.mjs';
|
|
14
14
|
import { resolvePluginData } from '../../../shared/plugin-paths.mjs';
|
|
15
15
|
import { getLlmDispatcher } from '../../../shared/llm/http-agent.mjs';
|
|
16
16
|
|
|
@@ -33,6 +33,7 @@ export const TOKEN_URL = assertSafeTokenURL(process.env.ANTHROPIC_OAUTH_TOKEN_UR
|
|
|
33
33
|
export const DEFAULT_CREDENTIALS_PATH = join(resolvePluginData(), 'anthropic-oauth-credentials.json');
|
|
34
34
|
export const CLAUDE_CODE_CLIENT_ID = process.env.ANTHROPIC_OAUTH_CLIENT_ID || '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
|
35
35
|
export const TOKEN_REFRESH_SKEW_MS = 5 * 60_000;
|
|
36
|
+
export const ANTHROPIC_OAUTH_REFRESH_DISABLED_ENV = 'MIXDOG_ANTHROPIC_OAUTH_REFRESH_DISABLED';
|
|
36
37
|
const CLAUDE_AI_AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize';
|
|
37
38
|
const ALL_OAUTH_SCOPES = [
|
|
38
39
|
'org:create_api_key',
|
|
@@ -67,10 +68,9 @@ function _pushUnique(list, value) {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
export function credentialCandidates() {
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return paths;
|
|
71
|
+
const explicit = process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH;
|
|
72
|
+
if (explicit) return [resolve(explicit)];
|
|
73
|
+
return [DEFAULT_CREDENTIALS_PATH];
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
// Fallback expiry from the access_token's JWT `exp` claim (epoch ms) when the
|
|
@@ -109,6 +109,11 @@ function _loadCredentialsFile(path) {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
export function loadCredentialsFromPath(path) {
|
|
113
|
+
if (!path) return null;
|
|
114
|
+
return _loadCredentialsFile(resolve(path));
|
|
115
|
+
}
|
|
116
|
+
|
|
112
117
|
// Cross-process safe credential save. Lockfile (O_EXCL) prevents two Mixdog
|
|
113
118
|
// refreshers from clobbering each other; atomic rename guarantees readers see
|
|
114
119
|
// either the old or new file, never a half-written one. Used so refresh_token
|
|
@@ -215,7 +220,15 @@ export function _scrubTokens(text) {
|
|
|
215
220
|
.replace(/"refresh_token"\s*:\s*"[^"]+"/g, '"refresh_token":"[REDACTED]"');
|
|
216
221
|
}
|
|
217
222
|
|
|
218
|
-
export
|
|
223
|
+
export function isAnthropicOAuthRefreshDisabled() {
|
|
224
|
+
return process.env[ANTHROPIC_OAUTH_REFRESH_DISABLED_ENV] === '1';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function _refreshLockPath(path) {
|
|
228
|
+
return `${resolve(path)}.anthropic-oauth-refresh.lock`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function _refreshOAuthCredentialsUnlocked(creds) {
|
|
219
232
|
if (!creds?.refreshToken) {
|
|
220
233
|
throw new Error('Anthropic OAuth refresh token not available. Open /providers in mixdog to sign in again.');
|
|
221
234
|
}
|
|
@@ -294,6 +307,130 @@ export async function refreshOAuthCredentials(creds) {
|
|
|
294
307
|
}
|
|
295
308
|
}
|
|
296
309
|
|
|
310
|
+
export async function refreshOAuthCredentials(creds) {
|
|
311
|
+
if (isAnthropicOAuthRefreshDisabled()) {
|
|
312
|
+
throw new Error(
|
|
313
|
+
'Anthropic OAuth refresh is disabled in this process; '
|
|
314
|
+
+ 'host credential preflight must provide a fresh snapshot.',
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
const credentialPath = resolve(
|
|
318
|
+
creds?.path
|
|
319
|
+
|| process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH
|
|
320
|
+
|| DEFAULT_CREDENTIALS_PATH,
|
|
321
|
+
);
|
|
322
|
+
return withFileLock(_refreshLockPath(credentialPath), async () => {
|
|
323
|
+
const disk = _loadCredentialsFile(credentialPath);
|
|
324
|
+
// A waiter that started with the prior generation must consume the
|
|
325
|
+
// winner's persisted rotation, not replay the single-use token.
|
|
326
|
+
if (disk?.accessToken && creds?.accessToken
|
|
327
|
+
&& disk.accessToken !== creds.accessToken
|
|
328
|
+
&& (!disk.expiresAt || disk.expiresAt > Date.now())) {
|
|
329
|
+
return disk;
|
|
330
|
+
}
|
|
331
|
+
return _refreshOAuthCredentialsUnlocked(
|
|
332
|
+
disk || { ...creds, path: credentialPath },
|
|
333
|
+
);
|
|
334
|
+
}, {
|
|
335
|
+
timeoutMs: 120_000,
|
|
336
|
+
staleMs: 120_000,
|
|
337
|
+
secret: true,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Serialize host-side refresh, establish a bounded access-token lease, and
|
|
343
|
+
* optionally write an owner-only snapshot while the lease lock is still held.
|
|
344
|
+
* The returned object deliberately contains metadata only, never token bytes.
|
|
345
|
+
*/
|
|
346
|
+
export async function preflightAnthropicOAuthCredentials({
|
|
347
|
+
credentialsPath = null,
|
|
348
|
+
minimumValidityMs = TOKEN_REFRESH_SKEW_MS,
|
|
349
|
+
snapshotPath = null,
|
|
350
|
+
refreshFn = refreshOAuthCredentials,
|
|
351
|
+
now = () => Date.now(),
|
|
352
|
+
lockTimeoutMs = 120_000,
|
|
353
|
+
} = {}) {
|
|
354
|
+
if (isAnthropicOAuthRefreshDisabled()) {
|
|
355
|
+
throw new Error('Anthropic OAuth host preflight cannot run while refresh is disabled.');
|
|
356
|
+
}
|
|
357
|
+
const requiredMs = Number(minimumValidityMs);
|
|
358
|
+
if (!Number.isFinite(requiredMs) || requiredMs < 0) {
|
|
359
|
+
throw new Error('Anthropic OAuth host preflight minimumValidityMs must be a non-negative number.');
|
|
360
|
+
}
|
|
361
|
+
const pinnedPath = resolve(
|
|
362
|
+
credentialsPath
|
|
363
|
+
|| process.env.ANTHROPIC_OAUTH_CREDENTIALS_PATH
|
|
364
|
+
|| DEFAULT_CREDENTIALS_PATH,
|
|
365
|
+
);
|
|
366
|
+
const initial = _loadCredentialsFile(pinnedPath);
|
|
367
|
+
if (!initial?.path || !initial.accessToken) {
|
|
368
|
+
throw new Error(
|
|
369
|
+
`Anthropic OAuth host preflight found no credentials at ${pinnedPath}. `
|
|
370
|
+
+ 'Open /providers in mixdog to sign in.',
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return withFileLock(_refreshLockPath(pinnedPath), async () => {
|
|
375
|
+
let leased = _loadCredentialsFile(pinnedPath);
|
|
376
|
+
if (!leased?.path || !leased.accessToken) {
|
|
377
|
+
throw new Error('Anthropic OAuth credentials disappeared during host preflight.');
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const validAfter = now() + requiredMs;
|
|
381
|
+
let refreshed = false;
|
|
382
|
+
if (!leased.expiresAt || leased.expiresAt < validAfter) {
|
|
383
|
+
if (!leased.refreshToken) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
'Anthropic OAuth host preflight cannot establish the required lease: '
|
|
386
|
+
+ 'refresh token is unavailable.',
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
// The public refresh function owns this same lock. Call its
|
|
390
|
+
// unlocked exchange only because preflight already owns it;
|
|
391
|
+
// injected test exchanges run under the identical ownership.
|
|
392
|
+
const next = refreshFn === refreshOAuthCredentials
|
|
393
|
+
? await _refreshOAuthCredentialsUnlocked(leased)
|
|
394
|
+
: await refreshFn(leased);
|
|
395
|
+
const persisted = _loadCredentialsFile(pinnedPath);
|
|
396
|
+
if (!persisted?.accessToken || persisted.accessToken !== next?.accessToken) {
|
|
397
|
+
throw new Error('Anthropic OAuth host preflight refresh was not persisted.');
|
|
398
|
+
}
|
|
399
|
+
leased = persisted;
|
|
400
|
+
refreshed = true;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const remainingMs = leased.expiresAt ? leased.expiresAt - now() : 0;
|
|
404
|
+
if (remainingMs < requiredMs) {
|
|
405
|
+
throw new Error(
|
|
406
|
+
`Anthropic OAuth host preflight cannot satisfy the ${Math.ceil(requiredMs / 1000)}s `
|
|
407
|
+
+ `credential lease (provider granted ${Math.max(0, Math.floor(remainingMs / 1000))}s).`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (snapshotPath) {
|
|
412
|
+
const raw = JSON.parse(readFileSync(leased.path, 'utf-8'));
|
|
413
|
+
// Containers need only the leased access token. Never distribute
|
|
414
|
+
// the rotating refresh credential, even into disposable storage.
|
|
415
|
+
if (raw?.claudeAiOauth) {
|
|
416
|
+
delete raw.claudeAiOauth.refreshToken;
|
|
417
|
+
delete raw.claudeAiOauth.refresh_token;
|
|
418
|
+
}
|
|
419
|
+
_saveCredentialsFile(snapshotPath, raw);
|
|
420
|
+
}
|
|
421
|
+
return {
|
|
422
|
+
expiresAt: leased.expiresAt,
|
|
423
|
+
remainingMs,
|
|
424
|
+
refreshed,
|
|
425
|
+
snapshotWritten: Boolean(snapshotPath),
|
|
426
|
+
};
|
|
427
|
+
}, {
|
|
428
|
+
timeoutMs: lockTimeoutMs,
|
|
429
|
+
staleMs: 120_000,
|
|
430
|
+
secret: true,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
297
434
|
// --- Login flow (PKCE loopback, export for setup UI / CLI) ---
|
|
298
435
|
|
|
299
436
|
function _oauthGeneratePKCE() {
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { sanitizeToolPairs, sanitizeAnthropicContentPairs, foldUserTextIntoToolResultTail } from '../session/context-utils.mjs';
|
|
28
28
|
import {
|
|
29
29
|
TOKEN_REFRESH_SKEW_MS,
|
|
30
|
+
isAnthropicOAuthRefreshDisabled,
|
|
30
31
|
resolveCliVersion,
|
|
31
32
|
loadCredentials,
|
|
32
33
|
hasAnthropicOAuthCredentials,
|
|
@@ -342,7 +343,7 @@ function toAnthropicMessages(messages) {
|
|
|
342
343
|
const m = messages[idx];
|
|
343
344
|
if (m.role === 'system') continue;
|
|
344
345
|
|
|
345
|
-
if (m.role === 'assistant' && (m.toolCalls?.length || m.assistantBlocks?.length)) {
|
|
346
|
+
if (m.role === 'assistant' && (m.toolCalls?.length || m.assistantBlocks?.length || m.thinkingBlocks?.length)) {
|
|
346
347
|
let content;
|
|
347
348
|
if (m.assistantBlocks?.length) {
|
|
348
349
|
content = m.assistantBlocks.slice();
|
|
@@ -358,7 +359,7 @@ function toAnthropicMessages(messages) {
|
|
|
358
359
|
}
|
|
359
360
|
}
|
|
360
361
|
if (m.content) content.push({ type: 'text', text: m.content });
|
|
361
|
-
for (const tc of m.toolCalls) {
|
|
362
|
+
for (const tc of m.toolCalls || []) {
|
|
362
363
|
content.push({
|
|
363
364
|
type: 'tool_use',
|
|
364
365
|
id: tc.id,
|
|
@@ -703,6 +704,12 @@ export class AnthropicOAuthProvider {
|
|
|
703
704
|
const expiring = this.credentials.expiresAt
|
|
704
705
|
&& this.credentials.expiresAt < Date.now() + TOKEN_REFRESH_SKEW_MS;
|
|
705
706
|
if (forceRefresh || expiring) {
|
|
707
|
+
if (isAnthropicOAuthRefreshDisabled()) {
|
|
708
|
+
throw new Error(
|
|
709
|
+
'Anthropic OAuth credentials require refresh, but refresh is disabled '
|
|
710
|
+
+ 'in this container. Host credential preflight must provide a fresh snapshot.',
|
|
711
|
+
);
|
|
712
|
+
}
|
|
706
713
|
this.credentials = await this._refreshCredentials({ force: forceRefresh, reason });
|
|
707
714
|
}
|
|
708
715
|
|
|
@@ -710,6 +717,12 @@ export class AnthropicOAuthProvider {
|
|
|
710
717
|
}
|
|
711
718
|
|
|
712
719
|
async _refreshCredentials({ force = false, reason = 'preemptive' } = {}) {
|
|
720
|
+
if (isAnthropicOAuthRefreshDisabled()) {
|
|
721
|
+
throw new Error(
|
|
722
|
+
'Anthropic OAuth refresh is disabled in this container; '
|
|
723
|
+
+ 'host credential preflight must provide a fresh snapshot.',
|
|
724
|
+
);
|
|
725
|
+
}
|
|
713
726
|
const currentToken = this.credentials?.accessToken || null;
|
|
714
727
|
const disk = loadCredentials();
|
|
715
728
|
const validAfter = Date.now() + (force ? 0 : TOKEN_REFRESH_SKEW_MS);
|
|
@@ -347,7 +347,7 @@ function toAnthropicMessages(messages) {
|
|
|
347
347
|
for (let idx = 0; idx < messages.length; idx++) {
|
|
348
348
|
const m = messages[idx];
|
|
349
349
|
if (m.role === 'system') continue;
|
|
350
|
-
if (m.role === 'assistant' && (m.toolCalls?.length || m.assistantBlocks?.length)) {
|
|
350
|
+
if (m.role === 'assistant' && (m.toolCalls?.length || m.assistantBlocks?.length || m.thinkingBlocks?.length)) {
|
|
351
351
|
let content;
|
|
352
352
|
if (m.assistantBlocks?.length) {
|
|
353
353
|
content = m.assistantBlocks.slice();
|
|
@@ -362,7 +362,7 @@ function toAnthropicMessages(messages) {
|
|
|
362
362
|
}
|
|
363
363
|
}
|
|
364
364
|
if (m.content) content.push({ type: 'text', text: m.content });
|
|
365
|
-
for (const tc of m.toolCalls) {
|
|
365
|
+
for (const tc of m.toolCalls || []) {
|
|
366
366
|
content.push({
|
|
367
367
|
type: 'tool_use',
|
|
368
368
|
id: tc.id,
|
|
@@ -51,6 +51,11 @@ import {
|
|
|
51
51
|
createActiveToolItemTracker,
|
|
52
52
|
hasCompleteToolCall as sharedHasCompleteToolCall,
|
|
53
53
|
} from './tool-stream-state.mjs';
|
|
54
|
+
import {
|
|
55
|
+
enableSessionTransportTracking,
|
|
56
|
+
disableSessionTransportTracking,
|
|
57
|
+
markSessionTransportActivity,
|
|
58
|
+
} from '../session/manager/runtime-liveness.mjs';
|
|
54
59
|
|
|
55
60
|
// Facade re-exports: the delta/matching helpers and usage/event helpers below
|
|
56
61
|
// were extracted to openai-ws-delta.mjs / openai-ws-events.mjs (no behavior
|
|
@@ -92,6 +97,11 @@ export const WS_PRE_RESPONSE_CREATED_MS = (() => {
|
|
|
92
97
|
// including metadata/keepalive, proves the socket is live.
|
|
93
98
|
export const WS_INTER_CHUNK_MS = PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS;
|
|
94
99
|
const X_CODEX_TURN_STATE_HEADER = 'x-codex-turn-state';
|
|
100
|
+
const WS_TRACE_ENABLED = process.env.MIXDOG_WS_TRACE === '1';
|
|
101
|
+
|
|
102
|
+
function _writeWsLifecycleTrace(lifecycle) {
|
|
103
|
+
process.stderr.write(`[ws-trace] t=${new Date().toISOString()} lifecycle=${lifecycle}\n`);
|
|
104
|
+
}
|
|
95
105
|
|
|
96
106
|
function _responseItemKey(item, fallbackIndex = 0) {
|
|
97
107
|
if (!item || typeof item !== 'object') return `primitive:${fallbackIndex}`;
|
|
@@ -239,6 +249,7 @@ export async function _streamResponse({
|
|
|
239
249
|
}) {
|
|
240
250
|
const errLabel = _wsErrLabel(traceProvider);
|
|
241
251
|
const socket = entry.socket;
|
|
252
|
+
enableSessionTransportTracking(state?.sessionId);
|
|
242
253
|
const preResponseCreatedMs = _positiveInt(_timeouts?.preResponseCreatedMs, WS_PRE_RESPONSE_CREATED_MS);
|
|
243
254
|
const interChunkMs = _positiveInt(_timeouts?.interChunkMs, WS_INTER_CHUNK_MS);
|
|
244
255
|
// First-MEANINGFUL-frame deadline. Distinct from preResponseCreatedMs (a
|
|
@@ -471,6 +482,9 @@ export async function _streamResponse({
|
|
|
471
482
|
let messageHandler = null;
|
|
472
483
|
let closeHandler = null;
|
|
473
484
|
let errorHandler = null;
|
|
485
|
+
let traceOpenHandler = null;
|
|
486
|
+
let tracePingHandler = null;
|
|
487
|
+
let tracePongHandler = null;
|
|
474
488
|
// SEMANTIC idle timer: distinct from the inter-chunk
|
|
475
489
|
// timer, which resets on EVERY frame (rate_limits/metadata/keepalive keep
|
|
476
490
|
// the socket "alive"). This timer resets ONLY on meaningful output deltas
|
|
@@ -668,6 +682,7 @@ export async function _streamResponse({
|
|
|
668
682
|
// frames never reach here, so they cannot satisfy the watchdog.
|
|
669
683
|
const bumpSemanticIdle = () => { clearFirstMeaningfulWatchdog(); resetSemanticIdle(); };
|
|
670
684
|
const cleanup = () => {
|
|
685
|
+
disableSessionTransportTracking(midState.sessionId);
|
|
671
686
|
if (idleTimer) clearTimeout(idleTimer);
|
|
672
687
|
if (interChunkTimer) { clearTimeout(interChunkTimer); interChunkTimer = null; }
|
|
673
688
|
if (semanticIdleTimer) { clearTimeout(semanticIdleTimer); semanticIdleTimer = null; }
|
|
@@ -676,6 +691,9 @@ export async function _streamResponse({
|
|
|
676
691
|
if (messageHandler) socket.off('message', messageHandler);
|
|
677
692
|
if (closeHandler) socket.off('close', closeHandler);
|
|
678
693
|
if (errorHandler) socket.off('error', errorHandler);
|
|
694
|
+
if (traceOpenHandler) socket.off('open', traceOpenHandler);
|
|
695
|
+
if (tracePingHandler) socket.off('ping', tracePingHandler);
|
|
696
|
+
if (tracePongHandler) socket.off('pong', tracePongHandler);
|
|
679
697
|
if (abortHandler && externalSignal) externalSignal.removeEventListener('abort', abortHandler);
|
|
680
698
|
};
|
|
681
699
|
const finish = () => {
|
|
@@ -755,6 +773,15 @@ export async function _streamResponse({
|
|
|
755
773
|
const text = typeof data === 'string' ? data : data.toString('utf-8');
|
|
756
774
|
const event = _parseEvent(text);
|
|
757
775
|
if (!event) return;
|
|
776
|
+
if (WS_TRACE_ENABLED) {
|
|
777
|
+
const type = typeof event.type === 'string' ? event.type : '';
|
|
778
|
+
const empty = type.endsWith('.delta')
|
|
779
|
+
&& (event.delta == null || event.delta === '');
|
|
780
|
+
process.stderr.write(
|
|
781
|
+
`[ws-trace] t=${new Date().toISOString()} type=${type} bytes=${Buffer.byteLength(text)} empty=${empty}\n`,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
markSessionTransportActivity(midState.sessionId);
|
|
758
785
|
_traceWsHeaderKeys(entry, event, midState, traceProvider, model);
|
|
759
786
|
_captureTurnStateFromEvent(entry, event);
|
|
760
787
|
if (event.error) {
|
|
@@ -780,12 +807,9 @@ export async function _streamResponse({
|
|
|
780
807
|
// first-meaningful watchdog (keepalive/metadata frames do
|
|
781
808
|
// NOT reach this case, so they never clear it).
|
|
782
809
|
clearFirstMeaningfulWatchdog();
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
// never emits a delta would stall unbounded until the outer
|
|
787
|
-
// watchdog. Arming here bounds that gap.
|
|
788
|
-
resetSemanticIdle();
|
|
810
|
+
// Do not arm semantic idle until actual model progress.
|
|
811
|
+
// Transport-only reasoning heartbeats are bounded by the
|
|
812
|
+
// outer first-visible ceiling instead.
|
|
789
813
|
break;
|
|
790
814
|
case 'response.output_text.delta':
|
|
791
815
|
try {
|
|
@@ -814,14 +838,12 @@ export async function _streamResponse({
|
|
|
814
838
|
case 'response.reasoning_summary_text.delta':
|
|
815
839
|
if (event.type === 'response.reasoning_text.delta') reasoningTextDeltaCount += 1;
|
|
816
840
|
else reasoningSummaryTextDeltaCount += 1;
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
try { onStreamDelta?.(); } catch {}
|
|
824
|
-
bumpSemanticIdle();
|
|
841
|
+
// Only non-empty reasoning text is model progress. Empty
|
|
842
|
+
// deltas remain transport activity via resetIdle() above.
|
|
843
|
+
if (event.delta) {
|
|
844
|
+
try { onStreamDelta?.(); } catch {}
|
|
845
|
+
bumpSemanticIdle();
|
|
846
|
+
}
|
|
825
847
|
break;
|
|
826
848
|
case 'response.output_item.added':
|
|
827
849
|
if (event.item?.type === 'function_call') {
|
|
@@ -1184,25 +1206,24 @@ export async function _streamResponse({
|
|
|
1184
1206
|
&& event.type.startsWith('response.reasoning')
|
|
1185
1207
|
&& event.type.endsWith('.delta')) {
|
|
1186
1208
|
reasoningOtherDeltaCount += 1;
|
|
1187
|
-
//
|
|
1188
|
-
//
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
bumpSemanticIdle();
|
|
1209
|
+
// Only non-empty reasoning deltas are model progress;
|
|
1210
|
+
// empty variants remain transport activity only.
|
|
1211
|
+
if (event.delta) {
|
|
1212
|
+
try { onStreamDelta?.(); } catch {}
|
|
1213
|
+
bumpSemanticIdle();
|
|
1214
|
+
}
|
|
1194
1215
|
}
|
|
1195
|
-
// response.in_progress is
|
|
1196
|
-
//
|
|
1197
|
-
// not counted as silence (resetIdle already ran at the top).
|
|
1216
|
+
// response.in_progress is transport activity only;
|
|
1217
|
+
// resetIdle() already kept the socket alive above.
|
|
1198
1218
|
else if (event.type === 'response.in_progress') {
|
|
1199
|
-
|
|
1219
|
+
// No semantic/model progress.
|
|
1200
1220
|
}
|
|
1201
1221
|
// Other trace-only events fall through.
|
|
1202
1222
|
break;
|
|
1203
1223
|
}
|
|
1204
1224
|
};
|
|
1205
1225
|
closeHandler = (code, reason) => {
|
|
1226
|
+
if (WS_TRACE_ENABLED) _writeWsLifecycleTrace('close');
|
|
1206
1227
|
if (done) return;
|
|
1207
1228
|
midState.wsCloseCode = code;
|
|
1208
1229
|
if (!terminalError) {
|
|
@@ -1219,6 +1240,7 @@ export async function _streamResponse({
|
|
|
1219
1240
|
finish();
|
|
1220
1241
|
};
|
|
1221
1242
|
errorHandler = (err) => {
|
|
1243
|
+
if (WS_TRACE_ENABLED) _writeWsLifecycleTrace('error');
|
|
1222
1244
|
if (done) return;
|
|
1223
1245
|
const wrapped = err instanceof Error ? err : new Error(String(err));
|
|
1224
1246
|
if (terminalError) {
|
|
@@ -1268,6 +1290,15 @@ export async function _streamResponse({
|
|
|
1268
1290
|
socket.on('message', messageHandler);
|
|
1269
1291
|
socket.on('close', closeHandler);
|
|
1270
1292
|
socket.on('error', errorHandler);
|
|
1293
|
+
if (WS_TRACE_ENABLED) {
|
|
1294
|
+
traceOpenHandler = () => _writeWsLifecycleTrace('open');
|
|
1295
|
+
tracePingHandler = () => _writeWsLifecycleTrace('ping');
|
|
1296
|
+
tracePongHandler = () => _writeWsLifecycleTrace('pong');
|
|
1297
|
+
socket.on('open', traceOpenHandler);
|
|
1298
|
+
socket.on('ping', tracePingHandler);
|
|
1299
|
+
socket.on('pong', tracePongHandler);
|
|
1300
|
+
if (socket.readyState === WebSocket.OPEN) _writeWsLifecycleTrace('open');
|
|
1301
|
+
}
|
|
1271
1302
|
armPreStreamWatchdog();
|
|
1272
1303
|
armFirstMeaningfulWatchdog();
|
|
1273
1304
|
// Periodic client-side WS ping while the stream is active. The server's
|
|
@@ -1280,6 +1311,7 @@ export async function _streamResponse({
|
|
|
1280
1311
|
keepaliveTimer = setInterval(() => {
|
|
1281
1312
|
try {
|
|
1282
1313
|
if (socket.readyState !== WebSocket.OPEN) return;
|
|
1314
|
+
if (WS_TRACE_ENABLED) _writeWsLifecycleTrace('ping');
|
|
1283
1315
|
socket.ping();
|
|
1284
1316
|
} catch {}
|
|
1285
1317
|
}, 10_000);
|
|
@@ -83,7 +83,7 @@ import {
|
|
|
83
83
|
restoreToolCallBodyForId,
|
|
84
84
|
} from './loop/stored-tool-args.mjs';
|
|
85
85
|
import { repairTranscriptBeforeProviderSend } from './loop/transcript-repair.mjs';
|
|
86
|
-
import { classifyTerminationReason, INCOMPLETE_STOP_REASONS } from './loop/termination.mjs';
|
|
86
|
+
import { classifyTerminationReason, INCOMPLETE_STOP_REASONS, isOutputLimitStopReason } from './loop/termination.mjs';
|
|
87
87
|
import { createSteeringLadder } from './loop/steering-ladder.mjs';
|
|
88
88
|
import { runPreSendCompactPass } from './pre-send-compact.mjs';
|
|
89
89
|
import { createEagerDispatcher } from './eager-dispatch.mjs';
|
|
@@ -116,6 +116,11 @@ export {
|
|
|
116
116
|
// this catches tight deterministic-failure loops (e.g. a command that errors
|
|
117
117
|
// the same way every time) far earlier than 100 iterations.
|
|
118
118
|
const REPEAT_FAIL_LIMIT = 3;
|
|
119
|
+
// A provider max-output stop is not a completed assistant turn, even when it
|
|
120
|
+
// contains useful text. Preserve each partial in the provider transcript and
|
|
121
|
+
// grant at most three direct continuations before surfacing a hard truncation.
|
|
122
|
+
const MAX_OUTPUT_RECOVERY_LIMIT = 3;
|
|
123
|
+
const MAX_OUTPUT_EXHAUSTED_NOTICE = '[mixdog-runtime] Output remained truncated after 3 continuation attempts.';
|
|
119
124
|
// _scopedCacheOutcomeForCall and executeTool moved to ./loop/tool-exec.mjs
|
|
120
125
|
// (imported above).
|
|
121
126
|
/**
|
|
@@ -233,10 +238,16 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
233
238
|
const reasoningItems = Array.isArray(resp.reasoningItems) && resp.reasoningItems.length
|
|
234
239
|
? resp.reasoningItems
|
|
235
240
|
: null;
|
|
236
|
-
|
|
241
|
+
const thinkingBlocks = Array.isArray(resp.thinkingBlocks) && resp.thinkingBlocks.length
|
|
242
|
+
? resp.thinkingBlocks
|
|
243
|
+
: null;
|
|
244
|
+
if (!content && !reasoningContent && !reasoningItems && !thinkingBlocks) return false;
|
|
237
245
|
messages.push({
|
|
238
246
|
role: 'assistant',
|
|
239
247
|
content,
|
|
248
|
+
// Anthropic adaptive-thinking signatures must be replayed verbatim
|
|
249
|
+
// before the continuation turn, just like tool-call trajectories.
|
|
250
|
+
...(thinkingBlocks ? { thinkingBlocks } : {}),
|
|
240
251
|
...(reasoningItems ? { reasoningItems } : {}),
|
|
241
252
|
...(reasoningContent ? { reasoningContent } : {}),
|
|
242
253
|
});
|
|
@@ -270,6 +281,8 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
270
281
|
// the loop as an explicit empty termination instead.
|
|
271
282
|
let _emptyNudgeStreak = 0;
|
|
272
283
|
const EMPTY_NUDGE_MAX = 3;
|
|
284
|
+
let _maxOutputRecoveryCount = 0;
|
|
285
|
+
const _maxOutputContentParts = [];
|
|
273
286
|
// Claude Code parity: queued prompt/task notifications are attached after a
|
|
274
287
|
// tool batch, before the continuation provider send. Normal batches drain
|
|
275
288
|
// up to 'next'; a Sleep-like tool grants a 'later' flush.
|
|
@@ -517,22 +530,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
517
530
|
// Provider may have returned despite an abort (SDKs that don't honour
|
|
518
531
|
// signal) — bail before processing any of its output.
|
|
519
532
|
throwIfAborted();
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
// when stopReason==='length' AND content is non-empty) used to look
|
|
523
|
-
// identical to a clean completion — the model's answer could be
|
|
524
|
-
// silently cut mid-sentence with zero signal to the operator. Surface
|
|
525
|
-
// it as a one-line stderr warning + trace event WITHOUT failing the
|
|
526
|
-
// turn (the partial content is still usable and the loop's own
|
|
527
|
-
// isIncompleteStop nudge below already re-prompts when content is
|
|
528
|
-
// empty).
|
|
533
|
+
// Keep a diagnostic for every provider-declared truncation. Eligible
|
|
534
|
+
// no-tool text turns are recovered below rather than accepted as final.
|
|
529
535
|
if (response?.truncated === true) {
|
|
530
536
|
try {
|
|
531
537
|
process.stderr.write(
|
|
532
538
|
`[loop] provider output truncated at max-output limit (sess=${sessionId || 'unknown'} `
|
|
533
539
|
+ `iter=${iterations} stopReason=${response.stopReason ?? response.stop_reason ?? 'length'} `
|
|
534
540
|
+ `contentLen=${typeof response.content === 'string' ? response.content.length : 0}); `
|
|
535
|
-
+ `
|
|
541
|
+
+ `continuation recovery will be attempted when eligible.\n`,
|
|
536
542
|
);
|
|
537
543
|
} catch { /* best-effort */ }
|
|
538
544
|
try {
|
|
@@ -600,6 +606,33 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
600
606
|
const isHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
|
|
601
607
|
const stopReason = response.stopReason ?? response.stop_reason ?? null;
|
|
602
608
|
const isIncompleteStop = stopReason && INCOMPLETE_STOP_REASONS.has(stopReason);
|
|
609
|
+
const isOutputLimitStop = isOutputLimitStopReason(stopReason);
|
|
610
|
+
if (hasContent && isOutputLimitStop) {
|
|
611
|
+
_maxOutputContentParts.push(response.content);
|
|
612
|
+
if (_maxOutputRecoveryCount < MAX_OUTPUT_RECOVERY_LIMIT) {
|
|
613
|
+
// The partial assistant turn must be visible to the model so
|
|
614
|
+
// it can resume at the exact cutoff instead of reconstructing
|
|
615
|
+
// or repeating it. askSession persists this natural recovery
|
|
616
|
+
// chain; historyContent below prevents the aggregate returned
|
|
617
|
+
// to callers from being persisted a second time.
|
|
618
|
+
pushIntermediateAssistantResponse(response);
|
|
619
|
+
_maxOutputRecoveryCount += 1;
|
|
620
|
+
messages.push({
|
|
621
|
+
role: 'user',
|
|
622
|
+
content: 'Output token limit hit. Resume directly — no apology, no recap. Pick up exactly where the previous text stopped.',
|
|
623
|
+
meta: { source: 'max-output-recovery', attempt: _maxOutputRecoveryCount },
|
|
624
|
+
});
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
const terminalSegment = `${response.content}\n\n${MAX_OUTPUT_EXHAUSTED_NOTICE}`;
|
|
628
|
+
response = {
|
|
629
|
+
...response,
|
|
630
|
+
content: `${_maxOutputContentParts.slice(0, -1).join('')}${terminalSegment}`,
|
|
631
|
+
historyContent: terminalSegment,
|
|
632
|
+
maxOutputRecoveryAttempts: _maxOutputRecoveryCount,
|
|
633
|
+
};
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
603
636
|
if (!hasContent && !isHidden) {
|
|
604
637
|
_emptyNudgeStreak += 1;
|
|
605
638
|
if (_emptyNudgeStreak > EMPTY_NUDGE_MAX) {
|
|
@@ -622,6 +655,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
622
655
|
messages.push({ role: 'user', content: nudgeMsg });
|
|
623
656
|
continue;
|
|
624
657
|
}
|
|
658
|
+
if (_maxOutputContentParts.length > 0) {
|
|
659
|
+
const terminalSegment = typeof response.content === 'string' ? response.content : '';
|
|
660
|
+
response = {
|
|
661
|
+
...response,
|
|
662
|
+
content: `${_maxOutputContentParts.join('')}${terminalSegment}`,
|
|
663
|
+
historyContent: terminalSegment,
|
|
664
|
+
maxOutputRecoveryAttempts: _maxOutputRecoveryCount,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
625
667
|
break;
|
|
626
668
|
}
|
|
627
669
|
_emptyNudgeStreak = 0;
|
|
@@ -307,6 +307,7 @@ function contextSummaryResult(state, count) {
|
|
|
307
307
|
|
|
308
308
|
export const DEFAULT_COMPACTION_BUFFER_TOKENS = 0;
|
|
309
309
|
export const DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
|
|
310
|
+
export const MAIN_COMPACTION_TRIGGER_RATIO = 0.95;
|
|
310
311
|
export const MAX_COMPACTION_BUFFER_RATIO = 0.25;
|
|
311
312
|
export const DEFAULT_COMPACTION_KEEP_TOKENS = 8_000;
|
|
312
313
|
const LEGACY_DEFAULT_COMPACTION_BUFFER_RATIO = 0.1;
|
|
@@ -443,8 +444,8 @@ export function resolveCompactTriggerTokens(sessionOrConfig = {}, boundaryTokens
|
|
|
443
444
|
// (trigger = limit) for every session type;
|
|
444
445
|
// - agent-owned semantic sessions otherwise keep the default early-trigger
|
|
445
446
|
// buffer (config-driven, default 10% -> compact at 90% of the boundary);
|
|
446
|
-
// - main/user recall-fasttrack sessions
|
|
447
|
-
// the boundary
|
|
447
|
+
// - main/user recall-fasttrack sessions keep 5% headroom and compact at 95%
|
|
448
|
+
// of the effective boundary.
|
|
448
449
|
// Returns the sanitized explicit limit (null when absent/legacy full-window)
|
|
449
450
|
// plus triggerTokens / bufferTokens / bufferRatio for the given boundary.
|
|
450
451
|
export function resolveSessionCompactPolicy(sessionOrConfig = {}, boundaryTokens = 0) {
|
|
@@ -466,7 +467,7 @@ export function resolveSessionCompactPolicy(sessionOrConfig = {}, boundaryTokens
|
|
|
466
467
|
} else if (isAgentOwner(sessionOrConfig)) {
|
|
467
468
|
triggerTokens = Math.max(1, boundary - resolveCompactBufferTokens(boundary, cfg));
|
|
468
469
|
} else {
|
|
469
|
-
triggerTokens = boundary;
|
|
470
|
+
triggerTokens = Math.max(1, Math.floor(boundary * MAIN_COMPACTION_TRIGGER_RATIO));
|
|
470
471
|
}
|
|
471
472
|
const bufferTokens = Math.max(0, boundary - triggerTokens);
|
|
472
473
|
const bufferRatio = bufferTokens / boundary;
|