From 6f1c65f9f2a73e638c469d7a0d1cf3c6770c7231 Mon Sep 17 00:00:00 2001 From: Lifferado Date: Sat, 22 Aug 2026 00:43:26 +0200 Subject: [PATCH] fix(agent): clear retryable error when agent ends its turn without JSON When a pipeline agent ends its final turn with plain prose - an early narrative stop or a turn aborted mid-task by provider stream errors - every structured-output candidate failed on the first byte and the raw encoding/json decoder error ("invalid character 'L' looking for beginning of value") leaked as the step error, misdirecting diagnosis toward format problems and ending the run with no retry (#811). parseStructuredTextOutput now returns a dedicated sentinel, errTurnEndedWithoutJSON, when no candidate parsed AND the text shows no attempt at JSON at all (no leading '{', no ```json fence). Malformed output that does attempt JSON keeps today's precise decoder/validation error. classifyTransient treats the sentinel as retryable, and runWithRetry re-invokes the next attempt (same session for adapters that resume one) with a one-time prompt reminder that the final message must be exactly the bare JSON object. Regressions: TestFinalizeTextResult_ProseFinalTurnReturnsDedicatedError, TestFinalizeTextResult_AttemptedJSONKeepsRawParseError, TestClassifyTransient_TurnEndedWithoutJSONIsRetryable, TestRunWithRetry_TurnEndedWithoutJSONRetriesWithReminder, TestRunWithRetry_TurnEndedWithoutJSONRecoversWhenNextAttemptSucceeds. Fixes #811 --- internal/agent/acpx.go | 4 +- internal/agent/agent.go | 42 +++++++++++++++++ internal/agent/agent_test.go | 61 +++++++++++++++++++++++++ internal/agent/antigravity.go | 4 +- internal/agent/claude.go | 4 +- internal/agent/codex.go | 4 +- internal/agent/copilot.go | 4 +- internal/agent/grok.go | 4 +- internal/agent/opencode.go | 4 +- internal/agent/pi.go | 4 +- internal/agent/retry.go | 34 +++++++++++--- internal/agent/retry_test.go | 86 ++++++++++++++++++++++++++++++++--- internal/agent/rovodev.go | 4 +- 13 files changed, 228 insertions(+), 31 deletions(-) diff --git a/internal/agent/acpx.go b/internal/agent/acpx.go index a064eb66e..3b2d5fbf9 100644 --- a/internal/agent/acpx.go +++ b/internal/agent/acpx.go @@ -32,8 +32,8 @@ func (a *acpxAgent) Name() string { return "acp:" + a.target } func (a *acpxAgent) ReportsAgentAttempts() bool { return true } func (a *acpxAgent) Run(ctx context.Context, opts RunOpts) (*Result, error) { - return runWithRetry(ctx, a.Name(), opts, claudeMaxRetries, classifyTransient, nil, func() (*Result, error) { - return a.runOnce(ctx, opts) + return runWithRetry(ctx, a.Name(), opts, claudeMaxRetries, classifyTransient, nil, func(o RunOpts) (*Result, error) { + return a.runOnce(ctx, o) }) } diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 905357303..06b50e402 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -270,6 +270,17 @@ type Options struct { Profile agentcfg.Profile } +// errTurnEndedWithoutJSON is returned when every structured-output candidate +// fails AND the final text shows no attempt at JSON at all: it neither opens +// a JSON object nor carries a ```json fence. That shape means the model ended +// its turn with plain prose - progress narration after an early stop, or a +// turn aborted mid-task by provider stream errors - so leaking the raw +// encoding/json decoder error ("invalid character 'L' looking for beginning +// of value", #811) would only misdirect diagnosis toward format problems. +// Malformed output that DOES attempt JSON keeps the precise decoder error. +// This failure is retryable with a JSON reminder (see runWithRetry). +var errTurnEndedWithoutJSON = errors.New("agent ended its turn without the required JSON result") + func finalizeTextResult(agentName, text string, schema json.RawMessage, usage TokenUsage) (*Result, error) { if text == "" { return nil, fmt.Errorf("%s returned no text output", agentName) @@ -352,9 +363,40 @@ func parseStructuredTextOutput(text string, schema json.RawMessage) (json.RawMes if candidateErr != nil { return nil, candidateErr } + // No candidate anywhere parsed and the text never attempted JSON: report + // the prose-final-turn condition instead of the whole-text decoder error, + // which only ever named the first character of the narration (#811). + if !outputAttemptsJSON(text) { + return nil, errTurnEndedWithoutJSON + } return nil, rawErr } +// outputAttemptsJSON reports whether the final text shows the model tried to +// produce JSON at all: either it opens a JSON object (possibly after leading +// whitespace) or it contains a ```json fence opener anywhere. When neither +// holds, no candidate in parseStructuredTextOutput could ever have parsed, +// so the failure is a prose final turn rather than malformed JSON. The scan +// deliberately mirrors fencedJSONCandidates' opener detection (case-insensitive +// info token) and errs on the side of "attempted JSON" so only genuinely +// prose-shaped output is reclassified away from the decoder error. +func outputAttemptsJSON(text string) bool { + if strings.HasPrefix(strings.TrimLeft(text, " \t\r\n"), "{") { + return true + } + rest := text + for { + marker := strings.Index(rest, "```") + if marker < 0 { + return false + } + if _, info := fenceContentStart(rest, marker); strings.EqualFold(strings.TrimSpace(info), "json") { + return true + } + rest = rest[marker+3:] + } +} + func textValidationSchema(schema json.RawMessage) (json.RawMessage, error) { if len(schema) == 0 { return nil, nil diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 735b7cecf..228b3fc46 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -3,6 +3,7 @@ package agent import ( "context" "encoding/json" + "errors" "os" "path/filepath" "runtime" @@ -752,3 +753,63 @@ func TestFinalizeTextResult_WithSchemaParsesProseQuotingFenceExampleThenClosedBl t.Errorf("expected summary=one issue, got %q", output.Summary) } } + +func TestFinalizeTextResult_ProseFinalTurnReturnsDedicatedError(t *testing.T) { + // Regression (upstream #811): when the model ends its turn with plain + // progress narration instead of the required JSON object, no-mistakes fed + // that prose to encoding/json and leaked "invalid character 'L' looking + // for beginning of value" as the step error, with no retry. + schema := json.RawMessage(`{"type":"object"}`) + for _, text := range []string{ + "Let me check something.", + "\n\nI've completed a thorough review of the diff. Let me verify a couple more details before finalizing.", + } { + _, err := finalizeTextResult("opencode", text, schema, TokenUsage{}) + if err == nil { + t.Fatalf("expected error for prose output %q", text) + } + if !errors.Is(err, errTurnEndedWithoutJSON) { + t.Fatalf("expected errTurnEndedWithoutJSON for %q, got %v", text, err) + } + msg := err.Error() + if !strings.Contains(msg, "agent ended its turn without the required JSON result") { + t.Errorf("error should name the missing JSON result, got %v", err) + } + if strings.Contains(msg, "invalid character") { + t.Errorf("raw decoder error must not leak for prose output, got %v", err) + } + if !strings.Contains(msg, "output snippet:") || !strings.Contains(msg, strings.TrimSpace(text)) { + t.Errorf("error should keep the diagnosable output snippet, got %v", err) + } + } +} + +func TestFinalizeTextResult_AttemptedJSONKeepsRawParseError(t *testing.T) { + // Output that shows an attempt at JSON - a leading '{', a ```json fence, + // or an embedded bare object - keeps today's precise parse/validation + // error instead of the dedicated prose-final-turn error (#811). + reviewSchema := json.RawMessage(`{ + "type":"object", + "properties":{"findings":{"type":"array"},"summary":{"type":"string"}}, + "required":["findings","summary"] + }`) + cases := []struct { + name string + text string + }{ + {"malformed leading object", `{"findings": [} truncated`}, + {"malformed fenced object", "```json\nnot json\n```"}, + {"bare object missing required keys", `I inspected the diff and found no issues. {"foo":"bar"}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := finalizeTextResult("codex", tc.text, reviewSchema, TokenUsage{}) + if err == nil { + t.Fatal("expected parse failure") + } + if errors.Is(err, errTurnEndedWithoutJSON) { + t.Fatalf("attempted-JSON failure must keep the raw parse error, got %v", err) + } + }) + } +} diff --git a/internal/agent/antigravity.go b/internal/agent/antigravity.go index e810f3a05..295758a38 100644 --- a/internal/agent/antigravity.go +++ b/internal/agent/antigravity.go @@ -25,8 +25,8 @@ func (a *antigravityAgent) Name() string { return "antigravity" } func (a *antigravityAgent) ReportsAgentAttempts() bool { return true } func (a *antigravityAgent) Run(ctx context.Context, opts RunOpts) (*Result, error) { - return runWithRetry(ctx, "antigravity", opts, claudeMaxRetries, classifyTransient, nil, func() (*Result, error) { - return a.runOnce(ctx, opts) + return runWithRetry(ctx, "antigravity", opts, claudeMaxRetries, classifyTransient, nil, func(o RunOpts) (*Result, error) { + return a.runOnce(ctx, o) }) } diff --git a/internal/agent/claude.go b/internal/agent/claude.go index c2d3a85d2..b86f6c41b 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -59,8 +59,8 @@ func (a *claudeAgent) NeutralizesGateInstructions() bool { } func (a *claudeAgent) Run(ctx context.Context, opts RunOpts) (*Result, error) { - return runWithRetry(ctx, "claude", opts, claudeMaxRetries, claudeRetryClassifier, nil, func() (*Result, error) { - return a.runOnce(ctx, opts) + return runWithRetry(ctx, "claude", opts, claudeMaxRetries, claudeRetryClassifier, nil, func(o RunOpts) (*Result, error) { + return a.runOnce(ctx, o) }) } diff --git a/internal/agent/codex.go b/internal/agent/codex.go index b96774efa..7ff99d68f 100644 --- a/internal/agent/codex.go +++ b/internal/agent/codex.go @@ -52,8 +52,8 @@ func (a *codexAgent) NeutralizesGateInstructions() bool { } func (a *codexAgent) Run(ctx context.Context, opts RunOpts) (*Result, error) { - return runWithRetry(ctx, "codex", opts, claudeMaxRetries, classifyTransient, nil, func() (*Result, error) { - return a.runOnce(ctx, opts) + return runWithRetry(ctx, "codex", opts, claudeMaxRetries, classifyTransient, nil, func(o RunOpts) (*Result, error) { + return a.runOnce(ctx, o) }) } diff --git a/internal/agent/copilot.go b/internal/agent/copilot.go index 7ba044f53..8c36bbf56 100644 --- a/internal/agent/copilot.go +++ b/internal/agent/copilot.go @@ -27,8 +27,8 @@ func (a *copilotAgent) Name() string { return "copilot" } func (a *copilotAgent) ReportsAgentAttempts() bool { return true } func (a *copilotAgent) Run(ctx context.Context, opts RunOpts) (*Result, error) { - return runWithRetry(ctx, "copilot", opts, claudeMaxRetries, classifyTransient, nil, func() (*Result, error) { - return a.runOnce(ctx, opts) + return runWithRetry(ctx, "copilot", opts, claudeMaxRetries, classifyTransient, nil, func(o RunOpts) (*Result, error) { + return a.runOnce(ctx, o) }) } diff --git a/internal/agent/grok.go b/internal/agent/grok.go index 3d0dcbccf..165d59052 100644 --- a/internal/agent/grok.go +++ b/internal/agent/grok.go @@ -55,8 +55,8 @@ func (a *grokAgent) NeutralizesGateInstructions() bool { } func (a *grokAgent) Run(ctx context.Context, opts RunOpts) (*Result, error) { - return runWithRetry(ctx, "grok", opts, claudeMaxRetries, classifyTransient, nil, func() (*Result, error) { - return a.runOnce(ctx, opts) + return runWithRetry(ctx, "grok", opts, claudeMaxRetries, classifyTransient, nil, func(o RunOpts) (*Result, error) { + return a.runOnce(ctx, o) }) } diff --git a/internal/agent/opencode.go b/internal/agent/opencode.go index e08aa91f9..73f48faba 100644 --- a/internal/agent/opencode.go +++ b/internal/agent/opencode.go @@ -39,8 +39,8 @@ func (a *opencodeAgent) Name() string { return "opencode" } func (a *opencodeAgent) ReportsAgentAttempts() bool { return true } func (a *opencodeAgent) Run(ctx context.Context, opts RunOpts) (*Result, error) { - return runWithRetry(ctx, "opencode", opts, claudeMaxRetries, classifyTransient, a.recoverTransientRetry, func() (*Result, error) { - return a.runOnce(ctx, opts) + return runWithRetry(ctx, "opencode", opts, claudeMaxRetries, classifyTransient, a.recoverTransientRetry, func(o RunOpts) (*Result, error) { + return a.runOnce(ctx, o) }) } diff --git a/internal/agent/pi.go b/internal/agent/pi.go index 0a32331f9..6109d425d 100644 --- a/internal/agent/pi.go +++ b/internal/agent/pi.go @@ -46,8 +46,8 @@ func (a *piAgent) NeutralizesGateInstructions() bool { } func (a *piAgent) Run(ctx context.Context, opts RunOpts) (*Result, error) { - return runWithRetry(ctx, "pi", opts, claudeMaxRetries, classifyTransient, nil, func() (*Result, error) { - return a.runOnce(ctx, opts) + return runWithRetry(ctx, "pi", opts, claudeMaxRetries, classifyTransient, nil, func(o RunOpts) (*Result, error) { + return a.runOnce(ctx, o) }) } diff --git a/internal/agent/retry.go b/internal/agent/retry.go index 645223ba7..38b5ebb58 100644 --- a/internal/agent/retry.go +++ b/internal/agent/retry.go @@ -49,10 +49,20 @@ func transientBackoffBaseDuration(attempt int, base time.Duration) time.Duration return delay } +// jsonResultReminder is appended to the prompt (exactly once per invocation) +// when a retry follows errTurnEndedWithoutJSON: the previous attempt ended +// with plain prose instead of the required structured result, so the +// re-invocation restates the final-message contract (#811). +const jsonResultReminder = "\n\nYour previous reply ended without the required JSON result. Return now: your final assistant message must be exactly one bare JSON object matching the given schema - no prose before or after it." + // runWithRetry invokes runOnce up to maxRetries+1 times, retrying when the // classifier marks the error as retriable. Between retries it sleeps with // exponential backoff (via transientBackoff) and respects ctx cancellation. -// The retry attempt and classification label are surfaced to opts.OnLifecycle, +// When the previous attempt failed with errTurnEndedWithoutJSON, the next +// attempt is invoked with a reminder appended to the prompt that the final +// message must be the bare JSON object; adapters that resume a durable +// session therefore re-invoke that same session with the reminder. The +// retry attempt and classification label are surfaced to opts.OnLifecycle, // falling back to opts.OnChunk for older direct callers. func runWithRetry( ctx context.Context, @@ -61,19 +71,24 @@ func runWithRetry( maxRetries int, classify retryClassifier, recoverRetry func(label string), - runOnce func() (*Result, error), + runOnce func(RunOpts) (*Result, error), ) (*Result, error) { var lastErr error var lastLabel string + reminded := false for attempt := 0; attempt <= maxRetries; attempt++ { if attempt > 0 { emitAgentRetry(opts, name, lastLabel, attempt+1, maxRetries+1) + if !reminded && errors.Is(lastErr, errTurnEndedWithoutJSON) { + opts.Prompt += jsonResultReminder + reminded = true + } if err := transientBackoff(ctx, attempt); err != nil { return nil, err } } startedAt := time.Now() - result, err := runOnce() + result, err := runOnce(opts) emitAgentAttempt(opts, name, result, err, startedAt, time.Now()) if err == nil { return result, nil @@ -146,9 +161,11 @@ var transientNeedles = []struct { {"unexpected eof", "unexpected eof"}, } -// classifyTransient reports whether an error message looks like a transient -// API or network failure. It deliberately ignores ctx cancellation/deadline -// errors so explicit cancellation is never silently retried. +// classifyTransient reports whether an error looks like a transient API or +// network failure, or the prose-final-turn structured-output failure +// (errTurnEndedWithoutJSON), which is recoverable by re-invoking with a JSON +// reminder. It deliberately ignores ctx cancellation/deadline errors so +// explicit cancellation is never silently retried. func classifyTransient(err error) (string, bool) { if err == nil { return "", false @@ -156,6 +173,11 @@ func classifyTransient(err error) (string, bool) { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return "", false } + // Identity check on purpose: only a real parse failure carrying this + // sentinel retries; the same sentence echoed in provider output must not. + if errors.Is(err, errTurnEndedWithoutJSON) { + return "missing JSON result", true + } msg := strings.ToLower(err.Error()) for _, sig := range transientNeedles { if strings.Contains(msg, sig.needle) { diff --git a/internal/agent/retry_test.go b/internal/agent/retry_test.go index 0caca3e15..512971821 100644 --- a/internal/agent/retry_test.go +++ b/internal/agent/retry_test.go @@ -156,7 +156,7 @@ func TestRunWithRetry_RetriesTransientThenSucceeds(t *testing.T) { OnAttempt: func(attempt Attempt) { attempts = append(attempts, attempt) }, } - res, err := runWithRetry(context.Background(), "claude", opts, 3, classifyTransient, nil, func() (*Result, error) { + res, err := runWithRetry(context.Background(), "claude", opts, 3, classifyTransient, nil, func(o RunOpts) (*Result, error) { calls++ if calls < 3 { return nil, transientErr @@ -207,7 +207,7 @@ func TestRunWithRetry_EmitsRetryLifecycleWhenConfigured(t *testing.T) { OnLifecycle: func(e LifecycleEvent) { events = append(events, e) }, } - _, err := runWithRetry(context.Background(), "codex", opts, 1, classifyTransient, nil, func() (*Result, error) { + _, err := runWithRetry(context.Background(), "codex", opts, 1, classifyTransient, nil, func(o RunOpts) (*Result, error) { calls++ if calls == 1 { return nil, errors.New("API Error: 503 overloaded") @@ -240,7 +240,7 @@ func TestRunWithRetry_CallsRetryRecoveryBeforeRetry(t *testing.T) { if label == "connection refused" { recovered = true } - }, func() (*Result, error) { + }, func(o RunOpts) (*Result, error) { calls++ if !recovered { return nil, errors.New("dial tcp 127.0.0.1:5555: connection refused") @@ -264,7 +264,7 @@ func TestRunWithRetry_PermanentErrorFailsImmediately(t *testing.T) { calls := 0 permErr := errors.New("API Error: authentication_error: invalid x-api-key") - _, err := runWithRetry(context.Background(), "claude", RunOpts{}, 3, classifyTransient, nil, func() (*Result, error) { + _, err := runWithRetry(context.Background(), "claude", RunOpts{}, 3, classifyTransient, nil, func(o RunOpts) (*Result, error) { calls++ return nil, permErr }) @@ -282,7 +282,7 @@ func TestRunWithRetry_ExhaustsRetries(t *testing.T) { calls := 0 transientErr := errors.New("503 service unavailable") - _, err := runWithRetry(context.Background(), "claude", RunOpts{}, 3, classifyTransient, nil, func() (*Result, error) { + _, err := runWithRetry(context.Background(), "claude", RunOpts{}, 3, classifyTransient, nil, func(o RunOpts) (*Result, error) { calls++ return nil, transientErr }) @@ -318,7 +318,7 @@ func TestRunWithRetry_RespectsContextCancellation(t *testing.T) { }() start := time.Now() - _, err := runWithRetry(ctx, "claude", RunOpts{}, 3, classifyTransient, nil, func() (*Result, error) { + _, err := runWithRetry(ctx, "claude", RunOpts{}, 3, classifyTransient, nil, func(o RunOpts) (*Result, error) { calls++ return nil, transientErr }) @@ -338,7 +338,7 @@ func TestRunWithRetry_CombinedClassifierForClaude(t *testing.T) { // claudeRetryClassifier should retry both transient API errors AND errNoStructuredOutput. calls := 0 - _, err := runWithRetry(context.Background(), "claude", RunOpts{}, 3, claudeRetryClassifier, nil, func() (*Result, error) { + _, err := runWithRetry(context.Background(), "claude", RunOpts{}, 3, claudeRetryClassifier, nil, func(o RunOpts) (*Result, error) { calls++ return nil, errNoStructuredOutput }) @@ -370,3 +370,75 @@ func TestTransientBackoffDuration_Progression(t *testing.T) { } } } + +func TestClassifyTransient_TurnEndedWithoutJSONIsRetryable(t *testing.T) { + wrapped := fmt.Errorf("opencode output parse: %w (output snippet: %q)", errTurnEndedWithoutJSON, "Let me check something.") + label, ok := classifyTransient(wrapped) + if !ok { + t.Fatal("expected turn-without-JSON failure to be retryable") + } + if !strings.Contains(strings.ToLower(label), "json") { + t.Errorf("label %q should mention json", label) + } + // Only the sentinel identity retries: the same words inside a fresh error + // (for example echoed by an unrelated provider message) stay terminal. + if _, ok := classifyTransient(errors.New(errTurnEndedWithoutJSON.Error())); ok { + t.Error("plain-text match without the sentinel identity must not retry") + } +} + +func TestRunWithRetry_TurnEndedWithoutJSONRetriesWithReminder(t *testing.T) { + defer withFastBackoff(t)() + + const originalPrompt = "review the diff" + calls := 0 + var prompts []string + parseErr := func() error { + return fmt.Errorf("opencode output parse: %w (output snippet: %q)", errTurnEndedWithoutJSON, "Let me check something.") + } + _, err := runWithRetry(context.Background(), "opencode", RunOpts{Prompt: originalPrompt}, 2, classifyTransient, nil, func(o RunOpts) (*Result, error) { + calls++ + prompts = append(prompts, o.Prompt) + return nil, parseErr() + }) + if !errors.Is(err, errTurnEndedWithoutJSON) { + t.Fatalf("expected sentinel to surface after exhausting retries, got %v", err) + } + if calls != 3 { // bounded: initial + maxRetries + t.Fatalf("expected bounded attempts (1+2), got %d", calls) + } + if prompts[0] != originalPrompt { + t.Errorf("first attempt prompt = %q, want untouched original", prompts[0]) + } + if prompts[1] == prompts[0] || !strings.Contains(prompts[1], originalPrompt) || + !strings.Contains(strings.ToLower(prompts[1]), "json") { + t.Errorf("first retry prompt = %q, want original prompt plus JSON reminder", prompts[1]) + } + if prompts[2] != prompts[1] { + t.Errorf("reminder must be appended exactly once; second retry prompt = %q, first = %q", prompts[2], prompts[1]) + } +} + +func TestRunWithRetry_TurnEndedWithoutJSONRecoversWhenNextAttemptSucceeds(t *testing.T) { + defer withFastBackoff(t)() + + calls := 0 + var prompts []string + res, err := runWithRetry(context.Background(), "codex", RunOpts{Prompt: "do the task"}, 2, classifyTransient, nil, func(o RunOpts) (*Result, error) { + calls++ + prompts = append(prompts, o.Prompt) + if calls == 1 { + return nil, fmt.Errorf("codex output parse: %w", errTurnEndedWithoutJSON) + } + return &Result{Text: "ok"}, nil + }) + if err != nil { + t.Fatalf("expected recovery on second attempt, got %v", err) + } + if res == nil || res.Text != "ok" || calls != 2 { + t.Fatalf("expected success on attempt 2, got calls=%d res=%+v err=%v", calls, res, err) + } + if prompts[1] == prompts[0] || !strings.Contains(strings.ToLower(prompts[1]), "json") { + t.Errorf("retry prompt = %q, want JSON reminder appended", prompts[1]) + } +} diff --git a/internal/agent/rovodev.go b/internal/agent/rovodev.go index d972eff43..853b74275 100644 --- a/internal/agent/rovodev.go +++ b/internal/agent/rovodev.go @@ -26,8 +26,8 @@ func (a *rovodevAgent) Name() string { return "rovodev" } func (a *rovodevAgent) ReportsAgentAttempts() bool { return true } func (a *rovodevAgent) Run(ctx context.Context, opts RunOpts) (*Result, error) { - return runWithRetry(ctx, "rovodev", opts, claudeMaxRetries, classifyTransient, a.recoverTransientRetry, func() (*Result, error) { - return a.runOnce(ctx, opts) + return runWithRetry(ctx, "rovodev", opts, claudeMaxRetries, classifyTransient, a.recoverTransientRetry, func(o RunOpts) (*Result, error) { + return a.runOnce(ctx, o) }) }