Skip to content

Commit 500ec2b

Browse files
Eyalm321claude
andauthored
fix(init): set ENABLE_TOOL_SEARCH=true so Claude Code keeps deferring tools (headroomlabs-ai#746) (headroomlabs-ai#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 (headroomlabs-ai#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 headroomlabs-ai#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>
1 parent f03e77b commit 500ec2b

8 files changed

Lines changed: 102 additions & 11 deletions

File tree

headroom/cli/init.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
)
3939
from headroom.install.state import load_manifest, save_manifest
4040
from headroom.install.supervisors import start_supervisor
41+
from headroom.providers.claude import TOOL_SEARCH_DEFAULT, TOOL_SEARCH_ENV
4142
from headroom.providers.codex.install import codex_uses_chatgpt_auth
4243

4344
from .main import main
@@ -161,6 +162,12 @@ def _ensure_claude_hooks(path: Path, profile: str, port: int) -> None:
161162
payload = _json_file(path)
162163
env_map = dict(payload.get("env") or {}) if isinstance(payload.get("env"), dict) else {}
163164
env_map["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{port}"
165+
# GH #746: with a custom ANTHROPIC_BASE_URL and ENABLE_TOOL_SEARCH unset,
166+
# Claude Code stops deferring MCP/system tool schemas and materializes them
167+
# all into its context window — overflowing it (breaks sub-agent spawns,
168+
# forces constant compaction). Keep deferral on; respect a user-set value.
169+
# Shares the TOOL_SEARCH_* constants with `wrap` and `install`.
170+
env_map.setdefault(TOOL_SEARCH_ENV, TOOL_SEARCH_DEFAULT)
164171
payload["env"] = env_map
165172

166173
hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {}

headroom/cli/wrap.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,13 @@
5050
resolve_subscription_bearer_token_details,
5151
)
5252
from headroom.providers.aider import build_launch_env as _build_aider_launch_env
53-
from headroom.providers.claude import proxy_base_url as _claude_proxy_base_url
53+
from headroom.providers.claude import (
54+
TOOL_SEARCH_DEFAULT,
55+
TOOL_SEARCH_ENV,
56+
)
57+
from headroom.providers.claude import (
58+
proxy_base_url as _claude_proxy_base_url,
59+
)
5460
from headroom.providers.codex import build_launch_env as _build_codex_launch_env
5561
from headroom.providers.codex.install import codex_uses_chatgpt_auth
5662
from headroom.providers.codex.threads import retag_to_headroom, retag_to_native
@@ -121,9 +127,10 @@
121127
# when we launch Claude Code keeps deferral on. Default to "true" — defer the
122128
# MCP/system tools for maximum context savings, matching native first-party
123129
# behaviour (core built-ins like Read/Edit/Bash are never deferred by Claude
124-
# Code, so the agent loop is unaffected).
125-
_TOOL_SEARCH_ENV = "ENABLE_TOOL_SEARCH"
126-
_TOOL_SEARCH_DEFAULT = "true"
130+
# Code, so the agent loop is unaffected). The key/default are shared with
131+
# `init` and `install` via the Claude provider package to prevent drift.
132+
_TOOL_SEARCH_ENV = TOOL_SEARCH_ENV
133+
_TOOL_SEARCH_DEFAULT = TOOL_SEARCH_DEFAULT
127134
_AGENT_SAVINGS_WRAP_AGENTS = {"claude", "codex", "cursor"}
128135
_DEFAULT_AGENT_SAVINGS_PROFILE = "agent-90"
129136

@@ -2519,7 +2526,7 @@ def _marker_pid_reused(marker: Path, pid: int) -> bool:
25192526
return False
25202527
src = rec.get("start_src")
25212528
recorded = rec.get("start_time")
2522-
if not isinstance(src, str) or not isinstance(recorded, (int, float)):
2529+
if not isinstance(src, str) or not isinstance(recorded, int | float):
25232530
return False # legacy / identity-less marker — can't tell
25242531
ident = _proc_identity(pid)
25252532
if ident is None or ident[0] != src:
Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
"""Claude-specific provider helpers."""
22

3-
from .runtime import DEFAULT_API_URL, proxy_base_url
3+
from .runtime import (
4+
DEFAULT_API_URL,
5+
TOOL_SEARCH_DEFAULT,
6+
TOOL_SEARCH_ENV,
7+
proxy_base_url,
8+
)
49

5-
__all__ = ["DEFAULT_API_URL", "proxy_base_url"]
10+
__all__ = [
11+
"DEFAULT_API_URL",
12+
"TOOL_SEARCH_DEFAULT",
13+
"TOOL_SEARCH_ENV",
14+
"proxy_base_url",
15+
]

headroom/providers/claude/install.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,23 @@
88
from headroom.install.models import ConfigScope, DeploymentManifest, ManagedMutation, ToolTarget
99
from headroom.install.paths import claude_settings_path
1010

11-
from .runtime import proxy_base_url
11+
from .runtime import TOOL_SEARCH_DEFAULT, TOOL_SEARCH_ENV, proxy_base_url
1212

1313

1414
def build_install_env(*, port: int, backend: str) -> dict[str, str]:
1515
"""Build the persistent install environment for Claude."""
1616
del backend
17-
return {"ANTHROPIC_BASE_URL": proxy_base_url(port)}
17+
# TOOL_SEARCH_ENV keeps Claude Code deferring MCP/system tool schemas behind
18+
# the server-side Tool Search Tool when pointed at the proxy's custom
19+
# ANTHROPIC_BASE_URL; without it Claude Code materializes every schema into
20+
# its context window (GH #746) — breaking sub-agents and forcing compaction.
21+
# The install env is headroom-managed and reverted on uninstall, so it is
22+
# authoritative — unlike `init`, it always writes the default rather than
23+
# deferring to a pre-existing user value.
24+
return {
25+
"ANTHROPIC_BASE_URL": proxy_base_url(port),
26+
TOOL_SEARCH_ENV: TOOL_SEARCH_DEFAULT,
27+
}
1828

1929

2030
def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation | None:

headroom/providers/claude/runtime.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44

55
DEFAULT_API_URL = "https://api.anthropic.com"
66

7+
# GH #746: Claude Code stops deferring MCP/system tool schemas (materializing
8+
# every one into its context window) when ANTHROPIC_BASE_URL is a custom host
9+
# and ENABLE_TOOL_SEARCH is unset. Every place that points Claude Code at the
10+
# proxy must keep deferral on, so the env key and its default live here as the
11+
# single source of truth shared by `wrap`, `init`, and `install`.
12+
TOOL_SEARCH_ENV = "ENABLE_TOOL_SEARCH"
13+
TOOL_SEARCH_DEFAULT = "true"
14+
715

816
def proxy_base_url(port: int) -> str:
917
"""Return the local proxy base URL used by Claude integrations."""

tests/test_cli/test_init_cli.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,11 @@ def test_ensure_claude_hooks_rewrites_existing_entries(monkeypatch, tmp_path: Pa
424424
init_cli._ensure_claude_hooks(settings_path, "init-local-demo", 9001)
425425

426426
payload = json.loads(settings_path.read_text(encoding="utf-8"))
427-
assert payload["env"] == {"KEEP": "1", "ANTHROPIC_BASE_URL": "http://127.0.0.1:9001"}
427+
assert payload["env"] == {
428+
"KEEP": "1",
429+
"ANTHROPIC_BASE_URL": "http://127.0.0.1:9001",
430+
"ENABLE_TOOL_SEARCH": "true",
431+
}
428432
session_entries = payload["hooks"]["SessionStart"]
429433
assert session_entries[0] == "not-a-dict"
430434
assert session_entries[1] == {"hooks": "not-a-list"}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""`headroom init claude` must set ENABLE_TOOL_SEARCH (GH #746).
2+
3+
Claude Code disables on-demand tool loading when ANTHROPIC_BASE_URL is a custom
4+
host and ENABLE_TOOL_SEARCH is unset, materializing all MCP/system tool schemas
5+
into the context window — which breaks sub-agent spawns and forces compaction.
6+
`headroom wrap claude` already sets it; `init` (and the persistent install) must
7+
too, otherwise init-wired users silently get eager tools.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import json
13+
from pathlib import Path
14+
15+
from headroom.cli import init as init_cli
16+
from headroom.providers.claude import install as claude_install
17+
18+
19+
def test_ensure_claude_hooks_sets_enable_tool_search(tmp_path: Path) -> None:
20+
settings = tmp_path / "settings.json"
21+
init_cli._ensure_claude_hooks(settings, profile="init-user", port=8787)
22+
23+
env = json.loads(settings.read_text(encoding="utf-8"))["env"]
24+
assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
25+
assert env["ENABLE_TOOL_SEARCH"] == "true"
26+
27+
28+
def test_ensure_claude_hooks_respects_user_tool_search_value(tmp_path: Path) -> None:
29+
settings = tmp_path / "settings.json"
30+
settings.write_text(
31+
json.dumps({"env": {"ENABLE_TOOL_SEARCH": "auto"}}) + "\n", encoding="utf-8"
32+
)
33+
init_cli._ensure_claude_hooks(settings, profile="init-user", port=8787)
34+
35+
env = json.loads(settings.read_text(encoding="utf-8"))["env"]
36+
assert env["ENABLE_TOOL_SEARCH"] == "auto" # setdefault preserves the user's choice
37+
38+
39+
def test_build_install_env_includes_enable_tool_search() -> None:
40+
env = claude_install.build_install_env(port=8787, backend="anthropic")
41+
assert env["ENABLE_TOOL_SEARCH"] == "true"
42+
assert env["ANTHROPIC_BASE_URL"].endswith(":8787")

tests/test_install/test_providers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,10 @@ def test_claude_build_install_env_returns_proxy_base_url() -> None:
382382
env = build_claude_install_env(port=5566, backend="ignored")
383383

384384
# Assert
385-
assert env == {"ANTHROPIC_BASE_URL": "http://127.0.0.1:5566"}
385+
assert env == {
386+
"ANTHROPIC_BASE_URL": "http://127.0.0.1:5566",
387+
"ENABLE_TOOL_SEARCH": "true",
388+
}
386389

387390

388391
def test_copilot_build_install_env_uses_provider_type_specific_proxy_urls() -> None:

0 commit comments

Comments
 (0)