Web search and fetch for the planner - #664
Conversation
There was a problem hiding this comment.
Thanks @IZO-Ong for this, it is a big piece of work and the spinner and settle pattern sits well with what was already there.
Three things I would like changed before it goes in, all in the planner, and I have left them inline. The short version: the 400 handler treats every bad request as a missing web search entitlement, the "Searched the web" status is sent before we know whether the search worked, and a paused turn spends the tool budget and can be returned to the user as a finished answer.
There is also a note inline on the allowlist. Not a change, but it is the only thing standing between fetched page content and the agent that edits someone's workflow, and that is worth saying in the config.
| response = self._call_api(system_prompt, messages, stream, stream_manager) | ||
| try: | ||
| response = self._call_api(system_prompt, messages, stream, stream_manager) | ||
| except BadRequestError as web_error: |
There was a problem hiding this comment.
This catches every 400, not only the one where the key has no web search. Any other bad request on a web-enabled turn lands here, and an oversized prompt is the easy way to hit it. The user is then told "Web search is unavailable for this account", which is not true, and we pay for a second full call before failing with the original error anyway.
The 400 body carries the reason, so gating on that would keep the fallback and lose the false claim.
| block_type = event.content_block.type | ||
| if block_type == "server_tool_use": | ||
| self._send_spinner(stream_manager, STATUS_SEARCHING_WEB) | ||
| elif block_type in ("web_search_tool_result", "web_fetch_tool_result") and not settled_this_round: |
There was a problem hiding this comment.
This settles on content_block_start, which arrives before the block's content does.
A refused fetch still produces a web_fetch_tool_result block. I checked against the API: asking for a URL outside allowed_domains comes back end_turn with no exception, and the result block's content is a BetaWebFetchToolResultErrorBlock with error_code: url_not_allowed. So a blocked or unreachable fetch renders as "Searched the web", and since _send_settled also records into response_segments it comes back on reload. The turn then answers from memory while the user has been told we looked it up.
Reading response.content after _call_api returns would let you branch on the error shape.
Smaller thing on the same block: the spinner fires for every server_tool_use but the settle is capped at one per round, so three searches in a round leave two spinners unresolved.
| tool_call_count += len(tool_use_blocks) | ||
| paused_text = "" | ||
|
|
||
| elif response.stop_reason == "pause_turn": |
There was a problem hiding this comment.
I can see the test pinning this, so I take it the budget spend is deliberate. Two things I would still like to talk through.
A paused round made no tool call, so counting it against max_tool_calls means web search can eat the planner's ability to call subagents. With the budget at 10 that bites quickly.
The one I would push on harder is test_paused_text_survives_the_max_tool_calls_exit_without_duplicating. It asserts the answer is "AB" when the loop exits while still paused, which means the user gets the head of a reply the server had split, presented as the finished answer. The empty-output guard below cannot catch it because paused_text is not empty. Could we surface that as a truncation, the way max_tokens is, rather than return it as complete?
| ] | ||
|
|
||
|
|
||
| def build_web_tools(config: dict) -> list[dict]: |
There was a problem hiding this comment.
Worth a line here saying what this list is holding up. Fetched page text reaches the planner's context, and the planner writes the arguments for call_workflow_agent, which edits someone's workflow. The allowlist is the whole of that boundary and it is passed through from config with no validation. The empty-list kill switch is documented, but the reason the list matters is not.
|
|
||
| // Only when the planner has web tools on: | ||
| "web_searches": 2, | ||
| "web_fetches": 1, |
There was a problem hiding this comment.
This example says docs.dhis2.org, but the shipped allowlist is hl7.org and docs.openfn.org, so no turn can produce it.
Short Description
Adds an opt-in server-side
web_search/web_fetchto the global_chat planner agent in order to look up external API docs (e.g. FHIR).Fixes Issue #496
Implementation Details
web_search/web_fetchis off by default. A request turns it on withoptions.web_search: true, and the flag is plumbed viaglobal_chat.py→router.py→PlannerAgent.Tool definitions (
tools/tool_definitions.py).build_web_tools(config)readsplanner.web_searchfromconfig.yaml, and returnsweb_search_20260209+web_fetch_20260209. Current config shipsmax_uses: 5andmax_content_tokens: 10000.Tool loop (
planner.py), covering the issue's code-audit list:server_tool_usedoes not consumemax_tool_callsas they run on Anthropic's server and are not local tools, as such we track it usingmax_uses.stop_reason: "pause_turn"is handled by continuing the request. Text from the paused round accumulates inpaused_textand is prepended to the final answer.response.content.BadRequestErrorwhile web tools are active (when caller's key does not have web search enabled) drops the web tools, rebuilds the system prompt, and retries once.Streaming (
streaming_util.py,planner.py). NewSTATUS_SEARCHING_WEBsettled to "Searched the web" on the firstweb_search_tool_result/web_fetch_tool_resultof a round.Prompt (
prompts.yaml).planner_web_tools_promptis appended only when web tools are active.Telemetry (
PAYLOAD_SPEC.md).metagainsweb_searches,web_fetches,web_domains(deduped fetch hostnames),web_search_downgraded, andweb_search_requested.Allowlist
The allowlist currently only ships
opendocs.openfn.organdhl7.org(FHIR R4/R5) as the external docs target.Running a demo
Requires an
ANTHROPIC_API_KEYin.envwith web search enabled on the key.Save a payload as
demo.json:{ "content": "Using the published FHIR R4 specification, which fields does the Patient resource define? Fetch the spec page and confirm from it rather than answering from memory.", "options": { "stream": false, "web_search": true } }The
metashould show the web activity:Testing
Verified:
pytest services/global_chat/tests/unit services/job_chat/tests/unit services/workflow_chat/tests/unit -q-> 129 passed.AI Usage
Please disclose whether you've used AI in this work (it's cool, we just want to know!):
You can read more details in our Responsible AI Policy