From 59f39a3557002c1e8b38c67911836d5115c6a448 Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Sat, 3 Oct 2026 00:18:58 +1000 Subject: [PATCH 1/2] agent: show partial results when a run hits its credit limit When a run fails with stopReason "credit_limit_reached", print a clear notice with the run's message, output the partial result (JSON keeps partial, partialSchemaValid and stopReason; human output labels it incomplete and shows schema validity), and say how to continue: follow up on the thread or rerun with a higher --max-credits. Waited runs still exit 1. Thread listings show per-run partials and a continue hint when the latest turn stopped at its credit limit. The pinned SDK (4.40.0) does not type these fields yet, so they are read defensively; with the fields absent, output is unchanged. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/alexandria-beta.test.ts | 158 ++++++++++++++++++++ src/commands/agent.ts | 200 ++++++++++++++++++++++++-- src/types/agent.ts | 20 ++- 3 files changed, 362 insertions(+), 16 deletions(-) diff --git a/src/__tests__/alexandria-beta.test.ts b/src/__tests__/alexandria-beta.test.ts index fd7b095d43..5818fcb408 100644 --- a/src/__tests__/alexandria-beta.test.ts +++ b/src/__tests__/alexandria-beta.test.ts @@ -1102,6 +1102,163 @@ it('surfaces chat replies and thread position on status', async () => { expect(readable.stdout).toContain('Dig deeper: List every link.'); }); +const creditStopped = { + success: true, + status: 'failed', + error: 'Agent reached max credits', + expiresAt: '2026-09-17T00:00:00.000Z', + creditsUsed: 25, + threadId: THREAD_ID, + threadTurn: 1, + stopReason: 'credit_limit_reached', + message: 'Only Acme was found.', + partial: { companies: [{ name: 'Acme' }] }, + partialSchemaValid: false, +}; + +// POST /v2/agent starts the run; every GET poll returns `status`. +function runThenPoll(statusResponse: Record) { + responseFor = (body) => + body + ? { success: true, id: RUN_ID, threadId: THREAD_ID, threadTurn: 1 } + : statusResponse; +} + +it('returns the partial result when a waited run hits its credit limit', async () => { + runThenPoll(creditStopped); + const args = ['agent', 'Find companies.', '--max-credits', '25', '--wait']; + const json = await cli([...args, '--poll-interval', '0.01', '--json']); + expect(json.code).toBe(1); + expect(requests[0].body.maxCredits).toBe(25); + expect(JSON.parse(json.stdout)).toMatchObject({ + success: false, + error: 'Agent reached max credits', + id: RUN_ID, + status: 'failed', + stopReason: 'credit_limit_reached', + partial: { companies: [{ name: 'Acme' }] }, + partialSchemaValid: false, + message: 'Only Acme was found.', + threadId: THREAD_ID, + creditsUsed: 25, + }); + expect(json.stderr).toContain('Stopped at credit limit'); + expect(json.stderr).toContain('Only Acme was found.'); + expect(json.stderr).toContain(`--thread ${THREAD_ID}`); + expect(json.stderr).toContain('--max-credits'); + + const readable = await cli([...args, '--poll-interval', '0.01']); + expect(readable.code).toBe(1); + expect(readable.stdout).toContain( + 'Stopped at credit limit: the agent used 25 credits and reached its credit limit before finishing.' + ); + expect(readable.stdout).toContain('Only Acme was found.'); + expect(readable.stdout).toContain( + 'Partial Result (incomplete, does not match schema):' + ); + expect(readable.stdout).toContain('"name": "Acme"'); + expect(readable.stdout).toContain( + `firecrawl agent "" --thread ${THREAD_ID} --wait` + ); + expect(readable.stdout).toContain('higher --max-credits'); +}); + +it('explains a credit-limit stop that recovered no partial', async () => { + const { partial, partialSchemaValid, ...noPartial } = creditStopped; + runThenPoll(noPartial); + const result = await cli([ + 'agent', + 'Find companies.', + '--wait', + '--poll-interval', + '0.01', + ]); + expect(result.code).toBe(1); + expect(result.stdout).toContain('Stopped at credit limit'); + expect(result.stdout).toContain('No partial result was recovered.'); + expect(result.stdout).not.toContain('Partial Result'); +}); + +it('keeps other failed runs unchanged', async () => { + runThenPoll({ + success: true, + status: 'failed', + error: 'Agent crashed', + expiresAt: '2026-09-17T00:00:00.000Z', + }); + const result = await cli([ + 'agent', + 'Find companies.', + '--wait', + '--poll-interval', + '0.01', + '--json', + ]); + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('Error: Agent crashed'); + expect(result.stderr).not.toContain('credit limit'); +}); + +it('shows the partial on a status check of a credit-stopped run', async () => { + response = { ...creditStopped, partialSchemaValid: true }; + const json = await cli(['agent', RUN_ID, '--json']); + // A status check reports the run's state; failed runs exit 0 here as before. + expect(json.code).toBe(0); + expect(JSON.parse(json.stdout)).toMatchObject({ + success: true, + status: 'failed', + stopReason: 'credit_limit_reached', + partial: { companies: [{ name: 'Acme' }] }, + partialSchemaValid: true, + }); + expect(json.stderr).toContain('Stopped at credit limit'); + + const readable = await cli(['agent', RUN_ID]); + expect(readable.stdout).toContain( + 'Partial Result (incomplete, matches schema):' + ); +}); + +it('shows partials and how to continue on a credit-stopped thread', async () => { + response = { + success: true, + thread: { + id: THREAD_ID, + createdAt: '2026-09-16T10:00:00.000Z', + updatedAt: '2026-09-16T10:05:00.000Z', + status: 'idle', + runs: [ + { + id: RUN_ID, + turn: 1, + mode: 'extract', + prompt: 'Find companies.', + status: 'credit_limit_reached', + createdAt: '2026-09-16T10:00:00.000Z', + finishedAt: '2026-09-16T10:01:00.000Z', + creditsUsed: 25, + message: 'Only Acme was found.', + stopReason: 'credit_limit_reached', + partial: { companies: [{ name: 'Acme' }] }, + }, + ], + }, + }; + const json = await cli(['agent', 'thread', THREAD_ID, '--json']); + expect(json.code).toBe(0); + expect(JSON.parse(json.stdout)).toEqual(response); + + const readable = await cli(['agent', 'thread', THREAD_ID]); + expect(readable.code).toBe(0); + expect(readable.stdout).toContain('Turn 1 (extract) - credit_limit_reached'); + expect(readable.stdout).toContain( + 'Partial Result (incomplete): {"companies":[{"name":"Acme"}]}' + ); + expect(readable.stdout).toContain('Turn 1 stopped at its credit limit.'); + expect(readable.stdout).toContain(`--thread ${THREAD_ID} --wait`); +}); + it('relays thread_busy conflicts when a turn is still running', async () => { status = 409; response = { @@ -1161,6 +1318,7 @@ it('lists a thread through the thread endpoint', async () => { expect(readable.stdout).toContain('Turn 1 (extract) - succeeded'); expect(readable.stdout).toContain('Extract the page title.'); expect(readable.stdout).toContain('"title":"Example Domain"'); + expect(readable.stdout).not.toContain('credit limit'); }); it('fails clearly on an unknown thread', async () => { diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 28832cdd41..670d50cb37 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -4,6 +4,7 @@ import type { AgentEffort, + AgentIncompleteFields, AgentModel, AgentOptions, AgentResult, @@ -78,6 +79,42 @@ function normalizeAgentStatus(status: AgentStatusFromApi): AgentStatus { return status as AgentStatus; } +const CREDIT_LIMIT_REACHED = 'credit_limit_reached'; + +/** + * Read the incomplete-result fields from a status response or thread run. + * The pinned SDK does not type them yet and older APIs never send them, so + * each field is copied only when it is present and well-formed. + */ +function readIncompleteFields(source: unknown): AgentIncompleteFields { + const raw = (source ?? {}) as Record; + return { + ...(raw.partial !== undefined && + raw.partial !== null && { partial: raw.partial }), + ...(typeof raw.partialSchemaValid === 'boolean' && { + partialSchemaValid: raw.partialSchemaValid, + }), + ...(typeof raw.stopReason === 'string' && { + stopReason: raw.stopReason, + }), + }; +} + +function isCreditLimitStop( + data: { status?: string; stopReason?: string } | undefined +): boolean { + return ( + data?.stopReason === CREDIT_LIMIT_REACHED || + data?.status === CREDIT_LIMIT_REACHED + ); +} + +function failureLabel(status: AgentStatusResponse): string { + return isCreditLimitStop(readIncompleteFields(status)) + ? 'Agent stopped at credit limit' + : 'Agent failed'; +} + function toStatusData( jobId: string, status: AgentStatusResponse, @@ -94,6 +131,7 @@ function toStatusData( ...(status.mode !== undefined && { mode: status.mode }), ...(status.message !== undefined && { message: status.message }), ...(status.suggestions?.length && { suggestions: status.suggestions }), + ...readIncompleteFields(status), }; } @@ -164,7 +202,7 @@ async function checkAgentStatus( } if (currentNormalizedStatus === 'failed') { - spinner.fail('Agent failed'); + spinner.fail(failureLabel(agentStatus)); return { success: false, data: toStatusData(jobId, agentStatus, currentNormalizedStatus), @@ -344,7 +382,7 @@ export async function executeAgent( if (normalizedStatus === 'failed') { process.removeListener('SIGINT', handleInterrupt); - spinner.fail('Agent failed'); + spinner.fail(failureLabel(agentStatus)); return { success: false, data: toStatusData(jobId, agentStatus, normalizedStatus), @@ -405,6 +443,67 @@ export async function executeAgent( } } +/** + * Heading for a partial result, e.g. "Partial Result (incomplete, matches schema)". + */ +function partialHeading(data: AgentIncompleteFields): string { + const schemaNote = + data.partialSchemaValid === true + ? ', matches schema' + : data.partialSchemaValid === false + ? ', does not match schema' + : ''; + return `Partial Result (incomplete${schemaNote})`; +} + +/** + * First line of the credit-limit notice. + */ +function creditLimitHeadline(data: { creditsUsed?: number | null }): string { + const used = + data.creditsUsed !== undefined && data.creditsUsed !== null + ? `used ${data.creditsUsed} credits and ` + : ''; + return `Stopped at credit limit: the agent ${used}reached its credit limit before finishing.`; +} + +/** + * How to pick up a run that stopped at its credit limit. + */ +function creditLimitNextSteps(threadId?: string): string[] { + const lines = ['To continue:']; + if (threadId) { + lines.push( + ' - Send a follow-up on the thread (it continues from the partial result):', + ` firecrawl agent "" --thread ${threadId} --wait` + ); + } + lines.push(' - Or rerun the prompt with a higher --max-credits.'); + return lines; +} + +/** + * Short credit-limit notice for stderr, used when the result itself goes to + * JSON or to a file and so is not shown on the terminal. + */ +function formatCreditLimitNotice( + data: NonNullable, + outputPath?: string +): string { + const lines = [creditLimitHeadline(data)]; + if (data.message) { + lines.push(data.message); + } + const where = outputPath ? ` in ${outputPath}` : ' under "partial"'; + lines.push( + data.partial !== undefined + ? `The ${partialHeading(data).toLowerCase()} is${where}.` + : 'No partial result was recovered.' + ); + lines.push(...creditLimitNextSteps(data.threadId)); + return lines.join('\n') + '\n'; +} + /** * Format agent status in human-readable way */ @@ -412,6 +511,14 @@ function formatAgentStatus(data: AgentStatusResult['data']): string { if (!data) return ''; const lines: string[] = []; + const creditLimited = isCreditLimitStop(data); + if (creditLimited) { + lines.push(creditLimitHeadline(data)); + if (data.partial === undefined) { + lines.push('No partial result was recovered.'); + } + lines.push(''); + } lines.push(`Job ID: ${data.id}`); lines.push(`Status: ${data.status}`); @@ -454,6 +561,12 @@ function formatAgentStatus(data: AgentStatusResult['data']): string { lines.push(JSON.stringify(data.data, null, 2)); } + if (data.partial !== undefined) { + lines.push(''); + lines.push(`${partialHeading(data)}:`); + lines.push(JSON.stringify(data.partial, null, 2)); + } + if (data.suggestions?.length) { lines.push(''); lines.push('Suggestions:'); @@ -462,6 +575,11 @@ function formatAgentStatus(data: AgentStatusResult['data']): string { } } + if (creditLimited) { + lines.push(''); + lines.push(...creditLimitNextSteps(data.threadId)); + } + return lines.join('\n') + '\n'; } @@ -486,6 +604,28 @@ function formatAgentThread(thread: AgentThread): string { if (run.data !== undefined) { lines.push(` Result: ${JSON.stringify(run.data)}`); } + const incomplete = readIncompleteFields(run); + if (incomplete.partial !== undefined) { + lines.push( + ` ${partialHeading(incomplete)}: ${JSON.stringify(incomplete.partial)}` + ); + } + } + + // Only the latest turn can be continued, so only hint when it is the one + // that stopped at its credit limit. + const latest = thread.runs[thread.runs.length - 1]; + if ( + latest && + thread.status !== 'running' && + isCreditLimitStop({ + status: latest.status, + ...readIncompleteFields(latest), + }) + ) { + lines.push(''); + lines.push(`Turn ${latest.turn} stopped at its credit limit.`); + lines.push(...creditLimitNextSteps(thread.id)); } return lines.join('\n') + '\n'; @@ -520,6 +660,38 @@ export async function handleAgentThreadCommand( writeOutput(outputContent, options.output, !!options.output); } +/** + * Write a status result as JSON or human-readable text. A credit-limit stop + * also gets a stderr notice whenever the result is not printed as text on + * the terminal (JSON mode or --output), so the stop is never silent. + */ +function writeAgentStatusOutput( + data: NonNullable, + options: AgentOptions, + envelope: { success: boolean; error?: string } +): void { + let outputContent: string; + + if (options.json) { + const payload = { + success: envelope.success, + ...(envelope.error !== undefined && { error: envelope.error }), + ...data, + }; + outputContent = options.pretty + ? JSON.stringify(payload, null, 2) + : JSON.stringify(payload); + } else { + outputContent = formatAgentStatus(data); + } + + if (isCreditLimitStop(data) && (options.json || options.output)) { + process.stderr.write(formatCreditLimitNotice(data, options.output)); + } + + writeOutput(outputContent, options.output, !!options.output); +} + /** * Handle agent command output */ @@ -527,6 +699,16 @@ export async function handleAgentCommand(options: AgentOptions): Promise { const result = await executeAgent(options); if (!result.success) { + // A run that stopped at its credit limit still has a result worth showing + // (the partial, its message, how to continue). It is still a failure. + const failedData = (result as AgentStatusResult).data; + if (failedData && 'id' in failedData && isCreditLimitStop(failedData)) { + writeAgentStatusOutput(failedData, options, { + success: false, + error: result.error, + }); + process.exit(1); + } console.error('Error:', result.error); process.exit(1); } @@ -535,19 +717,7 @@ export async function handleAgentCommand(options: AgentOptions): Promise { if ('data' in result && result.data && 'data' in result.data) { const statusResult = result as AgentStatusResult; if (statusResult.data) { - let outputContent: string; - - if (options.json) { - // JSON format - outputContent = options.pretty - ? JSON.stringify({ success: true, ...statusResult.data }, null, 2) - : JSON.stringify({ success: true, ...statusResult.data }); - } else { - // Human-readable format - outputContent = formatAgentStatus(statusResult.data); - } - - writeOutput(outputContent, options.output, !!options.output); + writeAgentStatusOutput(statusResult.data, options, { success: true }); return; } } diff --git a/src/types/agent.ts b/src/types/agent.ts index 4c876c520a..d10969ded6 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -10,6 +10,24 @@ export type AgentEffort = 'low' | 'medium' | 'high'; export type AgentStatus = 'processing' | 'completed' | 'failed' | 'cancelled'; +/** + * Why a failed run stopped early. The API currently sends only + * "credit_limit_reached"; other values are passed through untouched. + */ +export type AgentStopReason = 'credit_limit_reached' | (string & {}); + +/** + * Incomplete-result fields the API adds to a failed run (and to thread runs). + * The pinned SDK does not type these yet, so they are read defensively. + */ +export interface AgentIncompleteFields { + /** Best-effort JSON from an incomplete run; `data` stays completed-only */ + partial?: unknown; + /** Whether `partial` matches the supplied schema (only sent with a schema) */ + partialSchemaValid?: boolean; + stopReason?: AgentStopReason; +} + export interface AgentOptions { /** Natural language prompt describing the data to extract */ prompt: string; @@ -66,7 +84,7 @@ export interface AgentResult { export interface AgentStatusResult { success: boolean; - data?: { + data?: AgentIncompleteFields & { id: string; status: AgentStatus; data?: any; From d6c6ac84f738c7a24d98487e4785333183209821 Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Sat, 3 Oct 2026 01:03:36 +1000 Subject: [PATCH 2/2] agent: flush credit-limit output before exiting, hint on thread --json - The credit-limit failure path now sets process.exitCode = 1 and returns instead of calling process.exit(1), so a large JSON result piped to another program is no longer cut off at 64 KiB. Exit status stays 1. - `agent thread --json` (and --output) now prints the continuation hint on stderr when the latest turn stopped at its credit limit; stdout stays pure JSON. - Tests: --output sends the "in " notice to stderr and the result to the file; plain text mode keeps the notice off stderr; thread --json hint goes to stderr; a large piped JSON result arrives whole with exit 1. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/alexandria-beta.test.ts | 57 +++++++++++++++++++++++++++ src/commands/agent.ts | 45 +++++++++++++++------ 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/src/__tests__/alexandria-beta.test.ts b/src/__tests__/alexandria-beta.test.ts index 5818fcb408..055ef60ab8 100644 --- a/src/__tests__/alexandria-beta.test.ts +++ b/src/__tests__/alexandria-beta.test.ts @@ -1161,6 +1161,59 @@ it('returns the partial result when a waited run hits its credit limit', async ( `firecrawl agent "" --thread ${THREAD_ID} --wait` ); expect(readable.stdout).toContain('higher --max-credits'); + // The notice is already part of the text on stdout, so stderr stays clear. + expect(readable.stderr).not.toContain('Stopped at credit limit'); +}); + +it('writes a credit-stopped result to --output and points stderr at the file', async () => { + runThenPoll(creditStopped); + const dir = mkdtempSync(join(tmpdir(), 'agent-output-')); + const outputPath = join(dir, 'result.txt'); + try { + const result = await cli([ + 'agent', + 'Find companies.', + '--wait', + '--poll-interval', + '0.01', + '--output', + outputPath, + ]); + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('Stopped at credit limit'); + expect(result.stderr).toContain('Only Acme was found.'); + expect(result.stderr).toContain( + `The partial result (incomplete, does not match schema) is in ${outputPath}.` + ); + expect(result.stderr).toContain(`--thread ${THREAD_ID}`); + const written = readFileSync(outputPath, 'utf-8'); + expect(written).toContain( + 'Partial Result (incomplete, does not match schema):' + ); + expect(written).toContain('"name": "Acme"'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +it('flushes a large credit-stopped JSON result to a pipe before exiting 1', async () => { + const companies = Array.from({ length: 5000 }, (_, i) => ({ + name: `Company ${i}`, + website: `https://company-${i}.example.com`, + })); + runThenPoll({ ...creditStopped, partial: { companies } }); + const result = await cli([ + 'agent', + 'Find companies.', + '--wait', + '--poll-interval', + '0.01', + '--json', + ]); + expect(result.code).toBe(1); + expect(result.stdout.length).toBeGreaterThan(200_000); + expect(JSON.parse(result.stdout).partial.companies).toHaveLength(5000); }); it('explains a credit-limit stop that recovered no partial', async () => { @@ -1248,9 +1301,13 @@ it('shows partials and how to continue on a credit-stopped thread', async () => const json = await cli(['agent', 'thread', THREAD_ID, '--json']); expect(json.code).toBe(0); expect(JSON.parse(json.stdout)).toEqual(response); + // stdout stays pure JSON; the continuation hint goes to stderr. + expect(json.stderr).toContain('Turn 1 stopped at its credit limit.'); + expect(json.stderr).toContain(`--thread ${THREAD_ID} --wait`); const readable = await cli(['agent', 'thread', THREAD_ID]); expect(readable.code).toBe(0); + expect(readable.stderr).not.toContain('stopped at its credit limit'); expect(readable.stdout).toContain('Turn 1 (extract) - credit_limit_reached'); expect(readable.stdout).toContain( 'Partial Result (incomplete): {"companies":[{"name":"Acme"}]}' diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 670d50cb37..022cc2bb6d 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -612,23 +612,36 @@ function formatAgentThread(thread: AgentThread): string { } } - // Only the latest turn can be continued, so only hint when it is the one - // that stopped at its credit limit. + const hint = threadCreditLimitHint(thread); + if (hint.length) { + lines.push(''); + lines.push(...hint); + } + + return lines.join('\n') + '\n'; +} + +/** + * How to continue a thread whose latest turn stopped at its credit limit. + * Only the latest turn can be continued, so only hint when it is the one + * that stopped. Empty when there is nothing to hint. + */ +function threadCreditLimitHint(thread: AgentThread): string[] { const latest = thread.runs[thread.runs.length - 1]; if ( - latest && - thread.status !== 'running' && - isCreditLimitStop({ + !latest || + thread.status === 'running' || + !isCreditLimitStop({ status: latest.status, ...readIncompleteFields(latest), }) ) { - lines.push(''); - lines.push(`Turn ${latest.turn} stopped at its credit limit.`); - lines.push(...creditLimitNextSteps(thread.id)); + return []; } - - return lines.join('\n') + '\n'; + return [ + `Turn ${latest.turn} stopped at its credit limit.`, + ...creditLimitNextSteps(thread.id), + ]; } /** @@ -657,6 +670,13 @@ export async function handleAgentThreadCommand( ? JSON.stringify({ success: true, thread }, null, options.pretty ? 2 : 0) : formatAgentThread(thread); + // As with status output, keep the continuation hint visible on stderr when + // the listing itself is not printed as text on the terminal. + const hint = threadCreditLimitHint(thread); + if (hint.length && (options.json || options.output)) { + process.stderr.write(hint.join('\n') + '\n'); + } + writeOutput(outputContent, options.output, !!options.output); } @@ -707,7 +727,10 @@ export async function handleAgentCommand(options: AgentOptions): Promise { success: false, error: result.error, }); - process.exit(1); + // Set the exit code instead of calling process.exit() so the result + // written above is flushed to a piped stdout before the process ends. + process.exitCode = 1; + return; } console.error('Error:', result.error); process.exit(1);