mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-10 14:27:00 -04:00
## 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:
parent
f03e77bec0
commit
500ec2b7fa
8 changed files with 102 additions and 11 deletions
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
42
tests/test_cli/test_init_enable_tool_search.py
Normal file
42
tests/test_cli/test_init_enable_tool_search.py
Normal 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")
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue