Skip to content

feat: agent usability — bounded outputs, structured errors, complete tool schemas - #383

Draft
chenxin-yan wants to merge 21 commits into
firecrawl:mainfrom
chenxin-yan:agent-usability-clean
Draft

feat: agent usability — bounded outputs, structured errors, complete tool schemas#383
chenxin-yan wants to merge 21 commits into
firecrawl:mainfrom
chenxin-yan:agent-usability-clean

Conversation

@chenxin-yan

@chenxin-yan chenxin-yan commented Aug 21, 2026

Copy link
Copy Markdown

Agents hit three failure modes with this server: unbounded outputs that flood the context window, opaque errors that trigger pointless retries, and parameter descriptions that are silently dropped from tools/list. This PR fixes all three. Every change is validated by paired A/B evals against the base commit (results below).

What changed

Theme Change Commits
Bounded outputs Total output caps with compaction, in-band truncation notice, offset continuation for capped crawls (continuation pages bounded too) 1b0eb40 928222a 8223468 4bf7792 366cb7b
Structured errors Stable code taxonomy + retryable flag + recovery guidance; EMPTY_RESULT/LIKELY_BLOCKED flags on silent-empty results; non-retryable UNSUPPORTED_SITE for domain-policy rejections; agent-actionable fallback in AUTH_REQUIRED recovery guidance; non-idempotent tools never advertise retryable; threat-protection blocks classified permanent a6d966b dcf19a8 1f18740 f98fe9c d77be3e
Complete schemas Pin zod 4.1.13 (newer zod drops every .describe() in serialization — published npm packages ship zero param descriptions today); describe all 326 params on callable tools; cut description prose −48% b8c22e2 2ea6f6c 11f8520
Bugfix Local keyless tools/list advertised firecrawl_parse, which is not callable without a key; the advertised set now matches the callable set (Search + Scrape) 8c1bf20

Eval results

Paired A/B, 5 independent hosted runs: 300 traces, 150 pairs (2 models × 5
tasks × 3 reps × 5 runs). Baseline = this repo @ 678c92a, packed identically
to the branch, so the diff isolates exactly these commits. The final run
covers the exact branch tip.

Metric Baseline This PR
Task success 113/150 (75%) 113/150 (75%)
Paired win / tie / loss 4 / 142 / 4 *
Duration, tokens, cost (paired medians) ≈ 0 (cost/tool-calls consistently equal or lower)

* Of the 8 non-ties, 5 traced to Firecrawl rate-limit/quota hits (3 favoring
this PR, 2 favoring baseline — environmental noise, roughly symmetric). The
other 3 are product-attributable and tell one story: an early revision's
AUTH_REQUIRED recovery text gave only operator guidance ("configure a key"),
so on a spurious per-operation 401 one model obediently gave up (2 losses in
run 4); after rewording the guidance to include a same-session fallback
(1f18740), that model recovered 6/6 in runs 5–6 and beat baseline's
improvised recovery once (1 win). The eval loop caught and corrected its own
regression.

Behavior deltas (from transcripts):

Scenario Baseline This PR
Permanently blocked URL (LinkedIn) Retried after rejection in 2/3 reps 0/3 retries after UNSUPPORTED_SITE
Browser-interaction task, median tool calls 7 3 (both models)
Error text relayed to agent Tool 'firecrawl_scrape' execution failed: <raw string> Clean message + Recovery: guidance
Scrape payload, bounding active (same call count) 37.8 KB 6.6 KB (−83%)

Context-window cost of the schema changes:

Baseline This PR
Tool description prose 10.7 KB 5.6 KB (−48%)
tools/list total payload 41.7 KB 53.2 KB (+~2.9k tokens, one-time, cache-friendly — the growth is param docs the zod bug was stripping)
Fresh tokens per task (median, final hosted run) 16.5k 13.5k

Local adversarial suite: 360 runs; adversarial-recovery scenarios 0/30 → 24/30.

Known service-side limitation: crawl-status pagination orders documents by
finished_at, created_at with no unique tie-break column, so skip-based
resume (which the offset continuation builds on) is not provably stable
when those timestamps tie. The continuation inherits that property; it cannot
be fixed client-side.

Verification

85/85 tests, tsc --noEmit clean, eslint clean. Install verified from git and
tarball (builds via prepare, zod resolves 4.1.13 flat, 326/326 params on
callable tools described over stdio tools/list; only the deprecated
firecrawl_extract stub, which rejects immediately with redirect guidance,
leaves its unused params undescribed).

…nuation

Keep detailed as the compatibility default for small responses while applying a 64k-character hard ceiling. Concise responses use a 12k ceiling and remove screenshots, base64 fields, and duplicate raw HTML with explicit in-band omission guidance. Crawl collection stops at page boundaries after 25 documents or 48k bytes and exposes the upstream cursor through check_crawl_status.
…tion

Pin the application schema runtime to Zod 4.1.13. Zod 4.1.11 schemas lose registry metadata when xsschema converts them through its newer Zod peer, while 4.1.13 preserves descriptions without changing the emitted schema shape.
Document every full-surface property and nested object field with concise semantics, defaults, constraints, and material cost implications. The tools/list integration test guards at least 95 percent coverage and checks opaque high-risk fields.
M1: enforce the total serialized response ceiling for wide payloads and add a failing-first regression test.
m1: merge truncation metadata with existing _firecrawl result notices.
m2: limit LIKELY_BLOCKED notices to empty or near-empty scrape content.
m3: classify only the server's exact crawl-continuation validation message as INVALID_REQUEST.
m4: validate path-prefixed crawl continuations and pass a base-relative path to the SDK.
m5: cover cross-origin and wrong-job continuation rejection.
m7: force one Zod 4.1.13 tree through the pnpm workspace override; pnpm 11 ignores package.json pnpm.overrides.
n1: avoid splitting UTF-16 surrogate pairs when truncating strings.
n2: reject empty crawl job IDs at schema validation.
n3: correlate early validation action logs with the structured payload request_id.
n4: document the deliberate pre-FastMCP validation tradeoff.
n6: remove the fork-internal source-path reference.

Known scope limits: monitor tools remain unbounded by design in this PR; firecrawl_monitor_check is the only potentially large monitor response surface.
Firecrawl's domain denylist rejection ('we do not support this site')
previously fell through to the generic retryable UPSTREAM_REQUEST_FAILED,
telling agents to retry a permanently blocked URL. Hosted eval EXP-048
(session ax-db-20260821-201031-c8200fb6) surfaced agents relaying that
misleading retry guidance verbatim. The rejection now maps to a
non-retryable UNSUPPORTED_SITE code that steers agents to a different
source instead of a retry.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 9 files

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread pnpm-workspace.yaml
Comment thread src/index.ts
Comment thread src/index.ts Outdated
@chenxin-yan
chenxin-yan marked this pull request as draft August 21, 2026 22:06
…able

Cursor-fetched crawl pages bypassed the document/byte caps entirely: the
initial page assignment took pageDocuments.slice() unwindowed, so a
continuation page larger than the caps was returned whole. Every page now
goes through takeCrawlDocumentWindow. A partially shown page is resumable
by combining next with offset (previously next zeroed offset), and the
truncation notice carries both so agents can retrieve the remainder
instead of skipping to the following cursor.

Found by PR review on firecrawl#383.
Type options as Record<string, unknown> so removing parse-only keys with
delete is legal on the union, and key the interact session guard on
!scrapeId so control flow narrows it to string before client.interact.
Upstream per-operation 401s (seen chronically on firecrawl_interact in
hosted evals) previously got operator-only guidance ('configure an API
key'), which gpt-5.6-terra obeyed by abandoning the task even though
firecrawl_scrape worked in the same session (EXP-048 round 4: the only 2
paired losses in 90 pairs). The wrapped-error guidance now leads with a
same-session fallback: retry once or use another Firecrawl tool. The
pre-flight no-credential AUTH_REQUIRED sites keep setup-only guidance
since there the diagnosis is certain.
…file

Hosted A/B rounds (EXP-048) showed the concise/detailed knob is
second-order: the caps, compaction, truncation notices, and crawl
continuation carry the output bounding, while concise adoption is
model-dependent (heavy under gpt-5.6-sol, near-zero under gpt-5.6-terra)
and its savings overlap request narrowing agents already do. Dropping the
parameter removes seven schema fields and a second compaction profile.
All responses now use the former detailed behavior: 64k cap,
{arrayItems 50, objectFields 100, stringChars 12000} compaction with
iterative shrinking and _firecrawl truncation metadata.
@chenxin-yan
chenxin-yan marked this pull request as ready for review August 22, 2026 05:03

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 10 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tests/helpers.mjs">

<violation number="1" location="tests/helpers.mjs:18">
P3: getFreePort has a check-then-use race: the port is released before the spawned server binds it, so a concurrent process can claim it in between and cause a bind failure or a bad target, making tests flaky. Prefer binding port 0 directly in the child and reading the bound port back, or retry on EADDRINUSE.</violation>
</file>

<file name="src/index.ts">

<violation number="1" location="src/index.ts:1327">
P2: Local keyless sessions are filtered out of tools/list via isLocalKeylessSession in canList, but beforeValidate and execute never check that same condition, so calling a tool hidden from discovery (e.g. firecrawl_parse) is not rejected with the structured KEYLESS_TOOL_NOT_AVAILABLE payload like hosted keyless sessions. Add the isLocalKeylessSession && !localKeylessTool guard (with the same KEYLESS_TOOL_NOT_AVAILABLE recovery) to beforeValidate and execute so advertising and call-time behavior stay consistent.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread src/index.ts Outdated
Comment thread src/monitor.ts Outdated
Comment thread tests/helpers.mjs Outdated
Comment thread src/index.ts
? keylessTool
: true) &&
: isLocalKeylessSession(session)
? localKeylessTool

@cubic-dev-ai cubic-dev-ai Bot Aug 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Local keyless sessions are filtered out of tools/list via isLocalKeylessSession in canList, but beforeValidate and execute never check that same condition, so calling a tool hidden from discovery (e.g. firecrawl_parse) is not rejected with the structured KEYLESS_TOOL_NOT_AVAILABLE payload like hosted keyless sessions. Add the isLocalKeylessSession && !localKeylessTool guard (with the same KEYLESS_TOOL_NOT_AVAILABLE recovery) to beforeValidate and execute so advertising and call-time behavior stay consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/index.ts, line 1327:

<comment>Local keyless sessions are filtered out of tools/list via isLocalKeylessSession in canList, but beforeValidate and execute never check that same condition, so calling a tool hidden from discovery (e.g. firecrawl_parse) is not rejected with the structured KEYLESS_TOOL_NOT_AVAILABLE payload like hosted keyless sessions. Add the isLocalKeylessSession && !localKeylessTool guard (with the same KEYLESS_TOOL_NOT_AVAILABLE recovery) to beforeValidate and execute so advertising and call-time behavior stay consistent.</comment>

<file context>
@@ -1202,7 +1323,9 @@ function guardHostedTool(
         ? keylessTool
-        : true) &&
+        : isLocalKeylessSession(session)
+          ? localKeylessTool
+          : true) &&
       (canList?.(session) ?? true),
</file context>
Fix with cubic

Comment thread tests/helpers.mjs
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
return port;

@cubic-dev-ai cubic-dev-ai Bot Aug 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: getFreePort has a check-then-use race: the port is released before the spawned server binds it, so a concurrent process can claim it in between and cause a bind failure or a bad target, making tests flaky. Prefer binding port 0 directly in the child and reading the bound port back, or retry on EADDRINUSE.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/helpers.mjs, line 18:

<comment>getFreePort has a check-then-use race: the port is released before the spawned server binds it, so a concurrent process can claim it in between and cause a bind failure or a bad target, making tests flaky. Prefer binding port 0 directly in the child and reading the bound port back, or retry on EADDRINUSE.</comment>

<file context>
@@ -0,0 +1,72 @@
+  await new Promise((resolve, reject) => {
+    server.close((error) => (error ? reject(error) : resolve()));
+  });
+  return port;
+}
+
</file context>
Fix with cubic

…ssified failures

firecrawl_interact and firecrawl_monitor_create can produce external side
effects before an upstream error surfaces (a submitted form, a created
monitor). Marking those failures retryable invites an obedient agent to
repeat the side effect; guidance now says to verify current state first.
Read-only tools keep retryable unclassified failures.
…teardown

The monitor_create description lost the 'one or more non-empty values'
qualifier in the dedup refactor while the code still filters empty
queries before choosing search over page targets. stopChild now awaits
child exit after the SIGKILL fallback so killed servers cannot outlive
test cleanup.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread src/index.ts
@hmishra2250

hmishra2250 commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

I ran a local 72-run A/B against the current version using natural prompts that did not mention Firecrawl. This PR did not improve tool selection: Firecrawl was picked 15/36 times versus 16/36 on current, and succeeded 11/36 versus 13/36. Output quality was flat. I would keep the inline argument descriptions and the keyless parse fix, but I would not position this as a discoverability improvement.

@hmishra2250

hmishra2250 commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

I also ran a local 72-run A/B where agents were told to use Firecrawl. Both versions used it 36/36 times and succeeded 29/36, with similar output quality. The clearer blocked-site errors are useful and should stay. Before merging, I would classify errors from SDK status and code instead of message text. I would also fix crawl pagination: a partial page can return next: null and make an incomplete crawl look finished. The truncation approach is good, but it needs to preserve a usable cursor and have a regression test.

apps/api has a second permanent domain-policy 403 besides the blocklist
message: org-level threat protection ('blocked by your organization's
threat protection policy', threat-protection/error.ts). The classifier
only matched the blocklist phrasing, so threat-protection blocks fell
through to UPSTREAM_REQUEST_FAILED with retryable=true - inviting the
futile retry loop UNSUPPORTED_SITE exists to prevent. Found by auditing
the MCP error taxonomy against the service source.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread src/index.ts
@chenxin-yan

Copy link
Copy Markdown
Author

I ran a local 72-run A/B against the current version using natural prompts that did not mention Firecrawl. This PR did not improve tool selection: Firecrawl was picked 15/36 times versus 16/36 on current, and succeeded 11/36 versus 13/36. Output quality was flat. I would keep the inline argument descriptions and the keyless parse fix, but I would not position this as a discoverability improvement.

yes I was running eval closely just making sure the behavior changes of the mcp tools would not lead to any regressions. I will make some changes and let you know when its ready

@chenxin-yan
chenxin-yan marked this pull request as draft August 22, 2026 09:00
… by SDK status/code

Two pre-merge review requests:

Crawl cursor: when the client-side document window truncated a page that
had no upstream cursor, the response carried next:null while documents
remained, making an incomplete crawl look finished to anything keying on
the API-native field. A native skip cursor (/v2/crawl/:id?skip=N) is now
synthesized in that case - the service supports skip pagination and the
indices align with the shown-document count - so next is always usable
when truncated. Regression test follows the synthesized cursor and
asserts documents 26-50 arrive.

Error classification: wrapToolError now reads duck-typed SDK error
metadata (status, code, details) before falling back to message regexes:
401 -> AUTH_REQUIRED, 408/SCRAPE_TIMEOUT -> UPSTREAM_TIMEOUT (also
catches opaque messages like heartbeat_failed), 429 -> new RATE_LIMITED
(retryable - a 429 is rejected before execution - with concrete
retry-after seconds parsed from the body since the service sends no
Retry-After header). Plain-string errors still classify via regex.
…ble API code

monitor_run queues a new run and feedback creates a new record on every
POST, so their unclassified upstream failures must not advertise
retryable; update/delete stay retryable because repeating them converges
on the same target state. Threat-protection blocks now classify by the
service's stable code (unsafe_domain_blocked - kept for API stability
upstream) instead of relying on message wording; the regex remains as
fallback for the codeless global-blocklist 403.
…y review

Simplifications from an over-engineering review pass, no behavior change:

- reuse ClientLike from research.ts instead of the identical local
  CrawlClientLike
- drop the guidance.slice(0, 2000) cap (all guidance strings are short
  internal literals) and the unreachable never-typed default arm in
  withResultNotice
- drop per-separator byte bookkeeping in takeCrawlDocumentWindow; the
  48k cap does not need comma-level precision
- dedup the new smoke tests through startHostedCloudServer and
  startStdioClient helpers (exact-match conversions only)
- remove the brittle toolNames.length === 26 assertion; the name-based
  assertions already pin the tool contract

Verified: tsc --noEmit clean, npm test 89/89 pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants