@bridge4dev/runner 0.68.0 → 0.69.0
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/dist/adapters/claude.js +243 -10
- package/dist/adapters/codex.js +166 -3
- package/dist/adapters/error-policy.d.ts +17 -0
- package/dist/adapters/error-policy.js +74 -3
- package/dist/adapters/types.d.ts +12 -0
- package/dist/policy.d.ts +39 -0
- package/dist/policy.js +523 -8
- package/dist/protocol.d.ts +9 -2
- package/dist/regex-guard.js +84 -3
- package/dist/supervisor.d.ts +15 -0
- package/dist/supervisor.js +54 -9
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -353,6 +353,45 @@ class ClaudeSession {
|
|
|
353
353
|
*/
|
|
354
354
|
turnFailureCode = null;
|
|
355
355
|
turnFailureStatus = null;
|
|
356
|
+
/**
|
|
357
|
+
* The category the provider's own safety filter named, when it refused
|
|
358
|
+
* (#435, first item — recognition and nothing more).
|
|
359
|
+
*
|
|
360
|
+
* Two machine paths carry it and both are typed in the SDK the runner has
|
|
361
|
+
* (`@anthropic-ai/claude-agent-sdk@0.3.226`): the `model_refusal_no_fallback`
|
|
362
|
+
* system frame with `api_refusal_category`, and an ordinary assistant frame
|
|
363
|
+
* with `stop_reason: 'refusal'` and `stop_details.category`
|
|
364
|
+
* (`cyber | bio | frontier_llm | reasoning_extraction | general_harms`).
|
|
365
|
+
* Neither is read from prose — the ban on matching sentences is the whole
|
|
366
|
+
* reason `error-policy.ts` exists.
|
|
367
|
+
*
|
|
368
|
+
* Null on a CLI older than these frames, which reads as «we do not know» and
|
|
369
|
+
* leaves the session behaving exactly as it does today.
|
|
370
|
+
*/
|
|
371
|
+
turnRefusalCategory = null;
|
|
372
|
+
/**
|
|
373
|
+
* Is there a turn for a refusal to attach itself to? (#455)
|
|
374
|
+
*
|
|
375
|
+
* Its only reader is the one-shot refusal mark. A refusal that arrives
|
|
376
|
+
* BETWEEN turns has no ending to mark, and a mark left waiting is spent by
|
|
377
|
+
* the next turn — which may be a perfectly good one, run after the window
|
|
378
|
+
* reopened, and would be filed as refused. Codex has had this guard since
|
|
379
|
+
* #258; Claude had not, and S1 wrote it down as a finding.
|
|
380
|
+
*
|
|
381
|
+
* Tracked on its own and not read off `turnClosed`, which looks like the
|
|
382
|
+
* same thing and is not: `turnClosed` is cleared by the first message of
|
|
383
|
+
* ANY kind after a `result`, and `rate_limit_event` — the very message a
|
|
384
|
+
* refusal arrives on, sent by the SDK with every request — is one of them.
|
|
385
|
+
* So it always said «a turn is open» by the time the refusal was read.
|
|
386
|
+
*
|
|
387
|
+
* Opened by anything that means work (a prompt going in, the agent
|
|
388
|
+
* speaking, a status, a tool result), closed by the `result` that ends the
|
|
389
|
+
* turn. It starts `true`, and `system:init` reopens it: a session is
|
|
390
|
+
* started in order to run a turn, and that is the behaviour the #258 test
|
|
391
|
+
* pins. The case it exists for — a refusal arriving after a turn has ended
|
|
392
|
+
* — is the one it answers exactly.
|
|
393
|
+
*/
|
|
394
|
+
turnInFlight = true;
|
|
356
395
|
/**
|
|
357
396
|
* A `result` has gone out and no new message has arrived since (#406).
|
|
358
397
|
*
|
|
@@ -483,6 +522,7 @@ class ClaudeSession {
|
|
|
483
522
|
*/
|
|
484
523
|
resumingTurn() {
|
|
485
524
|
this.aborting = false;
|
|
525
|
+
this.turnInFlight = true;
|
|
486
526
|
// #252: the same «every path that starts a turn» property makes this the one
|
|
487
527
|
// correct place to forget what the PREVIOUS turn did. Reset any earlier and
|
|
488
528
|
// `turn_end` would report facts that had already been wiped; any later — say,
|
|
@@ -882,6 +922,29 @@ class ClaudeSession {
|
|
|
882
922
|
rateLimitsAvailable = false;
|
|
883
923
|
/** A refusal seen since the last turn ended, waiting to be reported with it. */
|
|
884
924
|
limitBlockPending = false;
|
|
925
|
+
/**
|
|
926
|
+
* The wall itself, with its own clock — not a flag that one turn spends (#455).
|
|
927
|
+
*
|
|
928
|
+
* 2026-09-19, 01:29:45 UTC, run `827b4db2`, stage S2. Two turns hit the same
|
|
929
|
+
* weekly wall two seconds apart. The provider sent the refusal event on the
|
|
930
|
+
* FIRST one only; the CLI then finished the tool call it had started, ran
|
|
931
|
+
* into the same wall again, and that second turn arrived with the flag
|
|
932
|
+
* already spent. Bare, it read as an ordinary API error, matched
|
|
933
|
+
* `claude.invalid_request` and took the session — and the stage, and the run
|
|
934
|
+
* — down with it. The same night did it again to another run's stage O3.
|
|
935
|
+
*
|
|
936
|
+
* So the mark is a STATE, not a one-shot: while a window has refused and its
|
|
937
|
+
* reset is still ahead, any turn that ends on an API error is a refused turn.
|
|
938
|
+
* It comes down when its own reset passes, or when the provider says
|
|
939
|
+
* something that is not a refusal about the SAME window — an `allowed` event
|
|
940
|
+
* about the five-hour window must not take down a weekly wall.
|
|
941
|
+
*
|
|
942
|
+
* **A refusal that names no reset does not raise it.** There would be no
|
|
943
|
+
* clock to take it down, and a wall that never falls would swallow every
|
|
944
|
+
* genuine API error for the rest of the session. That case keeps today's
|
|
945
|
+
* one-shot behaviour, and #258's rule against inventing a reset stands.
|
|
946
|
+
*/
|
|
947
|
+
limitWall = null;
|
|
885
948
|
/**
|
|
886
949
|
* The one key the window map is written under, from every source.
|
|
887
950
|
*
|
|
@@ -1083,6 +1146,22 @@ class ClaudeSession {
|
|
|
1083
1146
|
this.limitBlockPending = false;
|
|
1084
1147
|
return blocked;
|
|
1085
1148
|
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Is a refused window still refusing? (#455)
|
|
1151
|
+
*
|
|
1152
|
+
* Reads its own clock and forgets itself once the reset has passed, so the
|
|
1153
|
+
* caller never has to remember to. Returns the window so the feed line can
|
|
1154
|
+
* say until when.
|
|
1155
|
+
*/
|
|
1156
|
+
standingLimitWall(now = Date.now()) {
|
|
1157
|
+
if (this.limitWall === null)
|
|
1158
|
+
return null;
|
|
1159
|
+
if (this.limitWall.until <= now) {
|
|
1160
|
+
this.limitWall = null;
|
|
1161
|
+
return null;
|
|
1162
|
+
}
|
|
1163
|
+
return this.limitWall;
|
|
1164
|
+
}
|
|
1086
1165
|
emitRateLimits(blocked = null) {
|
|
1087
1166
|
// Signed by THIS session's account (#422 §8, S2 item 4): the windows come
|
|
1088
1167
|
// from its own stream and from readings measured under its own home. The
|
|
@@ -1227,8 +1306,25 @@ class ClaudeSession {
|
|
|
1227
1306
|
status: typeof info['status'] === 'string' ? info['status'] : null,
|
|
1228
1307
|
});
|
|
1229
1308
|
}
|
|
1230
|
-
if (refused)
|
|
1231
|
-
|
|
1309
|
+
if (refused) {
|
|
1310
|
+
// The one-shot flag keeps its old job: mark the turn that is running
|
|
1311
|
+
// RIGHT NOW. It is deliberately not set between turns — a refusal with
|
|
1312
|
+
// no turn around it has no ending to mark, and the flag would instead be
|
|
1313
|
+
// spent by the next turn, which may be a perfectly good one after the
|
|
1314
|
+
// window reopened. Codex has had this guard since #258; Claude had not.
|
|
1315
|
+
//
|
|
1316
|
+
if (this.turnInFlight)
|
|
1317
|
+
this.limitBlockPending = true;
|
|
1318
|
+
// …and the wall is what covers every turn after that one, for as long as
|
|
1319
|
+
// the provider says the window is shut (#455).
|
|
1320
|
+
const until = resetsAt === null ? Number.NaN : Date.parse(resetsAt);
|
|
1321
|
+
if (Number.isFinite(until) && until > Date.now())
|
|
1322
|
+
this.limitWall = { key, until };
|
|
1323
|
+
}
|
|
1324
|
+
else if (this.limitWall?.key === key) {
|
|
1325
|
+
// Not a refusal, and about the very window that refused: the wall is down.
|
|
1326
|
+
this.limitWall = null;
|
|
1327
|
+
}
|
|
1232
1328
|
this.emitRateLimits(refused ? { key, resetsAt } : null);
|
|
1233
1329
|
}
|
|
1234
1330
|
/** Fire-and-forget capability refresh — never throws into the caller. */
|
|
@@ -1797,9 +1893,36 @@ class ClaudeSession {
|
|
|
1797
1893
|
beginTurnFacts() {
|
|
1798
1894
|
this.turnFailureCode = null;
|
|
1799
1895
|
this.turnFailureStatus = null;
|
|
1896
|
+
this.turnRefusalCategory = null;
|
|
1800
1897
|
this.turnProduced = false;
|
|
1801
1898
|
this.turnIrreversible = false;
|
|
1802
1899
|
}
|
|
1900
|
+
/**
|
|
1901
|
+
* Record a refusal by the provider's safety filter, from either frame.
|
|
1902
|
+
*
|
|
1903
|
+
* It does two things and no more, because #435 is a much larger ticket and
|
|
1904
|
+
* this is only its first item: it gives the turn a cause of its own —
|
|
1905
|
+
* `model_refusal`, ours and not the SDK's, because the SDK's closed enum has
|
|
1906
|
+
* no member for this — and it keeps the category so the feed can name it and
|
|
1907
|
+
* the platform can store it.
|
|
1908
|
+
*
|
|
1909
|
+
* The cause matters beyond the wording. Without it a refusal that named
|
|
1910
|
+
* nothing else ended as `terminal_reason: 'api_error'`, which the adapter
|
|
1911
|
+
* spells `server_error`, which the policy table retries FIVE times — and
|
|
1912
|
+
* re-sending a request the filter already refused is the one repeat the
|
|
1913
|
+
* project has written down as actively harmful to the account.
|
|
1914
|
+
*/
|
|
1915
|
+
noteRefusal(category, explanation) {
|
|
1916
|
+
this.turnRefusalCategory = category ?? 'unknown';
|
|
1917
|
+
this.turnFailureCode = 'model_refusal';
|
|
1918
|
+
this.emit({
|
|
1919
|
+
type: 'notice',
|
|
1920
|
+
level: 'warn',
|
|
1921
|
+
text: `The model’s safety filter refused to answer (${this.turnRefusalCategory})` +
|
|
1922
|
+
(explanation ? ` — ${maskString(explanation).slice(0, 300)}` : '') +
|
|
1923
|
+
'. This is the provider’s filter, not a fault in the task or the connection.',
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1803
1926
|
/**
|
|
1804
1927
|
* Remember a tool call as work that happened, and judge whether it can be
|
|
1805
1928
|
* taken back.
|
|
@@ -2275,6 +2398,11 @@ class ClaudeSession {
|
|
|
2275
2398
|
this.turnClosed = false;
|
|
2276
2399
|
this.beginTurnFacts();
|
|
2277
2400
|
}
|
|
2401
|
+
// #455. Deliberately NOT folded into the guard above: that one is
|
|
2402
|
+
// cleared by every message, and `rate_limit_event` is a message. A
|
|
2403
|
+
// refusal must not be able to open the turn it is then attached to.
|
|
2404
|
+
if (msg.type !== 'result' && msg.type !== 'rate_limit_event')
|
|
2405
|
+
this.turnInFlight = true;
|
|
2278
2406
|
// Ticket #126: where we are in the transcript, remembered BEFORE the
|
|
2279
2407
|
// message is interpreted.
|
|
2280
2408
|
//
|
|
@@ -2326,6 +2454,37 @@ class ClaudeSession {
|
|
|
2326
2454
|
this.emit({ type: 'compaction', phase: 'finished', ok: true });
|
|
2327
2455
|
}
|
|
2328
2456
|
}
|
|
2457
|
+
else if (msg.subtype === 'model_refusal_fallback') {
|
|
2458
|
+
/**
|
|
2459
|
+
* Another model took the turn, so the turn is not refused — and
|
|
2460
|
+
* recording the category here branded the WHOLE turn a refusal
|
|
2461
|
+
* even when the fallback answered and the turn later broke on
|
|
2462
|
+
* something else entirely (independent QA of this stage). A line
|
|
2463
|
+
* in the feed, and nothing else.
|
|
2464
|
+
*/
|
|
2465
|
+
this.emit({
|
|
2466
|
+
type: 'notice',
|
|
2467
|
+
level: 'info',
|
|
2468
|
+
text: `The model’s safety filter refused (${msg.api_refusal_category ?? 'unknown'}); ` +
|
|
2469
|
+
`${msg.fallback_model} took the turn instead.`,
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
else if (msg.subtype === 'model_refusal_no_fallback') {
|
|
2473
|
+
/**
|
|
2474
|
+
* The provider's safety filter refused the answer (#435, п. 1).
|
|
2475
|
+
*
|
|
2476
|
+
* Until now this fell through to `onTaskMessage`, whose `default:
|
|
2477
|
+
* break` dropped it — so the one frame that says WHY was thrown
|
|
2478
|
+
* away and the turn ended as «the connection to the model
|
|
2479
|
+
* failed». The connection was fine in every case examined; on
|
|
2480
|
+
* the night of 2026-09-17 this machine saw seventeen of them.
|
|
2481
|
+
*
|
|
2482
|
+
* Only `…_no_fallback` is recorded as the turn's cause: it is
|
|
2483
|
+
* the one that kills the turn. Its sibling `…_fallback` is
|
|
2484
|
+
* handled one branch above and says nothing about the ending.
|
|
2485
|
+
*/
|
|
2486
|
+
this.noteRefusal(msg.api_refusal_category, msg.api_refusal_explanation);
|
|
2487
|
+
}
|
|
2329
2488
|
else if (msg.subtype === 'api_retry') {
|
|
2330
2489
|
// #252. The CLI hit a retryable error and is ALREADY retrying it
|
|
2331
2490
|
// itself — this is telemetry, not a failure. Two jobs here, and the
|
|
@@ -2397,6 +2556,23 @@ class ClaudeSession {
|
|
|
2397
2556
|
const failure = msg.error;
|
|
2398
2557
|
if (typeof failure === 'string' && failure)
|
|
2399
2558
|
this.turnFailureCode = failure;
|
|
2559
|
+
/**
|
|
2560
|
+
* The second machine path to the same fact (#435, п. 1), and the
|
|
2561
|
+
* one that needs no cast: `msg.message` is a typed `BetaMessage`,
|
|
2562
|
+
* and `refusal` is a member of its `stop_reason` union.
|
|
2563
|
+
*
|
|
2564
|
+
* `SDKAssistantMessageError` — the enum read one line above — has
|
|
2565
|
+
* no member for a refusal, which is exactly why this frame has to
|
|
2566
|
+
* be looked at separately rather than folded into `failureCode`.
|
|
2567
|
+
*/
|
|
2568
|
+
// A SUBAGENT's refusal is not the turn's ending — the Task tool
|
|
2569
|
+
// call it lives in reports its own failure and the parent turn
|
|
2570
|
+
// carries on. Branding the whole turn with it named the wrong
|
|
2571
|
+
// cause and stored the wrong reason (independent QA of this
|
|
2572
|
+
// stage). The same guard the block below uses, two lines up.
|
|
2573
|
+
if (msg.message.stop_reason === 'refusal' && !fromSubagent) {
|
|
2574
|
+
this.noteRefusal(msg.message.stop_details?.category, msg.message.stop_details?.explanation);
|
|
2575
|
+
}
|
|
2400
2576
|
for (const block of msg.message.content) {
|
|
2401
2577
|
if (block.type === 'text' && !fromSubagent && block.text.trim()) {
|
|
2402
2578
|
// An API-error message is the CLI TELLING us the turn broke, not
|
|
@@ -2513,9 +2689,29 @@ class ClaudeSession {
|
|
|
2513
2689
|
this.turnClosed = false;
|
|
2514
2690
|
this.beginTurnFacts();
|
|
2515
2691
|
}
|
|
2692
|
+
// Whatever it was, it is over now (#455).
|
|
2693
|
+
this.turnInFlight = false;
|
|
2516
2694
|
const gaveUp = msg.subtype === 'success' && terminalReason === 'api_error';
|
|
2695
|
+
/**
|
|
2696
|
+
* #455. A turn that ended on an API error while a refused window
|
|
2697
|
+
* is still refusing IS that refusal, whether or not the provider
|
|
2698
|
+
* troubled itself to send a second event about it.
|
|
2699
|
+
*
|
|
2700
|
+
* The wall is asked only on a turn that ended on an API error —
|
|
2701
|
+
* `terminal_reason: 'api_error'`, or a cause the API's own enum
|
|
2702
|
+
* named on the assistant frame. A turn that ran out of turns, or
|
|
2703
|
+
* one the person stopped, is not a refusal and is not dressed up
|
|
2704
|
+
* as one.
|
|
2705
|
+
*/
|
|
2706
|
+
const wall = gaveUp || this.turnFailureCode !== null ? this.standingLimitWall() : null;
|
|
2707
|
+
// Named only when the refusal is what ENDED the turn — the same
|
|
2708
|
+
// condition the reported category uses further down. A turn that
|
|
2709
|
+
// met a refusal and then broke on something else is not a refused
|
|
2710
|
+
// turn, and a sentence saying it was would be a new lie in place
|
|
2711
|
+
// of the old one (independent QA of this stage).
|
|
2712
|
+
const refusal = this.turnFailureCode === 'model_refusal' ? this.turnRefusalCategory : null;
|
|
2517
2713
|
const failure = gaveUp
|
|
2518
|
-
?
|
|
2714
|
+
? apiErrorTurnMessage(wall, refusal)
|
|
2519
2715
|
: msg.subtype === 'success'
|
|
2520
2716
|
? ''
|
|
2521
2717
|
: classifyError(msg.subtype, msg.errors);
|
|
@@ -2573,11 +2769,15 @@ class ClaudeSession {
|
|
|
2573
2769
|
this.refreshUsage();
|
|
2574
2770
|
}
|
|
2575
2771
|
else {
|
|
2772
|
+
// #455: the one-shot flag OR the standing wall. Read in this
|
|
2773
|
+
// order and both read: the flag has to be consumed either way,
|
|
2774
|
+
// or it would outlive the turn it belongs to.
|
|
2775
|
+
const markedByEvent = this.consumeLimitBlock();
|
|
2576
2776
|
this.emit({
|
|
2577
2777
|
type: 'turn_end',
|
|
2578
2778
|
ok: false,
|
|
2579
2779
|
errorMessage: failure,
|
|
2580
|
-
...(
|
|
2780
|
+
...(markedByEvent || wall !== null ? { limitBlocked: true } : {}),
|
|
2581
2781
|
// #252: the cause and the ending arrive on different messages —
|
|
2582
2782
|
// the closed enum rides on the assistant message, this `result`
|
|
2583
2783
|
// has no such field. Handed over so the supervisor can decide
|
|
@@ -2598,6 +2798,16 @@ class ClaudeSession {
|
|
|
2598
2798
|
: {}),
|
|
2599
2799
|
...(this.turnProduced ? { produced: true } : {}),
|
|
2600
2800
|
...(this.turnIrreversible ? { irreversible: true } : {}),
|
|
2801
|
+
// #435 п. 1: the reason travels up. Absent means «this runner
|
|
2802
|
+
// is older than the fix» — never «no refusal».
|
|
2803
|
+
//
|
|
2804
|
+
// Tied to the CAUSE and not merely to «a refusal happened
|
|
2805
|
+
// somewhere in this turn»: if the turn went on and broke on
|
|
2806
|
+
// something else, the refusal is not why it ended, and saying
|
|
2807
|
+
// it was would store a reason that is not true.
|
|
2808
|
+
...(this.turnRefusalCategory !== null && this.turnFailureCode === 'model_refusal'
|
|
2809
|
+
? { refusalCategory: this.turnRefusalCategory }
|
|
2810
|
+
: {}),
|
|
2601
2811
|
});
|
|
2602
2812
|
this.refreshUsage();
|
|
2603
2813
|
}
|
|
@@ -2783,14 +2993,37 @@ function stringifyContent(content) {
|
|
|
2783
2993
|
return content === undefined ? '' : JSON.stringify(content);
|
|
2784
2994
|
}
|
|
2785
2995
|
/**
|
|
2786
|
-
* What the feed says about a turn Claude Code gave up on (#406
|
|
2996
|
+
* What the feed says about a turn Claude Code gave up on (#406, rewritten for
|
|
2997
|
+
* #455 on the owner's decision of 2026-09-19).
|
|
2998
|
+
*
|
|
2999
|
+
* It used to be one sentence for every case: «The connection to the model
|
|
3000
|
+
* failed and Claude Code ended the turn on an API error». It was wrong in both
|
|
3001
|
+
* cases anybody has actually looked at — a spent plan limit and a refusal by
|
|
3002
|
+
* the model's safety filter — and wrong in the expensive direction: the person
|
|
3003
|
+
* spends an hour looking for a network fault that is not there. The connection
|
|
3004
|
+
* was in order every time.
|
|
3005
|
+
*
|
|
3006
|
+
* So it says what is known and stops where knowledge stops. A standing wall is
|
|
3007
|
+
* named with its reset; a recognised refusal is named as a refusal; and when
|
|
3008
|
+
* neither is known the sentence no longer asserts anything about the
|
|
3009
|
+
* connection.
|
|
2787
3010
|
*
|
|
2788
|
-
*
|
|
2789
|
-
* `error-policy.ts` matches those two phrases and narrows a retry to a
|
|
2790
|
-
* «carry on» attempt.
|
|
2791
|
-
* fact about the turn, and not from the wording of a sentence.
|
|
3011
|
+
* Still deliberately free of «mid-response» and «may be incomplete»: `refine()`
|
|
3012
|
+
* in `error-policy.ts` matches those two phrases and narrows a retry to a
|
|
3013
|
+
* single «carry on» attempt. The narrowing must come from `produced`, which is
|
|
3014
|
+
* a fact about the turn, and not from the wording of a sentence.
|
|
2792
3015
|
*/
|
|
2793
|
-
|
|
3016
|
+
function apiErrorTurnMessage(wall, refusalCategory) {
|
|
3017
|
+
if (wall !== null) {
|
|
3018
|
+
return ('The plan limit is spent — the provider refused this turn. It reopens at ' +
|
|
3019
|
+
`${new Date(wall.until).toISOString().replace('T', ' ').slice(0, 16)} UTC.`);
|
|
3020
|
+
}
|
|
3021
|
+
if (refusalCategory !== null) {
|
|
3022
|
+
return (`The model’s safety filter refused to answer (${refusalCategory}) and no fallback model ` +
|
|
3023
|
+
'took the turn. Nothing is wrong with the connection or with the task.');
|
|
3024
|
+
}
|
|
3025
|
+
return 'Claude Code ended the turn on an API error.';
|
|
3026
|
+
}
|
|
2794
3027
|
function classifyError(subtype, errors) {
|
|
2795
3028
|
// Optional on purpose despite the SDK's type: a `result` without `errors`
|
|
2796
3029
|
// threw from inside the event pump, which the loop's catch turned into a
|
package/dist/adapters/codex.js
CHANGED
|
@@ -1529,6 +1529,11 @@ class CodexSession {
|
|
|
1529
1529
|
// agent that would refuse every turn with the same credential.
|
|
1530
1530
|
if (this.endedOnAuthFailure(str(detail['message']) ?? ''))
|
|
1531
1531
|
return;
|
|
1532
|
+
// #446. A provider body with a machine-readable cause ends the TURN,
|
|
1533
|
+
// which is the one door the retry policy stands behind. Everything
|
|
1534
|
+
// else keeps going the old way — straight to a failed session.
|
|
1535
|
+
if (this.endedOnProviderApiError(detail))
|
|
1536
|
+
return;
|
|
1532
1537
|
this.classifyAndEmitFailure(new Error(str(detail['message']) ?? 'Codex reported an error'));
|
|
1533
1538
|
return;
|
|
1534
1539
|
}
|
|
@@ -2035,6 +2040,74 @@ class CodexSession {
|
|
|
2035
2040
|
* the same sentence printed itself once per refusal — eight times in the
|
|
2036
2041
|
* feed of the session this was found on.
|
|
2037
2042
|
*/
|
|
2043
|
+
/**
|
|
2044
|
+
* The provider rejected the request outright, and said so in a body we can
|
|
2045
|
+
* READ (#446).
|
|
2046
|
+
*
|
|
2047
|
+
* 2026-09-18, 20:27:44 UTC: OpenAI answered a turn with
|
|
2048
|
+
* `{"error":{"code":"unsupported_parameter","param":"access_programs.cyber"},
|
|
2049
|
+
* "status":400}` and the session died with the raw JSON in the feed, the
|
|
2050
|
+
* stage stopped as «unknown», and a run with three stages left went to
|
|
2051
|
+
* NEEDS_YOU. Nothing in this repository asks for that parameter — the CLI
|
|
2052
|
+
* adds it — and the same account, machine and model ran fifteen other
|
|
2053
|
+
* sessions that day without it.
|
|
2054
|
+
*
|
|
2055
|
+
* Two things had to change and this is the first. The machine cause never
|
|
2056
|
+
* reached the policy: this notification rebuilt the failure as
|
|
2057
|
+
* `new Error(detail.message)` and `classifyAndEmitFailure` threw everything
|
|
2058
|
+
* but the sentence away, so `classifyFailure` saw «no code» and answered
|
|
2059
|
+
* `stop` — which is the right answer to «no code» and the wrong one here.
|
|
2060
|
+
*
|
|
2061
|
+
* **Why this ends the TURN rather than calling the policy from here.** The
|
|
2062
|
+
* `error` branch is a dead end for retry by construction: `armApiRetry` has
|
|
2063
|
+
* exactly one call site, in `turn_end`, and with it live all the guards that
|
|
2064
|
+
* make an automatic retry safe — the session-wide ceiling, the open-card
|
|
2065
|
+
* check, the «same failure twice» rule, one counter, one feed line. A second
|
|
2066
|
+
* entrance next to it would be a second set of all of them. The machinery
|
|
2067
|
+
* for ending a turn from this notification already exists a few lines below,
|
|
2068
|
+
* where the limit refusal uses it, down to remembering the id so Codex's own
|
|
2069
|
+
* late ending is not counted twice.
|
|
2070
|
+
*
|
|
2071
|
+
* Returns false whenever there is nothing readable, or no turn to end — and
|
|
2072
|
+
* then the old path runs exactly as before.
|
|
2073
|
+
*/
|
|
2074
|
+
endedOnProviderApiError(detail) {
|
|
2075
|
+
const api = readProviderApiError(detail);
|
|
2076
|
+
if (api === null)
|
|
2077
|
+
return false;
|
|
2078
|
+
if (this.activeTurnId === null)
|
|
2079
|
+
return false;
|
|
2080
|
+
const turnId = this.activeTurnId;
|
|
2081
|
+
this.selfSettledTurnId = turnId;
|
|
2082
|
+
this.activeTurnId = null;
|
|
2083
|
+
this.subagents.endTurn();
|
|
2084
|
+
const detailMessage = str(detail['message']);
|
|
2085
|
+
this.emit({
|
|
2086
|
+
type: 'turn_end',
|
|
2087
|
+
ok: false,
|
|
2088
|
+
// «This REQUEST», not «this turn»: the turn may well have run tools —
|
|
2089
|
+
// the incident's had committed a second earlier, and the same event
|
|
2090
|
+
// carries `produced` and `irreversible` to say so. Saying the turn did
|
|
2091
|
+
// nothing would be the same false statement this ticket exists to
|
|
2092
|
+
// remove, pointing the other way (independent QA of this stage).
|
|
2093
|
+
errorMessage: maskString(`The provider rejected the request outright (${api.code}` +
|
|
2094
|
+
`${api.param === undefined ? '' : `, ${api.param}`}). Nothing of this request reached ` +
|
|
2095
|
+
`a model.${api.message === undefined ? '' : ` Provider: ${api.message}`}`).slice(0, 500),
|
|
2096
|
+
failureCode: providerApiFailureCode(api),
|
|
2097
|
+
...(api.status !== undefined ? { failureStatus: api.status } : {}),
|
|
2098
|
+
...(this.consumeLimitBlock() ? { limitBlocked: true } : {}),
|
|
2099
|
+
...(this.turnProduced ? { produced: true } : {}),
|
|
2100
|
+
...(this.turnIrreversible ? { irreversible: true } : {}),
|
|
2101
|
+
});
|
|
2102
|
+
log.info('codex: provider rejected the request', {
|
|
2103
|
+
sessionId: this.spec.sessionId,
|
|
2104
|
+
code: api.code,
|
|
2105
|
+
param: api.param ?? null,
|
|
2106
|
+
status: api.status ?? null,
|
|
2107
|
+
raw: detailMessage === undefined ? null : maskString(detailMessage).slice(0, 200),
|
|
2108
|
+
});
|
|
2109
|
+
return true;
|
|
2110
|
+
}
|
|
2038
2111
|
noteRateLimitRefusal(error) {
|
|
2039
2112
|
if (!this.announceRateLimitRefusal(error))
|
|
2040
2113
|
return false;
|
|
@@ -2177,12 +2250,33 @@ class CodexSession {
|
|
|
2177
2250
|
// `responseStreamConnectionFailed`, `contextWindowExceeded`, …) and it is
|
|
2178
2251
|
// the ONLY thing allowed to open the door to an automatic retry — the
|
|
2179
2252
|
// message next to it is prose, and prose can be written by agents.
|
|
2180
|
-
|
|
2181
|
-
|
|
2253
|
+
// #446. The provider's OWN body, when the turn failure carries one: a
|
|
2254
|
+
// code from the API's dictionary, kept apart from Codex's by its `api:`
|
|
2255
|
+
// prefix, so a rule can be about one without ever matching the other.
|
|
2256
|
+
// Codex's own code wins when both are there — it is the more specific
|
|
2257
|
+
// statement about what happened, and the table is written around it.
|
|
2258
|
+
const api = readProviderApiError(error);
|
|
2259
|
+
const failureCode = str(error['codexErrorInfo']) ?? (api === null ? undefined : providerApiFailureCode(api));
|
|
2260
|
+
const rawStatus = error['httpStatusCode'];
|
|
2261
|
+
const failureStatus = typeof rawStatus === 'number' ? rawStatus : api?.status;
|
|
2262
|
+
/**
|
|
2263
|
+
* #435, first item, the Codex half — so the platform says ONE thing
|
|
2264
|
+
* about one event whichever agent met it.
|
|
2265
|
+
*
|
|
2266
|
+
* `cyberPolicy` is Codex's word for «the content filter refused», the
|
|
2267
|
+
* same class of event as Claude's `stop_reason: 'refusal'`. It stays
|
|
2268
|
+
* `retry: false` and the session still ends FAILED; what changes is that
|
|
2269
|
+
* the reason now travels, instead of the platform calling a content
|
|
2270
|
+
* refusal a broken connection.
|
|
2271
|
+
*/
|
|
2272
|
+
const refusalCategory = failureCode === 'cyberPolicy' ? 'cyber' : null;
|
|
2182
2273
|
this.emit({
|
|
2183
2274
|
type: 'turn_end',
|
|
2184
2275
|
ok: false,
|
|
2185
|
-
errorMessage: maskString(
|
|
2276
|
+
errorMessage: maskString(refusalCategory === null
|
|
2277
|
+
? (str(error['message']) ?? 'The Codex turn failed')
|
|
2278
|
+
: 'The model’s safety filter refused to answer (cyber). This is the provider’s ' +
|
|
2279
|
+
'filter, not a fault in the task or the connection.').slice(0, 500),
|
|
2186
2280
|
// Without this the session goes FAILED — terminal — and the pause armed
|
|
2187
2281
|
// one line earlier would ring into a dead row and throw the person's
|
|
2188
2282
|
// queued words away instead of sending them.
|
|
@@ -2191,6 +2285,7 @@ class CodexSession {
|
|
|
2191
2285
|
...(typeof failureStatus === 'number' ? { failureStatus } : {}),
|
|
2192
2286
|
...(this.turnProduced ? { produced: true } : {}),
|
|
2193
2287
|
...(this.turnIrreversible ? { irreversible: true } : {}),
|
|
2288
|
+
...(refusalCategory !== null ? { refusalCategory } : {}),
|
|
2194
2289
|
});
|
|
2195
2290
|
return;
|
|
2196
2291
|
}
|
|
@@ -2701,6 +2796,74 @@ export function isAuthError(message) {
|
|
|
2701
2796
|
function describe(error) {
|
|
2702
2797
|
return String(error instanceof Error ? error.message : error);
|
|
2703
2798
|
}
|
|
2799
|
+
/**
|
|
2800
|
+
* Read `{"error":{"code":…,"param":…},"status":…}` out of a notification.
|
|
2801
|
+
*
|
|
2802
|
+
* **Parsed as data, not matched as prose**, and the difference is the whole
|
|
2803
|
+
* point: `error-policy.ts` forbids deciding anything from a sentence, because
|
|
2804
|
+
* an agent's own output travels in the same stream and a report quoting a
|
|
2805
|
+
* provider's error would otherwise be able to fire a rule. JSON.parse is not
|
|
2806
|
+
* pattern matching — either the body is a structure with a `code` field in it
|
|
2807
|
+
* or this returns null and nothing happens.
|
|
2808
|
+
*
|
|
2809
|
+
* Two shapes, because the body arrives either way depending on how far up the
|
|
2810
|
+
* CLI wrapped it: as the notification's own record, or as JSON inside its
|
|
2811
|
+
* `message` string. The nested `error` object is required — a bare top-level
|
|
2812
|
+
* `code` on a Codex notification is Codex's OWN vocabulary
|
|
2813
|
+
* (`codexErrorInfo`), and mixing the two dictionaries is the thing #446 п. 3
|
|
2814
|
+
* asked not to do.
|
|
2815
|
+
*/
|
|
2816
|
+
function readProviderApiError(root) {
|
|
2817
|
+
const fromRecord = (value) => {
|
|
2818
|
+
const inner = asRecord(asRecord(value)['error']);
|
|
2819
|
+
const code = str(inner['code']);
|
|
2820
|
+
if (code === undefined || code === '')
|
|
2821
|
+
return null;
|
|
2822
|
+
const param = str(inner['param']);
|
|
2823
|
+
const raw = asRecord(value)['status'] ?? inner['status'] ?? asRecord(value)['httpStatusCode'];
|
|
2824
|
+
const status = typeof raw === 'number' ? raw : undefined;
|
|
2825
|
+
const message = str(inner['message']);
|
|
2826
|
+
return {
|
|
2827
|
+
code,
|
|
2828
|
+
...(param === undefined ? {} : { param }),
|
|
2829
|
+
...(status === undefined ? {} : { status }),
|
|
2830
|
+
...(message === undefined ? {} : { message }),
|
|
2831
|
+
};
|
|
2832
|
+
};
|
|
2833
|
+
const direct = fromRecord(root);
|
|
2834
|
+
if (direct !== null)
|
|
2835
|
+
return direct;
|
|
2836
|
+
const text = str(asRecord(root)['message']);
|
|
2837
|
+
if (text === undefined)
|
|
2838
|
+
return null;
|
|
2839
|
+
// The body is sometimes wrapped in a sentence («stream error: {…}»), so the
|
|
2840
|
+
// braces are located rather than assumed to be the whole string. Bounded by
|
|
2841
|
+
// the first `{` and the last `}`: anything else is not a body.
|
|
2842
|
+
const start = text.indexOf('{');
|
|
2843
|
+
const end = text.lastIndexOf('}');
|
|
2844
|
+
if (start === -1 || end <= start)
|
|
2845
|
+
return null;
|
|
2846
|
+
try {
|
|
2847
|
+
return fromRecord(JSON.parse(text.slice(start, end + 1)));
|
|
2848
|
+
}
|
|
2849
|
+
catch {
|
|
2850
|
+
return null;
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
/**
|
|
2854
|
+
* The code a rule can match, with the provider's dictionary kept apart from
|
|
2855
|
+
* Codex's own (#446 п. 3).
|
|
2856
|
+
*
|
|
2857
|
+
* `api:` is the prefix, so `unsupported_parameter` can never be confused with
|
|
2858
|
+
* a `codexErrorInfo` member. The parameter's FIRST segment rides along when
|
|
2859
|
+
* there is one, so a rule can be about `access_programs` without being about
|
|
2860
|
+
* every `unsupported_parameter` there will ever be — and without being tied to
|
|
2861
|
+
* the exact sub-name (`access_programs.cyber`) the CLI happened to send.
|
|
2862
|
+
*/
|
|
2863
|
+
function providerApiFailureCode(api) {
|
|
2864
|
+
const family = api.param === undefined ? '' : `:${api.param.split('.')[0] ?? api.param}`;
|
|
2865
|
+
return `api:${api.code}${family}`;
|
|
2866
|
+
}
|
|
2704
2867
|
/**
|
|
2705
2868
|
* "The thread you asked for is not there." Matched on the message and not on an
|
|
2706
2869
|
* error class, because the same condition arrives as an RPC error or as a
|
|
@@ -76,6 +76,17 @@ export interface ErrorRule {
|
|
|
76
76
|
bucket?: Exclude<RetryBucket, 'stop'>;
|
|
77
77
|
attempts?: number;
|
|
78
78
|
backoff?: BackoffProfile;
|
|
79
|
+
/**
|
|
80
|
+
* The provider threw the whole request away — nothing of it reached a model.
|
|
81
|
+
*
|
|
82
|
+
* Changes nothing about WHETHER we carry on; it changes what we SAY when we
|
|
83
|
+
* do. The default sentence tells the agent its answer was cut off part-way
|
|
84
|
+
* and to check what it had already finished — true for a dropped stream,
|
|
85
|
+
* false and expensive here: the agent goes looking for half-done work that
|
|
86
|
+
* does not exist, and the feed tells the person the connection failed when
|
|
87
|
+
* it did not (#446 п. 6, #455 owner's decision of 2026-09-19).
|
|
88
|
+
*/
|
|
89
|
+
refusedWhole?: boolean;
|
|
79
90
|
/**
|
|
80
91
|
* `false` → the row is INERT: counted, never acted on.
|
|
81
92
|
*
|
|
@@ -122,6 +133,12 @@ export interface RetryDecision {
|
|
|
122
133
|
backoff: BackoffProfile;
|
|
123
134
|
/** Which row decided, or `null` when nothing matched and the default applied. */
|
|
124
135
|
ruleId: string | null;
|
|
136
|
+
/**
|
|
137
|
+
* The provider threw the whole request away — so whatever we say next must
|
|
138
|
+
* not talk about a connection or about half-finished work. See
|
|
139
|
+
* `ErrorRule.refusedWhole`.
|
|
140
|
+
*/
|
|
141
|
+
refusedWhole: boolean;
|
|
125
142
|
}
|
|
126
143
|
/**
|
|
127
144
|
* What may this failure do next?
|