diff --git a/headroom/cli/init.py b/headroom/cli/init.py index 037b6754d..8abc98b12 100644 --- a/headroom/cli/init.py +++ b/headroom/cli/init.py @@ -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 {} diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index aafc34575..9113afae6 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -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: diff --git a/headroom/providers/claude/__init__.py b/headroom/providers/claude/__init__.py index 0ea0c7e39..cdff9c83c 100644 --- a/headroom/providers/claude/__init__.py +++ b/headroom/providers/claude/__init__.py @@ -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", +] diff --git a/headroom/providers/claude/install.py b/headroom/providers/claude/install.py index 30b6928c2..fcce25c4d 100644 --- a/headroom/providers/claude/install.py +++ b/headroom/providers/claude/install.py @@ -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: diff --git a/headroom/providers/claude/runtime.py b/headroom/providers/claude/runtime.py index 0039bcb98..45b65781c 100644 --- a/headroom/providers/claude/runtime.py +++ b/headroom/providers/claude/runtime.py @@ -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.""" diff --git a/tests/test_cli/test_init_cli.py b/tests/test_cli/test_init_cli.py index 9df842824..f94d293fd 100644 --- a/tests/test_cli/test_init_cli.py +++ b/tests/test_cli/test_init_cli.py @@ -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"} diff --git a/tests/test_cli/test_init_enable_tool_search.py b/tests/test_cli/test_init_enable_tool_search.py new file mode 100644 index 000000000..de65e56ed --- /dev/null +++ b/tests/test_cli/test_init_enable_tool_search.py @@ -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") diff --git a/tests/test_install/test_providers.py b/tests/test_install/test_providers.py index a4182a29d..f02da01c5 100644 --- a/tests/test_install/test_providers.py +++ b/tests/test_install/test_providers.py @@ -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: