From 14b8d42565ad257e3c461474679acb2068ff60f7 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Tue, 8 Sep 2026 17:51:57 +0200 Subject: [PATCH 1/2] fix(runtime): surface typed harness provider failures --- packages/runtime/README.md | 26 ++++ packages/runtime/src/ctx.test.ts | 31 ++++ packages/runtime/src/ctx.ts | 11 ++ .../src/harness-provider-error.test.ts | 83 +++++++++++ .../runtime/src/harness-provider-error.ts | 140 ++++++++++++++++++ packages/runtime/src/index.ts | 1 + packages/runtime/src/runner.test.ts | 31 ++++ packages/runtime/src/runner.ts | 4 +- packages/runtime/src/types.ts | 6 + 9 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 packages/runtime/README.md create mode 100644 packages/runtime/src/harness-provider-error.test.ts create mode 100644 packages/runtime/src/harness-provider-error.ts diff --git a/packages/runtime/README.md b/packages/runtime/README.md new file mode 100644 index 00000000..c322cf27 --- /dev/null +++ b/packages/runtime/README.md @@ -0,0 +1,26 @@ +# Workforce runtime + +## Harness provider failures + +`ctx.harness.run()` rejects with `HarnessProviderError` when a failed model CLI +run reports a recognized usage/credit limit, rate limit, authentication error, +context limit, request timeout, or provider outage. Classification is shared by +all personas; handlers do not need their own output parsers. Successful runs, +unrecognized failures, and OS kill exits retain the `HarnessRunResult` contract. + +The error's `message` is safe to display. `providerFailure` contains its `kind`, +message, optional provider, and optional `resetHint` copied only from a valid +clock time with an explicit timezone. Reset hints are provider reports, not +promises of recovery. The original result is retained as the non-enumerable +`error.result` for explicit operator diagnosis; do not post it to users. + +The runtime logs `harness.provider_error`, and the runner preserves +`providerFailure` alongside the actionable `error` on `runner.handler.error`. +Cloud can persist that run error and use the structured metadata for customer +notifications without reparsing CLI output. Callers that intentionally handle a +provider failure can catch `HarnessProviderError`; existing generic exit-code +checks no longer replace recognized provider causes. + +This boundary does not retry tasks, change credentials, or fall back to another +provider or a paid API key. Those actions need their own explicit policy because +a failed task may already have performed work. diff --git a/packages/runtime/src/ctx.test.ts b/packages/runtime/src/ctx.test.ts index 28d70bab..558deed2 100644 --- a/packages/runtime/src/ctx.test.ts +++ b/packages/runtime/src/ctx.test.ts @@ -30,6 +30,37 @@ const stubSandbox: SandboxContext = { } }; +test('ctx.harness.run surfaces provider usage limits before a caller can replace them with exit 1', async () => { + let calls = 0; + let reachedCallerFailure = false; + const logs: Array<{ message: string; attrs?: Record }> = []; + const ctx = buildCtx({ + persona: basePersona, + workspaceId: 'ws-test', + agent: { id: 'agent-test', deployedName: 'example', spawnedByAgentId: null }, + deployment: { id: 'deployment-test', triggerKind: 'inbox', parentDeploymentId: null }, + sandbox: stubSandbox, + log: (_level, message, attrs) => logs.push({ message, attrs }), + harnessRunner: async () => { + calls++; + return { output: "You've hit your limit · resets 3:40pm (UTC)", stderr: 'secret-fixture-value', exitCode: 1, durationMs: 1900 }; + } + }); + await assert.rejects(async () => { + const run = await ctx.harness.run({ prompt: 'Perform a task' }); + reachedCallerFailure = true; + if (run.exitCode !== 0) throw new Error(`The harness exited with code ${run.exitCode}`); + }, (error: Error) => { + assert.match(error.message, /Claude account.*usage limit/); + assert.match(error.message, /3:40pm \(UTC\)/); + assert.doesNotMatch(error.message, /secret-fixture/); + return true; + }); + assert.equal(calls, 1, 'provider failure must not replay task side effects'); + assert.equal(reachedCallerFailure, false); + assert.ok(logs.some((entry) => entry.message === 'harness.provider_error')); +}); + function ctxFor( persona: PersonaSpec, inputValues?: Record, diff --git a/packages/runtime/src/ctx.ts b/packages/runtime/src/ctx.ts index 58cc1fa8..c46cab6f 100644 --- a/packages/runtime/src/ctx.ts +++ b/packages/runtime/src/ctx.ts @@ -17,6 +17,7 @@ import type { import { attachTrajectoryRecorder, createTrajectoryRecorder } from './trajectory.js'; import { buildRelayContext } from './relay.js'; import { NO_REPLY_MARKER, sanitizeNoReplyOutput } from './no-reply.js'; +import { classifyHarnessProviderFailure, HarnessProviderError } from './harness-provider-error.js'; type AgentInputValue = string | number | boolean | null | undefined; @@ -169,6 +170,16 @@ export function buildCtx(options: CtxBuildOptions): WorkforceCtx { harness: { async run(args) { const result = await options.harnessRunner(args); + const providerFailure = classifyHarnessProviderFailure(result, options.persona.harness); + if (providerFailure) { + log('error', 'harness.provider_error', { + providerFailure, + exitCode: result.exitCode, + durationMs: result.durationMs, + harness: options.persona.harness + }); + throw new HarnessProviderError(providerFailure, result); + } const sanitizedOutput = sanitizeNoReplyOutput(result.output); const sanitizedStderr = result.stderr === undefined ? undefined diff --git a/packages/runtime/src/harness-provider-error.test.ts b/packages/runtime/src/harness-provider-error.test.ts new file mode 100644 index 00000000..150a9097 --- /dev/null +++ b/packages/runtime/src/harness-provider-error.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { classifyHarnessProviderFailure, HarnessProviderError } from './harness-provider-error.js'; +import type { HarnessProviderFailure } from './harness-provider-error.js'; + +function classify(output: string, harness = 'claude') { + return classifyHarnessProviderFailure({ output, exitCode: 1 }, harness); +} + +test('Claude usage limit preserves a reported reset time without leaking surrounding output', () => { + const failure = classify("\x1b[31mYou’ve hit your limit · resets 3:40pm (UTC)\x1b[0m\nsecret-fixture-value"); + assert.equal(failure?.kind, 'usage_limit'); + assert.equal(failure?.provider, 'anthropic'); + assert.equal(failure?.resetHint, '3:40pm (UTC)'); + assert.match(failure!.message, /Claude account.*usage limit/); + assert.match(failure!.message, /Wait for.*reset/); + assert.doesNotMatch(failure!.message, /secret-fixture/); +}); + +test('handles Codex error envelopes and does not label another harness as Claude', () => { + const result = classify(JSON.stringify({ type: 'turn.failed', error: { message: "You've hit your usage limit. Please try later." } }), 'codex'); + assert.equal(result?.kind, 'usage_limit'); + assert.equal(result?.provider, 'openai'); + assert.match(result!.message, /OpenAI account/); + assert.doesNotMatch(result!.message, /Claude/); + assert.equal(classify(JSON.stringify({ type: 'result', is_error: true, result: "You've hit your limit" }))?.kind, 'usage_limit'); + assert.equal(classify(JSON.stringify({ type: 'error', message: "You've hit your usage limit" }), 'codex')?.kind, 'usage_limit'); + assert.match(classify("You've hit your limit", 'opencode')!.message, /AI account/); +}); + +test('classifies known provider diagnostics from stderr using safe messages', () => { + const cases: Array<[string, HarnessProviderFailure['kind']]> = [ + ['API Error: 429 request throttled', 'rate_limit'], + ['{"error":{"type":"rate_limit_error","message":"private detail"}}', 'rate_limit'], + ['{"error":{"code":"insufficient_quota"}}', 'usage_limit'], + ['Your credit balance is too low to access the Anthropic API.', 'usage_limit'], + ['API Error: 401 private detail', 'authentication'], + ['{"error":{"type":"authentication_error"}}', 'authentication'], + ['OAuth token has expired.', 'authentication'], + ['Invalid API key · Please run /login', 'authentication'], + ['Prompt is too long: private detail', 'context_limit'], + ['{"error":{"code":"context_length_exceeded"}}', 'context_limit'], + ['API Error: 400 {"error":{"type":"invalid_request_error","message":"prompt is too long: private detail"}}', 'context_limit'], + ['API Error: Request timed out.', 'timeout'], + ['API Error: 529 private detail', 'provider_unavailable'], + ['{"error":{"type":"overloaded_error"}}', 'provider_unavailable'], + ]; + for (const [stderr, kind] of cases) { + const failure = classifyHarnessProviderFailure({ stderr, output: 'unfinished-task-fixture', exitCode: 1 }); + assert.equal(failure?.kind, kind, stderr); + assert.doesNotMatch(failure!.message, /private detail|unfinished-task-fixture/); + } +}); + +test('reset hints require a valid explicit timezone and clock time', () => { + for (const suffix of ['resets soon secret-fixture', 'resets 3:40pm', 'resets 99:99pm (UTC)', 'resets 3:40pm (Secret/Fixture)', 'resets 3:40pm (UTC)[secret-fixture]']) { + const failure = classify(`You've hit your limit · ${suffix}`); + assert.equal(failure?.kind, 'usage_limit'); + assert.equal(failure?.resetHint, undefined, suffix); + assert.doesNotMatch(failure!.message, /secret-fixture|99:99|3:40|Secret\/Fixture/); + } + assert.equal(classify("You've hit your limit · resets 17:40 (Europe/Oslo)")?.resetHint, '17:40 (Europe/Oslo)'); +}); + +test('does not interpret successful task output, OS kills, or unknown process failures', () => { + for (const exitCode of [0, 137, 143, NaN]) { + assert.equal(classifyHarnessProviderFailure({ output: "You've hit your limit", exitCode }), null); + } + for (const output of ['', 'gh: HTTP 429 Too Many Requests', 'test failed with exit 1', 'ECONNRESET', 'Timeout waiting for database', 'The test fixture contains API Error: 401']) { + assert.equal(classify(output), null, output); + } +}); + +test('typed errors expose safe metadata and retain non-enumerable original diagnostics', () => { + const result = { output: "You've hit your limit", stderr: 'secret-fixture-value', exitCode: 1, durationMs: 1900 }; + const failure = classifyHarnessProviderFailure(result, 'claude')!; + const error = new HarnessProviderError(failure, result); + assert.equal(error.name, 'HarnessProviderError'); + assert.equal(error.message, failure.message); + assert.equal(error.result, result); + assert.deepEqual(error.providerFailure, failure); + assert.doesNotMatch(JSON.stringify(error), /secret-fixture-value/); +}); diff --git a/packages/runtime/src/harness-provider-error.ts b/packages/runtime/src/harness-provider-error.ts new file mode 100644 index 00000000..00aead15 --- /dev/null +++ b/packages/runtime/src/harness-provider-error.ts @@ -0,0 +1,140 @@ +import type { HarnessRunResult } from './types.js'; + +export interface HarnessProviderFailure { + kind: 'usage_limit' | 'rate_limit' | 'authentication' | 'context_limit' | 'provider_unavailable' | 'timeout'; + message: string; + resetHint?: string; + provider?: 'anthropic' | 'openai'; +} + +function validTimezone(value: string): boolean { + try { + new Intl.DateTimeFormat('en', { timeZone: value }); + return true; + } catch { + return false; + } +} + +function errorMessages(text: string): string[] { + const messages: string[] = []; + for (const line of text.split(/\r?\n/)) { + try { + const value = JSON.parse(line) as Record | null; + if (!value || typeof value !== 'object') continue; + // CLI error envelopes, not arbitrary assistant/tool text in a stream. + if (value.type === 'error' && typeof value.message === 'string') messages.push(value.message); + if (value.type === 'result' && value.is_error === true && typeof value.result === 'string') messages.push(value.result); + if (value.type === 'turn.failed' && value.error && typeof value.error === 'object') { + const message = (value.error as { message?: unknown }).message; + if (typeof message === 'string') messages.push(message); + } + } catch { + // Most CLIs print plain text diagnostics. + } + } + return messages; +} + +/** + * Classify failed model CLI runs, never arbitrary successful agent output. + * Customer messages are fixed templates, not excerpts of stdout/stderr: + * those streams can contain code, credentials, and unfinished task output. + */ +export function classifyHarnessProviderFailure(run: Pick, harness?: string): HarnessProviderFailure | null { + // OS kills and successful output retain their existing caller contract. + if (!Number.isFinite(run.exitCode) || run.exitCode === 0 || run.exitCode === 137 || run.exitCode === 143) return null; + const provider = harness === 'claude' ? 'anthropic' : harness === 'codex' ? 'openai' : undefined; + const account = provider === 'anthropic' ? 'Claude' : provider === 'openai' ? 'OpenAI' : 'AI'; + const result = run as { output?: unknown; stderr?: unknown } | null; + const rawText = [result?.output, result?.stderr] + .filter((value): value is string => typeof value === 'string') + .map((value) => value.slice(-16000).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')) + .join('\n'); + const text = [rawText, ...errorMessages(rawText)].join('\n'); + + const claudeLimit = text.match(/^\s*You['’]ve hit your (?:usage )?limit\b([^\r\n]*)/im); + if (claudeLimit) { + // Only copy a clock time with an explicit timezone. Do not echo the + // remainder of an arbitrary line or invent an absolute reset date. + const reset = claudeLimit[1].match( + /\bresets\s+((?:1[0-2]|0?[1-9])(?::[0-5]\d)?\s*[ap]m|(?:[01]?\d|2[0-3]):[0-5]\d)\s*\((UTC|GMT|[A-Za-z_]+\/[A-Za-z_]+(?:\/[A-Za-z_]+)?)\)(?=$|[\s.,;])/i, + ); + const resetHint = reset && validTimezone(reset[2]) ? `${reset[1].trim()} (${reset[2]})` : undefined; + return { + ...(provider ? { provider } : {}), + kind: 'usage_limit', + message: [ + `The ${account} account selected for this run has reached its usage limit.`, + ...(resetHint ? [`The provider reports a reset at ${resetHint}.`] : []), + 'Wait for the limit to reset, or ask the account owner to restore available usage before retrying the task.', + ].join(' '), + ...(resetHint ? { resetHint } : {}), + }; + } + + if (/"(?:type|code)"\s*:\s*"(?:insufficient_quota|billing_hard_limit_reached)"|\byou exceeded your current quota\b|\byour credit balance is too low to access the Anthropic API\b/i.test(text)) { + return { + ...(provider ? { provider } : {}), + kind: 'usage_limit', + message: 'The AI account used for this run has no available quota or credits. Ask the account owner to check usage and billing and restore capacity before retrying the task.', + }; + } + + if (/"(?:type|code)"\s*:\s*"(?:rate_limit_error|rate_limit_exceeded)"|^\s*API Error:\s*429\b|\bThis request would exceed your account's rate limit\b/im.test(text)) { + return { + ...(provider ? { provider } : {}), + kind: 'rate_limit', + message: 'The AI provider rate-limited this run. Wait for available capacity before retrying; if this persists, ask the account owner to check the account limits.', + }; + } + + if (/"(?:type|code)"\s*:\s*"(?:authentication_error|invalid_api_key)"|^\s*API Error:\s*401\b|^\s*Invalid API key\b.*\/login|\bOAuth token has expired\b/im.test(text)) { + return { + ...(provider ? { provider } : {}), + kind: 'authentication', + message: 'The AI provider rejected the credentials used for this run. Ask the account owner to reconnect the AI account before retrying the task.', + }; + } + + if (/"(?:type|code)"\s*:\s*"context_length_exceeded"|"message"\s*:\s*"prompt is too long\b|^\s*(?:API Error:\s*400\s+)?prompt is too long\b/im.test(text)) { + return { + ...(provider ? { provider } : {}), + kind: 'context_limit', + message: 'The task input exceeds the AI model context limit. Reduce the task scope or select a model with a larger context window before retrying.', + }; + } + + if (/^\s*API Error:\s*(?:Request timed out|Request timeout)\b|"(?:type|code)"\s*:\s*"(?:request_timeout|timeout_error)"/im.test(text)) { + return { + ...(provider ? { provider } : {}), + kind: 'timeout', + message: 'The AI request for this run timed out. Retry when the provider is responsive; if this persists, an operator should check the request timeout and task scope.', + }; + } + + if (/"type"\s*:\s*"overloaded_error"|^\s*API Error:\s*(?:500|502|503|504|529)\b/im.test(text)) { + return { + ...(provider ? { provider } : {}), + kind: 'provider_unavailable', + message: 'The AI provider could not serve this run request. Retry after the provider recovers; if this persists, an operator should check provider availability.', + }; + } + + return null; +} + +/** Recognized provider failure, shared by every ctx.harness.run caller. */ +export class HarnessProviderError extends Error { + readonly providerFailure: HarnessProviderFailure; + readonly result!: HarnessRunResult; + + constructor(failure: HarnessProviderFailure, result: HarnessRunResult) { + super(failure.message); + this.name = 'HarnessProviderError'; + this.providerFailure = Object.freeze({ ...failure }); + // Retain diagnostics for explicit recovery without serializing secrets + // when the error is logged or passed to a customer-facing surface. + Object.defineProperty(this, 'result', { value: result, enumerable: false }); + } +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index cf5277b7..b1a5bf9b 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -229,3 +229,4 @@ export type { TypedTriggerMap, WatchRule } from '@agentworkforce/persona-kit'; +export { classifyHarnessProviderFailure, HarnessProviderError, type HarnessProviderFailure } from './harness-provider-error.js'; diff --git a/packages/runtime/src/runner.test.ts b/packages/runtime/src/runner.test.ts index 9dbd3173..43035507 100644 --- a/packages/runtime/src/runner.test.ts +++ b/packages/runtime/src/runner.test.ts @@ -155,6 +155,37 @@ test('startRunner logs and continues when the handler throws', async () => { assert.equal(errors.length, 2); }); +test('provider failures reach runner error reporting without changing the agent handler', async () => { + const logs: Array<{ level: string; message: string; attrs?: Record }> = []; + let posted = 0; + let calls = 0; + await startRunner({ + persona, + agent: runtimeAgent, + deployment: runtimeDeployment, + workspaceId: 'ws-test', + handler: handler(async (ctx) => { + const result = await ctx.harness.run({ prompt: 'Perform the task' }); + if (result.exitCode !== 0) throw new Error(`The harness exited with code ${result.exitCode}`); + posted++; + }), + harnessRunner: async () => { + calls++; + return { output: "You've hit your limit · resets 3:40pm (UTC)", stderr: 'secret-fixture', exitCode: 1, durationMs: 1900 }; + }, + subsystems: { sandbox: stubSandbox, log: (level, message, attrs) => logs.push({ level, message, attrs }) }, + envelopes: streamOf([{ id: 'quota-event', workspace: 'ws-test', type: 'cron.tick', occurredAt: 'x', name: 'tick' }]) + }); + const error = logs.find((entry) => entry.message === 'runner.handler.error'); + assert.match(String(error?.attrs?.error), /Claude account.*usage limit/); + assert.match(String(error?.attrs?.error), /3:40pm \(UTC\)/); + assert.equal((error?.attrs?.providerFailure as { kind: string }).kind, 'usage_limit'); + assert.doesNotMatch(JSON.stringify(error), /secret-fixture|exited with code 1/); + assert.equal(logs.some((entry) => entry.message === 'runner.handler.ok'), false); + assert.equal(calls, 1); + assert.equal(posted, 0, 'failure output cannot reach the task success path'); +}); + test('startRunner skips envelopes that the shim can not translate', async () => { const received: WorkforceEvent[] = []; const logs: Array<{ level: string; message: string }> = []; diff --git a/packages/runtime/src/runner.ts b/packages/runtime/src/runner.ts index df200a37..3bd8b611 100644 --- a/packages/runtime/src/runner.ts +++ b/packages/runtime/src/runner.ts @@ -6,6 +6,7 @@ import { isWorkforceHandler } from './handler.js'; import { type RawGatewayEnvelope } from './shim.js'; import { envelopeToAgentEvent } from './to-agent-event.js'; import { isCronTickEvent } from '@agent-relay/events'; +import { HarnessProviderError } from './harness-provider-error.js'; import type { HarnessRunArgs, HarnessRunResult, @@ -207,7 +208,8 @@ async function dispatch( attempt: event.attempt, durationMs: Date.now() - t0, error: err instanceof Error ? err.message : String(err), - stack: err instanceof Error ? err.stack : undefined + stack: err instanceof Error ? err.stack : undefined, + ...(err instanceof HarnessProviderError ? { providerFailure: err.providerFailure } : {}) }); await recorder.fail(err); // Surface the failure to the outer process so the deploy layer can diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 80822a03..efe31514 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -492,6 +492,12 @@ export interface WorkforceCtx { llm: LlmContext; /** Spawn the persona's harness inside the sandbox. */ harness: { + /** + * Recognized provider errors (quota, rate limit, auth, context, timeout, + * availability) reject with HarnessProviderError before returning output. + * Other nonzero exits retain the HarnessRunResult contract. No automatic + * retry or credential/provider fallback is performed by this boundary. + */ run(args: HarnessRunArgs): Promise; }; /** Sandbox shell + filesystem. */ From e6748e51ff868e73ca4f1b254261ace5889d7659 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Tue, 8 Sep 2026 18:00:04 +0200 Subject: [PATCH 2/2] Ignore task records when classifying provider diagnostics --- .../runtime/src/harness-provider-error.test.ts | 13 +++++++++++++ packages/runtime/src/harness-provider-error.ts | 16 ++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/harness-provider-error.test.ts b/packages/runtime/src/harness-provider-error.test.ts index 150a9097..1d3a6b14 100644 --- a/packages/runtime/src/harness-provider-error.test.ts +++ b/packages/runtime/src/harness-provider-error.test.ts @@ -81,3 +81,16 @@ test('typed errors expose safe metadata and retain non-enumerable original diagn assert.deepEqual(error.providerFailure, failure); assert.doesNotMatch(JSON.stringify(error), /secret-fixture-value/); }); + + +test('ignores provider-like fixtures inside failed task output and non-error CLI records', () => { + const payload = { error: { type: 'rate_limit_error', message: 'example' } }; + for (const output of [ + `The test fixture is ${JSON.stringify(payload)}`, + JSON.stringify({ type: 'assistant', message: JSON.stringify(payload) }), + JSON.stringify({ type: 'tool_result', content: JSON.stringify(payload) }), + JSON.stringify({ type: 'result', is_error: false, result: JSON.stringify(payload) }), + JSON.stringify({ type: 'assistant', ...payload }), + JSON.stringify({ type: 'tool_result', content: "You've hit your limit" }), + ]) assert.equal(classify(output), null, output); +}); diff --git a/packages/runtime/src/harness-provider-error.ts b/packages/runtime/src/harness-provider-error.ts index 00aead15..9e6186e4 100644 --- a/packages/runtime/src/harness-provider-error.ts +++ b/packages/runtime/src/harness-provider-error.ts @@ -16,21 +16,29 @@ function validTimezone(value: string): boolean { } } -function errorMessages(text: string): string[] { +function diagnosticMessages(text: string): string[] { const messages: string[] = []; for (const line of text.split(/\r?\n/)) { try { const value = JSON.parse(line) as Record | null; - if (!value || typeof value !== 'object') continue; + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; // CLI error envelopes, not arbitrary assistant/tool text in a stream. if (value.type === 'error' && typeof value.message === 'string') messages.push(value.message); + // Provider API response envelopes are valid diagnostics, but a nested + // error inside assistant/tool output is still task content. + if ((value.type === undefined || value.type === 'error') && value.error && typeof value.error === 'object') { + const error = value.error as Record; + if (typeof error.type === 'string' || typeof error.code === 'string') messages.push(JSON.stringify({ error })); + } if (value.type === 'result' && value.is_error === true && typeof value.result === 'string') messages.push(value.result); if (value.type === 'turn.failed' && value.error && typeof value.error === 'object') { const message = (value.error as { message?: unknown }).message; if (typeof message === 'string') messages.push(message); } } catch { - // Most CLIs print plain text diagnostics. + // Preserve plain diagnostics, excluding prose/code containing embedded JSON. + // API Error lines are the CLI's explicit provider diagnostic prefix. + if (/^\s*API Error:/i.test(line) || !/[{}]/.test(line)) messages.push(line); } } return messages; @@ -51,7 +59,7 @@ export function classifyHarnessProviderFailure(run: Pick typeof value === 'string') .map((value) => value.slice(-16000).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')) .join('\n'); - const text = [rawText, ...errorMessages(rawText)].join('\n'); + const text = diagnosticMessages(rawText).join('\n'); const claudeLimit = text.match(/^\s*You['’]ve hit your (?:usage )?limit\b([^\r\n]*)/im); if (claudeLimit) {