diff --git a/README.md b/README.md index 20170e2d68..6fac9d6110 100644 --- a/README.md +++ b/README.md @@ -660,6 +660,38 @@ 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 +``` + +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 | @@ -670,6 +702,13 @@ 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 | +| `--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 | +| `--mode ` | `extract` (default, returns JSON) or `chat` (prose replies) | +| `--effort ` | Effort level: `low`, `medium`, or `high` | | `--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 fd04e7d3a3..25f78a3a42 100644 --- a/src/__tests__/cli-argv.test.ts +++ b/src/__tests__/cli-argv.test.ts @@ -1,12 +1,50 @@ 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', + // 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, + 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(), @@ -97,6 +135,119 @@ 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', + ]) { + expect(flattened).toContain(flag); + } + expect(flattened).toContain('thread [options] '); + 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'); + }); + + /** + * `--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', + () => { + const result = runAuthedCli([ + 'agent', + 'a prompt', + '--api-url', + 'http://127.0.0.1:9', + ]); + const output = `${result.stdout}${result.stderr}`; + + // 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/); + } + ); + + testWithBuiltCli('requires a thread to clear URLs or schema', () => { + for (const flag of ['--no-urls', '--no-schema']) { + const result = runAuthedCli(['agent', flag, 'a prompt']); + + 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 = 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 = runAuthedCli([ + 'agent', + '--continue', + '--schema', + '{"type":"object"}', + '--no-schema', + 'a prompt', + ]); + + 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, + [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..65bc97700a --- /dev/null +++ b/src/__tests__/commands/agent.test.ts @@ -0,0 +1,591 @@ +/** + * 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 { + executeAgent, + executeAgentThread, + formatSuggestions, + formatThread, + handleAgentCommand, + resolveThreadIntent, +} 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(); + // 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( + { apiKey: 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({ 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({ apiKey: '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({ apiKey: 'fc-other-key' })?.lastThreadId + ).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') + .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({ apiKey: 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({ apiKey: API_KEY })?.lastThreadId).toBe( + 'thread-9' + ); + }); + + it('clears the entry and starts fresh when the thread is gone', async () => { + rememberThread({ apiKey: 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({ 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.'); + stderr.mockRestore(); + }); + + it('clears the entry when an expired thread is gone for good', async () => { + rememberThread({ apiKey: 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({ apiKey: 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('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') + ); + }); + + 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({ apiKey: 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({ apiKey: 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({ apiKey: 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( + 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(); + }); + }); + + 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' }, + }); + }); + + 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/__tests__/utils/agent-threads.test.ts b/src/__tests__/utils/agent-threads.test.ts new file mode 100644 index 0000000000..cc2f62b46d --- /dev/null +++ b/src/__tests__/utils/agent-threads.test.ts @@ -0,0 +1,200 @@ +/** + * 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 { + threadFingerprint, + 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({ apiKey: 'fc-test-key' })).toBeNull(); + }); + + it('round-trips the last thread and run for an API key', () => { + rememberThread( + { apiKey: 'fc-test-key' }, + { threadId: 'thread-1', runId: 'run-1' } + ); + + 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({ apiKey: 'fc-test-key' }, { threadId: 'thread-1' }); + + const store = loadAgentThreadStore(); + 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({ apiKey: 'fc-key-a' }, { threadId: 'thread-a' }); + rememberThread({ apiKey: 'fc-key-b' }, { threadId: '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({ 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 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' }); + + expect(getRememberedThread({ apiKey: 'fc-test-key' })?.lastThreadId).toBe( + 'thread-1' + ); + }); + + 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' }); + + 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({ apiKey: 'fc-test-key' })).toBeNull(); + }); +}); 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 7063637952..5f79d484a3 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -3,18 +3,232 @@ */ import type { + AgentEffort, + AgentMode, AgentOptions, 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, isDefaultApiUrl, 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'; + +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 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[]; +}; + +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; + 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. 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 }; +} + +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|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; + /** 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; + threadId?: string; + mode?: AgentMode; + effort?: AgentEffort; +} + +/** 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', + }; + + // 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; + if (params.threadId) body.threadId = params.threadId; + if (params.mode) body.mode = params.mode; + if (params.effort) body.effort = params.effort; + + 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.clearUrls || + params.clearSchema + ); +} + +/** + * Which thread this run continues, if any. `--thread` wins over `--continue`, + * and `--continue` falls back to the thread remembered for this API key. + */ +export function resolveThreadIntent( + options: Pick, + remembered: string | null +): { threadId?: string; fromMemory: boolean; missingMemory: boolean } { + if (options.thread) { + return { + threadId: options.thread, + fromMemory: false, + missingMemory: false, + }; + } + + if (!options.continue || 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 +284,22 @@ 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 } + : {}), + }; +} + /** * Execute agent status check (with optional wait/polling) */ @@ -96,6 +326,7 @@ async function checkAgentStatus( data: status.data, creditsUsed: status.creditsUsed, expiresAt: status.expiresAt, + ...threadFields(status), }, }; } catch (error) { @@ -144,6 +375,7 @@ async function checkAgentStatus( data: agentStatus.data, creditsUsed: agentStatus.creditsUsed, expiresAt: agentStatus.expiresAt, + ...threadFields(agentStatus), }, }; } @@ -158,6 +390,7 @@ async function checkAgentStatus( data: agentStatus.data, creditsUsed: agentStatus.creditsUsed, expiresAt: agentStatus.expiresAt, + ...threadFields(agentStatus), }, error: agentStatus.error, }; @@ -173,6 +406,7 @@ async function checkAgentStatus( data: agentStatus.data, creditsUsed: agentStatus.creditsUsed, expiresAt: agentStatus.expiresAt, + ...threadFields(agentStatus), }, }; } @@ -204,6 +438,87 @@ async function checkAgentStatus( } } +/** + * 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; + } + }; + + // 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); + + 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(identity); + } + + if (!intent.fromMemory) throw error; + + onNotice('That thread is gone; starting a new one.'); + response = await attempt({ ...params, threadId: undefined }); + } + + if (response?.threadId) { + rememberThread(identity, { + threadId: response.threadId, + runId: response.id, + }); + } + + return response; +} + /** * Execute agent command */ @@ -246,20 +561,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; @@ -267,8 +569,14 @@ 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 as 'spark-1-pro' | 'spark-1-mini'; + agentParams.model = options.model; } if (options.maxCredits !== undefined) { agentParams.maxCredits = options.maxCredits; @@ -276,16 +584,28 @@ 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 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 +614,7 @@ export async function executeAgent( }; } const jobId = response.id; + const threadId = response.threadId; // Handle Ctrl+C gracefully const handleInterrupt = () => { @@ -329,6 +650,8 @@ export async function executeAgent( data: agentStatus.data, creditsUsed: agentStatus.creditsUsed, expiresAt: agentStatus.expiresAt, + ...(threadId ? { threadId } : {}), + ...threadFields(agentStatus), }, }; } @@ -344,6 +667,8 @@ export async function executeAgent( data: agentStatus.data, creditsUsed: agentStatus.creditsUsed, expiresAt: agentStatus.expiresAt, + ...(threadId ? { threadId } : {}), + ...threadFields(agentStatus), }, error: agentStatus.error, }; @@ -368,9 +693,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 +717,7 @@ export async function executeAgent( data: { jobId: response.id, status: 'processing', + ...(response.threadId ? { threadId: response.threadId } : {}), }, }; } catch (error) { @@ -396,6 +728,32 @@ export async function executeAgent( } } +/** + * 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 ${shellQuote(prompt)}`); + } + return lines.join('\n'); +} + /** * Format agent status in human-readable way */ @@ -403,6 +761,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 +794,11 @@ function formatAgentStatus(data: AgentStatusResult['data']): string { lines.push(JSON.stringify(data.data, null, 2)); } + if (data.suggestions && data.suggestions.length > 0) { + lines.push(''); + lines.push(formatSuggestions(data.suggestions)); + } + return lines.join('\n') + '\n'; } @@ -443,6 +813,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 +856,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 +872,103 @@ 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.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..8169056e7c 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, @@ -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,12 +1538,28 @@ 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)', 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('--status', 'Check status of existing agent job', false) .option('--cancel', 'Cancel active agent job by job ID', false) .option( @@ -1565,22 +1585,82 @@ 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) => { + // 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}=`)); + + // 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; + + const prompt: string = 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 (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( + `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) { - urls = options.urls + if (urlsValue) { + urls = urlsValue .split(',') .map((u: string) => u.trim()) .filter((u: string) => u.length > 0); @@ -1588,9 +1668,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); @@ -1624,9 +1704,11 @@ function createAgentCommand(): Command { } const agentOptions = { - prompt: promptOrJobId, + prompt, urls, schema, + clearUrls, + clearSchema, model: options.model, maxCredits: options.maxCredits, status: isStatusCheck, @@ -1640,11 +1722,48 @@ 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, }; 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..11bd4e4622 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -8,6 +8,16 @@ 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; +} + export interface AgentOptions { /** Natural language prompt describing the data to extract */ prompt: string; @@ -19,6 +29,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 */ @@ -43,6 +57,16 @@ 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; } export interface AgentResult { @@ -50,6 +74,7 @@ export interface AgentResult { data?: { jobId: string; status: AgentStatus; + threadId?: string; }; error?: string; } @@ -62,6 +87,49 @@ export interface AgentStatusResult { data?: any; creditsUsed?: number; expiresAt?: string; + threadId?: string; + threadTurn?: number; + mode?: AgentMode; + message?: string; + suggestions?: AgentSuggestion[]; }; 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; +} + +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; +} diff --git a/src/utils/agent-threads.ts b/src/utils/agent-threads.ts new file mode 100644 index 0000000000..3b7cf45a9e --- /dev/null +++ b/src/utils/agent-threads.ts @@ -0,0 +1,199 @@ +/** + * Agent thread memory + * 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 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, normalizeApiUrl } from './config'; +import { getConfigDirectoryPath } from './credentials'; + +export interface RememberedThread { + lastThreadId: string; + lastRunId?: string; + updatedAt: string; +} + +/** `{ [threadFingerprint]: RememberedThread }` */ +export type AgentThreadStore = Record; + +/** + * 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'; + +/** + * 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 threadFingerprint(identity: ThreadIdentity): string { + const key = identity.apiKey?.trim() || NO_API_KEY; + return crypto + .createHash('sha256') + .update(`${normalizeApiUrl(identity.baseUrl)}\n${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 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. + 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 { + // Ignore on Windows + } +} + +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 { + // 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; + + 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( + identity: ThreadIdentity, + thread: { threadId: string; runId?: string } +): void { + try { + 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 identity (e.g. the server 404s it). */ +export function forgetThread(identity: ThreadIdentity): void { + try { + updateStore((store) => { + delete store[threadFingerprint(identity)]; + }); + } catch { + // Ignore errors + } +} diff --git a/src/utils/config.ts b/src/utils/config.ts index 1374a308fc..455efac0e5 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; @@ -72,14 +73,37 @@ 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'; + +/** + * 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); } /** @@ -100,6 +124,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) */