From bd1c768393764b163affe9a1340a5f40f89b5b1e Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 19:01:01 -0800 Subject: [PATCH 1/2] feat(agents): Claude Code 2.1.322, which knows Claude Fable 5.2 The pinned 2.1.280 predates Claude Fable 5.2: it runs claude-fable-5-2 at a 32K output limit, a 200K window for the bare id, and an unknown price. 2.1.322 gives it 1M and 128K, as it does Sonnet 5.5 and Haiku 5.5. 2.1.322 also turns tool search on by default, which holds back every MCP tool and about half its own until the model searches for them. The bridge sets ENABLE_TOOL_SEARCH=false unless the agent's environment sets it, so the CLI offers the env's tools up front as 2.1.280 did. Its own tools are then 2.1.280's plus Monitor. Co-Authored-By: Claude Opus 5.5 --- src/agent_env/agents/README.md | 3 ++- src/agent_env/agents/agents/claude-code/Dockerfile | 2 +- .../agents/agents/claude-code/claude_bridge.py | 10 ++++++---- .../agents/claude_code/test_claude_bridge_model.py | 4 ++-- tst/unit/agents/claude_code/test_cli_env.py | 7 ++++++- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/agent_env/agents/README.md b/src/agent_env/agents/README.md index 43872d88c..1be4e6582 100644 --- a/src/agent_env/agents/README.md +++ b/src/agent_env/agents/README.md @@ -24,7 +24,8 @@ run it. The CLI's version is the Dockerfile's `node`, without npm or npx, and the Python packages `model-requirements.txt` pins. A `deploy_agent` `builtin_tools` selection picks the CLI's own tools: `{"disable": "*"}` and `{"only": [...]}` become its `--tools` list, a list to turn off becomes `--disallowedTools`, which keeps the CLI's defaults for the rest, and any selection adds -`--strict-mcp-config`, so its MCP servers are only the task env's. The names are `ClaudeCodeTool`'s in +`--strict-mcp-config`, so its MCP servers are only the task env's. Tool search is off unless the agent's environment sets +`ENABLE_TOOL_SEARCH`, so the CLI sends the env's tools and its own up front. The names are `ClaudeCodeTool`'s in `claude_bridge.py`: every tool the pinned CLI has, though which it offers by default depends on the model. A test fails when the Dockerfile's `CLAUDE_CODE_VERSION` moves, until `ClaudeCodeTool` and `TOOLS_CLI_VERSION` move with it. diff --git a/src/agent_env/agents/agents/claude-code/Dockerfile b/src/agent_env/agents/agents/claude-code/Dockerfile index 99d8539ae..cbe95d6e6 100644 --- a/src/agent_env/agents/agents/claude-code/Dockerfile +++ b/src/agent_env/agents/agents/claude-code/Dockerfile @@ -6,7 +6,7 @@ FROM public.ecr.aws/docker/library/python:3.11-slim@sha256:c20888b6acdd1e63e1c43 RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl poppler-utils \ && rm -rf /var/lib/apt/lists/* # Claude Code's native build for this machine's platform, checked against its release manifest. -ARG CLAUDE_CODE_VERSION=2.1.280 +ARG CLAUDE_CODE_VERSION=2.1.322 RUN set -eu; \ case "$(uname -m)" in x86_64) platform=linux-x64 ;; aarch64) platform=linux-arm64 ;; \ *) echo "Claude Code has no build for $(uname -m)" >&2; exit 1 ;; esac; \ diff --git a/src/agent_env/agents/agents/claude-code/claude_bridge.py b/src/agent_env/agents/agents/claude-code/claude_bridge.py index cecd8286f..fad10df25 100644 --- a/src/agent_env/agents/agents/claude-code/claude_bridge.py +++ b/src/agent_env/agents/agents/claude-code/claude_bridge.py @@ -56,11 +56,11 @@ def project_dir(workspace: Path) -> Path: # The CLI version ClaudeCodeTool names the tools of; a test holds it to the Dockerfile's CLAUDE_CODE_VERSION. -TOOLS_CLI_VERSION = "2.1.280" +TOOLS_CLI_VERSION = "2.1.322" # The CLI's own tools, by the names its model sees. Which of them it offers by default depends on the model, and its MCP # resource tools come only with MCP servers. ClaudeCodeTool = Literal["Agent", "Bash", "CronCreate", "CronDelete", "CronList", "Edit", "EnterWorktree", - "ExitWorktree", "ListAgents", "ListMcpResourcesTool", "NotebookEdit", "Read", + "ExitWorktree", "ListAgents", "ListMcpResourcesTool", "Monitor", "NotebookEdit", "Read", "ReadMcpResourceDirTool", "ReadMcpResourceTool", "ReportFindings", "ScheduleWakeup", "SendMessage", "Skill", "TaskCreate", "TaskGet", "TaskList", "TaskStop", "TaskUpdate", "WebFetch", "WebSearch", "Workflow", "Write"] @@ -194,6 +194,8 @@ def cli_env(environ: Mapping[str, str], *, model: str | None = None, project_id: env["ANTHROPIC_CUSTOM_HEADERS"] = "\n".join(f"{name}: {value}" for name, value in headers.items()) if max_thinking_tokens: env["MAX_THINKING_TOKENS"] = str(max_thinking_tokens) + # Left on, the CLI sends no MCP tool and only some of its own until the model searches for them. + env.setdefault("ENABLE_TOOL_SEARCH", "false") # Older CLIs prepend a changing attribution block to the system prompt on custom base URLs, which defeats the # provider's prefix cache. env["CLAUDE_CODE_ATTRIBUTION_HEADER"] = "0" @@ -593,8 +595,8 @@ async def feed_stdin() -> None: # Behind a gateway the CLI gives a bare id of these a 200K window, and envs whose tool definitions alone pass that # make it exit with "Autocompact is thrashing". Add a model only once the pinned CLI maps its [1m] form. -_NATIVE_1M_MODELS = frozenset({"claude-opus-5", "claude-opus-5-5", "claude-fable-5-1", "claude-opus-4-8", - "claude-opus-4-7"}) +_NATIVE_1M_MODELS = frozenset({"claude-opus-5", "claude-opus-5-5", "claude-fable-5-1", "claude-fable-5-2", + "claude-opus-4-8", "claude-opus-4-7"}) def _with_1m_window(model: str) -> str: diff --git a/tst/unit/agents/claude_code/test_claude_bridge_model.py b/tst/unit/agents/claude_code/test_claude_bridge_model.py index 702f2208f..c8f876a3b 100644 --- a/tst/unit/agents/claude_code/test_claude_bridge_model.py +++ b/tst/unit/agents/claude_code/test_claude_bridge_model.py @@ -11,8 +11,8 @@ def _cli_model(model: str | None) -> str | None: return cmd[cmd.index("--model") + 1] if "--model" in cmd else None -@pytest.mark.parametrize("model", ["claude-opus-5-5", "claude-opus-5", "claude-fable-5-1", "claude-opus-4-8", - "claude-opus-4-7"]) +@pytest.mark.parametrize("model", ["claude-opus-5-5", "claude-opus-5", "claude-fable-5-1", "claude-fable-5-2", + "claude-opus-4-8", "claude-opus-4-7"]) def test_a_native_1m_model_gets_its_1m_window(model): assert _cli_model(model) == f"{model}[1m]" diff --git a/tst/unit/agents/claude_code/test_cli_env.py b/tst/unit/agents/claude_code/test_cli_env.py index 443f4940a..7c92fcc82 100644 --- a/tst/unit/agents/claude_code/test_cli_env.py +++ b/tst/unit/agents/claude_code/test_cli_env.py @@ -41,6 +41,11 @@ def test_anthropic_variables_already_set_are_kept(): assert (env["ANTHROPIC_AUTH_TOKEN"], env["ANTHROPIC_BASE_URL"]) == ("own", "https://own.example") +def test_the_cli_offers_its_tools_up_front_unless_tool_search_is_asked_for(): + assert claude_bridge.cli_env(GATEWAY)["ENABLE_TOOL_SEARCH"] == "false" + assert claude_bridge.cli_env({**GATEWAY, "ENABLE_TOOL_SEARCH": "auto"})["ENABLE_TOOL_SEARCH"] == "auto" + + def test_no_endpoint_leaves_the_cli_to_its_own_and_an_api_key_alone(): env = claude_bridge.cli_env({"ANTHROPIC_API_KEY": "direct"}, model="claude-x") @@ -141,7 +146,7 @@ async def _exec(*cmd, **kwargs): @pytest.mark.parametrize("workspace, project", [ ("/app", "-app"), - ("/workspaces/my_ws.x-y/Sub Dir", "-workspaces-my-ws-x-y-Sub-Dir"), # as the CLI names it, checked against 2.1.280 + ("/workspaces/my_ws.x-y/Sub Dir", "-workspaces-my-ws-x-y-Sub-Dir"), # as the CLI names it, checked against 2.1.322 ]) def test_a_sessions_transcripts_are_where_the_cli_keeps_them_for_its_workspace(workspace, project): assert claude_bridge.project_dir(Path(workspace)) == claude_bridge.CLAUDE_STATE_DIR / "projects" / project From fad2317c354b95bfde58852f4d39af82902df4dd Mon Sep 17 00:00:00 2001 From: Edgar Arakelyan Date: Tue, 10 Nov 2026 20:05:15 -0800 Subject: [PATCH 2/2] test(agents): fail the retried-install tests' first turn with a 403 Claude Code 2.1.322 resends a request a custom endpoint answered with 400, or 422, without some of its betas, so one injected 400 no longer fails the turn. It sends a 403 once, as 2.1.280 did a 400. Co-Authored-By: Claude Opus 5.5 --- tst/integration/cli/run_bundle_claude_code_install_test.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tst/integration/cli/run_bundle_claude_code_install_test.py b/tst/integration/cli/run_bundle_claude_code_install_test.py index f3b76d0af..47b4fc068 100644 --- a/tst/integration/cli/run_bundle_claude_code_install_test.py +++ b/tst/integration/cli/run_bundle_claude_code_install_test.py @@ -135,7 +135,8 @@ def test_a_retried_install_replaces_the_agent_an_earlier_attempt_left_running_in """The first turn fails, and its retry installs again into the same container, on the same port, while the first install's agent still runs there.""" endpoint.respond = tool_then_answer("Bash", {"command": f"cat notes.txt; pwd; {READ_SKILL}"}) - endpoint.fail_next(Fault("status", status=400)) + # A 403, which the CLI never resends; a 400 it resends without some of its betas. + endpoint.fail_next(Fault("status", status=403)) bundle = _bundle(state / "bundle", "debian") task = bundle / "tasks/install.json" steps = json.loads(task.read_text()) @@ -190,7 +191,8 @@ def test_a_retried_host_install_stops_the_agent_an_earlier_attempt_started(monke """install_agent empties its work dir before each attempt, so the first install's agent must live outside it: found there, it is stopped, rather than keeping the port the retry's agent needs.""" endpoint.respond = tool_then_answer("Bash", {"command": f"echo {NOTES}; {READ_SKILL}"}) - endpoint.fail_next(Fault("status", status=400)) + # A 403, which the CLI never resends; a 400 it resends without some of its betas. + endpoint.fail_next(Fault("status", status=403)) bundle = _bundle(state / "bundle", None, agents=("helper",)) task = bundle / "tasks/install.json" steps = json.loads(task.read_text())