Skip to content

feat(anthropic): add native web search and fetch - #68337

Open
akinfold wants to merge 7 commits into
NousResearch:mainfrom
akinfold:agent/anthropic-web-tools
Open

feat(anthropic): add native web search and fetch#68337
akinfold wants to merge 7 commits into
NousResearch:mainfrom
akinfold:agent/anthropic-web-tools

Conversation

@akinfold

Copy link
Copy Markdown

What does this PR do?

Adds support for Anthropic's native server-side web search and web
fetch tools while preserving Hermes' existing provider-neutral web_search and
web_extract capabilities.

When web.backend: anthropic is selected and Hermes is talking directly to the
Anthropic Messages API, the adapter maps:

  • web_search to web_search_20250305
  • web_extract to web_fetch_20250910 with citations enabled

The implementation deliberately does not add a local WebSearchProvider:
Anthropic executes these tools inside the Messages API request, so the correct
integration boundary is the Anthropic adapter/transport rather than the local
provider registry. The logical server-only binding is projected per transport:
direct Anthropic receives the native definition, while third-party Anthropic
endpoints and non-Anthropic fallbacks omit the unexecutable tool entirely.

The change also preserves native server-tool blocks across turns, exposes
citations to CLI/gateway clients as a compact source list, and handles
Anthropic's pause_turn continuation without inserting a synthetic user or
tool message.

Related Issue

Related to #25234, which explored native Anthropic web search but was closed
without merging. This PR is a focused implementation against current main;
the original author is credited in the commit trailer.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • Add a generic internal server-only tool binding to the existing web schemas
    when the Anthropic backend is explicitly selected.
  • Serialize native web search/fetch specs only for the direct Anthropic
    endpoint; omit them from compatible third-party and fallback transports.
  • Project server-only tools at the transport boundary so Hermes metadata never
    reaches Chat Completions, Bedrock, or Responses API wire payloads.
  • Detect native Anthropic endpoints with hostname-boundary matching rather than
    substring matching.
  • Safely capture and replay server_tool_use, web_search_tool_result, and
    web_fetch_tool_result blocks in their original order.
  • Preserve pause_turn as a distinct stop reason and continue it up to a
    bounded limit before falling back or returning a partial error.
  • Preserve structured citation metadata for replay and render source URLs in
    Hermes' provider-neutral response text.
  • Add Anthropic Web Search & Fetch to hermes tools without requesting a
    second API key.
  • Document configuration, tool versions, endpoint behavior, and usage limits.
  • Add behavioral coverage for serialization, replay, citations, continuation,
    explicit backend selection, per-capability selection, and CLI configuration.

Design and compatibility notes

  • Anthropic is explicit-only: merely having ANTHROPIC_API_KEY does not replace
    an existing web backend. This avoids selecting an unavailable server-side
    backend when the active model uses OpenRouter or another transport.
  • Existing Brave, SearXNG, Firecrawl, Tavily, Exa, Parallel, DDGS, and custom
    provider paths are unchanged.
  • web_search and web_fetch each use max_uses: 5 per model request.
  • No beta header is required for the selected stable tool versions.
  • Anthropic performs fetches server-side, so its URL/provenance and private-IP
    protections apply instead of Hermes' local fetch guard.

How to Test

  1. Configure a direct Anthropic model and ANTHROPIC_API_KEY.

  2. Run hermes config set web.backend anthropic.

  3. Ask Hermes to search for current information and then fetch one of the
    returned URLs; verify that the answer contains source URLs.

  4. Switch to a third-party Anthropic-compatible endpoint or Chat Completions
    fallback and verify the server-only tools are omitted from the request.

  5. Run:

    scripts/run_tests.sh \
      tests/agent/test_anthropic_web_server_tools.py \
      tests/agent/test_anthropic_output_field_leak.py \
      tests/agent/transports/test_transport.py \
      tests/agent/test_anthropic_thinking_block_order.py

Validation completed:

  • 13/13 feature contracts, 228/228 transport regressions, and 221/221 affected
    Anthropic adapter/replay tests passed locally.
  • The fork CI built the production amd64 image and passed an in-container
    native-tool serialization smoke test.
  • ruff check, py_compile, and git diff --check passed for the changed
    files.
  • scripts/check-windows-footguns.py --diff origin/main reported no findings.
  • Paid live tests against the direct Anthropic API passed on
    claude-sonnet-4-6: one web_search and two web_fetch executions. The
    response contained the expected server-use/result blocks, Anthropic usage
    reported exactly one server-tool request per call, block order survived
    replay, output-only fields stayed off the request path, and source URLs were
    exposed in normalized CLI/gateway text. The first fetch run revealed a
    missing source-URL fallback for document-relative citations; this was fixed,
    covered by a regression assertion, and confirmed by the second live fetch.
  • The hardened transport projection was then revalidated with another paid
    live web_search and web_fetch: Anthropic reported exactly one matching
    server-tool request per call, search/fetch exposed 2/1 normalized source URLs,
    and native block order survived replay. The same run confirmed that a
    third-party Anthropic endpoint and Chat Completions fallback omit the
    server-only binding entirely.

CI evidence:
https://github.com/akinfold/hermes-agent/actions/runs/29794982779

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS, plus GitHub Actions Ubuntu for tests and image build

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A (no new config key)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Screenshots / Logs

Not applicable; this is an adapter/transport integration. The linked GitHub
Actions run contains the focused test, image-build, and container smoke-test
logs.

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint tool/web Web search and extraction provider/anthropic Anthropic native Messages API area/config Config system, migrations, profiles sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 21, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the focused native-transport implementation.

Problems

  • website/docs/user-guide/features/web-search.md:40 says compatible third-party Anthropic endpoints “keep the normal client-side web tools.” The new adapter instead omits server-only tools on those endpoints, and tools/web_tools.py returns a local-execution error when the Anthropic backend is selected. The documentation should describe the actual unavailable/choose-another-backend behavior.

Suggested changes

  • Correct that third-party-endpoint documentation sentence.
  • Add a loop-level pause_turn test. tests/agent/test_anthropic_web_server_tools.py currently verifies only normalization/mapping, while the new behavior in agent/conversation_loop.py appends and persists messages, retries, and can activate fallback.

Automated hermes-sweeper review.

`web_fetch` tools. The tools run inside the Messages API request, use the same
Anthropic credential as the model, and return source citations in Claude's
response. Compatible third-party Anthropic endpoints are not assumed to host
these tools; on those endpoints Hermes keeps the normal client-side web tools.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This contradicts the implementation: the new adapter omits server-only bindings on third-party Anthropic endpoints, and selecting web.backend: anthropic makes the local handlers return an error rather than fall back to client-side web tools. Please document that users must select another backend on those endpoints.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Jul 30, 2026
The page claimed compatible third-party Anthropic endpoints keep the
client-side web tools. They do not: the server-only binding is omitted
from those requests, and the local handlers return an error because these
tools only ever execute inside Anthropic's API. Document the actual
behavior and point at the fix (select another backend).
The existing coverage stops at the transport: pause_turn survives
map_finish_reason and normalization. The behavior that matters is in
conversation_loop — it appends and persists the paused turn, replays the
native blocks without inventing a user/tool message, bounds consecutive
pauses, and hands the turn to the fallback chain before failing.

These drive the real run_conversation against an in-process mock Messages
endpoint, so the assertions are on observable turn behavior rather than
internals. Only /v1/messages draws from the scripted response queue: the
agent also POSTs probe endpoints, and letting those consume responses
would shift each test's script by one and make results depend on probe
timing rather than loop behavior.

The fixture relies on the runner's per-file process isolation and on
conftest's autouse HERMES_HOME redirect rather than purging sys.modules
or managing its own tempdir: re-importing agent.transports mid-session
builds a second, empty transport registry while modules imported earlier
still hold the first, which makes get_transport return None for them.

Each test was verified to fail against a deliberately broken loop:
disabling the pause branch reds four of the five, removing the persist
guard reds the persistence test, and removing the per-run counter reset
reds the budget test.
Selecting web.backend anthropic while the model is served by anything else
left the toolset registered and visible — in the startup banner, in
`hermes tools`, in valid_tool_names and over the hermes-tools MCP export —
while the transport stripped both tools from every request. The agent had
no web capability and nothing said so; the handlers' own errors were
unreachable, because a tool that is never advertised is never called.

Availability now accounts for the endpoint, not just the credential: the
native tools execute inside Anthropic's Messages API, so a key alone does
not make them usable. The backend reports unavailable when the configured
model is reached any other way, which makes the banner, the picker,
valid_tool_names and the MCP export correct at once rather than papering
over each in turn. Resolution stays a cheap, network-free read of the
persisted model config — this runs while schemas are assembled and on
every `hermes tools` repaint — and is deliberately permissive: a config it
cannot classify keeps web access, since request-time projection remains
the authority on what reaches the wire.

Both places that silently withheld a server-only tool now say so once per
process, naming the mismatch and the remedy: the transport projection and
the third-party-endpoint path in convert_tools_to_anthropic.

Documentation follows the code: it no longer claims the tools "report that
they are unavailable" on foreign endpoints, which they never did.
The local web_extract path is bounded twice before a result reaches the
model — the auxiliary summariser, then max_result_size_chars on the
registry entry. The native fetch executes inside the Messages API request,
so neither guard ever sees it: one large page was injected whole and, being
preserved for replay, resent on every later turn of the session.

Set max_content_tokens on the tool definition, tied to the local cap
(100_000 chars, ~4 chars/token) rather than picked freely, so choosing this
backend does not silently change how much of a page can land in the
context. The test asserts that relationship instead of freezing the number,
so raising one bound points a reader at the other.

Anthropic applies the limit to page text only; binary content such as a
large PDF remains bounded solely by the context window. Domain
allow/deny lists are a separate hardening step and are not addressed here.
@akinfold

akinfold commented Aug 7, 2026

Copy link
Copy Markdown
Author

Both review points are addressed, and a closer pass over the branch turned up two more problems I fixed here. Four commits on top of the original three.

Review feedback

  • 123347e01 — docs. The page claimed third-party endpoints "keep the normal client-side web tools". They do not, and these tools have no client-side fallback at all. Rewritten to match the code.
  • 7f86af6cc — loop-level pause_turn test. tests/agent/test_anthropic_pause_turn_loop.py: five tests driving the real run_conversation against an in-process mock Messages endpoint. Covers block replay with no synthetic user/tool message, a persistence failure that must not abort the turn, the bounded continuation ending as an explicit partial failure, fallback activation, and the per-run counter reset.

Each test was checked against a deliberately broken loop: disabling the pause branch fails four of the five, removing the persistence guard fails the persistence test, removing the counter reset fails the budget test. Two earlier drafts passed under mutation and were rewritten — worth knowing, since a test that cannot fail proves nothing.

Two problems found while re-reviewing

6ac80a175web.backend: anthropic advertised web access the model could not use.
On a non-Anthropic model check_web_api_key() returned true on the credential alone, so the toolset registered and showed as enabled in the banner, in hermes tools, in valid_tool_names and over the MCP export — while the transport stripped both tools from every request. No web capability, and nothing said so; the handlers' own errors were unreachable, because a tool that is never advertised is never called.

Availability now accounts for the endpoint, not just the credential, which corrects all four surfaces at once. It stays a cheap, network-free read of the persisted model config, and is deliberately permissive — a config it cannot classify keeps web access. Both paths that silently withheld a server-only tool now log it once per process.

One real behaviour change: a session that starts on a non-Anthropic model and /model-switches to Anthropic won't gain the native tools until restart, since agent.tools is built once at init.

29c12ef5a — a native web_fetch could inject an unbounded page.
The local web_extract path is bounded twice before a result reaches the model; the native fetch runs inside the request, so neither guard sees it — one large page was injected whole and, being kept for replay, resent every later turn. max_content_tokens is now set, tied to the local cap rather than picked freely, and the test asserts that relationship instead of freezing a number. Anthropic applies the limit to page text only, so a large PDF is still bounded solely by the context window.

Test runs

macOS, Python 3.11.15, uv sync --locked --extra all --extra dev, via scripts/run_tests.sh:

passed failed
branch point (a41d280f9) 43 640 33
with all four commits 43 745 32

No new failures — the failing set is a strict subset of the branch point's (the one difference is tests/tools/test_base_environment.py, flaky here either way). Those failures are environment-specific and reproduce unchanged on a41d280f9, including the three TestRunOauthSetupToken failures in tests/agent/test_anthropic_adapter.py.

That is why the "ran pytest tests/ -q and all tests pass" box stays unchecked: the suite is not green on this machine and was not green before the branch, so ticking it would be false. ruff check and ty are clean on the changed files.

Two defects I did not fix — your call

Both reproduced. Left alone rather than expand a PR you have already reviewed, but happy to fold either in.

  1. Replayed server-tool blocks are not endpoint-gated. convert_tools_to_anthropic withholds the tool definitions from third-party endpoints, but _convert_assistant_message replays server_tool_use / web_search_tool_result blocks verbatim and takes no base_url. Search on api.anthropic.com, then fail over to a MiniMax/Kimi/DeepSeek endpoint, and the request carries block types it cannot parse. The blocks live in history, so every later request repeats it. This branch's own pause_turn path can reach that failover.
  2. Two consecutive pause_turn turns are merged before the next request. repair_message_sequence Pass 0 merges consecutive assistant messages and carries tool_calls / content but not anthropic_content_blocks; _is_codex_interim exempts incomplete but not pause_turn. The continuation then repeats the previous request byte for byte, so a search needing more than one continuation runs the bound out. My new tests miss this — the mock replies from a script without inspecting the request body.

Separate PRs or folded in, whichever you prefer. Note that 1 wants a live check against a real third-party endpoint, which I have not run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P3 Low — cosmetic, nice to have provider/anthropic Anthropic native Messages API sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state tool/web Web search and extraction type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants