jeo-code 0.7.28 → 0.7.30
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/CHANGELOG.md +21 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/engine.ts +58 -26
- package/src/agent/loop-guards.ts +7 -2
- package/src/ai/providers/openai-responses.ts +4 -3
- package/src/util/provider-error.ts +1 -1
- package/src/util/retry.ts +23 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
|
|
8
8
|
|
|
9
|
+
## [0.7.30] - 2026-07-02
|
|
10
|
+
_GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs._
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **GPT/Codex OAuth no longer sends `max_output_tokens` to the ChatGPT/Codex backend.** `0.7.28` moved the computed output cap onto the shared Responses payload, but the OAuth Codex endpoint rejects that public-API-only parameter with `HTTP 400 {"detail":"Unsupported parameter: max_output_tokens"}`. The cap is now applied only on the public `/v1/responses` API-key path; OAuth requests keep using the Codex backend shape that `jeo doctor` probes.
|
|
14
|
+
- **`JEO_TURN_MAX_MS` is now a stall budget, not an absolute kill switch.** The old implementation measured 30 minutes from turn start and could terminate a long autonomous run even while it was still executing tools and making progress. The clock now resets on every executed tool step and on mid-turn steering, so it only stops no-progress loops such as refusal backoff spins, endless provider retries, or bounce loops. The existing step hard cap and repeat/cycle/failure guards still bound genuinely long runs.
|
|
15
|
+
|
|
16
|
+
### Verified
|
|
17
|
+
- `bun test` green — 2147 pass / 0 fail across 244 files; `bun run typecheck` clean.
|
|
18
|
+
- Live GPT/Codex OAuth smoke tests through the installed `jeo` symlink: `jeo chat "Say exactly: hello" --model gpt-5.5` and `jeo chat "Reply with one word: pong" --model gpt-5.5` both returned the requested text. Added/updated regression coverage in `test/codex-responses.test.ts` and `test/cycle-and-turn-budget.test.ts`.
|
|
19
|
+
|
|
20
|
+
## [0.7.29] - 2026-07-02
|
|
21
|
+
_gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder._
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **Safety refusals now keep retrying instead of surfacing a terminal provider error.** jeo already had the stronger context-mutating recovery ladder (plain resend → tool-result/thinking-replay reset → project-context guidance strip), but once those rungs were spent it still returned `Anthropic declined to answer (safety refusal — no content returned) even after automatic retries with a context reset`. gjc's session retry path treats that class as non-terminal and keeps the session alive. jeo now adds the same final rung: capped exponential backoff resends after all context mutations are spent, with an `Esc`/abort-cancellable wait and the turn wall-clock budget still bounding the whole run.
|
|
25
|
+
- **The stale fallback refusal copy no longer claims automatic retries were exhausted.** The friendly provider error text is now reserved for non-engine call sites and points users at `/retry`, `/compact`, `/new`, or `/model` without implying the agent already gave up.
|
|
26
|
+
|
|
27
|
+
### Verified
|
|
28
|
+
- `bun run typecheck` clean; full suite green — 2146 pass / 0 fail across 244 files. Targeted coverage updated in `test/refusal-recovery.test.ts` for the new post-ladder backoff recovery and for Esc/Ctrl+C aborting a refusal backoff as `Cancelled.` instead of surfacing the refusal error.
|
|
29
|
+
|
|
9
30
|
## [0.7.28] - 2026-07-02
|
|
10
31
|
_gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests)._
|
|
11
32
|
|
package/README.ja.md
CHANGED
|
@@ -205,11 +205,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
205
205
|
## 変更履歴 (Changelog)
|
|
206
206
|
|
|
207
207
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
208
|
+
- **[0.7.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
|
|
209
|
+
- **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
|
|
208
210
|
- **[0.7.28]** (2026-07-02) — gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests).
|
|
209
211
|
- **[0.7.27]** (2026-07-02) — Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks.
|
|
210
212
|
- **[0.7.26]** (2026-07-02) — Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits.
|
|
211
|
-
- **[0.7.25]** (2026-07-02) — Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts.
|
|
212
|
-
- **[0.7.24]** (2026-07-02) — OAuth callback page redesign with embedded jeo wordmark + Close button; memory budget O(n²)→O(n) incremental tracking fix.
|
|
213
213
|
|
|
214
214
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
215
215
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -205,11 +205,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
205
205
|
## 변경 이력 (Changelog)
|
|
206
206
|
|
|
207
207
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
208
|
+
- **[0.7.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
|
|
209
|
+
- **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
|
|
208
210
|
- **[0.7.28]** (2026-07-02) — gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests).
|
|
209
211
|
- **[0.7.27]** (2026-07-02) — Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks.
|
|
210
212
|
- **[0.7.26]** (2026-07-02) — Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits.
|
|
211
|
-
- **[0.7.25]** (2026-07-02) — Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts.
|
|
212
|
-
- **[0.7.24]** (2026-07-02) — OAuth callback page redesign with embedded jeo wordmark + Close button; memory budget O(n²)→O(n) incremental tracking fix.
|
|
213
213
|
|
|
214
214
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
215
215
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -232,11 +232,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
232
232
|
## Changelog
|
|
233
233
|
|
|
234
234
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
235
|
+
- **[0.7.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
|
|
236
|
+
- **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
|
|
235
237
|
- **[0.7.28]** (2026-07-02) — gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests).
|
|
236
238
|
- **[0.7.27]** (2026-07-02) — Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks.
|
|
237
239
|
- **[0.7.26]** (2026-07-02) — Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits.
|
|
238
|
-
- **[0.7.25]** (2026-07-02) — Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts.
|
|
239
|
-
- **[0.7.24]** (2026-07-02) — OAuth callback page redesign with embedded jeo wordmark + Close button; memory budget O(n²)→O(n) incremental tracking fix.
|
|
240
240
|
|
|
241
241
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
242
242
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -205,11 +205,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
205
205
|
## 更新日志 (Changelog)
|
|
206
206
|
|
|
207
207
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
208
|
+
- **[0.7.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
|
|
209
|
+
- **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
|
|
208
210
|
- **[0.7.28]** (2026-07-02) — gajae-code 0.7.9–0.7.10 OAuth + model-execution parity: generic long rate-limit windows are retried (never fatal), retry waits are abort-cancellable, Codex prompt caching via session correlation, and the Kimi Code device-flow OAuth is completed end-to-end (catalog, gating, discovery, tests).
|
|
209
211
|
- **[0.7.27]** (2026-07-02) — Extended-keyboard decode layer: Esc/Ctrl+C work again on kitty-protocol terminals, Shift+Enter breaks lines on every terminal incl. Windows, row/draft-aware Cmd+←→/↑↓ + Home/End/Ctrl+Home/End, Ctrl+V text-paste fallback. Plus Anthropic 5th-gen omp-parity: catalog-derived output budgets end thinking-vs-reply starvation; refusals fail fast at the transport and the recovery ladder strips replayed thinking blocks.
|
|
210
212
|
- **[0.7.26]** (2026-07-02) — Esc/Ctrl+C reliably clears the input box (fixes a gate the earlier reorder missed), and un-bracketed multi-line pastes no longer shred into premature per-line submits.
|
|
211
|
-
- **[0.7.25]** (2026-07-02) — Shift+Enter now reliably distinguishable from Enter on kitty/WezTerm/iTerm2/xterm via modifyOtherKeys + kitty keyboard protocol; row-aware Home/End and Cmd+Left/Right in multi-row drafts.
|
|
212
|
-
- **[0.7.24]** (2026-07-02) — OAuth callback page redesign with embedded jeo wordmark + Close button; memory budget O(n²)→O(n) incremental tracking fix.
|
|
213
213
|
|
|
214
214
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
215
215
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/engine.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { nativeToolSchemasFor, normalizeNativeToolName } from "./tool-schemas";
|
|
|
15
15
|
import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool, mkdirTool, deleteTool, type ToolResult } from "./tools";
|
|
16
16
|
import { webSearchTool, setWebSearchActiveModel } from "./web-search";
|
|
17
17
|
import { executeComputerAction } from "../commands/computer";
|
|
18
|
-
import { isRateLimitError } from "../util/retry";
|
|
18
|
+
import { isRateLimitError, waitAbortable } from "../util/retry";
|
|
19
19
|
import { isContextOverflowError, isRefusalError, friendlyProviderError } from "../util/provider-error";
|
|
20
20
|
import { runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
|
|
21
21
|
import { truncateToolOutput, formatToolResultBody } from "./tool-output";
|
|
@@ -286,10 +286,12 @@ export interface AgentLoopResult {
|
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
|
|
289
|
-
/** Wall-clock budget for ONE agent turn (ms)
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
* the
|
|
289
|
+
/** Wall-clock STALL budget for ONE agent turn (ms): the maximum time a turn may run
|
|
290
|
+
* WITHOUT PROGRESS (no executed tool step). JEO_TURN_MAX_MS overrides; 0 disables.
|
|
291
|
+
* Default 30 minutes: long autonomous runs that keep executing tools stay alive
|
|
292
|
+
* indefinitely (the step budget's hard cap bounds them), while a turn that spins in
|
|
293
|
+
* "thinking" (refusal backoff, endless provider retries) is guaranteed to terminate
|
|
294
|
+
* into the consolidation wrap-up instead of running for hours. */
|
|
293
295
|
export function turnMaxMs(env: Record<string, string | undefined> = process.env): number {
|
|
294
296
|
const raw = jeoEnv("TURN_MAX_MS", env);
|
|
295
297
|
if (raw !== undefined && raw !== "") {
|
|
@@ -364,12 +366,18 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
364
366
|
const ev = opts.events ?? {};
|
|
365
367
|
const maxHistoryTokens = Math.max(10_000, opts.maxHistoryTokens ?? 80_000);
|
|
366
368
|
|
|
367
|
-
// Wall-clock
|
|
368
|
-
// guarantee. Step budgets bound the COUNT of model calls; this bounds
|
|
369
|
-
//
|
|
370
|
-
//
|
|
369
|
+
// Wall-clock STALL budget — the definitive "never sits in thinking forever"
|
|
370
|
+
// guarantee. Step budgets bound the COUNT of model calls; this bounds the time a
|
|
371
|
+
// turn may spend WITHOUT PROGRESS (no executed tool step): refusal-backoff spins,
|
|
372
|
+
// endless provider retries, and bounce loops terminate into the consolidation
|
|
373
|
+
// wrap-up, while a long-running turn that keeps executing tools stays alive —
|
|
374
|
+
// its termination is owned by the step budget's hard cap and the spin guards.
|
|
375
|
+
// Field regression this replaces: an ABSOLUTE 30m budget measured from turn start
|
|
376
|
+
// killed genuinely progressing autonomous runs mid-work.
|
|
371
377
|
const turnStartedAt = Date.now();
|
|
372
378
|
const turnBudgetMs = turnMaxMs();
|
|
379
|
+
// Reset on every executed tool step and on mid-turn steering.
|
|
380
|
+
let lastProgressAt = turnStartedAt;
|
|
373
381
|
// "steps" | "time" — drives honest wording in the consolidation message.
|
|
374
382
|
let stopKind: "steps" | "time" = "steps";
|
|
375
383
|
let step = 1;
|
|
@@ -443,11 +451,14 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
443
451
|
// Round-6 #4: ONE reactive recovery when the PROVIDER reports context overflow
|
|
444
452
|
// (authoritative where the local estimate drifted — images, tokenizer mismatch).
|
|
445
453
|
let contextOverflowRetryUsed = false;
|
|
446
|
-
// Refusal recovery
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
//
|
|
454
|
+
// Refusal recovery: a safety refusal (HTTP 200, no content) on routine coding
|
|
455
|
+
// work is usually a transient false-positive. The first rungs MUTATE the context
|
|
456
|
+
// (plain resend → context reset + re-grounding note → guidance strip); after the
|
|
457
|
+
// ladder is exhausted the loop keeps resending with capped exponential backoff
|
|
458
|
+
// instead of surfacing a terminal error (gjc parity: a refusal is retried until
|
|
459
|
+
// it clears or the user cancels — Esc aborts the backoff wait via opts.signal,
|
|
460
|
+
// and the stall budget bounds the spin: refusals execute no tools, so the
|
|
461
|
+
// no-progress clock keeps ticking and terminates the turn at JEO_TURN_MAX_MS).
|
|
451
462
|
const MAX_REFUSAL_RETRIES = GUARD_LIMITS.MAX_REFUSAL_RETRIES;
|
|
452
463
|
let refusalRetries = 0;
|
|
453
464
|
let lastSig = "";
|
|
@@ -473,9 +484,9 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
473
484
|
// schemas ONCE instead of rebuilding/reallocating the list on every step.
|
|
474
485
|
const turnToolSchemas = nativeToolSchemasFor(Object.keys(tools));
|
|
475
486
|
while (true) {
|
|
476
|
-
if (turnBudgetMs > 0 && Date.now() -
|
|
487
|
+
if (turnBudgetMs > 0 && Date.now() - lastProgressAt > turnBudgetMs) {
|
|
477
488
|
stopKind = "time";
|
|
478
|
-
budgetStopReason = `
|
|
489
|
+
budgetStopReason = `no tool progress for ${Math.round(turnBudgetMs / 60_000)}m — turn stall budget (JEO_TURN_MAX_MS) exceeded without done`;
|
|
479
490
|
break;
|
|
480
491
|
}
|
|
481
492
|
if (step > budget.limit()) {
|
|
@@ -513,6 +524,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
513
524
|
consecutiveFailures = 0;
|
|
514
525
|
recentStepSigs.length = 0;
|
|
515
526
|
budget.noteSteer?.();
|
|
527
|
+
lastProgressAt = Date.now(); // fresh instruction = fresh progress window
|
|
516
528
|
}
|
|
517
529
|
}
|
|
518
530
|
|
|
@@ -610,7 +622,16 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
610
622
|
// routine). Strip that block for the rest of the turn and retry once;
|
|
611
623
|
// core instructions stay intact. Field case: `$gjc init` inside a
|
|
612
624
|
// repo whose guidance files refuse-trip the OAuth classifier.
|
|
613
|
-
|
|
625
|
+
// 4) unbounded backoff — the classifier verdict is TIME-sensitive (rolling
|
|
626
|
+
// classifier state, per-request user-id rotation), so once every
|
|
627
|
+
// context mutation is spent, keep resending with capped exponential
|
|
628
|
+
// backoff instead of surfacing a terminal error (gjc parity: gjc's
|
|
629
|
+
// session auto-retry classifies a refusal as retryable and never ends
|
|
630
|
+
// the turn on it). Esc/cancel aborts the wait via opts.signal and the
|
|
631
|
+
// loop-top check turns it into "Cancelled."; the JEO_TURN_MAX_MS stall
|
|
632
|
+
// budget still bounds the spin (a refusal loop executes no tools, so
|
|
633
|
+
// the no-progress clock never resets).
|
|
634
|
+
if (isRefusalError(err)) {
|
|
614
635
|
refusalRetries++;
|
|
615
636
|
if (refusalRetries === 1) {
|
|
616
637
|
ev.onNotice?.("provider refused the last call (no content) — retrying the same step");
|
|
@@ -642,15 +663,24 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
642
663
|
step++;
|
|
643
664
|
continue;
|
|
644
665
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
666
|
+
if (refusalRetries === 3) {
|
|
667
|
+
const sys = history[0];
|
|
668
|
+
if (sys?.role === "system" && sys.content.includes("<project_context>")) {
|
|
669
|
+
const stripped = sys.content.replace(/\n*<project_context>[\s\S]*?<\/project_context>/, "").trimEnd();
|
|
670
|
+
history[0] = { ...sys, content: stripped }; // replace, never mutate (identity caches)
|
|
671
|
+
ev.onNotice?.("provider refused a third time — removed project-context guidance from the system prompt and retrying once more");
|
|
672
|
+
continue; // same step, reduced system prompt
|
|
673
|
+
}
|
|
674
|
+
// Nothing left to strip — fall through to the backoff rung below.
|
|
651
675
|
}
|
|
652
|
-
//
|
|
653
|
-
|
|
676
|
+
// Rung 4: every context mutation is spent. Backoff-resend forever (abortable).
|
|
677
|
+
const attempt = Math.max(1, refusalRetries - MAX_REFUSAL_RETRIES);
|
|
678
|
+
const baseRaw = Number(jeoEnv("REFUSAL_BACKOFF_BASE_MS") ?? NaN);
|
|
679
|
+
const baseMs = Number.isFinite(baseRaw) && baseRaw >= 0 ? baseRaw : GUARD_LIMITS.REFUSAL_BACKOFF_BASE_MS;
|
|
680
|
+
const delayMs = Math.min(baseMs * 2 ** (attempt - 1), GUARD_LIMITS.REFUSAL_BACKOFF_MAX_MS);
|
|
681
|
+
ev.onNotice?.(`provider refused again — auto-retry #${attempt} in ${Math.max(1, Math.round(delayMs / 1000))}s (Esc to cancel)`);
|
|
682
|
+
await waitAbortable(delayMs, opts.signal);
|
|
683
|
+
continue; // loop top surfaces "Cancelled." when the wait was aborted
|
|
654
684
|
}
|
|
655
685
|
const message = friendlyProviderError(err);
|
|
656
686
|
// The error IS the turn's doneReason and every caller displays that — emitting a
|
|
@@ -1114,6 +1144,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1114
1144
|
// repeated call's ACTUAL last outcome (A). A skipped/bounced step never reaches
|
|
1115
1145
|
// here, so this always holds the last REAL execution's results.
|
|
1116
1146
|
lastResults = results;
|
|
1147
|
+
// Executed tool step = progress: the stall budget clocks time WITHOUT this.
|
|
1148
|
+
if (results.some(r => r.executed)) lastProgressAt = Date.now();
|
|
1117
1149
|
step++;
|
|
1118
1150
|
}
|
|
1119
1151
|
|
|
@@ -1124,7 +1156,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1124
1156
|
const extInfo = budget.extensionsUsed() > 0 ? ` after ${budget.extensionsUsed()} extension(s)` : "";
|
|
1125
1157
|
const stopInfo = budgetStopReason ? `; ${budgetStopReason}` : "";
|
|
1126
1158
|
const budgetLabel = stopKind === "time"
|
|
1127
|
-
? `turn time budget of ${Math.round(turnBudgetMs / 60_000)}m reached`
|
|
1159
|
+
? `turn time budget of ${Math.round(turnBudgetMs / 60_000)}m reached (no tool progress)`
|
|
1128
1160
|
: `step budget of ${budget.limit()} reached`;
|
|
1129
1161
|
try {
|
|
1130
1162
|
if (!opts.signal?.aborted) {
|
package/src/agent/loop-guards.ts
CHANGED
|
@@ -25,7 +25,7 @@ export type GuardState =
|
|
|
25
25
|
| "invalid_tool_stop" // MAX_INVALID_CALLS replies with no usable tool field → stop
|
|
26
26
|
| "parse_salvage" // repeated non-JSON prose → salvage the text as the final answer
|
|
27
27
|
| "context_overflow_retry" // provider reported context overflow → ONE trim + retry
|
|
28
|
-
| "refusal_retry" //
|
|
28
|
+
| "refusal_retry" // safety refusal → context-reset ladder, then unbounded backoff resends
|
|
29
29
|
| "done_unverified" // mutated files, no verification signal → pushback on done
|
|
30
30
|
| "done_stale_verification" // verified, then mutated again → re-verify before done
|
|
31
31
|
| "done_hook_failing" // post-turn hook still failing → pushback on done
|
|
@@ -41,8 +41,13 @@ export const GUARD_LIMITS = Object.freeze({
|
|
|
41
41
|
MAX_REPEAT: 4,
|
|
42
42
|
/** Consecutive different-but-failing steps before the turn stops. */
|
|
43
43
|
MAX_FAILURES: 5,
|
|
44
|
-
/**
|
|
44
|
+
/** Context-mutating refusal-ladder rungs (resend → reset → guidance strip) before
|
|
45
|
+
* recovery switches to unbounded backoff resends (gjc parity: never terminal). */
|
|
45
46
|
MAX_REFUSAL_RETRIES: 3,
|
|
47
|
+
/** First post-ladder refusal backoff wait (ms); doubles per attempt (gjc retry.baseDelayMs). */
|
|
48
|
+
REFUSAL_BACKOFF_BASE_MS: 2_000,
|
|
49
|
+
/** Ceiling on the post-ladder refusal backoff wait (ms). */
|
|
50
|
+
REFUSAL_BACKOFF_MAX_MS: 30_000,
|
|
46
51
|
/** Replies with no usable `tool`/`tools` field before the turn stops. */
|
|
47
52
|
MAX_INVALID_CALLS: 3,
|
|
48
53
|
/** Consecutive non-JSON parse failures before the prose is salvaged as the answer. */
|
|
@@ -126,13 +126,14 @@ export function codexResponsesRequest(
|
|
|
126
126
|
// frame can show the model's thinking instead of a frozen "calling model (Ns)…".
|
|
127
127
|
payload.reasoning = { effort: options.reasoningEffort, summary: "auto" };
|
|
128
128
|
}
|
|
129
|
-
// Cap the response length (gjc parity)
|
|
130
|
-
//
|
|
131
|
-
|
|
129
|
+
// Cap the response length (gjc parity) on the PUBLIC Responses API only. The ChatGPT/Codex
|
|
130
|
+
// OAuth backend 400s on this parameter ({"detail":"Unsupported parameter: max_output_tokens"}),
|
|
131
|
+
// so the cap is applied inside the api_key branch below.
|
|
132
132
|
// OAuth → the undocumented ChatGPT/Codex backend (codex headers + account-id).
|
|
133
133
|
// API key → the public OpenAI Responses API (`/v1/responses`) with a plain Bearer.
|
|
134
134
|
// Both speak the same Responses schema (the body above), so only url+headers differ.
|
|
135
135
|
if (credential.kind === "api_key") {
|
|
136
|
+
if (options.maxTokens) payload.max_output_tokens = options.maxTokens;
|
|
136
137
|
const base = (options.baseUrl ?? "https://api.openai.com/v1").replace(/\/$/, "");
|
|
137
138
|
// Stateless reasoning replay (public Responses API): ask for encrypted reasoning content
|
|
138
139
|
// so it can be captured and threaded back into a later `input` (store stays false).
|
|
@@ -61,7 +61,7 @@ export function friendlyProviderError(err: unknown): string {
|
|
|
61
61
|
return `${provider} rejected the request: the conversation no longer fits the model's context window. Run /compact, drop large attachments, or start a fresh session.`;
|
|
62
62
|
}
|
|
63
63
|
if (isRefusalError(err)) {
|
|
64
|
-
return `${provider} declined to answer (safety refusal — no content returned)
|
|
64
|
+
return `${provider} declined to answer (safety refusal — no content returned). Usually a content classifier tripped on recently read file/search content: /retry, /compact or /new to drop the triggering context, or switch model with /model. If this persists on a Claude subscription (OAuth) login, Anthropic restricts third-party OAuth clients — set ANTHROPIC_API_KEY or use another provider.`;
|
|
65
65
|
}
|
|
66
66
|
if (status === 404) {
|
|
67
67
|
return `${provider} does not recognize the requested model (HTTP 404). The id may be retired, gated, or mistyped — pick another with /model.`;
|
package/src/util/retry.ts
CHANGED
|
@@ -208,6 +208,26 @@ function abortableSleep(sleep: (ms: number) => Promise<void>, ms: number, signal
|
|
|
208
208
|
return promise;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/** Abortable wait for the ENGINE's post-ladder refusal backoff: resolves EARLY (never
|
|
212
|
+
* rejects) when `signal` aborts, so the agent loop's top-of-loop cancellation check
|
|
213
|
+
* owns the exit path. Distinct from the rejecting `abortableSleep` inside `withRetry`,
|
|
214
|
+
* whose callers need the abort reason propagated as an error. */
|
|
215
|
+
export function waitAbortable(ms: number, signal?: AbortSignal): Promise<void> {
|
|
216
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
217
|
+
if (signal?.aborted) {
|
|
218
|
+
resolve();
|
|
219
|
+
return promise;
|
|
220
|
+
}
|
|
221
|
+
const finish = () => {
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
signal?.removeEventListener("abort", finish);
|
|
224
|
+
resolve();
|
|
225
|
+
};
|
|
226
|
+
const timer = setTimeout(finish, ms);
|
|
227
|
+
signal?.addEventListener("abort", finish, { once: true });
|
|
228
|
+
return promise;
|
|
229
|
+
}
|
|
230
|
+
|
|
211
231
|
// Extract a non-negative `retryAfterMs` from an error, if it carries one (e.g. ProviderHttpError).
|
|
212
232
|
function retryAfterOf(err: unknown): number | undefined {
|
|
213
233
|
if (typeof err === "object" && err !== null) {
|
|
@@ -258,8 +278,9 @@ export function isUsageLimitError(err: unknown): boolean {
|
|
|
258
278
|
* block reasons). Lives here (not provider-error.ts) so `defaultRetryable` can
|
|
259
279
|
* fail fast on it: a refusal is DETERMINISTIC for the same conversation content —
|
|
260
280
|
* transport-level resends of an identical payload just burn billed calls. The
|
|
261
|
-
* engine's
|
|
262
|
-
*
|
|
281
|
+
* engine's refusal ladder (resend → context reset → guidance strip, then unbounded
|
|
282
|
+
* capped backoff — gjc parity: a refusal is never terminal) is the correct recovery
|
|
283
|
+
* layer because it MUTATES the context between attempts. */
|
|
263
284
|
export function isRefusalError(err: unknown): boolean {
|
|
264
285
|
return /stop_reason=refusal|finish_reason=content_filter|\(content_filter\)|\(SAFETY\)|\(PROHIBITED_CONTENT\)|\(BLOCKLIST\)/i.test(errorMessageOf(err));
|
|
265
286
|
}
|