From 366ac2ca67a8a1f16528e4c06147d0eb2eb42cee Mon Sep 17 00:00:00 2001 From: Zeding Zhang Date: Tue, 3 Nov 2026 01:51:06 +0800 Subject: [PATCH] Python: Skip Claude client settings when a client is injected --- .../claude/agent_framework_claude/_agent.py | 31 ++++---- .../claude/tests/test_claude_agent.py | 72 ++++++++++++++++++- 2 files changed, 88 insertions(+), 15 deletions(-) diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index bed9ba7f0f2..e50710b4cdc 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -335,7 +335,8 @@ def __init__( Keyword Args: client: Optional pre-configured ClaudeSDKClient instance. If not provided, - a new client will be created using the other parameters. + a new client will be created using the other parameters. An injected + client remains caller-owned and bypasses client settings and .env loading. id: Unique identifier for the agent. name: Name of the agent. description: Description of the agent. @@ -375,19 +376,21 @@ def __init__( self._mcp_servers: dict[str, Any] = opts.pop("mcp_servers", None) or {} self._function_approval_handler: FunctionApprovalCallback | None = opts.pop("on_function_approval", None) - # Load settings from environment and options - self._settings = load_settings( - ClaudeAgentSettings, - env_prefix="CLAUDE_AGENT_", - cli_path=cli_path, - model=model, - cwd=cwd, - permission_mode=permission_mode, - max_turns=max_turns, - max_budget_usd=max_budget_usd, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) + # Resolve client configuration only for clients created by the framework. + self._settings: ClaudeAgentSettings = {} + if self._owns_client: + self._settings = load_settings( + ClaudeAgentSettings, + env_prefix="CLAUDE_AGENT_", + cli_path=cli_path, + model=model, + cwd=cwd, + permission_mode=permission_mode, + max_turns=max_turns, + max_budget_usd=max_budget_usd, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) # Separate built-in tools (strings) from custom tools (callables/FunctionTool) self._builtin_tools: list[str] = [] diff --git a/python/packages/claude/tests/test_claude_agent.py b/python/packages/claude/tests/test_claude_agent.py index 51c5b5b5aed..444d454da3e 100644 --- a/python/packages/claude/tests/test_claude_agent.py +++ b/python/packages/claude/tests/test_claude_agent.py @@ -2,6 +2,7 @@ import copy import json +from pathlib import Path from types import SimpleNamespace from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -10,8 +11,9 @@ from agent_framework import AgentResponseUpdate, AgentSession, Content, MCPStdioTool, Message, tool from agent_framework._settings import load_settings from agent_framework.exceptions import AgentInvalidRequestException +from claude_agent_sdk import ClaudeSDKClient -from agent_framework_claude import ClaudeAgent, ClaudeAgentOptions, ClaudeAgentSettings +from agent_framework_claude import ClaudeAgent, ClaudeAgentOptions, ClaudeAgentSettings, RawClaudeAgent from agent_framework_claude._agent import TOOLS_MCP_SERVER_NAME from agent_framework_claude._feature_usage import FeatureIndex @@ -103,6 +105,74 @@ def test_with_default_options(self) -> None: assert agent._settings["permission_mode"] == "default" # type: ignore[reportPrivateUsage] assert agent._settings["max_turns"] == 10 # type: ignore[reportPrivateUsage] + @pytest.mark.parametrize("agent_type", [RawClaudeAgent, ClaudeAgent]) + @pytest.mark.parametrize("env_var", ["CLAUDE_AGENT_MAX_TURNS", "CLAUDE_AGENT_MAX_BUDGET_USD"]) + def test_injected_client_ignores_invalid_environment( + self, monkeypatch: pytest.MonkeyPatch, agent_type: type[RawClaudeAgent[Any]], env_var: str + ) -> None: + monkeypatch.setenv(env_var, "not-a-number") + with pytest.raises(ValueError, match=env_var): + agent_type() + + client = MagicMock(spec=ClaudeSDKClient) + agent = agent_type(client=client) + assert agent._client is client + + @pytest.mark.parametrize("agent_type", [RawClaudeAgent, ClaudeAgent]) + async def test_injected_client_bypasses_settings_and_remains_caller_owned( + self, tmp_path: Path, agent_type: type[RawClaudeAgent[Any]] + ) -> None: + client = MagicMock(spec=ClaudeSDKClient) + client.connect = AsyncMock() + client.disconnect = AsyncMock() + with ( + patch("agent_framework_claude._agent.load_settings", side_effect=AssertionError("Must not load settings")), + patch("agent_framework_claude._agent.ClaudeSDKClient") as factory, + ): + agent = agent_type( + client=client, + instructions="Follow the task instructions.", + tools=["Read"], + default_options={ + "cli_path": "/configured/claude", + "model": "configured-model", + "cwd": str(tmp_path), + "permission_mode": "default", + "max_turns": 10, + "max_budget_usd": 5.0, + }, + env_file_path=str(tmp_path / "missing.env"), + env_file_encoding="invalid-encoding", + ) + assert agent._settings == {} + assert agent._default_options["system_prompt"] == "Follow the task instructions." + assert agent._builtin_tools == ["Read"] + acquired, owned = await agent._acquire_client(agent.create_session()) + await agent.stop() + + assert acquired is client + assert not owned + factory.assert_not_called() + client.connect.assert_awaited_once() + client.disconnect.assert_not_awaited() + + @pytest.mark.parametrize("agent_type", [RawClaudeAgent, ClaudeAgent]) + def test_owned_client_settings_precedence( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, agent_type: type[RawClaudeAgent[Any]] + ) -> None: + monkeypatch.setenv("CLAUDE_AGENT_MODEL", "environment-model") + monkeypatch.setenv("CLAUDE_AGENT_MAX_TURNS", "8") + monkeypatch.setenv("CLAUDE_AGENT_MAX_BUDGET_USD", "1.25") + env_file = tmp_path / "claude.env" + env_file.write_text("CLAUDE_AGENT_MODEL=file-model\nCLAUDE_AGENT_MAX_TURNS=12\n", encoding="utf-8") + + agent = agent_type(default_options={"model": "explicit-model"}, env_file_path=str(env_file)) + options = agent._prepare_client_options() + + assert options.model == "explicit-model" + assert options.max_turns == 12 + assert options.max_budget_usd == 1.25 + def test_with_function_tool(self) -> None: """Test agent with function tool."""