From aeca81cf0be68512c17357622b08545ddc0559b0 Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Wed, 28 Oct 2026 13:46:12 -0400 Subject: [PATCH 1/3] fix(claude-agents)!: honour the config's max_tokens and disable thinking unless the config asks For AIC-3585. The handler ran with the Claude Code CLI's own defaults: the config's max_tokens was dropped, and with no thinking setting the CLI chose one (a 31,999-token budget on claude-haiku-4-5). max_tokens now reaches the CLI as CLAUDE_CODE_MAX_OUTPUT_TOKENS in env, which the Agent SDK adds to os.environ. When the config sets neither thinking nor max_thinking_tokens, the handler passes thinking={"type": "disabled"}. BREAKING CHANGE: a config that sets no thinking now runs without thinking. Configs that relied on the CLI's adaptive-thinking default must set thinking in their model parameters, for example {"type": "adaptive"}. Co-Authored-By: Claude Opus 5.5 --- packages/claude-agents/README.md | 9 ++ .../launchdarkly_ai_claude_agents/handler.py | 35 +++++- packages/claude-agents/tests/test_handler.py | 103 ++++++++++++++++-- .../claude-agents/tests/test_native_graph.py | 47 ++++++++ tests/forwarding_spec.py | 1 + 5 files changed, 184 insertions(+), 11 deletions(-) diff --git a/packages/claude-agents/README.md b/packages/claude-agents/README.md index 670742be..d227e8f2 100644 --- a/packages/claude-agents/README.md +++ b/packages/claude-agents/README.md @@ -74,6 +74,15 @@ to_claude_agents( ) ``` +### Output limit and thinking + +- `max_tokens` in the config's model parameters sets the output-token limit for each model request. The Agent SDK has no option for it, so the handler sets `CLAUDE_CODE_MAX_OUTPUT_TOKENS` in the environment of the Claude Code process. Without `max_tokens`, Claude Code uses its own default. +- Thinking is off unless the config sets `thinking` or `max_thinking_tokens`. Claude Code lowers a thinking budget to one less than `max_tokens`, so when a config sets both, keep `max_tokens` above the budget. + +#### Migrating from a version that used Claude Code's thinking default + +Earlier versions passed no thinking setting when the config had none, so Claude Code chose one: adaptive thinking on models that support it, and a 31,999-token budget on others such as `claude-haiku-4-5`. A run now thinks only when its config asks. To keep thinking, set it in the config's model parameters, for example `{"thinking": {"type": "adaptive"}}`. + ### Convenience wrapper ```python diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py index 664b1dbf..83eb01f2 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -92,9 +92,11 @@ #: This is the cross-SDK list for Claude Agents (TESTING.md section 1.12), shared with the native #: graph through :func:`_options_parameters`. #: -#: The SDK offers no ``temperature``/``top_p``/``top_k``/``max_tokens``/``stop_sequences``/ -#: ``tool_choice``/``metadata``, all of which the LaunchDarkly UI's model parameters panel offers -#: for other providers; they are dropped like any other key not listed here. +#: The SDK offers no ``temperature``/``top_p``/``top_k``/``stop_sequences``/``tool_choice``/ +#: ``metadata``, all of which the LaunchDarkly UI's model parameters panel offers for other +#: providers; they are dropped like any other key not listed here. It has no ``max_tokens`` field +#: either, but ``max_tokens`` is not dropped: :func:`_options_parameters` sends it to the CLI as +#: :data:`_MAX_OUTPUT_TOKENS_ENV`. _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS = frozenset( { "betas", @@ -600,6 +602,12 @@ def _raise_if_run_failed(message: Any) -> None: ) +#: The variable the CLI reads its output-token limit from. ``ClaudeAgentOptions`` has no +#: output-token field, so the config's ``max_tokens`` reaches the CLI as this variable in its +#: environment, the only value this package writes there. Without it the CLI sends its own default +#: (32000 on ``claude-haiku-4-5``) whatever the config asks for. +_MAX_OUTPUT_TOKENS_ENV = "CLAUDE_CODE_MAX_OUTPUT_TOKENS" + #: The ``thinking`` types ``claude_agent_sdk`` knows (``ThinkingConfig``). It reads #: ``thinking["type"]`` when it builds the CLI command, so a ``thinking`` without one raises. _THINKING_TYPES = frozenset({"adaptive", "enabled", "disabled"}) @@ -636,9 +644,16 @@ def _options_parameters(config: AiConfigRep) -> dict[str, Any]: :data:`_CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS`, with malformed object values dropped and ``thinking`` rebuilt by :func:`_rebuild_thinking`. The handler and the native graph both call this, so they forward the same keys. + + Two fields are set even though the config does not name them. ``thinking`` is + ``{"type": "disabled"}`` unless the config sets ``thinking`` or ``max_thinking_tokens``, + because the CLI otherwise picks thinking itself (a 31,999-token budget on + ``claude-haiku-4-5``). And a positive integer ``max_tokens`` becomes + ``env={_MAX_OUTPUT_TOKENS_ENV: ...}``, which the SDK adds to the inherited ``os.environ``. """ + parameters = model_parameters(config) params = select_forwarded_parameters( - model_parameters(config), + parameters, _CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS, mapping_keys=_CLAUDE_AGENT_OPTIONS_MAPPING_KEYS, ) @@ -646,6 +661,18 @@ def _options_parameters(config: AiConfigRep) -> dict[str, Any]: thinking = _rebuild_thinking(params.pop("thinking")) if thinking is not None: params["thinking"] = thinking + if "thinking" not in params and params.get("max_thinking_tokens") is None: + params["thinking"] = {"type": "disabled"} + max_tokens = parameters.get("max_tokens") + # JSON has one number type, so ``200.0`` is the same value the JS SDK reads as 200. + if isinstance(max_tokens, float) and max_tokens.is_integer(): + max_tokens = int(max_tokens) + if ( + isinstance(max_tokens, int) + and not isinstance(max_tokens, bool) + and max_tokens > 0 + ): + params["env"] = {_MAX_OUTPUT_TOKENS_ENV: str(max_tokens)} return params diff --git a/packages/claude-agents/tests/test_handler.py b/packages/claude-agents/tests/test_handler.py index ea8838ca..707bb95c 100644 --- a/packages/claude-agents/tests/test_handler.py +++ b/packages/claude-agents/tests/test_handler.py @@ -1664,7 +1664,7 @@ async def test_unset_when_no_parameters( async def test_ui_keys_the_sdk_rejects_are_dropped_without_raising( self, monkeypatch: pytest.MonkeyPatch ) -> None: - """``ClaudeAgentOptions`` has no ``temperature``/``top_p``/``top_k``/``max_tokens``/ + """``ClaudeAgentOptions`` has no ``temperature``/``top_p``/``top_k``/ ``stop_sequences``/``tool_choice``/``metadata`` fields, all of which the LaunchDarkly UI's model parameters panel offers for other providers. Forwarding one unfiltered raises ``TypeError`` before any request is made; the filter must drop them instead. @@ -1677,7 +1677,6 @@ async def test_ui_keys_the_sdk_rejects_are_dropped_without_raising( "temperature": 0.2, "top_p": 0.5, "top_k": 10, - "max_tokens": 256, "stop_sequences": ["STOP"], "tool_choice": "auto", "metadata": {"user_id": "u1"}, @@ -1691,7 +1690,6 @@ async def test_ui_keys_the_sdk_rejects_are_dropped_without_raising( "temperature", "top_p", "top_k", - "max_tokens", "stop_sequences", "tool_choice", "metadata", @@ -1732,6 +1730,96 @@ async def test_temperature_top_p_and_max_turns_run_without_type_error( assert options.max_turns == 5 +class TestOutputLimitAndThinkingDefault: + """The cross-SDK Claude Agents rule: ``max_tokens`` reaches the CLI as + ``CLAUDE_CODE_MAX_OUTPUT_TOKENS``, and thinking is off unless the config asks for it. The CLI + would otherwise send its own defaults (``max_tokens`` 32000 and a 31,999-token thinking budget + on ``claude-haiku-4-5``).""" + + @staticmethod + async def _options( + parameters: dict[str, Any], path: str, monkeypatch: pytest.MonkeyPatch + ) -> Any: + captured: dict[str, Any] = {} + + async def _query(**kwargs: Any) -> AsyncIterator[Any]: + captured["options"] = kwargs["options"] + build_cli_command(kwargs["options"]) + yield assistant_message() + yield result_message() + + monkeypatch.setattr(handler_mod, "query", _query) + config = { + **BASE_CONFIG, + "model": {**BASE_CONFIG["model"], "parameters": parameters}, + } + h = create_claude_agents_handler() + if path == "invoke": + await h(config, "q") + else: + await _collect(await h.stream(config, "q")) + return captured["options"] + + @pytest.fixture(params=["invoke", "stream"]) + def path(self, request: pytest.FixtureRequest) -> str: + return request.param + + async def test_max_tokens_sets_the_cli_output_limit_and_thinking_is_disabled( + self, path: str, monkeypatch: pytest.MonkeyPatch + ) -> None: + options = await self._options({"max_tokens": 200}, path, monkeypatch) + # The SDK adds ``env`` to the inherited ``os.environ``, so only the one variable is set. + assert options.env == {"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "200"} + assert options.thinking == {"type": "disabled"} + assert "--thinking" in (command := build_cli_command(options)) + assert command[command.index("--thinking") + 1] == "disabled" + + async def test_an_integral_float_max_tokens_is_read_as_an_integer( + self, path: str, monkeypatch: pytest.MonkeyPatch + ) -> None: + options = await self._options({"max_tokens": 200.0}, path, monkeypatch) + assert options.env == {"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "200"} + + async def test_no_env_without_max_tokens( + self, path: str, monkeypatch: pytest.MonkeyPatch + ) -> None: + options = await self._options({"max_turns": 2}, path, monkeypatch) + assert options.env == {} + assert options.thinking == {"type": "disabled"} + + @pytest.mark.parametrize( + "max_tokens", [0, -5, 1.5, float("inf"), "200", True, None, {"value": 200}] + ) + async def test_a_max_tokens_that_is_not_a_positive_integer_is_dropped( + self, path: str, max_tokens: Any, monkeypatch: pytest.MonkeyPatch + ) -> None: + options = await self._options({"max_tokens": max_tokens}, path, monkeypatch) + assert options.env == {} + + async def test_a_configured_thinking_replaces_the_default( + self, path: str, monkeypatch: pytest.MonkeyPatch + ) -> None: + options = await self._options( + { + "max_tokens": 4096, + "thinking": {"type": "enabled", "budget_tokens": 2048}, + }, + path, + monkeypatch, + ) + assert options.thinking == {"type": "enabled", "budget_tokens": 2048} + assert options.env == {"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "4096"} + + async def test_max_thinking_tokens_alone_leaves_thinking_unset( + self, path: str, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``thinking`` takes precedence over ``max_thinking_tokens`` in the SDK, so the default + must not be added or the config's budget would be ignored.""" + options = await self._options({"max_thinking_tokens": 2048}, path, monkeypatch) + assert options.max_thinking_tokens == 2048 + assert options.thinking is None + + class TestFinishReasonMapping: async def test_tool_use_maps_to_tool_calls( self, monkeypatch: pytest.MonkeyPatch @@ -2821,12 +2909,13 @@ def test_a_known_type_keeps_only_the_sub_keys_the_sdk_reads( @pytest.mark.parametrize("value", [{"budget_tokens": 1024}, {"type": "enabled"}]) def test_the_shapes_the_sdk_cannot_read_are_dropped(self, value: Any) -> None: - """Both raise ``KeyError`` in the SDK's command builder when passed through.""" + """Both raise ``KeyError`` in the SDK's command builder when passed through. A dropped + thinking counts as not set, so the handler's disabled default applies.""" from claude_agent_sdk import ClaudeAgentOptions with pytest.raises(KeyError): build_cli_command(ClaudeAgentOptions(thinking=value)) - assert self._thinking(value) is None + assert self._thinking(value) == {"type": "disabled"} @pytest.mark.parametrize( "value", @@ -2838,7 +2927,7 @@ def test_the_shapes_the_sdk_cannot_read_are_dropped(self, value: Any) -> None: ], ) def test_an_unknown_type_or_a_malformed_budget_is_dropped(self, value: Any) -> None: - assert self._thinking(value) is None + assert self._thinking(value) == {"type": "disabled"} class TestMalformedObjectValuesAreDropped: @@ -2858,7 +2947,7 @@ def test_a_thinking_that_is_not_an_object_and_output_format_are_dropped( } } ) - assert params == {"max_turns": 3} + assert params == {"max_turns": 3, "thinking": {"type": "disabled"}} async def test_a_builtin_tool_result_is_not_marked_failed( diff --git a/packages/claude-agents/tests/test_native_graph.py b/packages/claude-agents/tests/test_native_graph.py index e3dc332e..0165ea26 100644 --- a/packages/claude-agents/tests/test_native_graph.py +++ b/packages/claude-agents/tests/test_native_graph.py @@ -1367,6 +1367,53 @@ async def test_node_options_take_run_settings_and_nothing_never_forwarded( assert opts["max_turns"] == 7 assert not find_leaks(opts) + @pytest.mark.asyncio + async def test_node_max_tokens_sets_the_cli_output_limit_and_thinking_is_disabled( + self, + ) -> None: + """A node's ``max_tokens`` reaches its CLI as + ``CLAUDE_CODE_MAX_OUTPUT_TOKENS``, and thinking is off unless the node's config asks.""" + + async def node_options(parameters: dict[str, Any]) -> dict[str, Any]: + mock_sdk = _make_sdk_mock("done") + nodes = { + "root": { + "key": "root", + "config": { + "model": {"name": "claude-3", "parameters": parameters}, + "instructions": "be helpful", + }, + "meta": {"variationKey": "v1", "version": 1}, + "edges": [], + "is_terminal": True, + } + } + captured: list[dict[str, Any]] = [] + mock_sdk.ClaudeAgentOptions = MagicMock( + side_effect=lambda **kw: (captured.append(kw), kw)[1] + ) + with patch( + "importlib.import_module", + side_effect=lambda n: ( + mock_sdk if n == "claude_agent_sdk" else __import__(n) + ), + ): + await to_claude_agents( + _make_def_promise(_make_graph_def(nodes=nodes)) + ).invoke("hi") + assert len(captured) == 1 + return captured[0] + + limited = await node_options({"max_tokens": 200}) + assert limited["env"] == {"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "200"} + assert limited["thinking"] == {"type": "disabled"} + + thinking = await node_options( + {"thinking": {"type": "enabled", "budget_tokens": 2048}} + ) + assert "env" not in thinking + assert thinking["thinking"] == {"type": "enabled", "budget_tokens": 2048} + def _build_cli_command(options_kwargs: dict[str, Any]) -> list[str]: """The CLI command the real ``claude_agent_sdk`` would launch for one node's options, built by diff --git a/tests/forwarding_spec.py b/tests/forwarding_spec.py index 3e79a890..bd557680 100644 --- a/tests/forwarding_spec.py +++ b/tests/forwarding_spec.py @@ -54,6 +54,7 @@ "fallback_model", "max_budget_usd", "max_thinking_tokens", + "max_tokens", "max_turns", "thinking", } From 837053dac9d6c47d024100ac871ede1e9f370231 Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Wed, 28 Oct 2026 14:26:24 -0400 Subject: [PATCH 2/3] fix(claude-agents): bound max_tokens at 2^53 - 1 and close the review's test gaps Drop a max_tokens above 2^53 - 1, as the JS SDK does for a value that is not a safe integer, so both SDKs send the same thing for the same config. Tests: the environment the Agent SDK builds for the CLI still holds os.environ; effort alone and thinking with max_thinking_tokens; and a native-graph node's CLI command carries --thinking disabled, plus the invalid max_tokens and max_thinking_tokens-only node cases. README notes that a settings-file env wins and that some models reject disabled thinking. Co-Authored-By: Claude Opus 5.5 --- packages/claude-agents/README.md | 4 +- .../launchdarkly_ai_claude_agents/handler.py | 9 ++- packages/claude-agents/tests/test_handler.py | 55 ++++++++++++++++++- .../claude-agents/tests/test_native_graph.py | 13 +++++ 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/claude-agents/README.md b/packages/claude-agents/README.md index d227e8f2..23d0d559 100644 --- a/packages/claude-agents/README.md +++ b/packages/claude-agents/README.md @@ -76,8 +76,8 @@ to_claude_agents( ### Output limit and thinking -- `max_tokens` in the config's model parameters sets the output-token limit for each model request. The Agent SDK has no option for it, so the handler sets `CLAUDE_CODE_MAX_OUTPUT_TOKENS` in the environment of the Claude Code process. Without `max_tokens`, Claude Code uses its own default. -- Thinking is off unless the config sets `thinking` or `max_thinking_tokens`. Claude Code lowers a thinking budget to one less than `max_tokens`, so when a config sets both, keep `max_tokens` above the budget. +- `max_tokens` in the config's model parameters sets the output-token limit for each model request. The Agent SDK has no option for it, so the handler sets `CLAUDE_CODE_MAX_OUTPUT_TOKENS` in the environment of the Claude Code process. Without `max_tokens`, Claude Code uses its own default. A `CLAUDE_CODE_MAX_OUTPUT_TOKENS` in the `env` of a Claude Code settings file, including `~/.claude.json`, wins over `max_tokens`. +- Thinking is off unless the config sets `thinking` or `max_thinking_tokens`. Models that reject disabled thinking, such as `claude-fable-5`, still use their own default. Claude Code lowers a thinking budget to one less than `max_tokens`, so when a config sets both, keep `max_tokens` above the budget. #### Migrating from a version that used Claude Code's thinking default diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py index 83eb01f2..825ab070 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -608,6 +608,10 @@ def _raise_if_run_failed(message: Any) -> None: #: (32000 on ``claude-haiku-4-5``) whatever the config asks for. _MAX_OUTPUT_TOKENS_ENV = "CLAUDE_CODE_MAX_OUTPUT_TOKENS" +#: The largest ``max_tokens`` kept, ``Number.MAX_SAFE_INTEGER`` in JS: the JS SDK drops a larger +#: value, so both SDKs send the same thing for the same config. +_MAX_TOKENS_LIMIT = 2**53 - 1 + #: The ``thinking`` types ``claude_agent_sdk`` knows (``ThinkingConfig``). It reads #: ``thinking["type"]`` when it builds the CLI command, so a ``thinking`` without one raises. _THINKING_TYPES = frozenset({"adaptive", "enabled", "disabled"}) @@ -648,7 +652,8 @@ def _options_parameters(config: AiConfigRep) -> dict[str, Any]: Two fields are set even though the config does not name them. ``thinking`` is ``{"type": "disabled"}`` unless the config sets ``thinking`` or ``max_thinking_tokens``, because the CLI otherwise picks thinking itself (a 31,999-token budget on - ``claude-haiku-4-5``). And a positive integer ``max_tokens`` becomes + ``claude-haiku-4-5``). And a positive integer ``max_tokens`` up to + :data:`_MAX_TOKENS_LIMIT` becomes ``env={_MAX_OUTPUT_TOKENS_ENV: ...}``, which the SDK adds to the inherited ``os.environ``. """ parameters = model_parameters(config) @@ -670,7 +675,7 @@ def _options_parameters(config: AiConfigRep) -> dict[str, Any]: if ( isinstance(max_tokens, int) and not isinstance(max_tokens, bool) - and max_tokens > 0 + and 0 < max_tokens <= _MAX_TOKENS_LIMIT ): params["env"] = {_MAX_OUTPUT_TOKENS_ENV: str(max_tokens)} return params diff --git a/packages/claude-agents/tests/test_handler.py b/packages/claude-agents/tests/test_handler.py index 707bb95c..ba97fa56 100644 --- a/packages/claude-agents/tests/test_handler.py +++ b/packages/claude-agents/tests/test_handler.py @@ -14,6 +14,7 @@ from __future__ import annotations +import os from collections.abc import AsyncIterator from typing import Any, ClassVar from unittest.mock import AsyncMock, MagicMock, patch @@ -1788,7 +1789,8 @@ async def test_no_env_without_max_tokens( assert options.thinking == {"type": "disabled"} @pytest.mark.parametrize( - "max_tokens", [0, -5, 1.5, float("inf"), "200", True, None, {"value": 200}] + "max_tokens", + [0, -5, 1.5, 2**53, float("inf"), "200", True, None, {"value": 200}], ) async def test_a_max_tokens_that_is_not_a_positive_integer_is_dropped( self, path: str, max_tokens: Any, monkeypatch: pytest.MonkeyPatch @@ -1819,6 +1821,57 @@ async def test_max_thinking_tokens_alone_leaves_thinking_unset( assert options.max_thinking_tokens == 2048 assert options.thinking is None + async def test_thinking_and_max_thinking_tokens_together_pass_as_given( + self, path: str, monkeypatch: pytest.MonkeyPatch + ) -> None: + options = await self._options( + { + "max_thinking_tokens": 4096, + "thinking": {"type": "enabled", "budget_tokens": 2048}, + }, + path, + monkeypatch, + ) + assert options.max_thinking_tokens == 4096 + assert options.thinking == {"type": "enabled", "budget_tokens": 2048} + + async def test_effort_alone_does_not_turn_thinking_on( + self, path: str, monkeypatch: pytest.MonkeyPatch + ) -> None: + options = await self._options({"effort": "low"}, path, monkeypatch) + assert options.effort == "low" + assert options.thinking == {"type": "disabled"} + + async def test_the_cli_process_keeps_the_inherited_environment( + self, path: str, monkeypatch: pytest.MonkeyPatch + ) -> None: + """``env`` holds only the one variable because the SDK adds it to ``os.environ``. Start + the SDK's own transport with the options and read the environment it gives the CLI, so a + change to that merge fails here.""" + import dataclasses + + import claude_agent_sdk._internal.transport.subprocess_cli as subprocess_cli + from claude_agent_sdk import CLIConnectionError + + options = await self._options({"max_tokens": 200}, path, monkeypatch) + monkeypatch.setenv("LD_TEST_INHERITED", "kept") + monkeypatch.setenv("CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK", "1") + spawned: dict[str, Any] = {} + + async def _open_process(cmd: Any, **kwargs: Any) -> Any: + spawned["env"] = kwargs["env"] + raise RuntimeError("stop before spawning") + + monkeypatch.setattr(subprocess_cli.anyio, "open_process", _open_process) + transport = subprocess_cli.SubprocessCLITransport( + prompt="q", options=dataclasses.replace(options, cli_path="/bin/echo") + ) + with pytest.raises(CLIConnectionError): + await transport.connect() + assert spawned["env"]["CLAUDE_CODE_MAX_OUTPUT_TOKENS"] == "200" + assert spawned["env"]["LD_TEST_INHERITED"] == "kept" + assert spawned["env"]["PATH"] == os.environ["PATH"] + class TestFinishReasonMapping: async def test_tool_use_maps_to_tool_calls( diff --git a/packages/claude-agents/tests/test_native_graph.py b/packages/claude-agents/tests/test_native_graph.py index 0165ea26..31a5d64f 100644 --- a/packages/claude-agents/tests/test_native_graph.py +++ b/packages/claude-agents/tests/test_native_graph.py @@ -1407,6 +1407,19 @@ async def node_options(parameters: dict[str, Any]) -> dict[str, Any]: limited = await node_options({"max_tokens": 200}) assert limited["env"] == {"CLAUDE_CODE_MAX_OUTPUT_TOKENS": "200"} assert limited["thinking"] == {"type": "disabled"} + # Built by the real SDK, so the node's CLI is started with thinking off. + command = _build_cli_command(limited) + assert command[command.index("--thinking") + 1] == "disabled" + + invalid = await node_options({"max_tokens": 1.5}) + assert "env" not in invalid + assert invalid["thinking"] == {"type": "disabled"} + + budget_only = await node_options({"max_thinking_tokens": 2048}) + assert budget_only["max_thinking_tokens"] == 2048 + assert "thinking" not in budget_only + command = _build_cli_command(budget_only) + assert command[command.index("--max-thinking-tokens") + 1] == "2048" thinking = await node_options( {"thinking": {"type": "enabled", "budget_tokens": 2048}} From cd9199348f5c118187f79e4360e9595d6dcde86e Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Wed, 28 Oct 2026 18:21:23 -0400 Subject: [PATCH 3/3] test(claude-agents): pin the disabled default for a graph node's malformed thinking and effort alone Match the JS graph tests: a node with a thinking that has no budget, and a node with only effort, each get thinking={"type": "disabled"}. Co-Authored-By: Claude Opus 5.5 --- packages/claude-agents/tests/test_native_graph.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/claude-agents/tests/test_native_graph.py b/packages/claude-agents/tests/test_native_graph.py index 31a5d64f..6269cc25 100644 --- a/packages/claude-agents/tests/test_native_graph.py +++ b/packages/claude-agents/tests/test_native_graph.py @@ -1415,6 +1415,13 @@ async def node_options(parameters: dict[str, Any]) -> dict[str, Any]: assert "env" not in invalid assert invalid["thinking"] == {"type": "disabled"} + malformed = await node_options({"thinking": {"type": "enabled"}}) + assert malformed["thinking"] == {"type": "disabled"} + + effort_only = await node_options({"effort": "low"}) + assert effort_only["effort"] == "low" + assert effort_only["thinking"] == {"type": "disabled"} + budget_only = await node_options({"max_thinking_tokens": 2048}) assert budget_only["max_thinking_tokens"] == 2048 assert "thinking" not in budget_only