fix(init): set ENABLE_TOOL_SEARCH=true so Claude Code keeps deferring tools (#746) (#995)

## Description

Claude Code disables on-demand tool loading (Tool Search) when
`ANTHROPIC_BASE_URL` is a custom host and `ENABLE_TOOL_SEARCH` is unset,
materializing all MCP/system tool schemas into its context window
(#746). With many MCP servers this overflows the window — breaking
sub-agent spawns ("prompt too long, ~214k > 200k") and forcing constant
compaction. `headroom wrap claude` already sets it; `init`/install did
not. Refs #746.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Keep tool deferral on at both entry points, sharing one
`TOOL_SEARCH_ENV` / `TOOL_SEARCH_DEFAULT` constant from the Claude
provider package (`providers/claude/runtime.py`) so the key/default
can't drift:
- `init` (`_ensure_claude_hooks`): sets `ENABLE_TOOL_SEARCH=true` via
`setdefault`, respecting a pre-existing user-provided value.
- `install` (`build_install_env`): always writes
`ENABLE_TOOL_SEARCH=true`. This is the headroom-managed install env
(recorded and reverted on uninstall), so it is authoritative rather than
deferring to an existing value.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ pytest tests/test_cli/test_init_enable_tool_search.py -q
3 passed in 0.63s
```

## Real Behavior Proof

- Environment: Python 3.14, headroom proxy on :8799, ~19 MCP servers
connected
- Exact command / steps: launched `claude` through the proxy with vs
without `ENABLE_TOOL_SEARCH=true`, asked each to spawn 5 parallel
sub-agents
- Observed result: without it, all 5 sub-agents fail ("prompt too long,
~214k > 200k"); with `ENABLE_TOOL_SEARCH=true` all 5 succeed and traffic
compresses
- Not tested: non-Claude-Code agents

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Eyal Mizrachi 2026-06-19 12:26:26 -04:00 committed by GitHub
parent f03e77bec0
commit 500ec2b7fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 102 additions and 11 deletions

View file

@ -38,6 +38,7 @@ from headroom.install.runtime import (
)
from headroom.install.state import load_manifest, save_manifest
from headroom.install.supervisors import start_supervisor
from headroom.providers.claude import TOOL_SEARCH_DEFAULT, TOOL_SEARCH_ENV
from headroom.providers.codex.install import codex_uses_chatgpt_auth
from .main import main
@ -161,6 +162,12 @@ def _ensure_claude_hooks(path: Path, profile: str, port: int) -> None:
payload = _json_file(path)
env_map = dict(payload.get("env") or {}) if isinstance(payload.get("env"), dict) else {}
env_map["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{port}"
# GH #746: with a custom ANTHROPIC_BASE_URL and ENABLE_TOOL_SEARCH unset,
# Claude Code stops deferring MCP/system tool schemas and materializes them
# all into its context window — overflowing it (breaks sub-agent spawns,
# forces constant compaction). Keep deferral on; respect a user-set value.
# Shares the TOOL_SEARCH_* constants with `wrap` and `install`.
env_map.setdefault(TOOL_SEARCH_ENV, TOOL_SEARCH_DEFAULT)
payload["env"] = env_map
hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {}

View file

@ -50,7 +50,13 @@ from headroom.copilot_auth import (
resolve_subscription_bearer_token_details,
)
from headroom.providers.aider import build_launch_env as _build_aider_launch_env
from headroom.providers.claude import proxy_base_url as _claude_proxy_base_url
from headroom.providers.claude import (
TOOL_SEARCH_DEFAULT,
TOOL_SEARCH_ENV,
)
from headroom.providers.claude import (
proxy_base_url as _claude_proxy_base_url,
)
from headroom.providers.codex import build_launch_env as _build_codex_launch_env
from headroom.providers.codex.install import codex_uses_chatgpt_auth
from headroom.providers.codex.threads import retag_to_headroom, retag_to_native
@ -121,9 +127,10 @@ _WRAP_PROXY_TIMEOUT_ML_MODULES = ("torch", "sentence_transformers", "spacy")
# when we launch Claude Code keeps deferral on. Default to "true" — defer the
# MCP/system tools for maximum context savings, matching native first-party
# behaviour (core built-ins like Read/Edit/Bash are never deferred by Claude
# Code, so the agent loop is unaffected).
_TOOL_SEARCH_ENV = "ENABLE_TOOL_SEARCH"
_TOOL_SEARCH_DEFAULT = "true"
# Code, so the agent loop is unaffected). The key/default are shared with
# `init` and `install` via the Claude provider package to prevent drift.
_TOOL_SEARCH_ENV = TOOL_SEARCH_ENV
_TOOL_SEARCH_DEFAULT = TOOL_SEARCH_DEFAULT
_AGENT_SAVINGS_WRAP_AGENTS = {"claude", "codex", "cursor"}
_DEFAULT_AGENT_SAVINGS_PROFILE = "agent-90"
@ -2519,7 +2526,7 @@ def _marker_pid_reused(marker: Path, pid: int) -> bool:
return False
src = rec.get("start_src")
recorded = rec.get("start_time")
if not isinstance(src, str) or not isinstance(recorded, (int, float)):
if not isinstance(src, str) or not isinstance(recorded, int | float):
return False # legacy / identity-less marker — can't tell
ident = _proc_identity(pid)
if ident is None or ident[0] != src:

View file

@ -1,5 +1,15 @@
"""Claude-specific provider helpers."""
from .runtime import DEFAULT_API_URL, proxy_base_url
from .runtime import (
DEFAULT_API_URL,
TOOL_SEARCH_DEFAULT,
TOOL_SEARCH_ENV,
proxy_base_url,
)
__all__ = ["DEFAULT_API_URL", "proxy_base_url"]
__all__ = [
"DEFAULT_API_URL",
"TOOL_SEARCH_DEFAULT",
"TOOL_SEARCH_ENV",
"proxy_base_url",
]

View file

@ -8,13 +8,23 @@ from pathlib import Path
from headroom.install.models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget
from headroom.install.paths import claude_settings_path
from .runtime import proxy_base_url
from .runtime import TOOL_SEARCH_DEFAULT, TOOL_SEARCH_ENV, proxy_base_url
def build_install_env(*, port: int, backend: str) -> dict[str, str]:
"""Build the persistent install environment for Claude."""
del backend
return {"ANTHROPIC_BASE_URL": proxy_base_url(port)}
# TOOL_SEARCH_ENV keeps Claude Code deferring MCP/system tool schemas behind
# the server-side Tool Search Tool when pointed at the proxy's custom
# ANTHROPIC_BASE_URL; without it Claude Code materializes every schema into
# its context window (GH #746) — breaking sub-agents and forcing compaction.
# The install env is headroom-managed and reverted on uninstall, so it is
# authoritative — unlike `init`, it always writes the default rather than
# deferring to a pre-existing user value.
return {
"ANTHROPIC_BASE_URL": proxy_base_url(port),
TOOL_SEARCH_ENV: TOOL_SEARCH_DEFAULT,
}
def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation | None:

View file

@ -4,6 +4,14 @@ from __future__ import annotations
DEFAULT_API_URL = "https://api.anthropic.com"
# GH #746: Claude Code stops deferring MCP/system tool schemas (materializing
# every one into its context window) when ANTHROPIC_BASE_URL is a custom host
# and ENABLE_TOOL_SEARCH is unset. Every place that points Claude Code at the
# proxy must keep deferral on, so the env key and its default live here as the
# single source of truth shared by `wrap`, `init`, and `install`.
TOOL_SEARCH_ENV = "ENABLE_TOOL_SEARCH"
TOOL_SEARCH_DEFAULT = "true"
def proxy_base_url(port: int) -> str:
"""Return the local proxy base URL used by Claude integrations."""

View file

@ -424,7 +424,11 @@ def test_ensure_claude_hooks_rewrites_existing_entries(monkeypatch, tmp_path: Pa
init_cli._ensure_claude_hooks(settings_path, "init-local-demo", 9001)
payload = json.loads(settings_path.read_text(encoding="utf-8"))
assert payload["env"] == {"KEEP": "1", "ANTHROPIC_BASE_URL": "http://127.0.0.1:9001"}
assert payload["env"] == {
"KEEP": "1",
"ANTHROPIC_BASE_URL": "http://127.0.0.1:9001",
"ENABLE_TOOL_SEARCH": "true",
}
session_entries = payload["hooks"]["SessionStart"]
assert session_entries[0] == "not-a-dict"
assert session_entries[1] == {"hooks": "not-a-list"}

View file

@ -0,0 +1,42 @@
"""`headroom init claude` must set ENABLE_TOOL_SEARCH (GH #746).
Claude Code disables on-demand tool loading when ANTHROPIC_BASE_URL is a custom
host and ENABLE_TOOL_SEARCH is unset, materializing all MCP/system tool schemas
into the context window which breaks sub-agent spawns and forces compaction.
`headroom wrap claude` already sets it; `init` (and the persistent install) must
too, otherwise init-wired users silently get eager tools.
"""
from __future__ import annotations
import json
from pathlib import Path
from headroom.cli import init as init_cli
from headroom.providers.claude import install as claude_install
def test_ensure_claude_hooks_sets_enable_tool_search(tmp_path: Path) -> None:
settings = tmp_path / "settings.json"
init_cli._ensure_claude_hooks(settings, profile="init-user", port=8787)
env = json.loads(settings.read_text(encoding="utf-8"))["env"]
assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
assert env["ENABLE_TOOL_SEARCH"] == "true"
def test_ensure_claude_hooks_respects_user_tool_search_value(tmp_path: Path) -> None:
settings = tmp_path / "settings.json"
settings.write_text(
json.dumps({"env": {"ENABLE_TOOL_SEARCH": "auto"}}) + "\n", encoding="utf-8"
)
init_cli._ensure_claude_hooks(settings, profile="init-user", port=8787)
env = json.loads(settings.read_text(encoding="utf-8"))["env"]
assert env["ENABLE_TOOL_SEARCH"] == "auto" # setdefault preserves the user's choice
def test_build_install_env_includes_enable_tool_search() -> None:
env = claude_install.build_install_env(port=8787, backend="anthropic")
assert env["ENABLE_TOOL_SEARCH"] == "true"
assert env["ANTHROPIC_BASE_URL"].endswith(":8787")

View file

@ -382,7 +382,10 @@ def test_claude_build_install_env_returns_proxy_base_url() -> None:
env = build_claude_install_env(port=5566, backend="ignored")
# Assert
assert env == {"ANTHROPIC_BASE_URL": "http://127.0.0.1:5566"}
assert env == {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:5566",
"ENABLE_TOOL_SEARCH": "true",
}
def test_copilot_build_install_env_uses_provider_type_specific_proxy_urls() -> None: