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.
There was a problem hiding this comment.
I looked at Anthropic's docs, and to my knowledge there's no documented stable error code for "web search not enabled", as the 400 body only carries type and a free-text message, and Anthropic's own docs say not to string-match on error messages.
As such, I worked around it by reordering the control flow. So now when we hit an error with web search, we first the retry without web tools. If dropping the web tools makes the retry succeed, then we can report that they were the cause. If the retry also 400s, we surface the original error.
However, any request sent with web search enabled that hits an error will go through one extra round-trip, so I am not sure if this fix is the right direction. I hope you can provide some guidance on this!
| 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.
There was a problem hiding this comment.
I moved the settle to fire after the result block is read, so a blocked or failed fetch/search no longer is reported as "Searched the web".
| 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?
There was a problem hiding this comment.
I address these in 2 ways:
- Pause continuations now have their own budget (max_pause_continuations: 5).
- A response that got cut off by hitting that pause limit is now flagged as truncated in the payload (meta.truncated / stop_reason).
Hope these resolves both concerns!
| ] | ||
|
|
||
|
|
||
| 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.
There was a problem hiding this comment.
Added a note on allowed_domains in the build_web_tools docstring and in config.yam!
|
|
||
| // 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.
There was a problem hiding this comment.
Changed to include an example of hl7.org and docs.openfn.org!
aac349a to
5e1b41c
Compare
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