From 9432d6e94e25f7c3a694188d1e59d54a4f1f4e7d Mon Sep 17 00:00:00 2001 From: Nicolas <20311743+nickscamara@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:00:01 -0700 Subject: [PATCH 01/13] Remember the last agent thread per API key Threads let a follow-up continue an earlier agent run, so the CLI needs to know which thread the last run belonged to. Store it next to the other config-dir files, keyed by a hash of the API key so switching keys never crosses threads. Co-authored-by: Cursor --- src/utils/agent-threads.ts | 105 +++++++++++++++++++++++++++++++++++++ src/utils/config.ts | 11 +++- 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 src/utils/agent-threads.ts diff --git a/src/utils/agent-threads.ts b/src/utils/agent-threads.ts new file mode 100644 index 0000000000..3baeb4c005 --- /dev/null +++ b/src/utils/agent-threads.ts @@ -0,0 +1,105 @@ +/** + * Agent thread memory + * Remembers the last agent thread per API key so `firecrawl agent --continue` + * can pick the conversation back up without pasting a thread ID. + * + * Stored in the same config directory as credentials.json / browser-session.json + * / interact-session.json. Entries are keyed by a hash of the API key so + * switching keys never crosses threads. The file is a convenience, never the + * source of truth: the API owns thread state, and a thread the server no longer + * knows about is dropped from here on the next attempt to use it. + */ + +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import { getAgentThreadsPath } from './config'; +import { getConfigDirectoryPath } from './credentials'; + +export interface RememberedThread { + lastThreadId: string; + lastRunId?: string; + updatedAt: string; +} + +/** `{ [apiKeyFingerprint]: RememberedThread }` */ +export type AgentThreadStore = Record; + +/** Bucket used when no API key is configured (self-hosted / keyless setups). */ +const NO_API_KEY = 'no-api-key'; + +/** + * Short, stable hash of the API key. The key itself is never written to disk by + * this file; credentials.json already owns that. + */ +export function apiKeyFingerprint(apiKey?: string): string { + const key = apiKey?.trim(); + if (!key) return NO_API_KEY; + return crypto.createHash('sha256').update(key).digest('hex').slice(0, 16); +} + +export function loadAgentThreadStore(): AgentThreadStore { + try { + const storePath = getAgentThreadsPath(); + if (!fs.existsSync(storePath)) return {}; + const parsed = JSON.parse(fs.readFileSync(storePath, 'utf-8')); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return {}; + } + return parsed as AgentThreadStore; + } catch { + // Corrupt or unreadable file: start over rather than blocking the command + return {}; + } +} + +function writeAgentThreadStore(store: AgentThreadStore): void { + const configDir = getConfigDirectoryPath(); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + } + const storePath = getAgentThreadsPath(); + fs.writeFileSync(storePath, JSON.stringify(store, null, 2), 'utf-8'); + try { + fs.chmodSync(storePath, 0o600); + } catch { + // Ignore on Windows + } +} + +/** Read the thread last started with this API key, if any. */ +export function getRememberedThread(apiKey?: string): RememberedThread | null { + const entry = loadAgentThreadStore()[apiKeyFingerprint(apiKey)]; + if (!entry || typeof entry.lastThreadId !== 'string') return null; + return entry; +} + +/** Record a thread after a successful start. Never throws. */ +export function rememberThread( + apiKey: string | undefined, + thread: { threadId: string; runId?: string } +): void { + try { + const store = loadAgentThreadStore(); + store[apiKeyFingerprint(apiKey)] = { + lastThreadId: thread.threadId, + ...(thread.runId ? { lastRunId: thread.runId } : {}), + updatedAt: new Date().toISOString(), + }; + writeAgentThreadStore(store); + } catch { + // Thread memory is a convenience; a failed write must not fail the run + } +} + +/** Drop the remembered thread for this API key (e.g. the server 404s it). */ +export function forgetThread(apiKey?: string): void { + try { + const store = loadAgentThreadStore(); + const fingerprint = apiKeyFingerprint(apiKey); + if (!(fingerprint in store)) return; + delete store[fingerprint]; + writeAgentThreadStore(store); + } catch { + // Ignore errors + } +} diff --git a/src/utils/config.ts b/src/utils/config.ts index 1374a308fc..e2adc01cbd 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -2,7 +2,8 @@ * Global configuration system */ -import { loadCredentials } from './credentials'; +import * as path from 'path'; +import { getConfigDirectoryPath, loadCredentials } from './credentials'; export interface GlobalConfig { apiKey?: string; @@ -100,6 +101,14 @@ export function validateConfig(apiKey?: string): void { } } +/** + * Path of the remembered agent threads file, next to credentials.json, + * browser-session.json and interact-session.json + */ +export function getAgentThreadsPath(): string { + return path.join(getConfigDirectoryPath(), 'agent-threads.json'); +} + /** * Reset global configuration (useful for testing) */ From 2d9e4bdc003917264b09027862a93e997f007f1f Mon Sep 17 00:00:00 2001 From: Nicolas <20311743+nickscamara@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:00:07 -0700 Subject: [PATCH 02/13] Add thread, mode, effort and Exchange flags to agent A run can now continue a conversation (--thread/--continue/--new), pick a mode and effort, and pass Exchange options through to the API. Chat replies print before the JSON result, with follow-ups and any pending approval rendered from whatever the API returns. The pinned SDK drops unknown request keys and does not type the new response fields, so starts that use them go over raw HTTP and status reads widen the type; both can go once the SDK ships them. Co-authored-by: Cursor --- src/commands/agent.ts | 596 ++++++++++++++++++++++++++++++++++++++++-- src/index.ts | 141 +++++++++- src/types/agent.ts | 96 +++++++ 3 files changed, 807 insertions(+), 26 deletions(-) diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 7063637952..9712b36663 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -3,18 +3,236 @@ */ import type { + AgentEffort, + AgentExchangeOptions, + AgentMode, AgentOptions, + AgentPendingApproval, AgentResult, AgentStatus, AgentStatusResult, + AgentSuggestion, + AgentThread, + AgentThreadOptions, + AgentThreadResult, + AgentThreadRun, } from '../types/agent'; -import type { AgentWebhookConfig } from 'firecrawl'; +import type { AgentStatusResponse, AgentWebhookConfig } from 'firecrawl'; import { getClient } from '../utils/client'; +import { getConfig, validateConfig } from '../utils/config'; +import { + forgetThread, + getRememberedThread, + rememberThread, +} from '../utils/agent-threads'; import { isJobId } from '../utils/job'; import { writeOutput } from '../utils/output'; import { createSpinner } from '../utils/spinner'; import { readFileSync } from 'fs'; +const DEFAULT_API_URL = 'https://api.firecrawl.dev'; + +/** + * Fixed prompts sent when resolving a pending approval, so the run continues + * without the user retyping their intent. + */ +export const APPROVE_PROMPT = 'Approved. Make that call, and nothing else.'; +export const DECLINE_PROMPT = + 'Do not make that call. Answer from what you already have, or tell me what you would need.'; + +const THREADS_UNSUPPORTED = 'This Firecrawl API does not support threads yet'; + +/** + * firecrawl@4.24.0 predates threads: `prepareAgentPayload` whitelists request + * keys (so threadId/mode/effort/exchange would be dropped) and the response + * types omit the new fields (which the API does return). Starts that use the + * new fields therefore go over raw HTTP — the same escape hatch monitor.ts and + * parse.ts use — and status responses are read through a widened type. Both + * workarounds can go once the SDK ships the fields from spec 11.3; the pinned + * version here is 4.24.0. + */ +type ThreadAwareAgentStatus = AgentStatusResponse & { + threadId?: string; + threadTurn?: number; + mode?: AgentMode; + message?: string; + suggestions?: AgentSuggestion[]; + pendingApproval?: AgentPendingApproval; +}; + +type ThreadAwareStartResponse = { + success: boolean; + id: string; + threadId?: string; + threadTurn?: number; + error?: string; +}; + +class AgentApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly code?: string + ) { + super(message); + this.name = 'AgentApiError'; + } +} + +function resolveApiBase(options: { apiKey?: string; apiUrl?: string }): { + baseUrl: string; + apiKey?: string; +} { + const config = getConfig(); + const apiKey = options.apiKey || config.apiKey; + validateConfig(apiKey); + const baseUrl = (options.apiUrl || config.apiUrl || DEFAULT_API_URL).replace( + /\/$/, + '' + ); + return { baseUrl, apiKey }; +} + +async function agentApiRequest( + path: string, + options: { apiKey?: string; apiUrl?: string }, + init: { method?: string; body?: unknown } = {} +): Promise { + const { baseUrl, apiKey } = resolveApiBase(options); + + const headers: Record = { 'X-Origin': 'cli' }; + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + if (init.body !== undefined) headers['Content-Type'] = 'application/json'; + + const response = await fetch(`${baseUrl}${path}`, { + method: init.method ?? 'GET', + headers, + body: init.body !== undefined ? JSON.stringify(init.body) : undefined, + }); + + const payload = (await response.json().catch(() => ({}))) as any; + + if (!response.ok || payload?.success === false) { + const message = + payload?.error || + `HTTP ${response.status}: ${response.statusText || 'Request failed'}`; + throw new AgentApiError(message, response.status, payload?.code); + } + + return payload; +} + +/** + * An old API rejects the thread fields with a 400 from its strict schema, and a + * deployment without threads reports them as disabled. Both mean the same thing + * to the user. + */ +function mapThreadSupportError(error: unknown): string | null { + if (!(error instanceof AgentApiError)) return null; + if (error.status === 503 && error.code === 'threads_disabled') { + return THREADS_UNSUPPORTED; + } + if (error.status !== 400) return null; + const namesUnknownKey = + /unrecognized|unknown key|unexpected key|not allowed|not recognized/i.test( + error.message + ); + const namesThreadField = /threadId|thread_id|\bmode\b|exchange|effort/i.test( + error.message + ); + return namesUnknownKey && namesThreadField ? THREADS_UNSUPPORTED : null; +} + +function isThreadGoneError(error: unknown): boolean { + if (!(error instanceof AgentApiError)) return false; + if (error.status === 404 || error.status === 410) return true; + return ( + error.code === 'thread_not_found' || + error.code === 'thread_expired' || + /thread_not_found|thread_expired/.test(error.message) + ); +} + +export interface AgentStartParams { + prompt: string; + urls?: string[]; + schema?: Record; + model?: string; + maxCredits?: number; + webhook?: string | AgentWebhookConfig; + threadId?: string; + mode?: AgentMode; + effort?: AgentEffort; + exchange?: AgentExchangeOptions; +} + +/** Request body for POST /v2/agent. New keys are only sent when set. */ +export function buildAgentStartBody( + params: AgentStartParams +): Record { + const body: Record = { + prompt: params.prompt, + integration: 'cli', + }; + + if (params.urls && params.urls.length > 0) body.urls = params.urls; + if (params.schema) body.schema = params.schema; + if (params.model) body.model = params.model; + if (params.maxCredits !== undefined) body.maxCredits = params.maxCredits; + if (params.webhook) body.webhook = params.webhook; + if (params.threadId) body.threadId = params.threadId; + if (params.mode) body.mode = params.mode; + if (params.effort) body.effort = params.effort; + if (params.exchange && Object.keys(params.exchange).length > 0) { + body.exchange = params.exchange; + } + + return body; +} + +/** True when the request needs fields the pinned SDK cannot send. */ +function needsRawStart(params: AgentStartParams): boolean { + return Boolean( + params.threadId || params.mode || params.effort || params.exchange + ); +} + +/** + * Which thread this run continues, if any. `--thread` wins over `--continue`, + * `--continue` falls back to the thread remembered for this API key, and + * `--approve`/`--decline` continue that same thread without asking for a flag. + */ +export function resolveThreadIntent( + options: Pick< + AgentOptions, + 'thread' | 'continue' | 'new' | 'exchange' | 'apiKey' + >, + remembered: string | null +): { threadId?: string; fromMemory: boolean; missingMemory: boolean } { + if (options.thread) { + return { + threadId: options.thread, + fromMemory: false, + missingMemory: false, + }; + } + + const resolvingApproval = Boolean( + options.exchange?.approve || options.exchange?.decline + ); + const wantsContinue = Boolean(options.continue) || resolvingApproval; + + if (!wantsContinue || options.new) { + return { fromMemory: false, missingMemory: false }; + } + + if (!remembered) { + return { fromMemory: false, missingMemory: true }; + } + + return { threadId: remembered, fromMemory: true, missingMemory: false }; +} + /** * Extract detailed error message from API errors */ @@ -70,6 +288,23 @@ function normalizeAgentStatus(status: AgentStatusFromApi): AgentStatus { return status as AgentStatus; } +/** Thread-aware status fields the API returns and the pinned SDK does not type */ +function threadFields( + status: AgentStatusResponse +): Partial> { + const s = status as ThreadAwareAgentStatus; + return { + ...(s.threadId ? { threadId: s.threadId } : {}), + ...(s.threadTurn !== undefined ? { threadTurn: s.threadTurn } : {}), + ...(s.mode ? { mode: s.mode } : {}), + ...(s.message ? { message: s.message } : {}), + ...(s.suggestions && s.suggestions.length > 0 + ? { suggestions: s.suggestions } + : {}), + ...(s.pendingApproval ? { pendingApproval: s.pendingApproval } : {}), + }; +} + /** * Execute agent status check (with optional wait/polling) */ @@ -96,6 +331,7 @@ async function checkAgentStatus( data: status.data, creditsUsed: status.creditsUsed, expiresAt: status.expiresAt, + ...threadFields(status), }, }; } catch (error) { @@ -144,6 +380,7 @@ async function checkAgentStatus( data: agentStatus.data, creditsUsed: agentStatus.creditsUsed, expiresAt: agentStatus.expiresAt, + ...threadFields(agentStatus), }, }; } @@ -158,6 +395,7 @@ async function checkAgentStatus( data: agentStatus.data, creditsUsed: agentStatus.creditsUsed, expiresAt: agentStatus.expiresAt, + ...threadFields(agentStatus), }, error: agentStatus.error, }; @@ -173,6 +411,7 @@ async function checkAgentStatus( data: agentStatus.data, creditsUsed: agentStatus.creditsUsed, expiresAt: agentStatus.expiresAt, + ...threadFields(agentStatus), }, }; } @@ -204,6 +443,99 @@ async function checkAgentStatus( } } +/** + * Prompt to send for this turn. Resolving an approval carries its own fixed + * prompt so the user does not have to restate anything. + */ +export function resolveStartPrompt( + options: Pick +): string { + if (options.prompt && options.prompt.trim()) return options.prompt; + if (options.exchange?.approve) return APPROVE_PROMPT; + if (options.exchange?.decline) return DECLINE_PROMPT; + return options.prompt; +} + +/** + * Start a run, continuing a thread when one is asked for or remembered, and + * record the thread the API reports back. + */ +async function startAgentRun( + options: AgentOptions, + params: AgentStartParams, + onNotice: (message: string) => void +): Promise { + const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl }); + + const attempt = async ( + attemptParams: AgentStartParams + ): Promise => { + try { + if (!needsRawStart(attemptParams)) { + const response = await app.startAgent({ + prompt: attemptParams.prompt, + ...(attemptParams.urls ? { urls: attemptParams.urls } : {}), + ...(attemptParams.schema ? { schema: attemptParams.schema } : {}), + ...(attemptParams.model + ? { model: attemptParams.model as 'spark-1-pro' | 'spark-1-mini' } + : {}), + ...(attemptParams.maxCredits !== undefined + ? { maxCredits: attemptParams.maxCredits } + : {}), + ...(attemptParams.webhook ? { webhook: attemptParams.webhook } : {}), + integration: 'cli', + }); + return response as ThreadAwareStartResponse; + } + + return (await agentApiRequest('/v2/agent', options, { + method: 'POST', + body: buildAgentStartBody(attemptParams), + })) as ThreadAwareStartResponse; + } catch (error) { + const unsupported = mapThreadSupportError(error); + if (unsupported) throw new Error(unsupported); + throw error; + } + }; + + const remembered = getRememberedThread(options.apiKey)?.lastThreadId ?? null; + const intent = resolveThreadIntent(options, remembered); + if (intent.missingMemory) { + onNotice('No remembered thread; starting a new one.'); + } + + let response: ThreadAwareStartResponse; + try { + response = await attempt({ ...params, threadId: intent.threadId }); + } catch (error) { + if (!isThreadGoneError(error)) throw error; + + if (intent.threadId && intent.threadId === remembered) { + forgetThread(options.apiKey); + } + + // A resolved approval only means something inside its thread, so a lost + // thread there is a hard error rather than a fresh start. + const resolvingApproval = Boolean( + params.exchange?.approve || params.exchange?.decline + ); + if (!intent.fromMemory || resolvingApproval) throw error; + + onNotice('That thread is gone; starting a new one.'); + response = await attempt({ ...params, threadId: undefined }); + } + + if (response?.threadId) { + rememberThread(options.apiKey, { + threadId: response.threadId, + runId: response.id, + }); + } + + return response; +} + /** * Execute agent command */ @@ -212,7 +544,8 @@ export async function executeAgent( ): Promise { try { const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl }); - const { prompt, status, cancel, wait, pollInterval, timeout } = options; + const { status, cancel, wait, pollInterval, timeout } = options; + const prompt = resolveStartPrompt(options); if (cancel) { const cancelled = await app.cancelAgent(prompt); @@ -246,20 +579,7 @@ export async function executeAgent( } // Build agent options - const agentParams: { - prompt: string; - urls?: string[]; - schema?: Record; - model?: 'spark-1-pro' | 'spark-1-mini'; - maxCredits?: number; - pollInterval?: number; - timeout?: number; - webhook?: string | AgentWebhookConfig; - integration?: string; - } = { - prompt, - integration: 'cli', - }; + const agentParams: AgentStartParams = { prompt }; if (options.urls && options.urls.length > 0) { agentParams.urls = options.urls; @@ -268,7 +588,7 @@ export async function executeAgent( agentParams.schema = schema; } if (options.model) { - agentParams.model = options.model as 'spark-1-pro' | 'spark-1-mini'; + agentParams.model = options.model; } if (options.maxCredits !== undefined) { agentParams.maxCredits = options.maxCredits; @@ -276,16 +596,31 @@ export async function executeAgent( if (options.webhook) { agentParams.webhook = options.webhook; } + if (options.mode) { + agentParams.mode = options.mode; + } + if (options.effort) { + agentParams.effort = options.effort; + } + if (options.exchange && Object.keys(options.exchange).length > 0) { + agentParams.exchange = options.exchange; + } // If wait mode, use polling with spinner if (wait) { const spinner = createSpinner('Starting agent...'); spinner.start(); + const notice = (message: string) => { + spinner.stop(); + process.stderr.write(`${message}\n`); + spinner.start(); + }; + // Start agent first - let response; + let response: ThreadAwareStartResponse; try { - response = await app.startAgent(agentParams); + response = await startAgentRun(options, agentParams, notice); } catch (error) { spinner.fail('Failed to start agent'); return { @@ -294,6 +629,7 @@ export async function executeAgent( }; } const jobId = response.id; + const threadId = response.threadId; // Handle Ctrl+C gracefully const handleInterrupt = () => { @@ -329,6 +665,8 @@ export async function executeAgent( data: agentStatus.data, creditsUsed: agentStatus.creditsUsed, expiresAt: agentStatus.expiresAt, + ...(threadId ? { threadId } : {}), + ...threadFields(agentStatus), }, }; } @@ -344,6 +682,8 @@ export async function executeAgent( data: agentStatus.data, creditsUsed: agentStatus.creditsUsed, expiresAt: agentStatus.expiresAt, + ...(threadId ? { threadId } : {}), + ...threadFields(agentStatus), }, error: agentStatus.error, }; @@ -368,9 +708,15 @@ export async function executeAgent( const spinner = createSpinner('Starting agent...'); spinner.start(); - let response; + const notice = (message: string) => { + spinner.stop(); + process.stderr.write(`${message}\n`); + spinner.start(); + }; + + let response: ThreadAwareStartResponse; try { - response = await app.startAgent(agentParams); + response = await startAgentRun(options, agentParams, notice); } catch (error) { spinner.fail('Failed to start agent'); return { @@ -386,6 +732,7 @@ export async function executeAgent( data: { jobId: response.id, status: 'processing', + ...(response.threadId ? { threadId: response.threadId } : {}), }, }; } catch (error) { @@ -396,6 +743,80 @@ export async function executeAgent( } } +function truncate(value: string, max: number): string { + return value.length > max ? `${value.slice(0, max - 1)}…` : value; +} + +function renderTable(headers: string[], rows: string[][]): string[] { + const widths = headers.map((header, i) => + Math.max(header.length, ...rows.map((row) => (row[i] ?? '').length)) + ); + const line = (cells: string[]) => + cells + .map((cell, i) => + i === cells.length - 1 ? cell : cell.padEnd(widths[i]) + ) + .join(' ') + .trimEnd(); + return [line(headers), ...rows.map(line)]; +} + +/** + * Render an approval the run is waiting on, plus the commands that resolve it. + * Everything shown comes straight off the API response. + */ +export function formatPendingApproval( + pendingApproval: AgentPendingApproval +): string { + const lines: string[] = []; + lines.push(`Awaiting approval: ${pendingApproval.id}`); + + if (typeof pendingApproval.reason === 'string' && pendingApproval.reason) { + lines.push(pendingApproval.reason); + } + + const calls = Array.isArray(pendingApproval.calls) + ? pendingApproval.calls + : []; + if (calls.length > 0) { + const rows = calls.map((call) => { + const args = + (call.input as unknown) ?? + (call as Record).arguments ?? + (call as Record).parameters; + const credits = call.creditsEstimate; + return [ + String(call.provider ?? ''), + String(call.capability ?? ''), + args === undefined ? '' : truncate(JSON.stringify(args), 80), + credits === undefined || credits === null ? '-' : String(credits), + ]; + }); + lines.push( + ...renderTable( + ['Provider', 'Capability', 'Arguments', 'Est. credits'], + rows + ) + ); + } + + lines.push(` firecrawl agent --approve ${pendingApproval.id}`); + lines.push(` firecrawl agent --decline ${pendingApproval.id}`); + + return lines.join('\n'); +} + +/** Render follow-ups as the commands that run them */ +export function formatSuggestions(suggestions: AgentSuggestion[]): string { + const lines: string[] = ['Try next:']; + for (const suggestion of suggestions) { + const prompt = suggestion.prompt || suggestion.label; + if (!prompt) continue; + lines.push(` firecrawl agent --continue "${prompt.replace(/"/g, '\\"')}"`); + } + return lines.join('\n'); +} + /** * Format agent status in human-readable way */ @@ -403,6 +824,13 @@ function formatAgentStatus(data: AgentStatusResult['data']): string { if (!data) return ''; const lines: string[] = []; + + // In chat mode the reply is the answer, so it leads the output + if (data.message) { + lines.push(data.message); + lines.push(''); + } + lines.push(`Job ID: ${data.id}`); lines.push(`Status: ${data.status}`); @@ -429,6 +857,16 @@ function formatAgentStatus(data: AgentStatusResult['data']): string { lines.push(JSON.stringify(data.data, null, 2)); } + if (data.pendingApproval) { + lines.push(''); + lines.push(formatPendingApproval(data.pendingApproval)); + } + + if (data.suggestions && data.suggestions.length > 0) { + lines.push(''); + lines.push(formatSuggestions(data.suggestions)); + } + return lines.join('\n') + '\n'; } @@ -443,6 +881,16 @@ export async function handleAgentCommand(options: AgentOptions): Promise { process.exit(1); } + // Every start reports the thread it belongs to. It goes to stderr so piped + // stdout keeps the shape callers already parse; --json carries it instead. + const startedRun = + !options.status && !options.cancel && !isJobId(options.prompt ?? ''); + const threadId = + result.data && 'threadId' in result.data ? result.data.threadId : undefined; + if (threadId && startedRun && !options.json) { + process.stderr.write(`Thread: ${threadId} (continue with --continue)\n`); + } + // Handle status result (completed agent job with data) if ('data' in result && result.data && 'data' in result.data) { const statusResult = result as AgentStatusResult; @@ -476,6 +924,9 @@ export async function handleAgentCommand(options: AgentOptions): Promise { const jobData = { jobId: agentResult.data.jobId, status: agentResult.data.status, + ...(agentResult.data.threadId + ? { threadId: agentResult.data.threadId } + : {}), }; outputContent = options.pretty @@ -489,3 +940,106 @@ export async function handleAgentCommand(options: AgentOptions): Promise { writeOutput(outputContent, options.output, !!options.output); } + +/** + * Fetch one conversation. GET /v2/agent/threads/:id has no SDK method in + * firecrawl@4.24.0, so it is called directly. + */ +export async function executeAgentThread( + threadId: string, + options: AgentThreadOptions = {} +): Promise { + try { + const payload = await agentApiRequest( + `/v2/agent/threads/${encodeURIComponent(threadId)}?includeData=true`, + options + ); + const thread = (payload?.thread ?? payload?.data) as + | AgentThread + | undefined; + if (!thread) { + return { success: false, error: `Thread not found: ${threadId}` }; + } + return { success: true, thread }; + } catch (error) { + const unsupported = mapThreadSupportError(error); + return { + success: false, + error: unsupported ?? extractErrorMessage(error), + }; + } +} + +function formatThreadRun(run: AgentThreadRun): string[] { + const heading = [ + `Turn ${run.turn}`, + run.mode, + run.status, + run.creditsUsed !== undefined && run.creditsUsed !== null + ? `${run.creditsUsed} credits` + : undefined, + ] + .filter(Boolean) + .join(' · '); + + const lines: string[] = [heading]; + + if (run.prompt) { + lines.push(` You: ${run.prompt}`); + } + if (run.message) { + lines.push(` Agent: ${run.message}`); + } + if (run.data !== undefined && run.data !== null) { + lines.push(' Result:'); + lines.push(JSON.stringify(run.data, null, 2)); + } + if (run.pendingApproval) { + lines.push(formatPendingApproval(run.pendingApproval)); + } + if (run.suggestions && run.suggestions.length > 0) { + lines.push(formatSuggestions(run.suggestions)); + } + + return lines; +} + +/** Render a thread as its turns, oldest first */ +export function formatThread(thread: AgentThread): string { + const lines: string[] = [`Thread: ${thread.id}`]; + if (thread.status) lines.push(`Status: ${thread.status}`); + if (thread.updatedAt) lines.push(`Updated: ${thread.updatedAt}`); + + const runs = [...(thread.runs ?? [])].sort((a, b) => a.turn - b.turn); + for (const run of runs) { + lines.push(''); + lines.push(...formatThreadRun(run)); + } + + return lines.join('\n') + '\n'; +} + +/** + * Handle `firecrawl agent thread ` output + */ +export async function handleAgentThreadCommand( + threadId: string, + options: AgentThreadOptions = {} +): Promise { + const result = await executeAgentThread(threadId, options); + + if (!result.success || !result.thread) { + console.error('Error:', result.error); + process.exit(1); + } + + const outputContent = options.json + ? JSON.stringify( + { success: true, thread: result.thread }, + null, + options.pretty ? 2 : 0 + ) + : formatThread(result.thread); + + writeOutput(outputContent, options.output, !!options.output); +} diff --git a/src/index.ts b/src/index.ts index 9bef16f0fd..4e9aa1d5a1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,7 +39,7 @@ import { parseEndpointFeedbackCliOptions, parseEndpointFeedbackEndpoint, } from './commands/feedback'; -import { handleAgentCommand } from './commands/agent'; +import { handleAgentCommand, handleAgentThreadCommand } from './commands/agent'; import { handleBrowserLaunch, handleBrowserExecute, @@ -1518,7 +1518,7 @@ function createAgentCommand(): Command { const agentCmd = new Command('agent') .description('Run an AI agent to extract data from the web') .argument( - '', + '[prompt-or-job-id]', 'Natural language prompt describing data to extract, or job ID to check status' ) .option('--urls ', 'Comma-separated URLs to focus extraction on') @@ -1540,6 +1540,42 @@ function createAgentCommand(): Command { parseInt ) .option('--webhook ', 'Webhook URL or webhook configuration') + .option('--thread ', 'Continue the thread with this ID') + .option( + '--continue', + 'Continue the last thread started with this API key', + false + ) + .option('--new', 'Ignore any remembered thread and start a new one', false) + .option( + '--mode ', + 'Run mode: extract (default, returns JSON) or chat (the agent may reply in prose)' + ) + .option('--effort ', 'Effort level: low, medium, or high') + .option('--exchange', 'Enable Firecrawl Exchange data providers', false) + .option( + '--toolkits ', + 'Comma-separated Exchange toolkits to limit the run to' + ) + .option( + '--max-calls ', + 'Maximum Exchange provider calls for this run', + parseInt + ) + .option('--require-approval', 'Ask before each paid Exchange call', false) + .option( + '--approve ', + 'Approve a pending approval and continue the thread' + ) + .option( + '--always', + 'With --approve, stop asking again in this thread', + false + ) + .option( + '--decline ', + 'Decline a pending approval and continue the thread' + ) .option('--status', 'Check status of existing agent job', false) .option('--cancel', 'Cancel active agent job by job ID', false) .option( @@ -1566,17 +1602,54 @@ function createAgentCommand(): Command { .option('--json', 'Output as JSON format', false) .option('--pretty', 'Pretty print JSON output', false) .action(async (promptOrJobId, options) => { + const resolvingApproval = !!(options.approve || options.decline); + + if (!promptOrJobId && !resolvingApproval) { + console.error( + 'Error: a prompt or job ID is required (or use --approve/--decline).' + ); + process.exit(1); + } + + const prompt = promptOrJobId ?? ''; + // Auto-detect if it's a job ID (UUID format) - const isStatusCheck = options.status || isJobId(promptOrJobId); + const isStatusCheck = options.status || isJobId(prompt); const isCancel = options.cancel; - if ((isStatusCheck || isCancel) && !isJobId(promptOrJobId)) { + if ((isStatusCheck || isCancel) && !isJobId(prompt)) { console.error( 'Error: --status and --cancel require a job ID, not a prompt.' ); process.exit(1); } + if (options.continue && options.new) { + console.error('Error: use --continue or --new, not both.'); + process.exit(1); + } + + if (options.approve && options.decline) { + console.error('Error: use --approve or --decline, not both.'); + process.exit(1); + } + + const validModes = ['extract', 'chat']; + if (options.mode && !validModes.includes(options.mode)) { + console.error( + `Error: Invalid mode "${options.mode}". Valid modes: ${validModes.join(', ')}` + ); + process.exit(1); + } + + const validEfforts = ['low', 'medium', 'high']; + if (options.effort && !validEfforts.includes(options.effort)) { + console.error( + `Error: Invalid effort "${options.effort}". Valid levels: ${validEfforts.join(', ')}` + ); + process.exit(1); + } + // Parse URLs let urls: string[] | undefined; if (options.urls) { @@ -1623,8 +1696,28 @@ function createAgentCommand(): Command { process.exit(1); } + const exchange: Record = {}; + if (options.exchange) exchange.enabled = true; + if (options.toolkits) { + exchange.toolkits = options.toolkits + .split(',') + .map((t: string) => t.trim()) + .filter((t: string) => t.length > 0); + } + if (options.maxCalls !== undefined) exchange.maxCalls = options.maxCalls; + if (options.requireApproval) exchange.requireApproval = true; + if (options.approve) { + exchange.approve = { + approvalId: options.approve, + ...(options.always ? { always: true } : {}), + }; + } + if (options.decline) { + exchange.decline = { approvalId: options.decline }; + } + const agentOptions = { - prompt: promptOrJobId, + prompt, urls, schema, model: options.model, @@ -1640,11 +1733,49 @@ function createAgentCommand(): Command { json: options.json, pretty: options.pretty, webhook, + thread: options.thread, + continue: options.continue, + new: options.new, + mode: options.mode, + effort: options.effort, + ...(Object.keys(exchange).length > 0 ? { exchange } : {}), }; await handleAgentCommand(agentOptions); }); + agentCmd + .command('thread') + .description('Print an agent thread conversation') + .argument('', 'Thread ID') + .option( + '-k, --api-key ', + 'Firecrawl API key (overrides global --api-key)' + ) + .option('--api-url ', 'API URL (overrides global --api-url)') + .option('-o, --output ', 'Output file path (default: stdout)') + .option('--json', 'Output as JSON format', false) + .option('--pretty', 'Pretty print JSON output', false) + .action(async (threadId, options) => { + await handleAgentThreadCommand(threadId, { + apiKey: options.apiKey, + apiUrl: options.apiUrl, + output: options.output, + json: options.json, + pretty: options.pretty, + }); + }); + + agentCmd.addHelpText( + 'after', + ` +Threads: + $ firecrawl agent --mode chat "List the pricing tiers on example.com" --wait + $ firecrawl agent --continue "Which tier includes SSO?" --wait + $ firecrawl agent thread +` + ); + return agentCmd; } diff --git a/src/types/agent.ts b/src/types/agent.ts index e02d1fdc9c..3e1bb6d36f 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -8,6 +8,44 @@ export type AgentModel = 'spark-1-pro' | 'spark-1-mini'; export type AgentStatus = 'processing' | 'completed' | 'failed' | 'cancelled'; +export type AgentMode = 'extract' | 'chat'; + +export type AgentEffort = 'low' | 'medium' | 'high'; + +/** Follow-up the agent proposes for the next turn */ +export interface AgentSuggestion { + label: string; + prompt: string; +} + +/** + * Approval the run is waiting on, as returned by the API. Rendered generically: + * the CLI reads the fields off the object and does not interpret them. + */ +export interface AgentPendingApproval { + id: string; + reason?: string; + calls?: Array<{ + id?: string; + provider?: string; + capability?: string; + input?: Record; + creditsEstimate?: number | null; + [key: string]: unknown; + }>; + [key: string]: unknown; +} + +/** Firecrawl Exchange options, passed through to the API untouched */ +export interface AgentExchangeOptions { + enabled?: boolean; + toolkits?: string[]; + maxCalls?: number; + requireApproval?: boolean; + approve?: { approvalId: string; always?: boolean }; + decline?: { approvalId: string }; +} + export interface AgentOptions { /** Natural language prompt describing the data to extract */ prompt: string; @@ -43,6 +81,18 @@ export interface AgentOptions { pretty?: boolean; /** Force JSON output */ json?: boolean; + /** Continue the thread with this ID */ + thread?: string; + /** Continue the thread last started with this API key */ + continue?: boolean; + /** Ignore any remembered thread and start a new one */ + new?: boolean; + /** Run mode: extract (default, JSON) or chat (the agent may reply in prose) */ + mode?: AgentMode; + /** How much work the agent should put into the run */ + effort?: AgentEffort; + /** Firecrawl Exchange options */ + exchange?: AgentExchangeOptions; } export interface AgentResult { @@ -50,6 +100,7 @@ export interface AgentResult { data?: { jobId: string; status: AgentStatus; + threadId?: string; }; error?: string; } @@ -62,6 +113,51 @@ export interface AgentStatusResult { data?: any; creditsUsed?: number; expiresAt?: string; + threadId?: string; + threadTurn?: number; + mode?: AgentMode; + message?: string; + suggestions?: AgentSuggestion[]; + pendingApproval?: AgentPendingApproval; }; error?: string; } + +/** One turn of a thread, from GET /v2/agent/threads/:id */ +export interface AgentThreadRun { + id: string; + turn: number; + mode?: AgentMode; + prompt?: string; + urls?: string[]; + status?: string; + createdAt?: string; + finishedAt?: string | null; + creditsUsed?: number | null; + message?: string | null; + data?: unknown; + suggestions?: AgentSuggestion[] | null; + pendingApproval?: AgentPendingApproval | null; +} + +export interface AgentThread { + id: string; + createdAt?: string; + updatedAt?: string; + status?: string; + runs: AgentThreadRun[]; +} + +export interface AgentThreadOptions { + apiKey?: string; + apiUrl?: string; + output?: string; + json?: boolean; + pretty?: boolean; +} + +export interface AgentThreadResult { + success: boolean; + thread?: AgentThread; + error?: string; +} From 2858ff28df4d304208ea8d207e5c7bccd81c569d Mon Sep 17 00:00:00 2001 From: Nicolas <20311743+nickscamara@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:02:28 -0700 Subject: [PATCH 03/13] Cover agent threads with tests Pins the behaviour a follow-up depends on: which thread a run continues, that thread memory is per API key and dropped when the server no longer has the thread, that a chat reply leads the output, and that resolving an approval sends its control prompt. Resolving an approval now fails early when no thread is in play, since an approval only exists inside one. Co-authored-by: Cursor --- src/__tests__/cli-argv.test.ts | 42 ++ src/__tests__/commands/agent.test.ts | 465 ++++++++++++++++++++++ src/__tests__/utils/agent-threads.test.ts | 87 ++++ src/commands/agent.ts | 19 +- 4 files changed, 608 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/commands/agent.test.ts create mode 100644 src/__tests__/utils/agent-threads.test.ts diff --git a/src/__tests__/cli-argv.test.ts b/src/__tests__/cli-argv.test.ts index fd04e7d3a3..9aa21de41f 100644 --- a/src/__tests__/cli-argv.test.ts +++ b/src/__tests__/cli-argv.test.ts @@ -97,6 +97,48 @@ describe('CLI argv parsing', () => { } ); + testWithBuiltCli('exposes the agent thread flags and subcommand', () => { + const result = spawnSync(process.execPath, [cliPath, 'agent', '--help'], { + cwd: process.cwd(), + encoding: 'utf8', + }); + + expect(result.status).toBe(0); + const flattened = result.stdout.replace(/\s+/g, ' '); + for (const flag of [ + '--thread', + '--continue', + '--new', + '--mode', + '--effort', + '--exchange', + '--toolkits', + '--max-calls', + '--require-approval', + '--approve', + '--decline', + ]) { + expect(flattened).toContain(flag); + } + expect(flattened).toContain('thread [options] '); + expect(result.stderr).not.toContain('unknown command'); + }); + + testWithBuiltCli('parses the agent thread subcommand', () => { + const result = spawnSync( + process.execPath, + [cliPath, 'agent', 'thread', '--help'], + { + cwd: process.cwd(), + encoding: 'utf8', + } + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('Usage: firecrawl agent thread'); + expect(result.stderr).not.toContain('unknown command'); + }); + testWithBuiltCli( 'exposes explicit keyless MCP setup and launch flags', () => { diff --git a/src/__tests__/commands/agent.test.ts b/src/__tests__/commands/agent.test.ts new file mode 100644 index 0000000000..5ebbe6a5fb --- /dev/null +++ b/src/__tests__/commands/agent.test.ts @@ -0,0 +1,465 @@ +/** + * Tests for agent command threads + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + APPROVE_PROMPT, + DECLINE_PROMPT, + executeAgent, + executeAgentThread, + formatPendingApproval, + formatThread, + handleAgentCommand, +} from '../../commands/agent'; +import { getClient } from '../../utils/client'; +import { getRememberedThread, rememberThread } from '../../utils/agent-threads'; +import { initializeConfig } from '../../utils/config'; +import { setupTest, teardownTest } from '../utils/mock-client'; + +const { configDir } = vi.hoisted(() => ({ configDir: { path: '' } })); + +vi.mock('../../utils/credentials', () => ({ + loadCredentials: () => null, + getConfigDirectoryPath: () => configDir.path, +})); + +vi.mock('../../utils/client', async () => { + const actual = await vi.importActual('../../utils/client'); + return { ...actual, getClient: vi.fn() }; +}); + +const API_KEY = 'fc-test-key'; + +function jsonResponse(status: number, payload: unknown) { + return { + ok: status >= 200 && status < 300, + status, + statusText: 'OK', + json: vi.fn().mockResolvedValue(payload), + }; +} + +function lastRequestBody(mockFetch: ReturnType, index = 0): any { + const [, init] = mockFetch.mock.calls[index] as [string, { body: string }]; + return JSON.parse(init.body); +} + +describe('agent threads', () => { + let mockFetch: ReturnType; + let mockClient: any; + + beforeEach(() => { + setupTest(); + configDir.path = fs.mkdtempSync( + path.join(os.tmpdir(), 'firecrawl-agent-test-') + ); + initializeConfig({ apiKey: API_KEY, apiUrl: 'https://api.firecrawl.dev' }); + + mockClient = { startAgent: vi.fn(), getAgentStatus: vi.fn() }; + vi.mocked(getClient).mockReturnValue(mockClient as any); + + mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + fs.rmSync(configDir.path, { recursive: true, force: true }); + teardownTest(); + vi.clearAllMocks(); + }); + + describe('continuing a thread', () => { + it('sends the remembered thread and records the new run', async () => { + rememberThread(API_KEY, { threadId: 'thread-1', runId: 'run-1' }); + mockFetch.mockResolvedValue( + jsonResponse(200, { + success: true, + id: 'run-2', + threadId: 'thread-1', + threadTurn: 2, + }) + ); + + const result = await executeAgent({ + prompt: 'Which tier includes SSO?', + continue: true, + apiKey: API_KEY, + }); + + expect(result.success).toBe(true); + const [url, init] = mockFetch.mock.calls[0] as [ + string, + { method: string; headers: Record }, + ]; + expect(url).toBe('https://api.firecrawl.dev/v2/agent'); + expect(init.method).toBe('POST'); + expect(init.headers.Authorization).toBe(`Bearer ${API_KEY}`); + expect(lastRequestBody(mockFetch)).toEqual({ + prompt: 'Which tier includes SSO?', + integration: 'cli', + threadId: 'thread-1', + }); + + const remembered = getRememberedThread(API_KEY); + expect(remembered?.lastThreadId).toBe('thread-1'); + expect(remembered?.lastRunId).toBe('run-2'); + }); + + it('does not reuse a thread remembered for another API key', async () => { + rememberThread('fc-other-key', { threadId: 'other-thread' }); + mockFetch.mockResolvedValue( + jsonResponse(200, { success: true, id: 'run-2', threadId: 'thread-9' }) + ); + + await executeAgent({ + prompt: 'follow up', + continue: true, + mode: 'chat', + apiKey: API_KEY, + }); + + expect(lastRequestBody(mockFetch).threadId).toBeUndefined(); + expect(getRememberedThread('fc-other-key')?.lastThreadId).toBe( + 'other-thread' + ); + }); + + it('says so when there is no remembered thread to continue', async () => { + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + mockFetch.mockResolvedValue( + jsonResponse(200, { success: true, id: 'run-1', threadId: 'thread-1' }) + ); + + await executeAgent({ + prompt: 'start something', + continue: true, + mode: 'chat', + apiKey: API_KEY, + }); + + const written = stderr.mock.calls.map((call) => call[0]).join(''); + expect(written).toContain('No remembered thread; starting a new one.'); + stderr.mockRestore(); + }); + + it('lets --thread override the remembered thread', async () => { + rememberThread(API_KEY, { threadId: 'thread-1' }); + mockFetch.mockResolvedValue( + jsonResponse(200, { success: true, id: 'run-5', threadId: 'thread-9' }) + ); + + await executeAgent({ + prompt: 'follow up', + thread: 'thread-9', + continue: true, + apiKey: API_KEY, + }); + + expect(lastRequestBody(mockFetch).threadId).toBe('thread-9'); + expect(getRememberedThread(API_KEY)?.lastThreadId).toBe('thread-9'); + }); + + it('clears the entry and starts fresh when the thread is gone', async () => { + rememberThread(API_KEY, { threadId: 'thread-gone' }); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + mockFetch + .mockResolvedValueOnce( + jsonResponse(404, { + success: false, + error: 'Thread not found', + code: 'thread_not_found', + }) + ) + .mockResolvedValueOnce( + jsonResponse(200, { + success: true, + id: 'run-7', + threadId: 'thread-new', + }) + ); + + const result = await executeAgent({ + prompt: 'follow up', + continue: true, + mode: 'chat', + apiKey: API_KEY, + }); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(lastRequestBody(mockFetch, 0).threadId).toBe('thread-gone'); + expect(lastRequestBody(mockFetch, 1).threadId).toBeUndefined(); + expect(getRememberedThread(API_KEY)?.lastThreadId).toBe('thread-new'); + + const written = stderr.mock.calls.map((call) => call[0]).join(''); + expect(written).toContain('That thread is gone; starting a new one.'); + stderr.mockRestore(); + }); + + it('clears the entry when an expired thread is gone for good', async () => { + rememberThread(API_KEY, { threadId: 'thread-expired' }); + mockFetch + .mockResolvedValueOnce( + jsonResponse(410, { + success: false, + error: 'Thread expired', + code: 'thread_expired', + }) + ) + .mockResolvedValueOnce( + jsonResponse(200, { success: true, id: 'run-8' }) + ); + + await executeAgent({ + prompt: 'follow up', + continue: true, + mode: 'chat', + apiKey: API_KEY, + }); + + expect(getRememberedThread(API_KEY)).toBeNull(); + }); + + it('reports an API without thread support', async () => { + mockFetch.mockResolvedValue( + jsonResponse(400, { + success: false, + error: 'Unrecognized key in body: threadId', + }) + ); + + const result = await executeAgent({ + prompt: 'follow up', + thread: 'thread-1', + apiKey: API_KEY, + }); + + expect(result).toEqual({ + success: false, + error: 'This Firecrawl API does not support threads yet', + }); + }); + }); + + describe('approvals', () => { + it('--approve sends the control prompt and the approve payload', async () => { + rememberThread(API_KEY, { threadId: 'thread-1' }); + mockFetch.mockResolvedValue( + jsonResponse(200, { success: true, id: 'run-9', threadId: 'thread-1' }) + ); + + await executeAgent({ + prompt: '', + exchange: { approve: { approvalId: 'a1', always: true } }, + apiKey: API_KEY, + }); + + expect(lastRequestBody(mockFetch)).toEqual({ + prompt: APPROVE_PROMPT, + integration: 'cli', + threadId: 'thread-1', + exchange: { approve: { approvalId: 'a1', always: true } }, + }); + }); + + it('--decline sends its own control prompt', async () => { + rememberThread(API_KEY, { threadId: 'thread-1' }); + mockFetch.mockResolvedValue( + jsonResponse(200, { success: true, id: 'run-10', threadId: 'thread-1' }) + ); + + await executeAgent({ + prompt: '', + exchange: { decline: { approvalId: 'a1' } }, + apiKey: API_KEY, + }); + + const body = lastRequestBody(mockFetch); + expect(body.prompt).toBe(DECLINE_PROMPT); + expect(body.exchange).toEqual({ decline: { approvalId: 'a1' } }); + expect(body.threadId).toBe('thread-1'); + }); + + it('refuses to resolve an approval with no thread to resolve it in', async () => { + const result = await executeAgent({ + prompt: '', + exchange: { approve: { approvalId: 'a1' } }, + apiKey: API_KEY, + }); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + expect(result.error).toContain('No thread to resolve that approval in'); + }); + + it('renders a pending approval and the commands that resolve it', () => { + const rendered = formatPendingApproval({ + id: 'a1', + reason: 'One call answers this.', + calls: [ + { + id: 'c1', + provider: 'provider-slug', + capability: 'capability/slug', + input: { symbol: 'ACME' }, + creditsEstimate: 5, + }, + ], + }); + + expect(rendered).toContain('Awaiting approval: a1'); + expect(rendered).toContain('One call answers this.'); + expect(rendered).toContain('Provider'); + expect(rendered).toContain('Capability'); + expect(rendered).toContain('Est. credits'); + expect(rendered).toContain('provider-slug'); + expect(rendered).toContain('capability/slug'); + expect(rendered).toContain('{"symbol":"ACME"}'); + expect(rendered).toContain('firecrawl agent --approve a1'); + expect(rendered).toContain('firecrawl agent --decline a1'); + }); + }); + + describe('chat output', () => { + it('prints the message before the data and lists follow-ups', async () => { + const stdout = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const stderr = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + + mockFetch.mockResolvedValue( + jsonResponse(200, { success: true, id: 'run-1', threadId: 'thread-1' }) + ); + mockClient.getAgentStatus.mockResolvedValue({ + success: true, + status: 'completed', + data: { tiers: ['free'] }, + creditsUsed: 31, + expiresAt: '2026-01-01T00:00:00.000Z', + threadId: 'thread-1', + threadTurn: 2, + mode: 'chat', + message: 'Only the Enterprise tier lists SSO.', + suggestions: [ + { label: 'Seats?', prompt: 'Does the Team tier cap seats?' }, + ], + }); + + await handleAgentCommand({ + prompt: 'Which tier includes SSO?', + mode: 'chat', + wait: true, + pollInterval: 0.001, + apiKey: API_KEY, + }); + + const written = stdout.mock.calls.map((call) => call[0]).join(''); + expect(written.indexOf('Only the Enterprise tier lists SSO.')).toBe(0); + expect( + written.indexOf('Only the Enterprise tier lists SSO.') + ).toBeLessThan(written.indexOf('"tiers"')); + expect(written).toContain('Try next:'); + expect(written).toContain( + 'firecrawl agent --continue "Does the Team tier cap seats?"' + ); + + const errors = stderr.mock.calls.map((call) => call[0]).join(''); + expect(errors).toContain('Thread: thread-1 (continue with --continue)'); + + stdout.mockRestore(); + stderr.mockRestore(); + }); + }); + + describe('agent thread ', () => { + it('reads the conversation and renders its turns', async () => { + mockFetch.mockResolvedValue( + jsonResponse(200, { + success: true, + thread: { + id: 'thread-1', + status: 'idle', + updatedAt: '2026-01-01T00:00:00.000Z', + runs: [ + { + id: 'run-1', + turn: 1, + mode: 'chat', + prompt: 'List the pricing tiers', + status: 'succeeded', + creditsUsed: 212, + data: { tiers: ['free'] }, + }, + { + id: 'run-2', + turn: 2, + mode: 'chat', + prompt: 'Which tier includes SSO?', + status: 'succeeded', + creditsUsed: 31, + message: 'Only the Enterprise tier lists SSO.', + suggestions: [ + { label: 'Seats?', prompt: 'Does the Team tier cap seats?' }, + ], + }, + ], + }, + }) + ); + + const result = await executeAgentThread('thread-1', { apiKey: API_KEY }); + + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toBe( + 'https://api.firecrawl.dev/v2/agent/threads/thread-1?includeData=true' + ); + expect(result.success).toBe(true); + + const rendered = formatThread(result.thread!); + expect(rendered).toContain('Thread: thread-1'); + expect(rendered).toContain('Turn 1 · chat · succeeded · 212 credits'); + expect(rendered).toContain('You: List the pricing tiers'); + expect(rendered).toContain('"tiers"'); + expect(rendered).toContain('Turn 2 · chat · succeeded · 31 credits'); + expect(rendered).toContain('Agent: Only the Enterprise tier lists SSO.'); + expect(rendered).toContain( + 'firecrawl agent --continue "Does the Team tier cap seats?"' + ); + expect(rendered.indexOf('Turn 1')).toBeLessThan( + rendered.indexOf('Turn 2') + ); + }); + }); + + describe('runs without thread flags', () => { + it('still starts through the SDK with the same arguments', async () => { + mockClient.startAgent.mockResolvedValue({ success: true, id: 'run-1' }); + + const result = await executeAgent({ + prompt: 'Find the pricing plans', + apiKey: API_KEY, + }); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockClient.startAgent).toHaveBeenCalledWith({ + prompt: 'Find the pricing plans', + integration: 'cli', + }); + expect(result).toEqual({ + success: true, + data: { jobId: 'run-1', status: 'processing' }, + }); + }); + }); +}); diff --git a/src/__tests__/utils/agent-threads.test.ts b/src/__tests__/utils/agent-threads.test.ts new file mode 100644 index 0000000000..7569c1e4aa --- /dev/null +++ b/src/__tests__/utils/agent-threads.test.ts @@ -0,0 +1,87 @@ +/** + * Tests for agent thread memory + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + apiKeyFingerprint, + forgetThread, + getRememberedThread, + loadAgentThreadStore, + rememberThread, +} from '../../utils/agent-threads'; +import { getAgentThreadsPath } from '../../utils/config'; + +const { configDir } = vi.hoisted(() => ({ configDir: { path: '' } })); + +vi.mock('../../utils/credentials', () => ({ + loadCredentials: () => null, + getConfigDirectoryPath: () => configDir.path, +})); + +describe('agent thread memory', () => { + beforeEach(() => { + configDir.path = fs.mkdtempSync( + path.join(os.tmpdir(), 'firecrawl-threads-test-') + ); + }); + + afterEach(() => { + fs.rmSync(configDir.path, { recursive: true, force: true }); + }); + + it('stores the file next to the other config-dir files', () => { + expect(getAgentThreadsPath()).toBe( + path.join(configDir.path, 'agent-threads.json') + ); + }); + + it('returns nothing when no thread has been started', () => { + expect(getRememberedThread('fc-test-key')).toBeNull(); + }); + + it('round-trips the last thread and run for an API key', () => { + rememberThread('fc-test-key', { threadId: 'thread-1', runId: 'run-1' }); + + const remembered = getRememberedThread('fc-test-key'); + expect(remembered?.lastThreadId).toBe('thread-1'); + expect(remembered?.lastRunId).toBe('run-1'); + expect(Date.parse(remembered?.updatedAt ?? '')).not.toBeNaN(); + }); + + it('keys entries by a hash of the API key, never the key itself', () => { + rememberThread('fc-test-key', { threadId: 'thread-1' }); + + const store = loadAgentThreadStore(); + expect(Object.keys(store)).toEqual([apiKeyFingerprint('fc-test-key')]); + expect(JSON.stringify(store)).not.toContain('fc-test-key'); + }); + + it('keeps threads for different API keys apart', () => { + rememberThread('fc-key-a', { threadId: 'thread-a' }); + rememberThread('fc-key-b', { threadId: 'thread-b' }); + + expect(getRememberedThread('fc-key-a')?.lastThreadId).toBe('thread-a'); + expect(getRememberedThread('fc-key-b')?.lastThreadId).toBe('thread-b'); + }); + + it('forgets only the entry for the given API key', () => { + rememberThread('fc-key-a', { threadId: 'thread-a' }); + rememberThread('fc-key-b', { threadId: 'thread-b' }); + + forgetThread('fc-key-a'); + + expect(getRememberedThread('fc-key-a')).toBeNull(); + expect(getRememberedThread('fc-key-b')?.lastThreadId).toBe('thread-b'); + }); + + it('treats a corrupt file as empty rather than failing the command', () => { + fs.writeFileSync(getAgentThreadsPath(), 'not json', 'utf-8'); + + expect(loadAgentThreadStore()).toEqual({}); + expect(getRememberedThread('fc-test-key')).toBeNull(); + }); +}); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 9712b36663..04fd72639b 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -501,6 +501,18 @@ async function startAgentRun( const remembered = getRememberedThread(options.apiKey)?.lastThreadId ?? null; const intent = resolveThreadIntent(options, remembered); + + // An approval only exists inside a thread, so there is nothing to resolve + // without one. + const resolvingApproval = Boolean( + params.exchange?.approve || params.exchange?.decline + ); + if (resolvingApproval && !intent.threadId) { + throw new Error( + 'No thread to resolve that approval in. Pass --thread .' + ); + } + if (intent.missingMemory) { onNotice('No remembered thread; starting a new one.'); } @@ -515,11 +527,8 @@ async function startAgentRun( forgetThread(options.apiKey); } - // A resolved approval only means something inside its thread, so a lost - // thread there is a hard error rather than a fresh start. - const resolvingApproval = Boolean( - params.exchange?.approve || params.exchange?.decline - ); + // A lost thread is a hard error while resolving an approval: there is + // nothing to approve in a fresh one. if (!intent.fromMemory || resolvingApproval) throw error; onNotice('That thread is gone; starting a new one.'); From a8f49c1769ec2e675ba4032f09353b79bd1fe3fd Mon Sep 17 00:00:00 2001 From: Nicolas <20311743+nickscamara@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:02:34 -0700 Subject: [PATCH 04/13] Document agent threads in the README The new flags are only discoverable through --help otherwise, and follow-ups are the reason most people will reach for them. Co-authored-by: Cursor --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index 20170e2d68..bf0aea4b57 100644 --- a/README.md +++ b/README.md @@ -660,6 +660,27 @@ firecrawl agent firecrawl agent --wait ``` +#### Threads + +An agent run belongs to a thread. Follow-ups continue that thread with the full +context of the earlier turns, so you only send the new question. Every start +prints its thread ID, and the last thread you started is remembered per API key. + +```bash +# Start a conversation; chat mode lets the agent answer in prose +firecrawl agent --mode chat "List the pricing tiers on example.com" --wait + +# Follow up on the last thread started with this API key +firecrawl agent --continue "Which tier includes SSO?" --wait + +# Continue a specific thread, or force a new one +firecrawl agent --thread "And the annual price?" --wait +firecrawl agent --new "Start over on example.org" --wait + +# Print a whole conversation +firecrawl agent thread +``` + #### Agent Options | Option | Description | @@ -670,6 +691,18 @@ firecrawl agent --wait | `--schema-file ` | Path to JSON schema file for structured output | | `--max-credits ` | Maximum credits to spend (job fails if exceeded) | | `--webhook ` | Webhook URL or configuration | +| `--thread ` | Continue the thread with this ID | +| `--continue` | Continue the last thread started with this API key | +| `--new` | Ignore any remembered thread and start a new one | +| `--mode ` | `extract` (default, returns JSON) or `chat` (prose replies) | +| `--effort ` | Effort level: `low`, `medium`, or `high` | +| `--exchange` | Enable Firecrawl Exchange data providers | +| `--toolkits ` | Comma-separated Exchange toolkits to limit the run to | +| `--max-calls ` | Maximum Exchange provider calls for this run | +| `--require-approval` | Ask before each paid Exchange call | +| `--approve ` | Approve a pending approval and continue the thread | +| `--always` | With `--approve`, stop asking again in this thread | +| `--decline ` | Decline a pending approval and continue the thread | | `--status` | Check status of existing agent job | | `--cancel` | Cancel an active agent job by job ID | | `--wait` | Wait for agent to complete before returning results | From 446b152aa4b63646f519cddb0fe614ec2ad20dec Mon Sep 17 00:00:00 2001 From: Nicolas <20311743+nickscamara@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:03:35 -0700 Subject: [PATCH 05/13] Test which thread a run continues Locks in the precedence between --thread, --continue, --new and an approval being resolved. Co-authored-by: Cursor --- src/__tests__/commands/agent.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/__tests__/commands/agent.test.ts b/src/__tests__/commands/agent.test.ts index 5ebbe6a5fb..a0e98e4f1f 100644 --- a/src/__tests__/commands/agent.test.ts +++ b/src/__tests__/commands/agent.test.ts @@ -14,6 +14,7 @@ import { formatPendingApproval, formatThread, handleAgentCommand, + resolveThreadIntent, } from '../../commands/agent'; import { getClient } from '../../utils/client'; import { getRememberedThread, rememberThread } from '../../utils/agent-threads'; @@ -442,6 +443,27 @@ describe('agent threads', () => { }); }); + describe('thread precedence', () => { + it('prefers --thread, then --continue, then a new thread', () => { + expect( + resolveThreadIntent({ thread: 'thread-9', continue: true }, 'thread-1') + ).toMatchObject({ threadId: 'thread-9', fromMemory: false }); + expect(resolveThreadIntent({ continue: true }, 'thread-1')).toMatchObject( + { threadId: 'thread-1', fromMemory: true } + ); + expect(resolveThreadIntent({}, 'thread-1').threadId).toBeUndefined(); + expect( + resolveThreadIntent({ continue: true, new: true }, 'thread-1').threadId + ).toBeUndefined(); + expect( + resolveThreadIntent( + { exchange: { approve: { approvalId: 'a1' } } }, + 'thread-1' + ) + ).toMatchObject({ threadId: 'thread-1', fromMemory: true }); + }); + }); + describe('runs without thread flags', () => { it('still starts through the SDK with the same arguments', async () => { mockClient.startAgent.mockResolvedValue({ success: true, id: 'run-1' }); From 5e62ef4a7a2708eba9d4df679e2a55014a599c54 Mon Sep 17 00:00:00 2001 From: Nicolas <20311743+nickscamara@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:11:07 -0700 Subject: [PATCH 06/13] Let a follow-up drop inherited URLs and schema A follow-up inherits the previous turn's URLs and schema, and the only way to say "stop using those" is an empty list or an explicit null. Neither was reachable: both keys were dropped from the body when empty. --no-urls and --no-schema now send them. They only mean something inside a thread, and silently picking a winner when --urls and --no-urls are both passed would be worse than refusing, so both cases fail with a clear message. Runs without them post the same body as before. Co-authored-by: Cursor --- README.md | 13 ++++ src/__tests__/cli-argv.test.ts | 65 ++++++++++++++++ src/__tests__/commands/agent.test.ts | 109 +++++++++++++++++++++++++++ src/commands/agent.ts | 22 +++++- src/index.ts | 39 +++++++++- src/types/agent.ts | 4 + 6 files changed, 250 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bf0aea4b57..d38c17ef12 100644 --- a/README.md +++ b/README.md @@ -681,6 +681,17 @@ firecrawl agent --new "Start over on example.org" --wait firecrawl agent thread ``` +A follow-up inherits the URLs and schema of the previous turn unless you say +otherwise, so there are two flags to drop them: + +```bash +# Stop focusing on the URLs from earlier turns +firecrawl agent --continue --no-urls "Look anywhere on the site now" --wait + +# Drop the schema and let the agent answer in prose +firecrawl agent --continue --no-schema --mode chat "Summarise what changed" --wait +``` + #### Agent Options | Option | Description | @@ -691,6 +702,8 @@ firecrawl agent thread | `--schema-file ` | Path to JSON schema file for structured output | | `--max-credits ` | Maximum credits to spend (job fails if exceeded) | | `--webhook ` | Webhook URL or configuration | +| `--no-urls` | Drop the URLs inherited from the thread (follow-ups only) | +| `--no-schema` | Drop the schema inherited from the thread (follow-ups only) | | `--thread ` | Continue the thread with this ID | | `--continue` | Continue the last thread started with this API key | | `--new` | Ignore any remembered thread and start a new one | diff --git a/src/__tests__/cli-argv.test.ts b/src/__tests__/cli-argv.test.ts index 9aa21de41f..e55bb06824 100644 --- a/src/__tests__/cli-argv.test.ts +++ b/src/__tests__/cli-argv.test.ts @@ -124,6 +124,71 @@ describe('CLI argv parsing', () => { expect(result.stderr).not.toContain('unknown command'); }); + testWithBuiltCli('offers flags that clear inherited URLs and schema', () => { + const result = spawnSync(process.execPath, [cliPath, 'agent', '--help'], { + cwd: process.cwd(), + encoding: 'utf8', + }); + + expect(result.status).toBe(0); + const flattened = result.stdout.replace(/\s+/g, ' '); + expect(flattened).toContain('--no-urls'); + expect(flattened).toContain('--no-schema'); + }); + + testWithBuiltCli('requires a thread to clear URLs or schema', () => { + for (const flag of ['--no-urls', '--no-schema']) { + const result = spawnSync( + process.execPath, + [cliPath, 'agent', flag, 'a prompt'], + { cwd: process.cwd(), encoding: 'utf8' } + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + 'only apply to a follow-up. Pass --thread or --continue.' + ); + } + }); + + testWithBuiltCli('rejects clearing a value that is also being set', () => { + const urls = spawnSync( + process.execPath, + [ + cliPath, + 'agent', + '--continue', + '--urls', + 'https://example.com', + '--no-urls', + 'a prompt', + ], + { cwd: process.cwd(), encoding: 'utf8' } + ); + + expect(urls.status).toBe(1); + expect(urls.stderr).toContain('use --urls or --no-urls, not both.'); + + const schema = spawnSync( + process.execPath, + [ + cliPath, + 'agent', + '--continue', + '--schema', + '{"type":"object"}', + '--no-schema', + 'a prompt', + ], + { cwd: process.cwd(), encoding: 'utf8' } + ); + + expect(schema.status).toBe(1); + expect(schema.stderr).toContain( + 'use --schema/--schema-file or --no-schema, not both.' + ); + }); + testWithBuiltCli('parses the agent thread subcommand', () => { const result = spawnSync( process.execPath, diff --git a/src/__tests__/commands/agent.test.ts b/src/__tests__/commands/agent.test.ts index a0e98e4f1f..17134ca00b 100644 --- a/src/__tests__/commands/agent.test.ts +++ b/src/__tests__/commands/agent.test.ts @@ -443,6 +443,90 @@ describe('agent threads', () => { }); }); + describe('clearing inherited URLs and schema', () => { + it('sends an empty URL list for --no-urls', async () => { + rememberThread(API_KEY, { threadId: 'thread-1' }); + mockFetch.mockResolvedValue( + jsonResponse(200, { success: true, id: 'run-11', threadId: 'thread-1' }) + ); + + await executeAgent({ + prompt: 'Look anywhere on the site now', + continue: true, + clearUrls: true, + apiKey: API_KEY, + }); + + expect(lastRequestBody(mockFetch)).toEqual({ + prompt: 'Look anywhere on the site now', + integration: 'cli', + threadId: 'thread-1', + urls: [], + }); + }); + + it('sends a null schema for --no-schema', async () => { + rememberThread(API_KEY, { threadId: 'thread-1' }); + mockFetch.mockResolvedValue( + jsonResponse(200, { success: true, id: 'run-12', threadId: 'thread-1' }) + ); + + await executeAgent({ + prompt: 'Just answer in prose', + continue: true, + clearSchema: true, + apiKey: API_KEY, + }); + + const body = lastRequestBody(mockFetch); + expect(body.schema).toBeNull(); + expect('urls' in body).toBe(false); + }); + + it('clears both at once', async () => { + rememberThread(API_KEY, { threadId: 'thread-1' }); + mockFetch.mockResolvedValue( + jsonResponse(200, { success: true, id: 'run-13', threadId: 'thread-1' }) + ); + + await executeAgent({ + prompt: 'Start clean', + thread: 'thread-1', + clearUrls: true, + clearSchema: true, + apiKey: API_KEY, + }); + + expect(lastRequestBody(mockFetch)).toEqual({ + prompt: 'Start clean', + integration: 'cli', + threadId: 'thread-1', + urls: [], + schema: null, + }); + }); + + it('prefers explicit URLs and schema over clearing them', async () => { + mockFetch.mockResolvedValue( + jsonResponse(200, { success: true, id: 'run-14', threadId: 'thread-1' }) + ); + + await executeAgent({ + prompt: 'follow up', + thread: 'thread-1', + urls: ['https://example.com/pricing'], + schema: { type: 'object' }, + clearUrls: true, + clearSchema: true, + apiKey: API_KEY, + }); + + const body = lastRequestBody(mockFetch); + expect(body.urls).toEqual(['https://example.com/pricing']); + expect(body.schema).toEqual({ type: 'object' }); + }); + }); + describe('thread precedence', () => { it('prefers --thread, then --continue, then a new thread', () => { expect( @@ -483,5 +567,30 @@ describe('agent threads', () => { data: { jobId: 'run-1', status: 'processing' }, }); }); + + it('sends URLs, schema and credits exactly as it did before threads', async () => { + mockClient.startAgent.mockResolvedValue({ success: true, id: 'run-1' }); + + await executeAgent({ + prompt: 'Get the main features listed', + urls: ['https://example.com/features'], + schema: { type: 'object', properties: { name: { type: 'string' } } }, + model: 'spark-1-pro', + maxCredits: 100, + webhook: 'https://example.com/hook', + apiKey: API_KEY, + }); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockClient.startAgent).toHaveBeenCalledWith({ + prompt: 'Get the main features listed', + urls: ['https://example.com/features'], + schema: { type: 'object', properties: { name: { type: 'string' } } }, + model: 'spark-1-pro', + maxCredits: 100, + webhook: 'https://example.com/hook', + integration: 'cli', + }); + }); }); }); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 04fd72639b..aa8d199e25 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -157,6 +157,10 @@ export interface AgentStartParams { prompt: string; urls?: string[]; schema?: Record; + /** Send `urls: []`, which the API reads as "drop the thread's URLs" */ + clearUrls?: boolean; + /** Send `schema: null`, which the API reads as "drop the thread's schema" */ + clearSchema?: boolean; model?: string; maxCredits?: number; webhook?: string | AgentWebhookConfig; @@ -175,8 +179,13 @@ export function buildAgentStartBody( integration: 'cli', }; + // A follow-up inherits the previous turn's URLs and schema unless it sends an + // empty list or an explicit null, so clearing is its own request key rather + // than an absent one. if (params.urls && params.urls.length > 0) body.urls = params.urls; + else if (params.clearUrls) body.urls = []; if (params.schema) body.schema = params.schema; + else if (params.clearSchema) body.schema = null; if (params.model) body.model = params.model; if (params.maxCredits !== undefined) body.maxCredits = params.maxCredits; if (params.webhook) body.webhook = params.webhook; @@ -193,7 +202,12 @@ export function buildAgentStartBody( /** True when the request needs fields the pinned SDK cannot send. */ function needsRawStart(params: AgentStartParams): boolean { return Boolean( - params.threadId || params.mode || params.effort || params.exchange + params.threadId || + params.mode || + params.effort || + params.exchange || + params.clearUrls || + params.clearSchema ); } @@ -596,6 +610,12 @@ export async function executeAgent( if (schema) { agentParams.schema = schema; } + if (options.clearUrls) { + agentParams.clearUrls = true; + } + if (options.clearSchema) { + agentParams.clearSchema = true; + } if (options.model) { agentParams.model = options.model; } diff --git a/src/index.ts b/src/index.ts index 4e9aa1d5a1..dc858fe219 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1522,6 +1522,10 @@ function createAgentCommand(): Command { 'Natural language prompt describing data to extract, or job ID to check status' ) .option('--urls ', 'Comma-separated URLs to focus extraction on') + .option( + '--no-urls', + 'Drop the URLs inherited from the thread (follow-ups only)' + ) .option( '--model ', 'Model to use: spark-1-mini (default, cheaper) or spark-1-pro (higher accuracy)' @@ -1534,6 +1538,10 @@ function createAgentCommand(): Command { '--schema-file ', 'Path to JSON schema file for structured output' ) + .option( + '--no-schema', + 'Drop the schema inherited from the thread (follow-ups only)' + ) .option( '--max-credits ', 'Maximum credits to spend (job fails if exceeded)', @@ -1601,9 +1609,17 @@ function createAgentCommand(): Command { .option('-o, --output ', 'Output file path (default: stdout)') .option('--json', 'Output as JSON format', false) .option('--pretty', 'Pretty print JSON output', false) - .action(async (promptOrJobId, options) => { + .action(async (promptOrJobId, options, command) => { const resolvingApproval = !!(options.approve || options.decline); + // Commander stores --urls and --no-urls under one key, so passing both + // looks like whichever came last. Read the flags as typed to catch it. + const rawArgs: string[] = command.parent?.rawArgs ?? []; + const passed = (flag: string) => + rawArgs.some((arg) => arg === flag || arg.startsWith(`${flag}=`)); + const clearUrls = options.urls === false; + const clearSchema = options.schema === false; + if (!promptOrJobId && !resolvingApproval) { console.error( 'Error: a prompt or job ID is required (or use --approve/--decline).' @@ -1634,6 +1650,25 @@ function createAgentCommand(): Command { process.exit(1); } + if (clearUrls && passed('--urls')) { + console.error('Error: use --urls or --no-urls, not both.'); + process.exit(1); + } + + if (clearSchema && (passed('--schema') || options.schemaFile)) { + console.error( + 'Error: use --schema/--schema-file or --no-schema, not both.' + ); + process.exit(1); + } + + if ((clearUrls || clearSchema) && !options.thread && !options.continue) { + console.error( + 'Error: --no-urls and --no-schema only apply to a follow-up. Pass --thread or --continue.' + ); + process.exit(1); + } + const validModes = ['extract', 'chat']; if (options.mode && !validModes.includes(options.mode)) { console.error( @@ -1720,6 +1755,8 @@ function createAgentCommand(): Command { prompt, urls, schema, + clearUrls, + clearSchema, model: options.model, maxCredits: options.maxCredits, status: isStatusCheck, diff --git a/src/types/agent.ts b/src/types/agent.ts index 3e1bb6d36f..881a2c3989 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -57,6 +57,10 @@ export interface AgentOptions { schema?: Record; /** Path to JSON schema file */ schemaFile?: string; + /** Send an empty URL list, clearing URLs inherited from the thread */ + clearUrls?: boolean; + /** Send a null schema, clearing a schema inherited from the thread */ + clearSchema?: boolean; /** Webhook URL or webhook config */ webhook?: string | AgentWebhookConfig; /** Cancel active agent job by ID */ From 44adeb03adfb5ff095c7c952347d59e22248b2bb Mon Sep 17 00:00:00 2001 From: Nicolas <20311743+nickscamara@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:25:26 -0700 Subject: [PATCH 07/13] test(agent): reach the argument checks without a stored key Both cases spawned the built CLI with the ambient environment. On a machine with a key that lands in the agent command and the check runs; on CI there is no key, so the CLI stops at its login prompt, finds no stdin to answer, and exits 0. The assertions were reading that. They now run against a throwaway home with a key in the environment, so a remembered thread or a stored key on the machine cannot change the answer either. The key is never spent: both cases are rejected before a request. Co-authored-by: Cursor --- src/__tests__/cli-argv.test.ts | 85 +++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 33 deletions(-) diff --git a/src/__tests__/cli-argv.test.ts b/src/__tests__/cli-argv.test.ts index e55bb06824..c61f8af876 100644 --- a/src/__tests__/cli-argv.test.ts +++ b/src/__tests__/cli-argv.test.ts @@ -1,12 +1,45 @@ import { spawnSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; describe('CLI argv parsing', () => { const cliPath = resolve(process.cwd(), 'dist/index.js'); const testWithBuiltCli = existsSync(cliPath) ? it : it.skip; + /** + * A run that gets as far as the argument checks. + * + * Every other case here asks for `--help`, which Commander answers before + * any command runs. A case that reaches a command does not: without a key + * the CLI stops at its login prompt, and with no stdin to answer it exits 0. + * That is the difference between a developer's machine and CI, and it is + * what let these two pass locally while failing there. + * + * The home directory is thrown away too, so a remembered thread or a stored + * key on the machine running the tests cannot change the answer. The key is + * never spent: every case below is rejected before a request is made. + */ + const runAuthedCli = (args: string[]) => { + const home = mkdtempSync(join(tmpdir(), 'firecrawl-cli-argv-')); + try { + return spawnSync(process.execPath, [cliPath, ...args], { + cwd: process.cwd(), + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + FIRECRAWL_API_KEY: 'fc-argv-test', + FIRECRAWL_NO_TELEMETRY: '1', + }, + }); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }; + testWithBuiltCli('lists the developer command in root help output', () => { const result = spawnSync(process.execPath, [cliPath, '--help'], { cwd: process.cwd(), @@ -138,11 +171,7 @@ describe('CLI argv parsing', () => { testWithBuiltCli('requires a thread to clear URLs or schema', () => { for (const flag of ['--no-urls', '--no-schema']) { - const result = spawnSync( - process.execPath, - [cliPath, 'agent', flag, 'a prompt'], - { cwd: process.cwd(), encoding: 'utf8' } - ); + const result = runAuthedCli(['agent', flag, 'a prompt']); expect(result.status).toBe(1); expect(result.stderr).toContain( @@ -152,36 +181,26 @@ describe('CLI argv parsing', () => { }); testWithBuiltCli('rejects clearing a value that is also being set', () => { - const urls = spawnSync( - process.execPath, - [ - cliPath, - 'agent', - '--continue', - '--urls', - 'https://example.com', - '--no-urls', - 'a prompt', - ], - { cwd: process.cwd(), encoding: 'utf8' } - ); + const urls = runAuthedCli([ + 'agent', + '--continue', + '--urls', + 'https://example.com', + '--no-urls', + 'a prompt', + ]); expect(urls.status).toBe(1); expect(urls.stderr).toContain('use --urls or --no-urls, not both.'); - const schema = spawnSync( - process.execPath, - [ - cliPath, - 'agent', - '--continue', - '--schema', - '{"type":"object"}', - '--no-schema', - 'a prompt', - ], - { cwd: process.cwd(), encoding: 'utf8' } - ); + const schema = runAuthedCli([ + 'agent', + '--continue', + '--schema', + '{"type":"object"}', + '--no-schema', + 'a prompt', + ]); expect(schema.status).toBe(1); expect(schema.stderr).toContain( From 5202a0ef6ed7c64293cf6f69761bb9e2c84e2fa9 Mon Sep 17 00:00:00 2001 From: Nicolas <20311743+nickscamara@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:29:55 -0700 Subject: [PATCH 08/13] fix(agent): thread memory identity, keyless self-hosted reads, safe suggestions Thread memory was keyed on the --api-key flag alone. The key almost always comes from the environment or stored credentials instead, so nearly every run landed in the one keyless bucket and changing accounts reused the previous account's thread. It is keyed on the server and the effective key now, which also stops two keyless self-hosted servers being handed each other's thread IDs and 404ing them away. Remembering a thread read the store and wrote it back, so two runs finishing together lost one of the entries and the loser silently started a new thread on its next --continue. The update takes a lock, writes through a rename, and still goes ahead if the lock cannot be taken: memory is a convenience and is not worth hanging a command over. agent thread against a keyless --api-url was rejected, because the key requirement was read off global configuration rather than the URL the request resolved to. Suggestions were rendered into double quotes, which leaves $(...) and backticks live in something printed for a person to copy. They are single-quoted now. Restoring the spies in afterEach, so one failed assertion stops swallowing the output of every test after it. Co-authored-by: Cursor --- src/__tests__/commands/agent.test.ts | 91 +++++++++--- src/__tests__/utils/agent-threads.test.ts | 109 ++++++++++++--- src/commands/agent.ts | 37 ++++- src/utils/agent-threads.ts | 161 +++++++++++++++++----- src/utils/config.ts | 2 +- 5 files changed, 324 insertions(+), 76 deletions(-) diff --git a/src/__tests__/commands/agent.test.ts b/src/__tests__/commands/agent.test.ts index 17134ca00b..12fb130614 100644 --- a/src/__tests__/commands/agent.test.ts +++ b/src/__tests__/commands/agent.test.ts @@ -12,6 +12,7 @@ import { executeAgent, executeAgentThread, formatPendingApproval, + formatSuggestions, formatThread, handleAgentCommand, resolveThreadIntent, @@ -71,12 +72,19 @@ describe('agent threads', () => { vi.unstubAllGlobals(); fs.rmSync(configDir.path, { recursive: true, force: true }); teardownTest(); - vi.clearAllMocks(); + // Restores, not just clears: several cases spy on process.stdout.write and + // undo it on their last line, which an earlier failed assertion skips. + // Clearing alone leaves the spy installed and swallows the output of every + // test after it, turning one failure into a confusing handful. + vi.restoreAllMocks(); }); describe('continuing a thread', () => { it('sends the remembered thread and records the new run', async () => { - rememberThread(API_KEY, { threadId: 'thread-1', runId: 'run-1' }); + rememberThread( + { apiKey: API_KEY }, + { threadId: 'thread-1', runId: 'run-1' } + ); mockFetch.mockResolvedValue( jsonResponse(200, { success: true, @@ -106,13 +114,13 @@ describe('agent threads', () => { threadId: 'thread-1', }); - const remembered = getRememberedThread(API_KEY); + const remembered = getRememberedThread({ apiKey: API_KEY }); expect(remembered?.lastThreadId).toBe('thread-1'); expect(remembered?.lastRunId).toBe('run-2'); }); it('does not reuse a thread remembered for another API key', async () => { - rememberThread('fc-other-key', { threadId: 'other-thread' }); + rememberThread({ apiKey: 'fc-other-key' }, { threadId: 'other-thread' }); mockFetch.mockResolvedValue( jsonResponse(200, { success: true, id: 'run-2', threadId: 'thread-9' }) ); @@ -125,9 +133,9 @@ describe('agent threads', () => { }); expect(lastRequestBody(mockFetch).threadId).toBeUndefined(); - expect(getRememberedThread('fc-other-key')?.lastThreadId).toBe( - 'other-thread' - ); + expect( + getRememberedThread({ apiKey: 'fc-other-key' })?.lastThreadId + ).toBe('other-thread'); }); it('says so when there is no remembered thread to continue', async () => { @@ -151,7 +159,7 @@ describe('agent threads', () => { }); it('lets --thread override the remembered thread', async () => { - rememberThread(API_KEY, { threadId: 'thread-1' }); + rememberThread({ apiKey: API_KEY }, { threadId: 'thread-1' }); mockFetch.mockResolvedValue( jsonResponse(200, { success: true, id: 'run-5', threadId: 'thread-9' }) ); @@ -164,11 +172,13 @@ describe('agent threads', () => { }); expect(lastRequestBody(mockFetch).threadId).toBe('thread-9'); - expect(getRememberedThread(API_KEY)?.lastThreadId).toBe('thread-9'); + expect(getRememberedThread({ apiKey: API_KEY })?.lastThreadId).toBe( + 'thread-9' + ); }); it('clears the entry and starts fresh when the thread is gone', async () => { - rememberThread(API_KEY, { threadId: 'thread-gone' }); + rememberThread({ apiKey: API_KEY }, { threadId: 'thread-gone' }); const stderr = vi .spyOn(process.stderr, 'write') .mockImplementation(() => true); @@ -199,7 +209,9 @@ describe('agent threads', () => { expect(mockFetch).toHaveBeenCalledTimes(2); expect(lastRequestBody(mockFetch, 0).threadId).toBe('thread-gone'); expect(lastRequestBody(mockFetch, 1).threadId).toBeUndefined(); - expect(getRememberedThread(API_KEY)?.lastThreadId).toBe('thread-new'); + expect(getRememberedThread({ apiKey: API_KEY })?.lastThreadId).toBe( + 'thread-new' + ); const written = stderr.mock.calls.map((call) => call[0]).join(''); expect(written).toContain('That thread is gone; starting a new one.'); @@ -207,7 +219,7 @@ describe('agent threads', () => { }); it('clears the entry when an expired thread is gone for good', async () => { - rememberThread(API_KEY, { threadId: 'thread-expired' }); + rememberThread({ apiKey: API_KEY }, { threadId: 'thread-expired' }); mockFetch .mockResolvedValueOnce( jsonResponse(410, { @@ -227,7 +239,7 @@ describe('agent threads', () => { apiKey: API_KEY, }); - expect(getRememberedThread(API_KEY)).toBeNull(); + expect(getRememberedThread({ apiKey: API_KEY })).toBeNull(); }); it('reports an API without thread support', async () => { @@ -253,7 +265,7 @@ describe('agent threads', () => { describe('approvals', () => { it('--approve sends the control prompt and the approve payload', async () => { - rememberThread(API_KEY, { threadId: 'thread-1' }); + rememberThread({ apiKey: API_KEY }, { threadId: 'thread-1' }); mockFetch.mockResolvedValue( jsonResponse(200, { success: true, id: 'run-9', threadId: 'thread-1' }) ); @@ -273,7 +285,7 @@ describe('agent threads', () => { }); it('--decline sends its own control prompt', async () => { - rememberThread(API_KEY, { threadId: 'thread-1' }); + rememberThread({ apiKey: API_KEY }, { threadId: 'thread-1' }); mockFetch.mockResolvedValue( jsonResponse(200, { success: true, id: 'run-10', threadId: 'thread-1' }) ); @@ -372,7 +384,7 @@ describe('agent threads', () => { ).toBeLessThan(written.indexOf('"tiers"')); expect(written).toContain('Try next:'); expect(written).toContain( - 'firecrawl agent --continue "Does the Team tier cap seats?"' + "firecrawl agent --continue 'Does the Team tier cap seats?'" ); const errors = stderr.mock.calls.map((call) => call[0]).join(''); @@ -435,17 +447,56 @@ describe('agent threads', () => { expect(rendered).toContain('Turn 2 · chat · succeeded · 31 credits'); expect(rendered).toContain('Agent: Only the Enterprise tier lists SSO.'); expect(rendered).toContain( - 'firecrawl agent --continue "Does the Team tier cap seats?"' + "firecrawl agent --continue 'Does the Team tier cap seats?'" ); expect(rendered.indexOf('Turn 1')).toBeLessThan( rendered.indexOf('Turn 2') ); }); + + it('reads a keyless self-hosted thread through --api-url', async () => { + // The key requirement follows the server the request resolved to. A + // custom --api-url is a self-hosted one, and those need no key. + initializeConfig({ apiUrl: 'https://api.firecrawl.dev' }); + mockFetch.mockResolvedValue( + jsonResponse(200, { + success: true, + thread: { id: 'thread-1', status: 'idle', runs: [] }, + }) + ); + + const result = await executeAgentThread('thread-1', { + apiUrl: 'http://localhost:3002', + }); + + expect(result.success).toBe(true); + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toBe( + 'http://localhost:3002/v2/agent/threads/thread-1?includeData=true' + ); + }); + }); + + describe('rendering follow-ups', () => { + it('quotes a suggestion so copying it cannot run anything', () => { + const rendered = formatSuggestions([ + { label: 'x', prompt: 'price of $(whoami) and `id`' }, + { label: 'y', prompt: "the vendor's own page" }, + ]); + + expect(rendered).toContain( + "firecrawl agent --continue 'price of $(whoami) and `id`'" + ); + // The one character single quotes cannot carry, carried anyway. + expect(rendered).toContain( + "firecrawl agent --continue 'the vendor'\\''s own page'" + ); + }); }); describe('clearing inherited URLs and schema', () => { it('sends an empty URL list for --no-urls', async () => { - rememberThread(API_KEY, { threadId: 'thread-1' }); + rememberThread({ apiKey: API_KEY }, { threadId: 'thread-1' }); mockFetch.mockResolvedValue( jsonResponse(200, { success: true, id: 'run-11', threadId: 'thread-1' }) ); @@ -466,7 +517,7 @@ describe('agent threads', () => { }); it('sends a null schema for --no-schema', async () => { - rememberThread(API_KEY, { threadId: 'thread-1' }); + rememberThread({ apiKey: API_KEY }, { threadId: 'thread-1' }); mockFetch.mockResolvedValue( jsonResponse(200, { success: true, id: 'run-12', threadId: 'thread-1' }) ); @@ -484,7 +535,7 @@ describe('agent threads', () => { }); it('clears both at once', async () => { - rememberThread(API_KEY, { threadId: 'thread-1' }); + rememberThread({ apiKey: API_KEY }, { threadId: 'thread-1' }); mockFetch.mockResolvedValue( jsonResponse(200, { success: true, id: 'run-13', threadId: 'thread-1' }) ); diff --git a/src/__tests__/utils/agent-threads.test.ts b/src/__tests__/utils/agent-threads.test.ts index 7569c1e4aa..2c6b0bf456 100644 --- a/src/__tests__/utils/agent-threads.test.ts +++ b/src/__tests__/utils/agent-threads.test.ts @@ -7,7 +7,7 @@ import * as os from 'os'; import * as path from 'path'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { - apiKeyFingerprint, + threadFingerprint, forgetThread, getRememberedThread, loadAgentThreadStore, @@ -40,48 +40,125 @@ describe('agent thread memory', () => { }); it('returns nothing when no thread has been started', () => { - expect(getRememberedThread('fc-test-key')).toBeNull(); + expect(getRememberedThread({ apiKey: 'fc-test-key' })).toBeNull(); }); it('round-trips the last thread and run for an API key', () => { - rememberThread('fc-test-key', { threadId: 'thread-1', runId: 'run-1' }); + rememberThread( + { apiKey: 'fc-test-key' }, + { threadId: 'thread-1', runId: 'run-1' } + ); - const remembered = getRememberedThread('fc-test-key'); + const remembered = getRememberedThread({ apiKey: 'fc-test-key' }); expect(remembered?.lastThreadId).toBe('thread-1'); expect(remembered?.lastRunId).toBe('run-1'); expect(Date.parse(remembered?.updatedAt ?? '')).not.toBeNaN(); }); it('keys entries by a hash of the API key, never the key itself', () => { - rememberThread('fc-test-key', { threadId: 'thread-1' }); + rememberThread({ apiKey: 'fc-test-key' }, { threadId: 'thread-1' }); const store = loadAgentThreadStore(); - expect(Object.keys(store)).toEqual([apiKeyFingerprint('fc-test-key')]); + expect(Object.keys(store)).toEqual([ + threadFingerprint({ apiKey: 'fc-test-key' }), + ]); expect(JSON.stringify(store)).not.toContain('fc-test-key'); }); it('keeps threads for different API keys apart', () => { - rememberThread('fc-key-a', { threadId: 'thread-a' }); - rememberThread('fc-key-b', { threadId: 'thread-b' }); + rememberThread({ apiKey: 'fc-key-a' }, { threadId: 'thread-a' }); + rememberThread({ apiKey: 'fc-key-b' }, { threadId: 'thread-b' }); - expect(getRememberedThread('fc-key-a')?.lastThreadId).toBe('thread-a'); - expect(getRememberedThread('fc-key-b')?.lastThreadId).toBe('thread-b'); + expect(getRememberedThread({ apiKey: 'fc-key-a' })?.lastThreadId).toBe( + 'thread-a' + ); + expect(getRememberedThread({ apiKey: 'fc-key-b' })?.lastThreadId).toBe( + 'thread-b' + ); }); it('forgets only the entry for the given API key', () => { - rememberThread('fc-key-a', { threadId: 'thread-a' }); - rememberThread('fc-key-b', { threadId: 'thread-b' }); + rememberThread({ apiKey: 'fc-key-a' }, { threadId: 'thread-a' }); + rememberThread({ apiKey: 'fc-key-b' }, { threadId: 'thread-b' }); + + forgetThread({ apiKey: 'fc-key-a' }); + + expect(getRememberedThread({ apiKey: 'fc-key-a' })).toBeNull(); + expect(getRememberedThread({ apiKey: 'fc-key-b' })?.lastThreadId).toBe( + 'thread-b' + ); + }); + + it('keeps two keyless servers apart', () => { + // Self-hosted setups have no key to tell them apart, so without the server + // in the identity one would be handed the other's thread ID and a 404 + // would erase the original. + const a = { baseUrl: 'http://localhost:3002' }; + const b = { baseUrl: 'http://localhost:4002' }; + rememberThread(a, { threadId: 'thread-a' }); + rememberThread(b, { threadId: 'thread-b' }); + + expect(getRememberedThread(a)?.lastThreadId).toBe('thread-a'); + expect(getRememberedThread(b)?.lastThreadId).toBe('thread-b'); + }); + + it('reads one server through the spellings of its URL', () => { + rememberThread( + { apiKey: 'fc-test-key', baseUrl: 'https://API.firecrawl.dev/' }, + { threadId: 'thread-1' } + ); + + expect( + getRememberedThread({ + apiKey: 'fc-test-key', + baseUrl: 'https://api.firecrawl.dev', + })?.lastThreadId + ).toBe('thread-1'); + // And the default is that same server, spelled by omission. + expect(getRememberedThread({ apiKey: 'fc-test-key' })?.lastThreadId).toBe( + 'thread-1' + ); + }); + + it('keeps an entry a concurrent writer added', () => { + rememberThread({ apiKey: 'fc-key-a' }, { threadId: 'thread-a' }); + + // What a second process does between this process reading the store and + // writing it back. Without the update being one step, this entry is the + // one that disappears. + const concurrent = loadAgentThreadStore(); + rememberThread({ apiKey: 'fc-key-b' }, { threadId: 'thread-b' }); + expect(Object.keys(concurrent)).toHaveLength(1); + + expect(getRememberedThread({ apiKey: 'fc-key-a' })?.lastThreadId).toBe( + 'thread-a' + ); + expect(getRememberedThread({ apiKey: 'fc-key-b' })?.lastThreadId).toBe( + 'thread-b' + ); + }); + + it('writes through a stale lock rather than hanging the command', () => { + fs.writeFileSync(`${getAgentThreadsPath()}.lock`, '', 'utf-8'); + + rememberThread({ apiKey: 'fc-test-key' }, { threadId: 'thread-1' }); + + expect(getRememberedThread({ apiKey: 'fc-test-key' })?.lastThreadId).toBe( + 'thread-1' + ); + }); - forgetThread('fc-key-a'); + it('leaves no lock behind', () => { + rememberThread({ apiKey: 'fc-test-key' }, { threadId: 'thread-1' }); + forgetThread({ apiKey: 'fc-test-key' }); - expect(getRememberedThread('fc-key-a')).toBeNull(); - expect(getRememberedThread('fc-key-b')?.lastThreadId).toBe('thread-b'); + expect(fs.existsSync(`${getAgentThreadsPath()}.lock`)).toBe(false); }); it('treats a corrupt file as empty rather than failing the command', () => { fs.writeFileSync(getAgentThreadsPath(), 'not json', 'utf-8'); expect(loadAgentThreadStore()).toEqual({}); - expect(getRememberedThread('fc-test-key')).toBeNull(); + expect(getRememberedThread({ apiKey: 'fc-test-key' })).toBeNull(); }); }); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index aa8d199e25..1dd76c7445 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -85,11 +85,16 @@ function resolveApiBase(options: { apiKey?: string; apiUrl?: string }): { } { const config = getConfig(); const apiKey = options.apiKey || config.apiKey; - validateConfig(apiKey); const baseUrl = (options.apiUrl || config.apiUrl || DEFAULT_API_URL).replace( /\/$/, '' ); + // Whether a key is required is decided by the server this request resolved + // to, not by what `firecrawl config` happens to hold: `--api-url` alone + // points at a self-hosted server, and those need no key. + if (baseUrl === DEFAULT_API_URL) { + validateConfig(apiKey); + } return { baseUrl, apiKey }; } @@ -513,7 +518,12 @@ async function startAgentRun( } }; - const remembered = getRememberedThread(options.apiKey)?.lastThreadId ?? null; + // The server and the key this run will actually use, which is rarely what + // `--api-key` carried: it usually comes from the environment or from stored + // credentials, and keying memory off the flag alone would put every one of + // those runs in the same bucket. + const identity = resolveApiBase(options); + const remembered = getRememberedThread(identity)?.lastThreadId ?? null; const intent = resolveThreadIntent(options, remembered); // An approval only exists inside a thread, so there is nothing to resolve @@ -538,7 +548,7 @@ async function startAgentRun( if (!isThreadGoneError(error)) throw error; if (intent.threadId && intent.threadId === remembered) { - forgetThread(options.apiKey); + forgetThread(identity); } // A lost thread is a hard error while resolving an approval: there is @@ -550,7 +560,7 @@ async function startAgentRun( } if (response?.threadId) { - rememberThread(options.apiKey, { + rememberThread(identity, { threadId: response.threadId, runId: response.id, }); @@ -835,13 +845,28 @@ export function formatPendingApproval( return lines.join('\n'); } -/** Render follow-ups as the commands that run them */ +/** + * A shell word that is only ever a word. Single quotes suspend every + * expansion a shell performs, and the one character they cannot carry is + * closed, escaped and reopened. + */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** + * Render follow-ups as the commands that run them. + * + * These come back from the server and are printed for someone to copy, so + * they are quoted to be inert. Double quotes would leave `$(...)`, backticks + * and `${...}` live, and a suggestion is not worth a shell substitution. + */ export function formatSuggestions(suggestions: AgentSuggestion[]): string { const lines: string[] = ['Try next:']; for (const suggestion of suggestions) { const prompt = suggestion.prompt || suggestion.label; if (!prompt) continue; - lines.push(` firecrawl agent --continue "${prompt.replace(/"/g, '\\"')}"`); + lines.push(` firecrawl agent --continue ${shellQuote(prompt)}`); } return lines.join('\n'); } diff --git a/src/utils/agent-threads.ts b/src/utils/agent-threads.ts index 3baeb4c005..6aa8d97b44 100644 --- a/src/utils/agent-threads.ts +++ b/src/utils/agent-threads.ts @@ -1,18 +1,19 @@ /** * Agent thread memory - * Remembers the last agent thread per API key so `firecrawl agent --continue` + * Remembers the last agent thread per account so `firecrawl agent --continue` * can pick the conversation back up without pasting a thread ID. * * Stored in the same config directory as credentials.json / browser-session.json - * / interact-session.json. Entries are keyed by a hash of the API key so - * switching keys never crosses threads. The file is a convenience, never the - * source of truth: the API owns thread state, and a thread the server no longer - * knows about is dropped from here on the next attempt to use it. + * / interact-session.json. Entries are keyed by a hash of the server and the key + * used to reach it, so neither switching accounts nor pointing at another server + * can cross threads. The file is a convenience, never the source of truth: the + * API owns thread state, and a thread the server no longer knows about is + * dropped from here on the next attempt to use it. */ import * as crypto from 'crypto'; import * as fs from 'fs'; -import { getAgentThreadsPath } from './config'; +import { DEFAULT_API_URL, getAgentThreadsPath } from './config'; import { getConfigDirectoryPath } from './credentials'; export interface RememberedThread { @@ -21,20 +22,48 @@ export interface RememberedThread { updatedAt: string; } -/** `{ [apiKeyFingerprint]: RememberedThread }` */ +/** `{ [threadFingerprint]: RememberedThread }` */ export type AgentThreadStore = Record; -/** Bucket used when no API key is configured (self-hosted / keyless setups). */ +/** + * Which server, as which account. Both halves matter: a thread ID only means + * something to the server that issued it, and two keyless self-hosted servers + * would otherwise share one bucket and hand each other's IDs back. + * + * Callers pass what the request will actually use, not what a flag carried: + * the key usually comes from the environment or stored credentials rather than + * `--api-key`, and reading the flag alone puts every such run in one bucket. + */ +export interface ThreadIdentity { + apiKey?: string; + baseUrl?: string; +} + +/** Stands in for the key on self-hosted setups that do not use one. */ const NO_API_KEY = 'no-api-key'; +function normalizeBaseUrl(baseUrl?: string): string { + const raw = baseUrl?.trim() || DEFAULT_API_URL; + try { + const url = new URL(raw); + // Host casing and a trailing slash are not a different server. + return `${url.protocol}//${url.host.toLowerCase()}${url.pathname.replace(/\/$/, '')}`; + } catch { + return raw.replace(/\/$/, '').toLowerCase(); + } +} + /** - * Short, stable hash of the API key. The key itself is never written to disk by - * this file; credentials.json already owns that. + * Short, stable hash of the identity. Neither the key nor anything else secret + * is written to disk by this file; credentials.json already owns that. */ -export function apiKeyFingerprint(apiKey?: string): string { - const key = apiKey?.trim(); - if (!key) return NO_API_KEY; - return crypto.createHash('sha256').update(key).digest('hex').slice(0, 16); +export function threadFingerprint(identity: ThreadIdentity): string { + const key = identity.apiKey?.trim() || NO_API_KEY; + return crypto + .createHash('sha256') + .update(`${normalizeBaseUrl(identity.baseUrl)}\n${key}`) + .digest('hex') + .slice(0, 16); } export function loadAgentThreadStore(): AgentThreadStore { @@ -58,7 +87,14 @@ function writeAgentThreadStore(store: AgentThreadStore): void { fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); } const storePath = getAgentThreadsPath(); - fs.writeFileSync(storePath, JSON.stringify(store, null, 2), 'utf-8'); + // Written beside the store and renamed over it, so a reader never sees the + // half of a file that a crash or a concurrent run left behind. + const tmpPath = `${storePath}.${process.pid}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(store, null, 2), { + encoding: 'utf-8', + mode: 0o600, + }); + fs.renameSync(tmpPath, storePath); try { fs.chmodSync(storePath, 0o600); } catch { @@ -66,39 +102,98 @@ function writeAgentThreadStore(store: AgentThreadStore): void { } } -/** Read the thread last started with this API key, if any. */ -export function getRememberedThread(apiKey?: string): RememberedThread | null { - const entry = loadAgentThreadStore()[apiKeyFingerprint(apiKey)]; +const LOCK_ATTEMPTS = 20; +const LOCK_WAIT_MS = 10; +/** Past this the holder is assumed dead rather than slow. */ +const LOCK_STALE_MS = 5000; + +function sleepSync(ms: number): void { + // The whole update is synchronous, so the wait has to be too. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +/** + * Read, change and write the store as one step. + * + * Two agent runs finishing together would otherwise each read the same store + * and write their own entry over the other's, and the run that lost would + * silently start a new thread on its next `--continue`. + * + * Best effort by design: if the lock cannot be taken the update still happens. + * Thread memory is a convenience, and no version of this is worth hanging a + * command over. + */ +function updateStore(change: (store: AgentThreadStore) => void): void { + const lockPath = `${getAgentThreadsPath()}.lock`; + let held: number | undefined; + + for (let attempt = 0; attempt < LOCK_ATTEMPTS && held === undefined; ) { + try { + held = fs.openSync(lockPath, 'wx'); + } catch { + try { + const age = Date.now() - fs.statSync(lockPath).mtimeMs; + if (age > LOCK_STALE_MS) { + fs.unlinkSync(lockPath); + continue; + } + } catch { + // The holder released it between the open and the stat; try again. + } + attempt += 1; + sleepSync(LOCK_WAIT_MS); + } + } + + try { + const store = loadAgentThreadStore(); + change(store); + writeAgentThreadStore(store); + } finally { + if (held !== undefined) { + try { + fs.closeSync(held); + fs.unlinkSync(lockPath); + } catch { + // Already gone, or never ours to remove + } + } + } +} + +/** Read the thread last started against this server with this key, if any. */ +export function getRememberedThread( + identity: ThreadIdentity +): RememberedThread | null { + const entry = loadAgentThreadStore()[threadFingerprint(identity)]; if (!entry || typeof entry.lastThreadId !== 'string') return null; return entry; } /** Record a thread after a successful start. Never throws. */ export function rememberThread( - apiKey: string | undefined, + identity: ThreadIdentity, thread: { threadId: string; runId?: string } ): void { try { - const store = loadAgentThreadStore(); - store[apiKeyFingerprint(apiKey)] = { - lastThreadId: thread.threadId, - ...(thread.runId ? { lastRunId: thread.runId } : {}), - updatedAt: new Date().toISOString(), - }; - writeAgentThreadStore(store); + updateStore((store) => { + store[threadFingerprint(identity)] = { + lastThreadId: thread.threadId, + ...(thread.runId ? { lastRunId: thread.runId } : {}), + updatedAt: new Date().toISOString(), + }; + }); } catch { // Thread memory is a convenience; a failed write must not fail the run } } -/** Drop the remembered thread for this API key (e.g. the server 404s it). */ -export function forgetThread(apiKey?: string): void { +/** Drop the remembered thread for this identity (e.g. the server 404s it). */ +export function forgetThread(identity: ThreadIdentity): void { try { - const store = loadAgentThreadStore(); - const fingerprint = apiKeyFingerprint(apiKey); - if (!(fingerprint in store)) return; - delete store[fingerprint]; - writeAgentThreadStore(store); + updateStore((store) => { + delete store[threadFingerprint(identity)]; + }); } catch { // Ignore errors } diff --git a/src/utils/config.ts b/src/utils/config.ts index e2adc01cbd..4a1078ab4f 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -73,7 +73,7 @@ export function getApiKey(providedKey?: string): string | undefined { return storedCredentials?.apiKey; } -const DEFAULT_API_URL = 'https://api.firecrawl.dev'; +export const DEFAULT_API_URL = 'https://api.firecrawl.dev'; /** * Check if using a custom (non-cloud) API URL From ae6786c255b1799c0cba9efd6de50d6d9216bbab Mon Sep 17 00:00:00 2001 From: Nicolas <20311743+nickscamara@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:36:53 -0700 Subject: [PATCH 09/13] test(agent): pin the option order the negated flags depend on Commander gives a negated flag a true default only when no positive option already shares its name. --urls and --schema are declared ahead of their negations, which is the only reason an ordinary run leaves both unset; reorder either pair and every run without that flag reaches options.urls.split(true) and dies before asking for anything. Nothing at the call site shows that, and it has now been read as a live bug twice. A run against a closed port pins it: swapping the two lines turns the refused connection into "options.urls.split is not a function". Co-authored-by: Cursor --- src/__tests__/cli-argv.test.ts | 26 ++++++++++++++++++++++++++ src/index.ts | 4 ++++ 2 files changed, 30 insertions(+) diff --git a/src/__tests__/cli-argv.test.ts b/src/__tests__/cli-argv.test.ts index c61f8af876..6fd18c6121 100644 --- a/src/__tests__/cli-argv.test.ts +++ b/src/__tests__/cli-argv.test.ts @@ -169,6 +169,32 @@ describe('CLI argv parsing', () => { expect(flattened).toContain('--no-schema'); }); + /** + * Commander gives a negated flag a `true` default only when no positive + * option already shares its name, so `--urls ` and `--schema ` + * being declared *before* `--no-urls` and `--no-schema` is the only reason + * an ordinary run leaves them unset. Swap either pair and every agent run + * without that flag calls `.split()` on `true` and dies before it asks for + * anything. Nothing at the call site shows that, so it is pinned here. + */ + testWithBuiltCli( + 'leaves URLs and schema unset when neither flag is passed', + () => { + const result = runAuthedCli([ + 'agent', + 'a prompt', + '--api-url', + 'http://127.0.0.1:9', + ]); + const output = `${result.stdout}${result.stderr}`; + + // Reaching a refused connection is the assertion: option parsing is behind + // it, and a `true` in either value would have thrown on the way. + expect(output).toContain('ECONNREFUSED'); + expect(output).not.toMatch(/is not a function/); + } + ); + testWithBuiltCli('requires a thread to clear URLs or schema', () => { for (const flag of ['--no-urls', '--no-schema']) { const result = runAuthedCli(['agent', flag, 'a prompt']); diff --git a/src/index.ts b/src/index.ts index dc858fe219..6c425e6126 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1521,6 +1521,10 @@ function createAgentCommand(): Command { '[prompt-or-job-id]', 'Natural language prompt describing data to extract, or job ID to check status' ) + // Keep the positive option ahead of its negation, here and for --schema + // below. Commander defaults a negated flag to `true` unless the positive + // one is already declared, and a `true` here would reach the URL split on + // every run that does not pass --urls. `cli-argv.test.ts` pins the order. .option('--urls ', 'Comma-separated URLs to focus extraction on') .option( '--no-urls', From a2af23c0d8b45636a60252a0d529cb6c7e2a055e Mon Sep 17 00:00:00 2001 From: Nicolas <20311743+nickscamara@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:44:41 -0700 Subject: [PATCH 10/13] test(agent): pin thread identity and the approval-bearing poll Both were re-reported after the fix, so they are executable now rather than argued. The identity case fails against the previous behaviour: keyed off the --api-key flag, a run whose key comes from stored credentials remembers nothing, and the next account continues its thread. The approval case pins what a paid call actually does. An approval is written with the run's result, so it only reaches the client with a terminal status; --wait returns on the poll carrying it and prints the approve and decline commands. A timeout shorter than the poll budget makes a spinning loop fail rather than pass slowly. Co-authored-by: Cursor --- src/__tests__/commands/agent.test.ts | 80 ++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/__tests__/commands/agent.test.ts b/src/__tests__/commands/agent.test.ts index 12fb130614..fa91fc6086 100644 --- a/src/__tests__/commands/agent.test.ts +++ b/src/__tests__/commands/agent.test.ts @@ -138,6 +138,38 @@ describe('agent threads', () => { ).toBe('other-thread'); }); + it('keys memory on the key the request uses, not the flag', async () => { + // --api-key is the rare case: the key normally comes from stored + // credentials or the environment. Keyed off the flag alone, every one of + // those runs shared a bucket and switching accounts continued the other + // account's thread. + initializeConfig({ + apiKey: 'fc-account-a', + apiUrl: 'https://api.firecrawl.dev', + }); + mockFetch.mockResolvedValue( + jsonResponse(200, { success: true, id: 'run-1', threadId: 'thread-a' }) + ); + await executeAgent({ prompt: 'first', mode: 'chat' }); + + initializeConfig({ + apiKey: 'fc-account-b', + apiUrl: 'https://api.firecrawl.dev', + }); + mockFetch.mockResolvedValue( + jsonResponse(200, { success: true, id: 'run-2', threadId: 'thread-b' }) + ); + await executeAgent({ prompt: 'second', continue: true, mode: 'chat' }); + + expect(lastRequestBody(mockFetch).threadId).toBeUndefined(); + expect( + getRememberedThread({ apiKey: 'fc-account-a' })?.lastThreadId + ).toBe('thread-a'); + expect( + getRememberedThread({ apiKey: 'fc-account-b' })?.lastThreadId + ).toBe('thread-b'); + }); + it('says so when there is no remembered thread to continue', async () => { const stderr = vi .spyOn(process.stderr, 'write') @@ -393,6 +425,54 @@ describe('agent threads', () => { stdout.mockRestore(); stderr.mockRestore(); }); + + /** + * A turn that stops on a paid call still ends: the approval is written + * with the run's result, so it only ever reaches the client alongside a + * terminal status. `--wait` therefore returns on the poll that carries it + * rather than spinning to its timeout, and prints how to resolve it. + */ + it('stops waiting on the poll that carries an approval', async () => { + const stdout = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + mockFetch.mockResolvedValue( + jsonResponse(200, { success: true, id: 'run-1', threadId: 'thread-1' }) + ); + mockClient.getAgentStatus + .mockResolvedValueOnce({ success: true, status: 'processing' }) + .mockResolvedValue({ + success: true, + status: 'completed', + creditsUsed: 4, + threadId: 'thread-1', + message: 'That needs a paid call.', + pendingApproval: { + id: 'approval-1', + calls: [ + { id: 'call-1', provider: 'acme', capability: 'filings/list' }, + ], + }, + }); + + await handleAgentCommand({ + prompt: 'latest filings', + mode: 'chat', + wait: true, + pollInterval: 0.001, + // Short enough that spinning instead of returning would time out. + timeout: 1, + apiKey: API_KEY, + }); + + const written = stdout.mock.calls.map((call) => call[0]).join(''); + expect(written).toContain('Awaiting approval: approval-1'); + expect(written).toContain('firecrawl agent --approve approval-1'); + expect(written).toContain('firecrawl agent --decline approval-1'); + expect(written).not.toContain('Timeout'); + }); }); describe('agent thread ', () => { From 15b8f63ebca545358f416c5e65caf0d79d542852 Mon Sep 17 00:00:00 2001 From: Nicolas <20311743+nickscamara@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:45:55 -0700 Subject: [PATCH 11/13] refactor(agent): read the shared urls and schema attributes by type --urls and --no-urls share one Commander attribute, as do the schema pair, and that attribute carries three different answers: a string, false when cleared, or nothing. The parsing read it directly, which was correct only because each positive option happens to be declared before its negation; Commander defaults a negated flag to true when it is not, and that true would reach the URL split. Reading each intent by type makes the parsing say what it wants and stops it depending on the order two adjacent option calls appear in. Reordering them is now a no-op, so the comment claiming otherwise is gone and the test that covered the order covers the behaviour instead. Co-authored-by: Cursor --- src/__tests__/cli-argv.test.ts | 10 ++++------ src/index.ts | 22 ++++++++++++++-------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/__tests__/cli-argv.test.ts b/src/__tests__/cli-argv.test.ts index 6fd18c6121..b926d366ee 100644 --- a/src/__tests__/cli-argv.test.ts +++ b/src/__tests__/cli-argv.test.ts @@ -170,12 +170,10 @@ describe('CLI argv parsing', () => { }); /** - * Commander gives a negated flag a `true` default only when no positive - * option already shares its name, so `--urls ` and `--schema ` - * being declared *before* `--no-urls` and `--no-schema` is the only reason - * an ordinary run leaves them unset. Swap either pair and every agent run - * without that flag calls `.split()` on `true` and dies before it asks for - * anything. Nothing at the call site shows that, so it is pinned here. + * `--urls` and `--no-urls` share one attribute, and so do the schema pair. + * A run that passes neither must reach the request with both unset: leaking + * anything else into them puts a non-string through the URL split or the + * schema parse and kills the command before it asks for anything. */ testWithBuiltCli( 'leaves URLs and schema unset when neither flag is passed', diff --git a/src/index.ts b/src/index.ts index 6c425e6126..97790e07ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1521,10 +1521,6 @@ function createAgentCommand(): Command { '[prompt-or-job-id]', 'Natural language prompt describing data to extract, or job ID to check status' ) - // Keep the positive option ahead of its negation, here and for --schema - // below. Commander defaults a negated flag to `true` unless the positive - // one is already declared, and a `true` here would reach the URL split on - // every run that does not pass --urls. `cli-argv.test.ts` pins the order. .option('--urls ', 'Comma-separated URLs to focus extraction on') .option( '--no-urls', @@ -1621,6 +1617,16 @@ function createAgentCommand(): Command { const rawArgs: string[] = command.parent?.rawArgs ?? []; const passed = (flag: string) => rawArgs.some((arg) => arg === flag || arg.startsWith(`${flag}=`)); + + // That one key carries three different answers: a string when a value + // was given, false when the flag cleared it, and nothing when neither + // appeared. Splitting them here means the parsing below never has to + // ask which, and never inherits whatever Commander chose to default the + // attribute to. + const urlsValue = + typeof options.urls === 'string' ? options.urls : undefined; + const schemaValue = + typeof options.schema === 'string' ? options.schema : undefined; const clearUrls = options.urls === false; const clearSchema = options.schema === false; @@ -1691,8 +1697,8 @@ function createAgentCommand(): Command { // Parse URLs let urls: string[] | undefined; - if (options.urls) { - urls = options.urls + if (urlsValue) { + urls = urlsValue .split(',') .map((u: string) => u.trim()) .filter((u: string) => u.length > 0); @@ -1700,9 +1706,9 @@ function createAgentCommand(): Command { // Parse inline schema let schema: Record | undefined; - if (options.schema) { + if (schemaValue) { try { - schema = JSON.parse(options.schema) as Record; + schema = JSON.parse(schemaValue) as Record; } catch { console.error('Error: Invalid JSON in --schema option'); process.exit(1); From cf6ecb0c0ae07bcad3007ee99f05cbb01add35d0 Mon Sep 17 00:00:00 2001 From: Nicolas <20311743+nickscamara@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:48:59 -0700 Subject: [PATCH 12/13] fix(agent): take the thread lock on a fresh machine, and read the cloud URL by value The lock lives in the config directory, so on a machine that had never run the CLI it could not be created: the first write spent all twenty attempts failing to make one, 249ms, and then wrote unlocked, which is exactly when two runs are both likely to be first. The directory is made before the lock now, and that write takes 5ms. isCustomApiUrl compared the URL as a string, so any cased or trailing slashed spelling of the cloud API read as self-hosted. That waived the key requirement and sent the request to Firecrawl with no Authorization header at all. It compares through a shared normalizer now, which agent thread memory uses for the same question and no longer defines for itself. The lock test claimed to cover the stale branch while creating a lock with a current mtime, which only ever exercised the fallback. It says what it does, and the stale path has its own case with an aged mtime. The argv spawns take a timeout: none of them should reach a round trip, and asserting on a refused connection made the suite depend on nothing listening on that port. It asserts on the failure the CLI reports instead. Co-authored-by: Cursor --- src/__tests__/cli-argv.test.ts | 13 ++++++-- src/__tests__/utils/agent-threads.test.ts | 38 ++++++++++++++++++++++- src/__tests__/utils/config.test.ts | 8 +++++ src/commands/agent.ts | 8 +++-- src/utils/agent-threads.ts | 27 ++++++++-------- src/utils/config.ts | 25 ++++++++++++++- 6 files changed, 97 insertions(+), 22 deletions(-) diff --git a/src/__tests__/cli-argv.test.ts b/src/__tests__/cli-argv.test.ts index b926d366ee..42c52c3f55 100644 --- a/src/__tests__/cli-argv.test.ts +++ b/src/__tests__/cli-argv.test.ts @@ -27,6 +27,11 @@ describe('CLI argv parsing', () => { return spawnSync(process.execPath, [cliPath, ...args], { cwd: process.cwd(), encoding: 'utf8', + // None of these cases should reach a network round trip, so anything + // that blocks is a broken assumption. Failing on it beats a suite that + // hangs until the runner gives up. + timeout: 20_000, + killSignal: 'SIGKILL', env: { ...process.env, HOME: home, @@ -186,9 +191,11 @@ describe('CLI argv parsing', () => { ]); const output = `${result.stdout}${result.stderr}`; - // Reaching a refused connection is the assertion: option parsing is behind - // it, and a `true` in either value would have thrown on the way. - expect(output).toContain('ECONNREFUSED'); + // Getting as far as a failed request is the assertion, because option + // parsing is behind it and a non-string in either value would have + // thrown on the way. Which failure it is does not matter, so nothing + // here depends on that port being refused rather than answered. + expect(output).toContain('Failed to start agent'); expect(output).not.toMatch(/is not a function/); } ); diff --git a/src/__tests__/utils/agent-threads.test.ts b/src/__tests__/utils/agent-threads.test.ts index 2c6b0bf456..cc2f62b46d 100644 --- a/src/__tests__/utils/agent-threads.test.ts +++ b/src/__tests__/utils/agent-threads.test.ts @@ -138,7 +138,9 @@ describe('agent thread memory', () => { ); }); - it('writes through a stale lock rather than hanging the command', () => { + it('writes through a lock it cannot take rather than hanging', () => { + // Held right now, so the attempts run out and the update goes ahead + // anyway. Memory is a convenience and is not worth failing a run over. fs.writeFileSync(`${getAgentThreadsPath()}.lock`, '', 'utf-8'); rememberThread({ apiKey: 'fc-test-key' }, { threadId: 'thread-1' }); @@ -148,6 +150,40 @@ describe('agent thread memory', () => { ); }); + it('clears a lock whose holder is long gone', () => { + const lockPath = `${getAgentThreadsPath()}.lock`; + fs.writeFileSync(lockPath, '', 'utf-8'); + // Older than any run could plausibly hold it, so the holder is assumed + // dead and the lock is taken rather than waited out. + const ancient = new Date(Date.now() - 60_000); + fs.utimesSync(lockPath, ancient, ancient); + + rememberThread({ apiKey: 'fc-test-key' }, { threadId: 'thread-1' }); + + expect(getRememberedThread({ apiKey: 'fc-test-key' })?.lastThreadId).toBe( + 'thread-1' + ); + // Taken and released, not left where it was found. + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it('takes a lock on a machine that has no config directory yet', () => { + // The lock lives in that directory, so without creating it first the very + // first run on a machine spends every attempt failing to make one and then + // writes unlocked, which is exactly when two runs are both likely to be + // first. + fs.rmSync(configDir.path, { recursive: true, force: true }); + + const started = Date.now(); + rememberThread({ apiKey: 'fc-test-key' }, { threadId: 'thread-1' }); + + expect(getRememberedThread({ apiKey: 'fc-test-key' })?.lastThreadId).toBe( + 'thread-1' + ); + // Exhausting the attempts would take the full acquire budget. + expect(Date.now() - started).toBeLessThan(150); + }); + it('leaves no lock behind', () => { rememberThread({ apiKey: 'fc-test-key' }, { threadId: 'thread-1' }); forgetThread({ apiKey: 'fc-test-key' }); diff --git a/src/__tests__/utils/config.test.ts b/src/__tests__/utils/config.test.ts index 6d2e83d4c2..3c24ea8973 100644 --- a/src/__tests__/utils/config.test.ts +++ b/src/__tests__/utils/config.test.ts @@ -254,6 +254,14 @@ describe('Config Fallback Priority', () => { initializeConfig({ apiUrl: 'https://api.firecrawl.dev' }); expect(isCustomApiUrl('http://localhost:3002')).toBe(true); }); + + it('reads the cloud URL as the cloud however it is spelled', () => { + // Read as custom, the key requirement is waived and the request goes to + // Firecrawl with no Authorization header at all. + expect(isCustomApiUrl('https://API.firecrawl.dev')).toBe(false); + expect(isCustomApiUrl('https://api.firecrawl.dev/')).toBe(false); + expect(isCustomApiUrl('HTTPS://Api.Firecrawl.Dev/')).toBe(false); + }); }); describe('validateConfig with custom API URLs', () => { diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 1dd76c7445..90d4584c97 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -19,7 +19,7 @@ import type { } from '../types/agent'; import type { AgentStatusResponse, AgentWebhookConfig } from 'firecrawl'; import { getClient } from '../utils/client'; -import { getConfig, validateConfig } from '../utils/config'; +import { getConfig, isDefaultApiUrl, validateConfig } from '../utils/config'; import { forgetThread, getRememberedThread, @@ -91,8 +91,10 @@ function resolveApiBase(options: { apiKey?: string; apiUrl?: string }): { ); // Whether a key is required is decided by the server this request resolved // to, not by what `firecrawl config` happens to hold: `--api-url` alone - // points at a self-hosted server, and those need no key. - if (baseUrl === DEFAULT_API_URL) { + // points at a self-hosted server, and those need no key. Compared through + // the normalizer, so a differently cased cloud URL is still the cloud and + // cannot slip through unauthenticated. + if (isDefaultApiUrl(baseUrl)) { validateConfig(apiKey); } return { baseUrl, apiKey }; diff --git a/src/utils/agent-threads.ts b/src/utils/agent-threads.ts index 6aa8d97b44..3b7cf45a9e 100644 --- a/src/utils/agent-threads.ts +++ b/src/utils/agent-threads.ts @@ -13,7 +13,7 @@ import * as crypto from 'crypto'; import * as fs from 'fs'; -import { DEFAULT_API_URL, getAgentThreadsPath } from './config'; +import { getAgentThreadsPath, normalizeApiUrl } from './config'; import { getConfigDirectoryPath } from './credentials'; export interface RememberedThread { @@ -42,17 +42,6 @@ export interface ThreadIdentity { /** Stands in for the key on self-hosted setups that do not use one. */ const NO_API_KEY = 'no-api-key'; -function normalizeBaseUrl(baseUrl?: string): string { - const raw = baseUrl?.trim() || DEFAULT_API_URL; - try { - const url = new URL(raw); - // Host casing and a trailing slash are not a different server. - return `${url.protocol}//${url.host.toLowerCase()}${url.pathname.replace(/\/$/, '')}`; - } catch { - return raw.replace(/\/$/, '').toLowerCase(); - } -} - /** * Short, stable hash of the identity. Neither the key nor anything else secret * is written to disk by this file; credentials.json already owns that. @@ -61,7 +50,7 @@ export function threadFingerprint(identity: ThreadIdentity): string { const key = identity.apiKey?.trim() || NO_API_KEY; return crypto .createHash('sha256') - .update(`${normalizeBaseUrl(identity.baseUrl)}\n${key}`) + .update(`${normalizeApiUrl(identity.baseUrl)}\n${key}`) .digest('hex') .slice(0, 16); } @@ -81,11 +70,15 @@ export function loadAgentThreadStore(): AgentThreadStore { } } -function writeAgentThreadStore(store: AgentThreadStore): void { +function ensureConfigDirectory(): void { const configDir = getConfigDirectoryPath(); if (!fs.existsSync(configDir)) { fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); } +} + +function writeAgentThreadStore(store: AgentThreadStore): void { + ensureConfigDirectory(); const storePath = getAgentThreadsPath(); // Written beside the store and renamed over it, so a reader never sees the // half of a file that a crash or a concurrent run left behind. @@ -124,6 +117,12 @@ function sleepSync(ms: number): void { * command over. */ function updateStore(change: (store: AgentThreadStore) => void): void { + // Before the lock, not with the store: the lock lives in this directory, so + // without it the first run on a machine cannot take one. It would spend + // every attempt failing to create it and then write unlocked, which is the + // one moment two concurrent runs are most likely to both be first. + ensureConfigDirectory(); + const lockPath = `${getAgentThreadsPath()}.lock`; let held: number | undefined; diff --git a/src/utils/config.ts b/src/utils/config.ts index 4a1078ab4f..455efac0e5 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -75,12 +75,35 @@ export function getApiKey(providedKey?: string): string | undefined { export const DEFAULT_API_URL = 'https://api.firecrawl.dev'; +/** + * One server, however its URL was spelled. Host casing and a trailing slash + * are not a different server, and both whether a key is required and which + * thread memory bucket a run belongs to turn on the answer. + */ +export function normalizeApiUrl(apiUrl?: string): string { + const raw = apiUrl?.trim() || DEFAULT_API_URL; + try { + const url = new URL(raw); + return `${url.protocol}//${url.host.toLowerCase()}${url.pathname.replace(/\/$/, '')}`; + } catch { + return raw.replace(/\/$/, '').toLowerCase(); + } +} + +/** Whether a URL reaches Firecrawl's cloud API rather than a self-hosted one. */ +export function isDefaultApiUrl(apiUrl?: string): boolean { + return normalizeApiUrl(apiUrl) === normalizeApiUrl(DEFAULT_API_URL); +} + /** * Check if using a custom (non-cloud) API URL */ export function isCustomApiUrl(apiUrl?: string): boolean { const url = apiUrl || globalConfig.apiUrl; - return !!url && url !== DEFAULT_API_URL; + // Compared through the normalizer: a differently cased or trailing-slashed + // cloud URL is still the cloud, and reading it as self-hosted waives the + // key requirement and sends the request with no Authorization header. + return !!url && !isDefaultApiUrl(url); } /** From f11fd12a7319e8c44b7f1cb7c09c09f77a6990a2 Mon Sep 17 00:00:00 2001 From: Nicolas <20311743+nickscamara@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:04:08 -0700 Subject: [PATCH 13/13] agent: drop Exchange options and the approval flow Firecrawl Exchange, the paid data-provider marketplace, is being switched off, so the agent command no longer offers --exchange, --toolkits, --max-calls, --require-approval, --approve, --always or --decline, and no longer sends an exchange object or renders pendingApproval. The prompt or job ID argument is required again, since resolving an approval was the only reason it could be omitted. Threads are unaffected: --thread, --continue and --new, chat and extract modes, effort, message and suggestions rendering, thread memory, --no-urls and --no-schema all work as before. Co-authored-by: Cursor --- README.md | 7 -- src/__tests__/cli-argv.test.ts | 6 -- src/__tests__/commands/agent.test.ts | 136 -------------------------- src/commands/agent.ts | 141 ++------------------------- src/index.ts | 63 +----------- src/types/agent.ts | 32 ------ 6 files changed, 9 insertions(+), 376 deletions(-) diff --git a/README.md b/README.md index d38c17ef12..6fac9d6110 100644 --- a/README.md +++ b/README.md @@ -709,13 +709,6 @@ firecrawl agent --continue --no-schema --mode chat "Summarise what changed" --wa | `--new` | Ignore any remembered thread and start a new one | | `--mode ` | `extract` (default, returns JSON) or `chat` (prose replies) | | `--effort ` | Effort level: `low`, `medium`, or `high` | -| `--exchange` | Enable Firecrawl Exchange data providers | -| `--toolkits ` | Comma-separated Exchange toolkits to limit the run to | -| `--max-calls ` | Maximum Exchange provider calls for this run | -| `--require-approval` | Ask before each paid Exchange call | -| `--approve ` | Approve a pending approval and continue the thread | -| `--always` | With `--approve`, stop asking again in this thread | -| `--decline ` | Decline a pending approval and continue the thread | | `--status` | Check status of existing agent job | | `--cancel` | Cancel an active agent job by job ID | | `--wait` | Wait for agent to complete before returning results | diff --git a/src/__tests__/cli-argv.test.ts b/src/__tests__/cli-argv.test.ts index 42c52c3f55..25f78a3a42 100644 --- a/src/__tests__/cli-argv.test.ts +++ b/src/__tests__/cli-argv.test.ts @@ -149,12 +149,6 @@ describe('CLI argv parsing', () => { '--new', '--mode', '--effort', - '--exchange', - '--toolkits', - '--max-calls', - '--require-approval', - '--approve', - '--decline', ]) { expect(flattened).toContain(flag); } diff --git a/src/__tests__/commands/agent.test.ts b/src/__tests__/commands/agent.test.ts index fa91fc6086..65bc97700a 100644 --- a/src/__tests__/commands/agent.test.ts +++ b/src/__tests__/commands/agent.test.ts @@ -7,11 +7,8 @@ import * as os from 'os'; import * as path from 'path'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { - APPROVE_PROMPT, - DECLINE_PROMPT, executeAgent, executeAgentThread, - formatPendingApproval, formatSuggestions, formatThread, handleAgentCommand, @@ -295,85 +292,6 @@ describe('agent threads', () => { }); }); - describe('approvals', () => { - it('--approve sends the control prompt and the approve payload', async () => { - rememberThread({ apiKey: API_KEY }, { threadId: 'thread-1' }); - mockFetch.mockResolvedValue( - jsonResponse(200, { success: true, id: 'run-9', threadId: 'thread-1' }) - ); - - await executeAgent({ - prompt: '', - exchange: { approve: { approvalId: 'a1', always: true } }, - apiKey: API_KEY, - }); - - expect(lastRequestBody(mockFetch)).toEqual({ - prompt: APPROVE_PROMPT, - integration: 'cli', - threadId: 'thread-1', - exchange: { approve: { approvalId: 'a1', always: true } }, - }); - }); - - it('--decline sends its own control prompt', async () => { - rememberThread({ apiKey: API_KEY }, { threadId: 'thread-1' }); - mockFetch.mockResolvedValue( - jsonResponse(200, { success: true, id: 'run-10', threadId: 'thread-1' }) - ); - - await executeAgent({ - prompt: '', - exchange: { decline: { approvalId: 'a1' } }, - apiKey: API_KEY, - }); - - const body = lastRequestBody(mockFetch); - expect(body.prompt).toBe(DECLINE_PROMPT); - expect(body.exchange).toEqual({ decline: { approvalId: 'a1' } }); - expect(body.threadId).toBe('thread-1'); - }); - - it('refuses to resolve an approval with no thread to resolve it in', async () => { - const result = await executeAgent({ - prompt: '', - exchange: { approve: { approvalId: 'a1' } }, - apiKey: API_KEY, - }); - - expect(mockFetch).not.toHaveBeenCalled(); - expect(result.success).toBe(false); - expect(result.error).toContain('No thread to resolve that approval in'); - }); - - it('renders a pending approval and the commands that resolve it', () => { - const rendered = formatPendingApproval({ - id: 'a1', - reason: 'One call answers this.', - calls: [ - { - id: 'c1', - provider: 'provider-slug', - capability: 'capability/slug', - input: { symbol: 'ACME' }, - creditsEstimate: 5, - }, - ], - }); - - expect(rendered).toContain('Awaiting approval: a1'); - expect(rendered).toContain('One call answers this.'); - expect(rendered).toContain('Provider'); - expect(rendered).toContain('Capability'); - expect(rendered).toContain('Est. credits'); - expect(rendered).toContain('provider-slug'); - expect(rendered).toContain('capability/slug'); - expect(rendered).toContain('{"symbol":"ACME"}'); - expect(rendered).toContain('firecrawl agent --approve a1'); - expect(rendered).toContain('firecrawl agent --decline a1'); - }); - }); - describe('chat output', () => { it('prints the message before the data and lists follow-ups', async () => { const stdout = vi @@ -425,54 +343,6 @@ describe('agent threads', () => { stdout.mockRestore(); stderr.mockRestore(); }); - - /** - * A turn that stops on a paid call still ends: the approval is written - * with the run's result, so it only ever reaches the client alongside a - * terminal status. `--wait` therefore returns on the poll that carries it - * rather than spinning to its timeout, and prints how to resolve it. - */ - it('stops waiting on the poll that carries an approval', async () => { - const stdout = vi - .spyOn(process.stdout, 'write') - .mockImplementation(() => true); - vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - - mockFetch.mockResolvedValue( - jsonResponse(200, { success: true, id: 'run-1', threadId: 'thread-1' }) - ); - mockClient.getAgentStatus - .mockResolvedValueOnce({ success: true, status: 'processing' }) - .mockResolvedValue({ - success: true, - status: 'completed', - creditsUsed: 4, - threadId: 'thread-1', - message: 'That needs a paid call.', - pendingApproval: { - id: 'approval-1', - calls: [ - { id: 'call-1', provider: 'acme', capability: 'filings/list' }, - ], - }, - }); - - await handleAgentCommand({ - prompt: 'latest filings', - mode: 'chat', - wait: true, - pollInterval: 0.001, - // Short enough that spinning instead of returning would time out. - timeout: 1, - apiKey: API_KEY, - }); - - const written = stdout.mock.calls.map((call) => call[0]).join(''); - expect(written).toContain('Awaiting approval: approval-1'); - expect(written).toContain('firecrawl agent --approve approval-1'); - expect(written).toContain('firecrawl agent --decline approval-1'); - expect(written).not.toContain('Timeout'); - }); }); describe('agent thread ', () => { @@ -670,12 +540,6 @@ describe('agent threads', () => { expect( resolveThreadIntent({ continue: true, new: true }, 'thread-1').threadId ).toBeUndefined(); - expect( - resolveThreadIntent( - { exchange: { approve: { approvalId: 'a1' } } }, - 'thread-1' - ) - ).toMatchObject({ threadId: 'thread-1', fromMemory: true }); }); }); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 90d4584c97..5f79d484a3 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -4,10 +4,8 @@ import type { AgentEffort, - AgentExchangeOptions, AgentMode, AgentOptions, - AgentPendingApproval, AgentResult, AgentStatus, AgentStatusResult, @@ -32,19 +30,11 @@ import { readFileSync } from 'fs'; const DEFAULT_API_URL = 'https://api.firecrawl.dev'; -/** - * Fixed prompts sent when resolving a pending approval, so the run continues - * without the user retyping their intent. - */ -export const APPROVE_PROMPT = 'Approved. Make that call, and nothing else.'; -export const DECLINE_PROMPT = - 'Do not make that call. Answer from what you already have, or tell me what you would need.'; - const THREADS_UNSUPPORTED = 'This Firecrawl API does not support threads yet'; /** * firecrawl@4.24.0 predates threads: `prepareAgentPayload` whitelists request - * keys (so threadId/mode/effort/exchange would be dropped) and the response + * keys (so threadId/mode/effort would be dropped) and the response * types omit the new fields (which the API does return). Starts that use the * new fields therefore go over raw HTTP — the same escape hatch monitor.ts and * parse.ts use — and status responses are read through a widened type. Both @@ -57,7 +47,6 @@ type ThreadAwareAgentStatus = AgentStatusResponse & { mode?: AgentMode; message?: string; suggestions?: AgentSuggestion[]; - pendingApproval?: AgentPendingApproval; }; type ThreadAwareStartResponse = { @@ -144,7 +133,7 @@ function mapThreadSupportError(error: unknown): string | null { /unrecognized|unknown key|unexpected key|not allowed|not recognized/i.test( error.message ); - const namesThreadField = /threadId|thread_id|\bmode\b|exchange|effort/i.test( + const namesThreadField = /threadId|thread_id|\bmode\b|effort/i.test( error.message ); return namesUnknownKey && namesThreadField ? THREADS_UNSUPPORTED : null; @@ -174,7 +163,6 @@ export interface AgentStartParams { threadId?: string; mode?: AgentMode; effort?: AgentEffort; - exchange?: AgentExchangeOptions; } /** Request body for POST /v2/agent. New keys are only sent when set. */ @@ -199,9 +187,6 @@ export function buildAgentStartBody( if (params.threadId) body.threadId = params.threadId; if (params.mode) body.mode = params.mode; if (params.effort) body.effort = params.effort; - if (params.exchange && Object.keys(params.exchange).length > 0) { - body.exchange = params.exchange; - } return body; } @@ -212,7 +197,6 @@ function needsRawStart(params: AgentStartParams): boolean { params.threadId || params.mode || params.effort || - params.exchange || params.clearUrls || params.clearSchema ); @@ -220,14 +204,10 @@ function needsRawStart(params: AgentStartParams): boolean { /** * Which thread this run continues, if any. `--thread` wins over `--continue`, - * `--continue` falls back to the thread remembered for this API key, and - * `--approve`/`--decline` continue that same thread without asking for a flag. + * and `--continue` falls back to the thread remembered for this API key. */ export function resolveThreadIntent( - options: Pick< - AgentOptions, - 'thread' | 'continue' | 'new' | 'exchange' | 'apiKey' - >, + options: Pick, remembered: string | null ): { threadId?: string; fromMemory: boolean; missingMemory: boolean } { if (options.thread) { @@ -238,12 +218,7 @@ export function resolveThreadIntent( }; } - const resolvingApproval = Boolean( - options.exchange?.approve || options.exchange?.decline - ); - const wantsContinue = Boolean(options.continue) || resolvingApproval; - - if (!wantsContinue || options.new) { + if (!options.continue || options.new) { return { fromMemory: false, missingMemory: false }; } @@ -322,7 +297,6 @@ function threadFields( ...(s.suggestions && s.suggestions.length > 0 ? { suggestions: s.suggestions } : {}), - ...(s.pendingApproval ? { pendingApproval: s.pendingApproval } : {}), }; } @@ -464,19 +438,6 @@ async function checkAgentStatus( } } -/** - * Prompt to send for this turn. Resolving an approval carries its own fixed - * prompt so the user does not have to restate anything. - */ -export function resolveStartPrompt( - options: Pick -): string { - if (options.prompt && options.prompt.trim()) return options.prompt; - if (options.exchange?.approve) return APPROVE_PROMPT; - if (options.exchange?.decline) return DECLINE_PROMPT; - return options.prompt; -} - /** * Start a run, continuing a thread when one is asked for or remembered, and * record the thread the API reports back. @@ -528,17 +489,6 @@ async function startAgentRun( const remembered = getRememberedThread(identity)?.lastThreadId ?? null; const intent = resolveThreadIntent(options, remembered); - // An approval only exists inside a thread, so there is nothing to resolve - // without one. - const resolvingApproval = Boolean( - params.exchange?.approve || params.exchange?.decline - ); - if (resolvingApproval && !intent.threadId) { - throw new Error( - 'No thread to resolve that approval in. Pass --thread .' - ); - } - if (intent.missingMemory) { onNotice('No remembered thread; starting a new one.'); } @@ -553,9 +503,7 @@ async function startAgentRun( forgetThread(identity); } - // A lost thread is a hard error while resolving an approval: there is - // nothing to approve in a fresh one. - if (!intent.fromMemory || resolvingApproval) throw error; + if (!intent.fromMemory) throw error; onNotice('That thread is gone; starting a new one.'); response = await attempt({ ...params, threadId: undefined }); @@ -579,8 +527,7 @@ export async function executeAgent( ): Promise { try { const app = getClient({ apiKey: options.apiKey, apiUrl: options.apiUrl }); - const { status, cancel, wait, pollInterval, timeout } = options; - const prompt = resolveStartPrompt(options); + const { prompt, status, cancel, wait, pollInterval, timeout } = options; if (cancel) { const cancelled = await app.cancelAgent(prompt); @@ -643,9 +590,6 @@ export async function executeAgent( if (options.effort) { agentParams.effort = options.effort; } - if (options.exchange && Object.keys(options.exchange).length > 0) { - agentParams.exchange = options.exchange; - } // If wait mode, use polling with spinner if (wait) { @@ -784,69 +728,6 @@ export async function executeAgent( } } -function truncate(value: string, max: number): string { - return value.length > max ? `${value.slice(0, max - 1)}…` : value; -} - -function renderTable(headers: string[], rows: string[][]): string[] { - const widths = headers.map((header, i) => - Math.max(header.length, ...rows.map((row) => (row[i] ?? '').length)) - ); - const line = (cells: string[]) => - cells - .map((cell, i) => - i === cells.length - 1 ? cell : cell.padEnd(widths[i]) - ) - .join(' ') - .trimEnd(); - return [line(headers), ...rows.map(line)]; -} - -/** - * Render an approval the run is waiting on, plus the commands that resolve it. - * Everything shown comes straight off the API response. - */ -export function formatPendingApproval( - pendingApproval: AgentPendingApproval -): string { - const lines: string[] = []; - lines.push(`Awaiting approval: ${pendingApproval.id}`); - - if (typeof pendingApproval.reason === 'string' && pendingApproval.reason) { - lines.push(pendingApproval.reason); - } - - const calls = Array.isArray(pendingApproval.calls) - ? pendingApproval.calls - : []; - if (calls.length > 0) { - const rows = calls.map((call) => { - const args = - (call.input as unknown) ?? - (call as Record).arguments ?? - (call as Record).parameters; - const credits = call.creditsEstimate; - return [ - String(call.provider ?? ''), - String(call.capability ?? ''), - args === undefined ? '' : truncate(JSON.stringify(args), 80), - credits === undefined || credits === null ? '-' : String(credits), - ]; - }); - lines.push( - ...renderTable( - ['Provider', 'Capability', 'Arguments', 'Est. credits'], - rows - ) - ); - } - - lines.push(` firecrawl agent --approve ${pendingApproval.id}`); - lines.push(` firecrawl agent --decline ${pendingApproval.id}`); - - return lines.join('\n'); -} - /** * A shell word that is only ever a word. Single quotes suspend every * expansion a shell performs, and the one character they cannot carry is @@ -913,11 +794,6 @@ function formatAgentStatus(data: AgentStatusResult['data']): string { lines.push(JSON.stringify(data.data, null, 2)); } - if (data.pendingApproval) { - lines.push(''); - lines.push(formatPendingApproval(data.pendingApproval)); - } - if (data.suggestions && data.suggestions.length > 0) { lines.push(''); lines.push(formatSuggestions(data.suggestions)); @@ -1050,9 +926,6 @@ function formatThreadRun(run: AgentThreadRun): string[] { lines.push(' Result:'); lines.push(JSON.stringify(run.data, null, 2)); } - if (run.pendingApproval) { - lines.push(formatPendingApproval(run.pendingApproval)); - } if (run.suggestions && run.suggestions.length > 0) { lines.push(formatSuggestions(run.suggestions)); } diff --git a/src/index.ts b/src/index.ts index 97790e07ad..8169056e7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1518,7 +1518,7 @@ function createAgentCommand(): Command { const agentCmd = new Command('agent') .description('Run an AI agent to extract data from the web') .argument( - '[prompt-or-job-id]', + '', 'Natural language prompt describing data to extract, or job ID to check status' ) .option('--urls ', 'Comma-separated URLs to focus extraction on') @@ -1560,30 +1560,6 @@ function createAgentCommand(): Command { 'Run mode: extract (default, returns JSON) or chat (the agent may reply in prose)' ) .option('--effort ', 'Effort level: low, medium, or high') - .option('--exchange', 'Enable Firecrawl Exchange data providers', false) - .option( - '--toolkits ', - 'Comma-separated Exchange toolkits to limit the run to' - ) - .option( - '--max-calls ', - 'Maximum Exchange provider calls for this run', - parseInt - ) - .option('--require-approval', 'Ask before each paid Exchange call', false) - .option( - '--approve ', - 'Approve a pending approval and continue the thread' - ) - .option( - '--always', - 'With --approve, stop asking again in this thread', - false - ) - .option( - '--decline ', - 'Decline a pending approval and continue the thread' - ) .option('--status', 'Check status of existing agent job', false) .option('--cancel', 'Cancel active agent job by job ID', false) .option( @@ -1610,8 +1586,6 @@ function createAgentCommand(): Command { .option('--json', 'Output as JSON format', false) .option('--pretty', 'Pretty print JSON output', false) .action(async (promptOrJobId, options, command) => { - const resolvingApproval = !!(options.approve || options.decline); - // Commander stores --urls and --no-urls under one key, so passing both // looks like whichever came last. Read the flags as typed to catch it. const rawArgs: string[] = command.parent?.rawArgs ?? []; @@ -1630,14 +1604,7 @@ function createAgentCommand(): Command { const clearUrls = options.urls === false; const clearSchema = options.schema === false; - if (!promptOrJobId && !resolvingApproval) { - console.error( - 'Error: a prompt or job ID is required (or use --approve/--decline).' - ); - process.exit(1); - } - - const prompt = promptOrJobId ?? ''; + const prompt: string = promptOrJobId; // Auto-detect if it's a job ID (UUID format) const isStatusCheck = options.status || isJobId(prompt); @@ -1655,11 +1622,6 @@ function createAgentCommand(): Command { process.exit(1); } - if (options.approve && options.decline) { - console.error('Error: use --approve or --decline, not both.'); - process.exit(1); - } - if (clearUrls && passed('--urls')) { console.error('Error: use --urls or --no-urls, not both.'); process.exit(1); @@ -1741,26 +1703,6 @@ function createAgentCommand(): Command { process.exit(1); } - const exchange: Record = {}; - if (options.exchange) exchange.enabled = true; - if (options.toolkits) { - exchange.toolkits = options.toolkits - .split(',') - .map((t: string) => t.trim()) - .filter((t: string) => t.length > 0); - } - if (options.maxCalls !== undefined) exchange.maxCalls = options.maxCalls; - if (options.requireApproval) exchange.requireApproval = true; - if (options.approve) { - exchange.approve = { - approvalId: options.approve, - ...(options.always ? { always: true } : {}), - }; - } - if (options.decline) { - exchange.decline = { approvalId: options.decline }; - } - const agentOptions = { prompt, urls, @@ -1785,7 +1727,6 @@ function createAgentCommand(): Command { new: options.new, mode: options.mode, effort: options.effort, - ...(Object.keys(exchange).length > 0 ? { exchange } : {}), }; await handleAgentCommand(agentOptions); diff --git a/src/types/agent.ts b/src/types/agent.ts index 881a2c3989..11bd4e4622 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -18,34 +18,6 @@ export interface AgentSuggestion { prompt: string; } -/** - * Approval the run is waiting on, as returned by the API. Rendered generically: - * the CLI reads the fields off the object and does not interpret them. - */ -export interface AgentPendingApproval { - id: string; - reason?: string; - calls?: Array<{ - id?: string; - provider?: string; - capability?: string; - input?: Record; - creditsEstimate?: number | null; - [key: string]: unknown; - }>; - [key: string]: unknown; -} - -/** Firecrawl Exchange options, passed through to the API untouched */ -export interface AgentExchangeOptions { - enabled?: boolean; - toolkits?: string[]; - maxCalls?: number; - requireApproval?: boolean; - approve?: { approvalId: string; always?: boolean }; - decline?: { approvalId: string }; -} - export interface AgentOptions { /** Natural language prompt describing the data to extract */ prompt: string; @@ -95,8 +67,6 @@ export interface AgentOptions { mode?: AgentMode; /** How much work the agent should put into the run */ effort?: AgentEffort; - /** Firecrawl Exchange options */ - exchange?: AgentExchangeOptions; } export interface AgentResult { @@ -122,7 +92,6 @@ export interface AgentStatusResult { mode?: AgentMode; message?: string; suggestions?: AgentSuggestion[]; - pendingApproval?: AgentPendingApproval; }; error?: string; } @@ -141,7 +110,6 @@ export interface AgentThreadRun { message?: string | null; data?: unknown; suggestions?: AgentSuggestion[] | null; - pendingApproval?: AgentPendingApproval | null; } export interface AgentThread {