Repository navigation
fix(community): normalize max_results before handing it to Firecrawl and fastCRW - #6175
Conversation
willem-bd
left a comment
There was a problem hiding this comment.
Clean, well-tested fix that faithfully mirrors the merged ddg_search guard: both web_search call sites now normalize max_results (bool / fractional / non-numeric / <=0 rejected with a warning naming the bad value, integer-string accepted, default 5), and I verified these are the only max_results call sites in both files — web_fetch does not use it. The new parametrized tests assert the exact limit= kwarg the client receives plus warning presence, the imports they rely on already exist at head, and leaving tavily/searxng to #5867 avoids conflicting with that PR. Two nits inline: the helper is now triplicated, and one config shape is untested.
| DEFAULT_MAX_RESULTS = 5 | ||
|
|
||
|
|
||
| def _coerce_max_results(value: object) -> int: |
There was a problem hiding this comment.
This is now the third near-identical copy of _coerce_max_results (ddg_search, firecrawl, fastcrw), on top of the ~10 per-provider _coerce_* guards already merged. deerflow.community already hosts shared helpers (url_safety, search_time_range), so a single coerce_max_results(value, default, label) there would keep the bool/float/int rules from drifting apart the next time one copy gets a fix and the others don't. Following the existing per-provider convention is fine, so deferring to a follow-up is reasonable — just noting the duplication has now tripled.
There was a problem hiding this comment.
Agreed, and it is the reason I hesitated before adding a third copy. I stayed with the per-provider shape because the existing guards are not one function wearing three hats — measured on this tree: ddg_search and image_search are the unbounded form my two copy (reject bool, reject fractional float, <= 0 -> warn + default, no upper bound); serper and serply take default/max_allowed and clamp with min(...) instead of rejecting, and serper falls back without a log line; exa._coerce_positive_int accepts on isinstance(value, int) plus str.isdecimal() and threads an option name through its message.
So a shared coerce_max_results(value, default, label) has to settle three policies at once — whether an upper bound exists, whether a silent fallback is allowed, and whether fractional floats truncate or warn — across six call sites, which is its own change with its own tests rather than something to fold into this diff. Your drift argument is exactly why that refactor deserves to be reviewed on its own. Happy to take it on as a follow-up if you want it; otherwise I will leave this as it stands.
| pytest.param("many", 5, True, id="non-numeric-string"), | ||
| pytest.param("8", 8, False, id="integer-string"), | ||
| pytest.param(7, 7, False, id="plain-int"), | ||
| pytest.param(None, 5, False, id="omitted"), |
There was a problem hiding this comment.
omitted uses None as the key-absent sentinel, so the explicit max_results: null YAML shape — key present with a None value, which reaches _coerce_max_results and exercises the int(None) -> TypeError path — is never tested. It behaves correctly today (warning + default), but a dedicated case with a distinct sentinel object instead of None would pin that path down. Same applies to the mirrored test in test_fastcrw_tools.py.
There was a problem hiding this comment.
Good catch — None was doing double duty, so extra["max_results"] = configured never ran for it and the key-present / value-null shape was never built. 24f8fb5 switches absence to a _OMITTED sentinel and adds an explicit-null case here and in test_fastcrw_tools.py. The two cases differ in the observable that matters: explicit-null warns, omitted does not, which is what pins the int(None) -> TypeError branch down (measured: _coerce_max_results(None) returns 5 with a warning in both providers).
pytest tests/test_firecrawl_tools.py tests/test_fastcrw_tools.py -k normalizes_max_results is 18 passed, up from 16; the only failures left in those two files are the three TestWebFetchTool cases that need a resolver, which this sandbox does not have.
The parametrized `omitted` case reused None as its "key absent" marker, so `extra["max_results"] = configured` never ran for None and the key-present, value-null YAML shape never reached _coerce_max_results. Use a distinct sentinel for absence and add an explicit-null case per provider.
What
firecrawlandfastcrwpass the configuredmax_resultsstraight intoclient.search(query, limit=max_results). Every other web-search provider undercommunity/normalizes that value first —ddg_search,image_search,exa,searxng,serper,serply,sofya,brave,groundroute,tencent_wsaeach have a_coerce_*guard; these two were the only ones that did not.Why it is user-visible
max_resultsis free-form YAML, so anything lands here:max_results: 0sendslimit=0, the provider answers with an empty result set, and the tool returns[]with no log line — the agent reads that as "the web has nothing about this" instead of "your config is wrong". The ten guarded providers log and fall back to the default for the same input.max_results: true(a typo for1, or a value YAML resolves as a bool) sendslimit=trueand the request is rejected, so the model getsError: …in place of search results.max_results: 3.5sends a float;int()would silently turn it into 3, which is why the acceptedddg_searchguard rejects fractional values outright.max_results: "8"sends a string where an integer is expected.How it is fixed
A module-level
_coerce_max_resultsin each provider, mirroring the shape already merged forddg_search(63401d79): reject booleans, fractional floats, non-numeric values and anything<= 0with a warning naming the bad value, fall back toDEFAULT_MAX_RESULTS = 5, and accept an integer or an integer-form string. Nothing else about the call changes.Checks (run on
63e399f2, Python 3.12)Diff is 4 files, +122 −4: two guards, two call sites, and a parametrized test per provider covering bool / zero / negative / fractional / non-numeric / integer-string / plain int / omitted, each asserting the exact
limit=the client receives plus whether a warning is expected.tavilyandsearxnghave the same gap and are already being handled in #5867, so this change deliberately leaves them alone.Agent-assisted contribution, disclosed per
AGENTS.md.