Skip to content

feat(engine): hand MCP deferral to anthropic's server-side tool search - #191

Open
andybons wants to merge 1 commit into
feat/anthropic-tool-searchfrom
feat/anthropic-tool-search-engine
Open

feat(engine): hand MCP deferral to anthropic's server-side tool search#191
andybons wants to merge 1 commit into
feat/anthropic-tool-searchfrom
feat/anthropic-tool-search-engine

Conversation

@andybons

Copy link
Copy Markdown
Contributor

Slice 2, stacked on #189. Do not merge. This is the wiring that makes #189 reachable — #189 alone sets no DeferLoading, so the live-fire evidence in #189's body was produced by this branch's binary.

What it does

Chooses the deferral mechanism per request, from the model that will actually serve it (streamTurn passes params.Model, so a chat.params rewrite or a mid-session SetModel both move the session onto the mechanism that model can honor). Native mode: every MCP definition sent, deferred ones marked, no catalog segment, selected set neither consulted nor reaped.

The persistence question, answered from the continuation rules

The API expands tool_reference blocks throughout conversation history, so a natively-discovered tool stays usable in later turns and across a reload with no harness bookkeeping. recMCPToolsSelected stays the client-side path's mechanism; native sessions never write to it. TestNativeModeIgnoresSelectionState encodes it. Confirmed live — see #189's live-fire section, where a resumed session called the discovered tool immediately with no re-search.

What to scrutinize

  1. Per-request model, not session model. The stranding case (TestModelSwapMovesBetweenMechanisms): swap away from a capable model and the catalog must come back the next turn.
  2. Every definition is still sent in native mode. Withholding a deferred definition would make it undiscoverable — the API needs it to search and expand.
  3. The gateway-stripped-block test (provider/anthropic). Observed live against Bifrost: content_block_start with an index and no content_block field, because the gateway does not model the block type the API emitted. The stream must not fail and the husk must not become a phantom history part.

Verification

Three guards red-verified: native mode engaging on a capable model, non-capable routes keeping the catalog, and the stripped block staying inert. TestNativeRequestCarriesDeferLoading drives Session.Prompt and asserts on the provider.Request. go test -race ./... green; go vet, gofmt clean.

The deferral core defers MCP tool schemas with harness's own mechanism: a
name-only catalog in the system prompt plus the mcp tool's search and
select actions. On a model that supports Anthropic's server-side tool
search there is a strictly better path, and this wires it: the provider
keeps deferred definitions out of the context window, runs the search
itself, and expands what the model discovers, with no harness round-trip.

The mode is chosen per REQUEST, from the model that will actually serve it.
streamTurn passes params.Model, so a chat.params hook that rewrites the
model moves the session onto the mechanism that model can honor, and a
mid-session SetModel swap does the same. The one state that must never
exist is a session deferring with no discovery path, and choosing per
request is what prevents it: a swap away from a capable model brings the
catalog back on the very next turn.

Native mode differs from client-side mode in three ways, each a deliberate
non-action. Every MCP definition is sent, deferred or not, because the API
needs the full definition to search it and to expand the tool_reference it
returns. No catalog segment is rendered, because a harness catalog would be
a second copy of the list the API is already withholding, spending the
tokens deferral exists to save. And the selected set is neither consulted
nor reaped, because discovery is the API's job.

That last one is the persistence answer the design asked for, and the
continuation rules settle it: the API expands tool_reference blocks
throughout the conversation history, so a natively-discovered tool stays
usable in later turns and after a reload with no harness bookkeeping at
all. recMCPToolsSelected stays what it always was, the client-side path's
mechanism, and native sessions simply never write to it.

Also lands a regression test for a shape a GATEWAY produces, observed live
against Bifrost while server-side tool search was active: a
content_block_start carrying an index and no content_block field at all,
because the gateway does not model the block type the API emitted. The
stream must not fail on it and the husk must not become a phantom part in
history. It already behaved correctly -- an unknown block type assembles as
an inert empty block -- but nothing pinned it, and this is now a shape we
know real infrastructure produces.

Verification: three guards red-verified against the exact mechanism each
names -- native mode engaging on a capable model, non-capable routes
keeping the client-side catalog, and the stripped block staying inert. The
request-level test drives Session.Prompt and asserts on the
provider.Request an adapter would transcode. go test -race ./... green.
@claude

claude Bot commented Aug 26, 2026

Copy link
Copy Markdown

Claude finished @andybons's task in 4m 23s —— View job


Review: hand MCP deferral to Anthropic's server-side tool search

Read AGENTS.md and reviewed against base origin/feat/anthropic-tool-search. The design is sound and the mechanism is well-chosen: per-request model selection (streamTurn passes params.Model post-chat.params), every definition sent in native mode, and no catalog segment. I traced the three "what to scrutinize" claims and they hold up.

Verified correct

  • Per-request model, not session model. streamTurn:2059 computes the plan from params.Model (the effective model after the chat.params hook), and req.Model/provider resolution use the same value (engine.go:2054, :2131) — so DeferLoading is only ever emitted to the route that will actually honor it. TestModelSwapMovesBetweenMechanisms covers the stranding case.
  • Every definition still sent in native mode. nativeMCPPlan returns all defs and marks only the deferred ones; the anthropic transcoder's deferred < len(req.Tools) guard (base) degrades an all-deferred request to eager rather than 400ing.
  • Gateway-stripped block stays inert. Confirmed in production code, not just the test: a content_block_start with no content_block field decodes to kind:"", is still appended to s.blocks (this is load-bearing — it keeps subsequent content_block_delta index alignment, anthropic.go:479/495), and assemble() (anthropic.go:646) has no arm for "", so no phantom part reaches history. Good characterization guard.
  • Tests follow the repo rules — no time.Sleep, deterministic scripted provider, and TestNativeRequestCarriesDeferLoading drives the real Session.Prompt entry point rather than a hand-built plan.

Findings (both inline, neither a merge blocker on its own)

  1. Dead method nativeToolSearch (mcp_lazy.go:289) — never called; planMCPToolsForModel inlines the same two predicates. Its doc comment presents it as the gate, which is a divergence trap. Wire it in or delete it.
  2. Native sessions still advertise mcp search/select whose description references a catalog native mode does not render (mcp_tool.go:131). Degrades gracefully (search reports all loaded, select is a no-op) but gives the model a second, misleadingly-described discovery path alongside the API's own tool_search. Worth a conscious call.

One process note: the PR body says the stripped-block guard was "red-verified," but this PR adds no production change in provider/anthropic — the inert behavior already exists in base. It's a valid characterization test; just flag whether the red-verify reverted a real named mechanism (per AGENTS.md's "red-verify the NAMED mechanism") or was a green-from-birth guard.

Nothing here touches the concurrency, startup-speed, or transcode-replay invariants. Since this is explicitly Do not merge (stacked wiring for #189), the two findings are fine to fold into a follow-up or address before the stack lands.

Todo

  • Read AGENTS.md priorities and PR diff
  • Review engine/engine.go changes (per-request model selection)
  • Review engine/mcp_lazy.go changes (native deferral)
  • Review provider/anthropic stripped-block handling
  • Review tests against AGENTS.md testing rules
  • Post review
    · feat/anthropic-tool-search-engine

Comment thread engine/mcp_lazy.go
// model separately when a chat.params hook has rewritten it; see
// planMCPToolsForModel.
func (s *Session) nativeToolSearch(model message.ModelRef) bool {
return s.sessionCanDefer() && modelmeta.SupportsToolSearch(model)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Dead method — never called, and it duplicates the live decision. nativeToolSearch is defined here but has no callers (grep -rn "nativeToolSearch(" --include=*.go finds only this definition). The actual decision in planMCPToolsForModel inlines the same two predicates independently:

deferring := s.sessionCanDefer()
if !deferring {
    return mcpToolPlan{defs: all}
}
if modelmeta.SupportsToolSearch(model) {
    return s.nativeMCPPlan(all)
}

The doc comment claims this method "reports whether THIS session should hand deferral to the provider" — i.e. it reads as the gate — but it governs nothing. That's a divergence trap: a future change to the native-mode condition touches one copy and silently leaves the other stale, and the misleading comment points a reader at the wrong function.

Either wire it in (if s.nativeToolSearch(model) { return s.nativeMCPPlan(all) }, which also collapses the two-step sessionCanDefer/SupportsToolSearch branch into the named predicate) or delete it. Fix this →

Comment thread engine/mcp_lazy.go
Comment on lines +383 to +394
func (s *Session) nativeMCPPlan(all []provider.ToolDef) mcpToolPlan {
overThreshold := len(all) > s.mcpDeferThreshold()
defs := make([]provider.ToolDef, 0, len(all))
for _, d := range all {
server, _, ok := splitMCPToolName(d.Name)
if ok && s.resolveMCPLoading(server, overThreshold) == MCPToolLoadingLazy {
d.DeferLoading = true
}
defs = append(defs, d)
}
return mcpToolPlan{defs: defs, native: true}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Native sessions still advertise the mcp tool's search/select actions, whose description references a catalog native mode never renders. sessionCanDefer() is true for a native session, so runMCPTool (engine/mcp_tool.go:171-186) dispatches search/select, and mcpToolDeferDescription (mcp_tool.go:131) tells the model "Some MCP tools are DEFERRED: the system prompt lists their names and one-line descriptions" — but in native mode planMCPToolsForModel renders no catalog segment, so that list is absent.

Functionally this degrades gracefully rather than breaking: mcpLoadedNames (mcp_search.go:395) runs the native plan, which returns every def, so search reports every tool loaded: true and select becomes a no-op. But the model is now handed two discovery mechanisms (Anthropic's tool_search tool and harness's mcp search/select) plus a description pointing it at a nonexistent prompt list — a plausible source of wasted turns and confused behavior on exactly the capable models this PR targets.

Worth deciding explicitly: either suppress search/select for native sessions, or adjust the description so it's accurate when the API owns discovery. Not a correctness bug, but the inconsistency is introduced by this change and deserves a conscious call rather than falling out of reusing sessionCanDefer.

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.

1 participant