mixdog 0.9.111 → 0.9.112
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 +10 -58
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/config.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +6 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +11 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -2
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +74 -3
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +26 -7
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +1 -5
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +7 -7
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +14 -30
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +4 -2
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +40 -0
- package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +0 -1
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +1 -1
- package/src/runtime/shared/agent-route-config.mjs +0 -5
- package/src/session-runtime/settings-api.mjs +9 -1
- package/src/standalone/daemon.mjs +1 -0
- package/src/standalone/session-protocol.mjs +1 -0
- package/src/tui/session/session-api.mjs +2 -1
- package/src/tui/session/session-flow.mjs +25 -1
- package/src/ui/statusline-segments.mjs +20 -5
- package/scripts/run-suite.mjs +0 -101
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +0 -188
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mixdog",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.112",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Standalone mixdog coding-agent CLI/TUI workspace.",
|
|
@@ -48,72 +48,24 @@
|
|
|
48
48
|
"prepublishOnly": "node -e \"if(!process.env.CI){console.error('local npm publish is disabled — run npm run release:patch');process.exit(1)}\"",
|
|
49
49
|
"start": "node src/cli.mjs",
|
|
50
50
|
"smoke": "node scripts/smoke.mjs",
|
|
51
|
-
"
|
|
52
|
-
"smoke:all": "npm run smoke && npm run smoke:boot && npm run smoke:patch && npm run smoke:output && npm run smoke:tui && npm run smoke:live-worker",
|
|
51
|
+
"smoke:all": "npm run smoke && npm run smoke:boot && npm run smoke:patch && npm run smoke:compact",
|
|
53
52
|
"smoke:boot": "node scripts/boot-smoke.mjs",
|
|
54
53
|
"smoke:compact": "node scripts/compact-smoke.mjs",
|
|
55
|
-
"test:compact": "node --test scripts/
|
|
56
|
-
"test:context": "node --test scripts/context-mcp-metering-test.mjs scripts/v4a-context-miss-excerpt-test.mjs",
|
|
54
|
+
"test:compact": "node --test scripts/suite-compact-test.mjs",
|
|
57
55
|
"smoke:loop": "node scripts/smoke-loop.mjs",
|
|
58
56
|
"smoke:loop:final": "node scripts/smoke-loop-report.mjs --require-complete --min-elapsed 5h --min-iterations 400 --max-gap 60s --max-smoke-ms 10000 --max-avg-smoke-ms 8500 --max-step-ms smoke.mjs=4000 --max-step-ms boot-smoke.mjs=8000 --max-rss-mb 140 --max-rss-growth-mb 50",
|
|
59
57
|
"smoke:loop:report": "node scripts/smoke-loop-report.mjs",
|
|
60
58
|
"test:tool-contracts": "node scripts/tool-smoke.mjs",
|
|
61
59
|
"smoke:patch": "node scripts/apply-patch-edit-smoke.mjs",
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"smoke:output": "node scripts/output-style-smoke.mjs",
|
|
65
|
-
"smoke:tui": "node scripts/build-tui.mjs && node scripts/tui-render-smoke.mjs && npm run test:tui-input-render && npm run test:tui-streaming-window && npm run test:tui-queue",
|
|
66
|
-
"smoke:freevars": "node scripts/freevar-smoke.mjs",
|
|
67
|
-
"smoke:logguard": "node scripts/log-writer-guard-smoke.mjs",
|
|
68
|
-
"smoke:live-worker": "node scripts/live-worker-smoke.mjs",
|
|
69
|
-
"smoke:agent-tag-reuse": "node scripts/agent-tag-reuse-smoke.mjs",
|
|
70
|
-
"test:agent-terminal-reap": "node scripts/agent-terminal-reap-test.mjs",
|
|
71
|
-
"test:agent-job-views": "node --test scripts/agent-job-terminal-view-test.mjs",
|
|
72
|
-
"test:agent-fanout": "node scripts/agent-parallel-smoke.mjs && node --test scripts/agent-route-batch-test.mjs scripts/execution-completion-dedup-test.mjs",
|
|
73
|
-
"test:toolcall": "node --test scripts/toolcall-args-test.mjs",
|
|
74
|
-
"test:tool-batching": "node --test scripts/eager-patch-shell-order-test.mjs scripts/session-bench-batching-test.mjs scripts/tool-cwd-rebind-test.mjs",
|
|
75
|
-
"test:shipmode": "node --test scripts/ship-mode-test.mjs",
|
|
76
|
-
"test:shellhardening": "node --test scripts/shell-hardening-test.mjs scripts/shell-failure-diagnostics-test.mjs scripts/windows-hide-spawn-options-test.mjs scripts/shell-job-session-scope-test.mjs",
|
|
77
|
-
"test:placeholder": "node --test scripts/compacted-placeholder-scrub-test.mjs",
|
|
78
|
-
"test:providers": "node --test scripts/provider-toolcall-test.mjs scripts/provider-contract-test.mjs scripts/provider-stream-stall-test.mjs scripts/provider-stream-outcome-test.mjs scripts/stream-frame-fault-matrix-test.mjs scripts/gemini-provider-test.mjs scripts/anthropic-transport-policy-test.mjs scripts/anthropic-native-block-replay-test.mjs scripts/openai-oauth-ws-1006-retry-test.mjs scripts/stream-close-retry-test.mjs scripts/openai-end-turn-signal-test.mjs",
|
|
79
|
-
"test:provider-admission": "node --test scripts/provider-admission-scheduler-test.mjs scripts/provider-stream-json-test.mjs",
|
|
80
|
-
"test:resource-admission": "node --test scripts/resource-admission-test.mjs scripts/child-spawn-isolation-test.mjs scripts/owner-fair-gate-test.mjs",
|
|
81
|
-
"test:deferred-tools": "node --test scripts/deferred-tool-loading-test.mjs",
|
|
82
|
-
"test:anthropic-oauth-race": "node --test scripts/anthropic-oauth-refresh-race-test.mjs",
|
|
83
|
-
"test:grok-oauth-race": "node --test scripts/grok-oauth-refresh-race-test.mjs",
|
|
84
|
-
"test:atomiclock": "node --test scripts/atomic-lock-tryonce-test.mjs",
|
|
85
|
-
"test:keychain": "node --test scripts/keychain-prewarm-test.mjs",
|
|
86
|
-
"test:memory-leaks": "node --expose-gc --test scripts/memory-retention-test.mjs scripts/memory-worker-stability-test.mjs scripts/session-transport-test.mjs apps/desktop/src/renderer/memory-retention.test.mjs",
|
|
87
|
-
"test:memory-routing": "node --test scripts/memory-cycle-routing-test.mjs scripts/maintenance-default-routes-test.mjs scripts/embedding-worker-exit-test.mjs scripts/embedding-runtime-prune-test.mjs",
|
|
88
|
-
"test:embedding-runtime": "node --test scripts/embedding-runtime-prune-test.mjs scripts/memory-pg-recovery-test.mjs && node scripts/verify-embedding-runtime.mjs",
|
|
89
|
-
"test:embedding-runtime:core": "node --test scripts/embedding-runtime-prune-test.mjs scripts/memory-pg-recovery-test.mjs && node scripts/verify-embedding-runtime.mjs --core",
|
|
60
|
+
"test:shellhardening": "node --test scripts/suite-shellhardening-test.mjs",
|
|
61
|
+
"test:providers": "node --test scripts/provider-toolcall-test.mjs scripts/provider-contract-test.mjs scripts/provider-stream-outcome-test.mjs",
|
|
90
62
|
"test:embedding-runtime:warmup": "node scripts/verify-embedding-runtime.mjs --warmup",
|
|
91
|
-
"
|
|
92
|
-
"test:
|
|
93
|
-
"test:
|
|
94
|
-
"test:
|
|
95
|
-
"test:
|
|
96
|
-
"test:tui-ambiguous-width": "node --test scripts/tui-ambiguous-width-test.mjs",
|
|
97
|
-
"test:release-assets": "node --check scripts/verify-release-assets.mjs && node --check scripts/verify-release-assets-test.mjs && node --check scripts/deploy-workflow-test.mjs && node --check scripts/release-version-discipline-test.mjs && node --test scripts/verify-release-assets-test.mjs scripts/deploy-workflow-test.mjs scripts/release-version-discipline-test.mjs",
|
|
98
|
-
"test:release-focused": "npm run test:release-focused:contracts && npm run test:release-focused:providers && npm run test:release-focused:compact && npm run test:release-focused:session",
|
|
99
|
-
"test:release-focused:contracts": "npm run test:release-assets && npm run test:tool-contracts && npm run test:tool-batching && npm run test:placeholder && npm run smoke:patch && npm run test:patch-binary-cache",
|
|
100
|
-
"test:release-focused:providers": "npm run test:providers && npm run test:deferred-tools && npm run test:compact && npm run test:context && npm run prepare:code-graph-test && node --test scripts/code-graph-root-federation-test.mjs scripts/code-graph-aggregate-cwd-test.mjs && npm run test:code-graph-dispatch && node --test scripts/code-graph-disk-hit-test.mjs",
|
|
101
|
-
"test:release-focused:compact": "npm run smoke:compact && npm run test:shellhardening",
|
|
102
|
-
"test:release-focused:session": "npm run test:project-registry && npm run test:session && npm run test:workflow-editor && npm run test:embedding-runtime && node --test scripts/tui-transcript-perf-test.mjs",
|
|
103
|
-
"test:native-edit-wire": "node --test scripts/native-edit-wire-test.mjs",
|
|
104
|
-
"test:patch-binary-cache": "node --test scripts/patch-binary-cache-test.mjs",
|
|
105
|
-
"test:patch-parity": "node --test scripts/v4a-parity-test.mjs",
|
|
106
|
-
"test:project-registry": "node --test scripts/project-registry-isolation-test.mjs",
|
|
107
|
-
"test:session-transport": "node --test scripts/fair-call-scheduler-test.mjs scripts/session-transport-test.mjs scripts/session-tui-parity-test.mjs scripts/session-recovery-test.mjs scripts/session-submit-delivery-test.mjs scripts/daemon-process-lifetime-test.mjs scripts/session-multipane-stress-test.mjs && node --test scripts/runtime-isolation-stress-test.mjs",
|
|
108
|
-
"smoke:session": "node scripts/session-smoke.mjs",
|
|
109
|
-
"test:session": "node --test scripts/runtime-turn-contract-test.mjs scripts/session-orphan-sweep-test.mjs scripts/interrupted-turn-history-test.mjs scripts/turn-checkpoint-crash-test.mjs scripts/turn-outcome-fault-matrix-test.mjs scripts/session-save-fault-store-test.mjs scripts/session-disk-authority-test.mjs scripts/session-load-cache-race-test.mjs scripts/agent-loop-complete-turn-test.mjs scripts/session-heartbeat-lifecycle-test.mjs scripts/remote-transition-order-test.mjs scripts/session-new-reset-test.mjs scripts/turn-ttft-critical-path-test.mjs",
|
|
110
|
-
"test:live-canary": "node --test scripts/live-canary-test.mjs",
|
|
111
|
-
"test:rebindtail": "node --test scripts/forwarder-rebind-tail-test.mjs scripts/channel-remote-format-test.mjs",
|
|
112
|
-
"test:workflow-editor": "node --test scripts/workflow-id-test.mjs scripts/workflow-pack-editor-test.mjs",
|
|
113
|
-
"test:route-scope": "node --test scripts/route-scope-isolation-test.mjs",
|
|
114
|
-
"test:schedule-reload": "node --test scripts/schedule-reload-arm-test.mjs",
|
|
63
|
+
"test:release-assets": "node --check scripts/verify-release-assets.mjs && node --check scripts/release-gate-test.mjs && node --test scripts/release-gate-test.mjs",
|
|
64
|
+
"test:release-focused": "npm run test:release-critical && npm run test:compact && npm run test:shellhardening && npm run test:tool-contracts && npm run test:session && npm run test:session-transport",
|
|
65
|
+
"test:release-critical": "npm run test:release-assets && npm run smoke:patch && npm run test:providers",
|
|
66
|
+
"test:session-transport": "node --test scripts/session-transport-test.mjs",
|
|
67
|
+
"test:session": "node --test scripts/runtime-turn-contract-test.mjs scripts/session-save-fault-store-test.mjs",
|
|
115
68
|
"test:media": "node --test src/runtime/media/store.test.mjs src/runtime/media/renditions.test.mjs src/runtime/media/adapters/codex-image.test.mjs",
|
|
116
|
-
"test:shell-harness": "node --test scripts/shell-harness-regression-test.mjs",
|
|
117
69
|
"failures": "node scripts/tool-failures.mjs",
|
|
118
70
|
"trace:llm": "node scripts/llm-trace-summary.mjs",
|
|
119
71
|
"diag:sessions": "node scripts/session-diag.mjs",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
known file/range→`read`;
|
|
9
9
|
web/current→`search`; returned URL body→`web_fetch`; prior work→`recall`
|
|
10
10
|
(history only, never current local state);
|
|
11
|
-
durable compact English memory→`memory`; explicit
|
|
11
|
+
durable compact English memory→`memory`; explicit Project change→`cwd`
|
|
12
|
+
(`shell.cwd` is call-local and never changes the Project);
|
|
12
13
|
explicit user-requested conversation reset→`session_manage`.
|
|
13
14
|
Use only named tools present in the current tool surface.
|
|
14
15
|
- Act only on verified identities (cwd/project/user/tool-returned) — paths,
|
|
@@ -26,14 +27,20 @@
|
|
|
26
27
|
route each once to the cheapest sufficient tool with all required
|
|
27
28
|
variants/scopes, and launch every independent call together — never
|
|
28
29
|
split or duplicate a facet across tools, mutate merely to widen
|
|
29
|
-
retrieval, reserve known work, or cap fanout.
|
|
30
|
+
retrieval, reserve known work, or cap fanout. Guessed terms go wide
|
|
31
|
+
with batched fan-out; narrow a scope only on verified cues — returned
|
|
32
|
+
siblings/conventions or known literals. Mine each returned output for
|
|
33
|
+
every remaining facet before the next round. Symbol relations end at
|
|
30
34
|
`code_graph`; values/locations end at the context grep returns; `read`
|
|
31
35
|
covers only what returned spans cannot, as an anchored offset/limit
|
|
32
36
|
window. The moment evidence determines the answer, edit, or deliverable,
|
|
33
37
|
stop retrieving; patch if needed.
|
|
34
38
|
- Once the edit or deliverable is determined, finish in one assistant turn:
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
before `apply_patch`, obtain every target hunk's exact current content and
|
|
40
|
+
anchor from `grep`, `code_graph`, or `read`; never infer patch context from
|
|
41
|
+
another file, a sample, or expected text. Then issue `apply_patch` calls
|
|
42
|
+
serially, never in parallel; use one cohesive call with one file section per
|
|
43
|
+
target, all patches first, then one
|
|
37
44
|
batched verification `shell` that runs the real required postconditions
|
|
38
45
|
on every changed file and produced artifact, never echoes a claim;
|
|
39
46
|
runtime waits for every patch and skips the shell
|
|
@@ -339,7 +339,6 @@ function canonicalizeShellStorage(value) {
|
|
|
339
339
|
function canonicalizeModulesStorage(value) {
|
|
340
340
|
const modules = configObject(value);
|
|
341
341
|
delete modules.memory;
|
|
342
|
-
delete modules.explore;
|
|
343
342
|
for (const name of ['search']) {
|
|
344
343
|
if (!Object.prototype.hasOwnProperty.call(modules, name)) continue;
|
|
345
344
|
const raw = modules[name];
|
|
@@ -107,6 +107,12 @@ function _anthropicSseError(event) {
|
|
|
107
107
|
err.httpStatus = status;
|
|
108
108
|
err.status = status;
|
|
109
109
|
}
|
|
110
|
+
// Wire-error marker: an error type OUTSIDE the documented enumeration
|
|
111
|
+
// (no status resolved above) default-retries under the shared wire-error
|
|
112
|
+
// contract instead of failing the turn as 'unknown'. Typed statuses and
|
|
113
|
+
// the fatal-code deny-list still take precedence in classifyError().
|
|
114
|
+
err.providerWireError = true;
|
|
115
|
+
if (typeof type === 'string' && type && type !== 'error') err.providerErrorCode = type;
|
|
110
116
|
return err;
|
|
111
117
|
}
|
|
112
118
|
|
|
@@ -841,7 +841,8 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
|
|
|
841
841
|
// Copy the TYPED failure evidence a Responses `response.failed` / `error`
|
|
842
842
|
// event carries (numeric HTTP status, provider error code/type) onto the
|
|
843
843
|
// thrown error. Message text is never parsed, and nothing is synthesized when
|
|
844
|
-
// the event declares no typed status.
|
|
844
|
+
// the event declares no typed status. The wire-event marker routes the error
|
|
845
|
+
// through the fatal-code deny-list / default-retry classification.
|
|
845
846
|
function _applyTypedResponsesFailure(err, event) {
|
|
846
847
|
const detail = event?.response?.error || event?.error || null;
|
|
847
848
|
const typed = typedStatusFrom(detail, event);
|
|
@@ -849,6 +850,7 @@ function _applyTypedResponsesFailure(err, event) {
|
|
|
849
850
|
const code = detail?.code ?? detail?.type ?? event?.code ?? null;
|
|
850
851
|
if (code != null && code !== '') err.providerErrorCode = String(code);
|
|
851
852
|
if (detail) err.providerError = detail;
|
|
853
|
+
err.providerWireError = true;
|
|
852
854
|
return err;
|
|
853
855
|
}
|
|
854
856
|
|
|
@@ -959,9 +959,15 @@ export async function sendViaHttpSse({
|
|
|
959
959
|
case 'response.failed': {
|
|
960
960
|
const msg = event.response?.error?.message || event.error?.message || event.message || 'response.failed';
|
|
961
961
|
const err = new Error(`OpenAI OAuth HTTP fallback response.failed: ${msg}`);
|
|
962
|
-
// Typed status only —
|
|
962
|
+
// Typed status only — nothing is synthesized from text. The
|
|
963
|
+
// frame itself is preserved so the wire-error default-retry
|
|
964
|
+
// classification applies (fatal codes stay terminal).
|
|
965
|
+
err.responseFailed = event;
|
|
963
966
|
const typed = typedStatusFrom(event.response?.error, event.error, event);
|
|
964
967
|
if (typed) err.httpStatus = typed;
|
|
968
|
+
const detail = event.response?.error || event.error || null;
|
|
969
|
+
const code = detail?.code ?? detail?.type ?? null;
|
|
970
|
+
if (typeof code === 'string' && code) err.providerErrorCode = code;
|
|
965
971
|
throw err;
|
|
966
972
|
}
|
|
967
973
|
case 'response.incomplete': {
|
|
@@ -987,8 +993,12 @@ export async function sendViaHttpSse({
|
|
|
987
993
|
case 'error': {
|
|
988
994
|
const msg = event.message || event.error?.message || 'unknown';
|
|
989
995
|
const err = new Error(`OpenAI OAuth HTTP fallback error: ${msg}`);
|
|
996
|
+
// Same wire-error contract as response.failed.
|
|
997
|
+
err.responseFailed = event;
|
|
990
998
|
const typed = typedStatusFrom(event.error, event);
|
|
991
999
|
if (typed) err.httpStatus = typed;
|
|
1000
|
+
const code = event.error?.code ?? event.error?.type ?? event.code ?? null;
|
|
1001
|
+
if (typeof code === 'string' && code) err.providerErrorCode = code;
|
|
992
1002
|
throw err;
|
|
993
1003
|
}
|
|
994
1004
|
default:
|
|
@@ -1310,11 +1310,15 @@ export async function _streamResponse({
|
|
|
1310
1310
|
responseFailed: event,
|
|
1311
1311
|
});
|
|
1312
1312
|
// TYPED status only (event/error payload). A failure whose
|
|
1313
|
-
// only evidence is message text
|
|
1314
|
-
//
|
|
1313
|
+
// only evidence is message text synthesizes no status; the
|
|
1314
|
+
// typed code/type string is preserved for the wire-error
|
|
1315
|
+
// default-retry classification (fatal codes stay terminal).
|
|
1315
1316
|
{
|
|
1316
1317
|
const typed = typedStatusFrom(event.response?.error, event.error, event);
|
|
1317
1318
|
if (typed) terminalError.httpStatus = typed;
|
|
1319
|
+
const detail = event.response?.error || event.error || null;
|
|
1320
|
+
const code = detail?.code ?? detail?.type ?? null;
|
|
1321
|
+
if (typeof code === 'string' && code) terminalError.providerErrorCode = code;
|
|
1318
1322
|
}
|
|
1319
1323
|
finish();
|
|
1320
1324
|
break;
|
|
@@ -1322,9 +1326,16 @@ export async function _streamResponse({
|
|
|
1322
1326
|
case 'error': {
|
|
1323
1327
|
const errMsg = String(event.message || event.error?.message || 'unknown');
|
|
1324
1328
|
terminalError = new Error(`${errLabel} error: ${errMsg}`);
|
|
1329
|
+
// Same wire-error contract as response.failed: stash the
|
|
1330
|
+
// frame so the mid-stream classifier and classifyError()
|
|
1331
|
+
// apply the typed fatal-code deny-list / default-retry.
|
|
1332
|
+
midState.responseFailedPayload = event;
|
|
1333
|
+
terminalError.responseFailed = event;
|
|
1325
1334
|
{
|
|
1326
1335
|
const typed = typedStatusFrom(event.error, event);
|
|
1327
1336
|
if (typed) terminalError.httpStatus = typed;
|
|
1337
|
+
const code = event.error?.code ?? event.error?.type ?? event.code ?? null;
|
|
1338
|
+
if (typeof code === 'string' && code) terminalError.providerErrorCode = code;
|
|
1328
1339
|
}
|
|
1329
1340
|
finish();
|
|
1330
1341
|
break;
|
|
@@ -133,6 +133,20 @@ export function classifyError(err) {
|
|
|
133
133
|
String(item?.message || '').trim(),
|
|
134
134
|
))) return 'transient'
|
|
135
135
|
|
|
136
|
+
// Provider wire error event (`response.failed` / terminal `error` frame):
|
|
137
|
+
// default-retry, codex parity. codex maps every response.failed whose typed
|
|
138
|
+
// code is not a deterministic refusal to ApiError::Retryable (fatal codes
|
|
139
|
+
// are an explicit allow-list); cc retries all 5xx/overloaded 10×; opencode
|
|
140
|
+
// marks server_error/server_is_overloaded isRetryable. Evidence stays
|
|
141
|
+
// structural — the event's own typed code/type field — message text is
|
|
142
|
+
// never parsed. Exposure precedence is preserved: the replayUnsafe gate at
|
|
143
|
+
// the top of this function already returned 'permanent' for any stream
|
|
144
|
+
// that relayed output or dispatched a tool call.
|
|
145
|
+
{
|
|
146
|
+
const wireKind = classifyWireErrorEvent(err)
|
|
147
|
+
if (wireKind) return wireKind
|
|
148
|
+
}
|
|
149
|
+
|
|
136
150
|
return 'unknown'
|
|
137
151
|
}
|
|
138
152
|
|
|
@@ -305,6 +319,54 @@ function isPermanentQuotaError(err) {
|
|
|
305
319
|
return false
|
|
306
320
|
}
|
|
307
321
|
|
|
322
|
+
// ── Wire error events: default-retry with a fatal-code deny-list ────────────
|
|
323
|
+
// Deterministic refusal codes a `response.failed` / `error` wire event may
|
|
324
|
+
// carry: retrying the identical request can never succeed. Mirrors codex's
|
|
325
|
+
// fatal set (context/quota/policy) plus the auth/billing refusals the
|
|
326
|
+
// Responses and Anthropic wire formats use. Everything OUTSIDE this set —
|
|
327
|
+
// server_error, server_is_overloaded, slow_down, or an event with no code at
|
|
328
|
+
// all — is a server-side fault and is retried under the bounded budgets
|
|
329
|
+
// (observed live 2026-08-11: two turns failed on typed `server_error`
|
|
330
|
+
// response.failed events that every reference implementation retries).
|
|
331
|
+
const WIRE_ERROR_FATAL_CODES = new Set([
|
|
332
|
+
'context_length_exceeded', 'context_window_exceeded',
|
|
333
|
+
'insufficient_quota', 'quota_exceeded', 'resource_exhausted',
|
|
334
|
+
'usage_not_included', 'usage_limit_reached',
|
|
335
|
+
'invalid_prompt', 'bio_policy', 'cyber_policy',
|
|
336
|
+
'invalid_request', 'invalid_request_error',
|
|
337
|
+
'invalid_api_key', 'authentication_error', 'permission_error',
|
|
338
|
+
'permission_denied', 'billing_not_active',
|
|
339
|
+
])
|
|
340
|
+
|
|
341
|
+
function wireErrorCode(err) {
|
|
342
|
+
const failed = err?.responseFailed
|
|
343
|
+
const detail = failed?.response?.error || failed?.error || err?.providerError || failed || null
|
|
344
|
+
for (const field of [detail?.code, detail?.type, err?.providerErrorCode]) {
|
|
345
|
+
if (typeof field === 'string' && field.trim()) return field.trim().toLowerCase()
|
|
346
|
+
}
|
|
347
|
+
return ''
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 'transient' | 'permanent' for errors born from a provider wire error event;
|
|
351
|
+
// null for everything else (no blanket retry for untyped local failures).
|
|
352
|
+
function classifyWireErrorEvent(err) {
|
|
353
|
+
if (!err || (err.responseFailed == null && err.providerWireError !== true)) return null
|
|
354
|
+
const code = wireErrorCode(err)
|
|
355
|
+
if (code && WIRE_ERROR_FATAL_CODES.has(code)) return 'permanent'
|
|
356
|
+
return 'transient'
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* True when `err` came from a provider wire error event whose typed code is
|
|
361
|
+
* not a deterministic refusal — i.e. the send-with-recovery loop may replay
|
|
362
|
+
* it (after text retraction when something was exposed). classifyError()
|
|
363
|
+
* reports 'permanent' the moment output was exposed, so the loop names this
|
|
364
|
+
* symptom directly, exactly like stall/truncated/non-terminal-close.
|
|
365
|
+
*/
|
|
366
|
+
export function isRetryableWireErrorEvent(err) {
|
|
367
|
+
return classifyWireErrorEvent(err) === 'transient'
|
|
368
|
+
}
|
|
369
|
+
|
|
308
370
|
/**
|
|
309
371
|
* Convenience predicate: should this error be retried at the request level?
|
|
310
372
|
* Wraps classifyError() with the standard "transient = retry, otherwise no"
|
|
@@ -541,9 +603,10 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
|
|
|
541
603
|
if (failed) {
|
|
542
604
|
// STRUCTURED evidence only: the failure's own numeric status and its
|
|
543
605
|
// explicit error code/type field. The payload is never stringified and
|
|
544
|
-
// searched — a message/body that merely CONTAINS "network_error"
|
|
545
|
-
// "stream_disconnected"
|
|
546
|
-
//
|
|
606
|
+
// searched — a message/body that merely CONTAINS "network_error" or
|
|
607
|
+
// "stream_disconnected" never selects a SPECIFIC bucket. Buckets need
|
|
608
|
+
// typed codes; the DEFAULT for an unrecognized (or absent) code is the
|
|
609
|
+
// bounded retry below, with fatal refusal codes staying terminal.
|
|
547
610
|
const detail = failed?.response?.error || failed?.error || failed
|
|
548
611
|
const failedStatus = typedStatusFrom(detail, failed?.response, failed)
|
|
549
612
|
if (failedStatus >= 500 && failedStatus < 600) {
|
|
@@ -553,7 +616,15 @@ function _classifyMidstreamWs(err, state, attemptIndex, policy) {
|
|
|
553
616
|
const key = typeof field === 'string' ? field.trim().toLowerCase() : ''
|
|
554
617
|
const classifier = RESPONSE_FAILED_CODE_CLASSIFIERS.get(key)
|
|
555
618
|
if (classifier) return _allowMidstream(classifier, attemptIndex, policy)
|
|
619
|
+
if (key && WIRE_ERROR_FATAL_CODES.has(key)) return null
|
|
556
620
|
}
|
|
621
|
+
// A typed non-transient 4xx on the failure payload is a deterministic
|
|
622
|
+
// refusal even without a recognized code string.
|
|
623
|
+
if (failedStatus >= 400 && failedStatus < 500 && !TRANSIENT_STATUSES.has(failedStatus)) return null
|
|
624
|
+
// Default-retry (codex parity): a wire failure that is neither a fatal
|
|
625
|
+
// refusal nor a typed 4xx is a server-side fault — re-issue it under the
|
|
626
|
+
// bounded mid-stream budget instead of failing the turn.
|
|
627
|
+
return _allowMidstream('response_failed_retryable', attemptIndex, policy)
|
|
557
628
|
}
|
|
558
629
|
|
|
559
630
|
return null
|
|
@@ -394,7 +394,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
394
394
|
const EMPTY_NUDGE_MAX = 3;
|
|
395
395
|
let _refusalRetryUsed = false;
|
|
396
396
|
let _maxOutputRecoveryCount = 0;
|
|
397
|
-
|
|
397
|
+
// Committed-but-unsealed text segments for the caller-facing aggregate:
|
|
398
|
+
// max-output recovery parts plus (Lead/TUI only) text-only continuation
|
|
399
|
+
// segments (provider pause_turn / terminal steering / stop hook). The
|
|
400
|
+
// terminal response returns content = parts + terminal so the UI row that
|
|
401
|
+
// accumulated every streamed segment is not overwritten down to only the
|
|
402
|
+
// last segment; historyContent keeps persistence single-copy. Reset after
|
|
403
|
+
// each executed tool batch — the UI seals its row at tool boundaries, so a
|
|
404
|
+
// pre-tool part re-prepended at terminal would duplicate a sealed row.
|
|
405
|
+
const _committedTextParts = [];
|
|
398
406
|
// Count of structured provider continuation signals honored this turn
|
|
399
407
|
// (endTurn === false / stopReason === 'pause_turn'). Diagnostic only; the
|
|
400
408
|
// hard iteration cap remains the sole bound on how long a provider may
|
|
@@ -799,7 +807,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
799
807
|
const isIncompleteStop = stopReason && INCOMPLETE_STOP_REASONS.has(stopReason);
|
|
800
808
|
const isOutputLimitStop = isOutputLimitStopReason(stopReason);
|
|
801
809
|
if (hasContent && isOutputLimitStop) {
|
|
802
|
-
|
|
810
|
+
_committedTextParts.push(response.content);
|
|
803
811
|
if (_maxOutputRecoveryCount < MAX_OUTPUT_RECOVERY_LIMIT) {
|
|
804
812
|
// The partial assistant turn must be visible to the model so
|
|
805
813
|
// it can resume at the exact cutoff instead of reconstructing
|
|
@@ -818,7 +826,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
818
826
|
const terminalSegment = `${response.content}\n\n${MAX_OUTPUT_EXHAUSTED_NOTICE}`;
|
|
819
827
|
response = {
|
|
820
828
|
...response,
|
|
821
|
-
content: `${
|
|
829
|
+
content: `${_committedTextParts.slice(0, -1).join('')}${terminalSegment}`,
|
|
822
830
|
historyContent: terminalSegment,
|
|
823
831
|
maxOutputRecoveryAttempts: _maxOutputRecoveryCount,
|
|
824
832
|
};
|
|
@@ -864,6 +872,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
864
872
|
} catch { /* best-effort */ }
|
|
865
873
|
} else if (continuationSignal && pushIntermediateAssistantResponse(response)) {
|
|
866
874
|
if (hasContent && !suppressMidTurnText) {
|
|
875
|
+
_committedTextParts.push(response.content);
|
|
867
876
|
try { opts.onAssistantText?.(response.content); } catch { /* best-effort */ }
|
|
868
877
|
}
|
|
869
878
|
_providerContinuationCount += 1;
|
|
@@ -913,7 +922,11 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
913
922
|
// Commit the terminal text first (beforeAppend), then resume.
|
|
914
923
|
if (!_capFinalToolsDisabled && drainSteeringIntoMessages('terminal', {
|
|
915
924
|
maxPriority: 'next',
|
|
916
|
-
beforeAppend: () => {
|
|
925
|
+
beforeAppend: () => {
|
|
926
|
+
if (pushIntermediateAssistantResponse(response) && hasContent && !suppressMidTurnText) {
|
|
927
|
+
_committedTextParts.push(response.content);
|
|
928
|
+
}
|
|
929
|
+
},
|
|
917
930
|
})) {
|
|
918
931
|
_emptyNudgeStreak = 0;
|
|
919
932
|
continue;
|
|
@@ -928,6 +941,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
928
941
|
const _hookPrompt = _toolFailureStopHook.takeContinuationPrompt();
|
|
929
942
|
if (_hookPrompt) {
|
|
930
943
|
pushIntermediateAssistantResponse(response);
|
|
944
|
+
if (hasContent && !suppressMidTurnText) _committedTextParts.push(response.content);
|
|
931
945
|
messages.push({
|
|
932
946
|
role: 'user',
|
|
933
947
|
content: _hookPrompt,
|
|
@@ -945,13 +959,15 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
945
959
|
continue;
|
|
946
960
|
}
|
|
947
961
|
}
|
|
948
|
-
if (
|
|
962
|
+
if (_committedTextParts.length > 0) {
|
|
949
963
|
const terminalSegment = typeof response.content === 'string' ? response.content : '';
|
|
950
964
|
response = {
|
|
951
965
|
...response,
|
|
952
|
-
content: `${
|
|
966
|
+
content: `${_committedTextParts.join('')}${terminalSegment}`,
|
|
953
967
|
historyContent: terminalSegment,
|
|
954
|
-
|
|
968
|
+
...(_maxOutputRecoveryCount > 0
|
|
969
|
+
? { maxOutputRecoveryAttempts: _maxOutputRecoveryCount }
|
|
970
|
+
: {}),
|
|
955
971
|
};
|
|
956
972
|
}
|
|
957
973
|
break;
|
|
@@ -1082,6 +1098,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
|
|
|
1082
1098
|
_lastToolBatchEndedAt = _toolsEndedAt;
|
|
1083
1099
|
_toolBatchJustCompleted = true;
|
|
1084
1100
|
_continuationsSinceToolBatch = 0;
|
|
1101
|
+
// The UI sealed its streaming row at this tool boundary; earlier
|
|
1102
|
+
// committed parts must not re-prepend at terminal (duplicate rows).
|
|
1103
|
+
_committedTextParts.length = 0;
|
|
1085
1104
|
_lastToolBatchHadSleep = _callsToExecute.some(isSleepLikeToolCall);
|
|
1086
1105
|
}
|
|
1087
1106
|
// Classify WHY the loop ended so agent-tool can promote an empty/abnormal
|
|
@@ -128,7 +128,7 @@ export function createEagerDispatcher({
|
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
await opts.beforeToolExecution?.();
|
|
131
|
-
return { ok: true, value: await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: getNextIteration()
|
|
131
|
+
return { ok: true, value: await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: getNextIteration() }) };
|
|
132
132
|
} catch (error) {
|
|
133
133
|
return { ok: false, error };
|
|
134
134
|
}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
classifyError,
|
|
10
10
|
isContextOverflowError,
|
|
11
11
|
isNonTerminalStreamClose,
|
|
12
|
+
isRetryableWireErrorEvent,
|
|
12
13
|
jitterDelayMs,
|
|
13
14
|
} from '../providers/retry-classifier.mjs';
|
|
14
15
|
import { setTimeout as sleepMs } from 'timers/promises';
|
|
@@ -405,6 +406,10 @@ export async function sendWithRecovery(ctx) {
|
|
|
405
406
|
// gemini/compat EOF): classifyError calls it
|
|
406
407
|
// 'transient' only while nothing was exposed.
|
|
407
408
|
|| outcome.truncatedStream === true
|
|
409
|
+
// And for retryable wire error events
|
|
410
|
+
// (response.failed server_error & co.): exposed
|
|
411
|
+
// text is retracted, then the send is replayed.
|
|
412
|
+
|| isRetryableWireErrorEvent(sendErr)
|
|
408
413
|
|| isNonTerminalStreamClose(sendErr))
|
|
409
414
|
&& await retractExposedTextForReplay()
|
|
410
415
|
)
|
|
@@ -37,7 +37,6 @@ import {
|
|
|
37
37
|
parseNativeToolSearchPayload,
|
|
38
38
|
} from './loop/tool-helpers.mjs';
|
|
39
39
|
import { restoreToolCallBodyForId } from './loop/stored-tool-args.mjs';
|
|
40
|
-
import { commitSessionCwdProbe } from '../tools/shell-state.mjs';
|
|
41
40
|
|
|
42
41
|
function classifyToolReturn(value) {
|
|
43
42
|
const normalized = normalizeToolEnvelope(value);
|
|
@@ -308,7 +307,7 @@ export async function processToolBatch(ctx) {
|
|
|
308
307
|
_resultKind = 'error';
|
|
309
308
|
} else {
|
|
310
309
|
await opts.beforeToolExecution?.();
|
|
311
|
-
result = await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: iterations
|
|
310
|
+
result = await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: iterations });
|
|
312
311
|
toolEndedAt = Date.now();
|
|
313
312
|
// Boundary: tool-return string convention → structural kind.
|
|
314
313
|
// The only prefix check in this codebase; downstream layers
|
|
@@ -330,9 +329,6 @@ export async function processToolBatch(ctx) {
|
|
|
330
329
|
result = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
331
330
|
_resultKind = 'error';
|
|
332
331
|
}
|
|
333
|
-
if (_isShellTool(call.name) && _resultKind !== 'skipped') {
|
|
334
|
-
commitSessionCwdProbe(sessionId, call.id);
|
|
335
|
-
}
|
|
336
332
|
// CENTRAL ENVELOPE NORMALIZE (general newMessages channel).
|
|
337
333
|
// executeTool (serial + eager) and cache/error paths above all
|
|
338
334
|
// funnel into `result`. Split ONCE here: downstream post-processing
|
|
@@ -461,7 +461,7 @@ function guardGrep(a) {
|
|
|
461
461
|
const hl = Number(a.head_limit);
|
|
462
462
|
if (grepContextKeyPresent(a, 'head_limit') && Number.isFinite(hl) && hl > GREP_CTX_HEAD_LIMIT_MAX) {
|
|
463
463
|
a.head_limit = GREP_CTX_HEAD_LIMIT_MAX;
|
|
464
|
-
pushClampNotice(a, `notice: grep
|
|
464
|
+
pushClampNotice(a, `notice: grep limit clamped to ${GREP_CTX_HEAD_LIMIT_MAX} match blocks (context mode)`);
|
|
465
465
|
}
|
|
466
466
|
}
|
|
467
467
|
return null;
|
|
@@ -706,12 +706,12 @@ function guardList(a) {
|
|
|
706
706
|
const coerced = coerceIntegerString(a.head_limit);
|
|
707
707
|
if (coerced !== null) a.head_limit = coerced;
|
|
708
708
|
if (!isFiniteInt(a.head_limit)) {
|
|
709
|
-
return `Error: list arg "
|
|
709
|
+
return `Error: list arg "limit" must be a finite integer (got ${describeType(a.head_limit)})`;
|
|
710
710
|
}
|
|
711
711
|
// 0 is the valid "no cap" sentinel; a negative value is nonsensical
|
|
712
712
|
// and downstream produces a degenerate window (clamps to 0 → empty).
|
|
713
713
|
if (a.head_limit < 0) {
|
|
714
|
-
return `Error: list arg "
|
|
714
|
+
return `Error: list arg "limit" must be >= 0 (0 means no cap); got ${a.head_limit}`;
|
|
715
715
|
}
|
|
716
716
|
}
|
|
717
717
|
return null;
|
|
@@ -745,10 +745,10 @@ function guardFind(a) {
|
|
|
745
745
|
const coerced = coerceIntegerString(a.head_limit);
|
|
746
746
|
if (coerced !== null) a.head_limit = coerced;
|
|
747
747
|
if (!isFiniteInt(a.head_limit)) {
|
|
748
|
-
return `Error: find arg "
|
|
748
|
+
return `Error: find arg "limit" must be a finite integer (got ${describeType(a.head_limit)})`;
|
|
749
749
|
}
|
|
750
750
|
if (a.head_limit < 0) {
|
|
751
|
-
return `Error: find arg "
|
|
751
|
+
return `Error: find arg "limit" must be >= 0 (0 means no cap); got ${a.head_limit}`;
|
|
752
752
|
}
|
|
753
753
|
}
|
|
754
754
|
return null;
|
|
@@ -787,12 +787,12 @@ function guardGlob(a) {
|
|
|
787
787
|
const coerced = coerceIntegerString(a.head_limit);
|
|
788
788
|
if (coerced !== null) a.head_limit = coerced;
|
|
789
789
|
if (!isFiniteInt(a.head_limit)) {
|
|
790
|
-
return `Error: glob arg "
|
|
790
|
+
return `Error: glob arg "limit" must be a finite integer (got ${describeType(a.head_limit)})`;
|
|
791
791
|
}
|
|
792
792
|
// 0 is the valid "no cap" sentinel; a negative value is nonsensical
|
|
793
793
|
// and downstream produces a degenerate window (clamps to 0 → empty).
|
|
794
794
|
if (a.head_limit < 0) {
|
|
795
|
-
return `Error: glob arg "
|
|
795
|
+
return `Error: glob arg "limit" must be >= 0 (0 means no cap); got ${a.head_limit}`;
|
|
796
796
|
}
|
|
797
797
|
}
|
|
798
798
|
return null;
|