From 4037b12f305c1c75bfa5a7c7b5e9b091fe21c1e8 Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Wed, 21 Oct 2026 14:56:34 -0400 Subject: [PATCH 1/2] fix(claude-agents)!: switch the built-ins off by default and load no host settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run with no NativeTool attached now sends tools=[], so the Claude Code built-ins (Read, Bash, Skill and the rest) are off unless a config attaches them. 50e82fd omitted the key instead, which gave every run the full built-in set. TypeScript has sent tools=[] since its first release. TESTING.md §2.x.2. create_claude_agents_handler takes cwd and setting_sources, forwarded to every query() on both paths. setting_sources not given sends [], because the Agent SDK reads None as "load every source": the host's plugins, skills, hooks, permission rules and CLAUDE.md reached every run (AIC-3584). cwd is sent only when given. to_claude_agents also sends setting_sources=[]. §2.x.1. BREAKING CHANGE: runs no longer get Claude Code built-ins unless the config attaches them as NativeTool sentinels, and no longer load host Claude Code settings unless setting_sources names them. Co-Authored-By: Claude Opus 5.5 --- packages/claude-agents/README.md | 24 +++ packages/claude-agents/agents.md | 13 +- .../launchdarkly_ai_claude_agents/handler.py | 63 ++++++-- .../native_graph.py | 4 + packages/claude-agents/tests/test_handler.py | 149 ++++++++++++++++-- .../claude-agents/tests/test_native_graph.py | 44 ++++++ 6 files changed, 274 insertions(+), 23 deletions(-) diff --git a/packages/claude-agents/README.md b/packages/claude-agents/README.md index 0e9f2137..813a7830 100644 --- a/packages/claude-agents/README.md +++ b/packages/claude-agents/README.md @@ -34,6 +34,28 @@ async def main(): asyncio.run(main()) ``` +### Handler options + +`create_claude_agents_handler()` takes keyword arguments: + +| Option | Default | Description | +|---|---|---| +| `capture_content` | `None` | Put prompts, model output, and tool arguments and results on the emitted spans. `None` follows `configure()`, which is off by default. | +| `cwd` | the process's working directory | Working directory for the Claude Code process the Agent SDK starts. A `str` or path-like. | +| `setting_sources` | `[]` (none) | Which Claude Code settings sources to load: `"user"` (`~/.claude/settings.json`), `"project"` (`.claude/settings.json`, and `CLAUDE.md`), `"local"` (`.claude/settings.local.json`). | + +These configure the host process, not the model, so only your application can set them. A flag's model parameters can't set or override either one. + +> **A run loads none of the host's Claude Code settings unless you opt in.** The Claude Agent SDK's own default loads the user, project and local settings of the machine it runs on, including their plugins, skills, hooks, permission rules and `CLAUDE.md`. This handler sends `setting_sources=[]` instead, so an agent runs from its LaunchDarkly config alone. To load host settings, name the sources: +> +> ```python +> create_claude_agents_handler(setting_sources=["user", "project", "local"]) +> ``` +> +> The Agent SDK still reads the managed-settings policy tier (managed settings files and MDM) whatever you pass. + +`claude_agents()`, `claude_graph()` and `to_claude_agents()` don't take `cwd` or `setting_sources`, and load no host settings. To set either, build the handler yourself and pass it to `config()` or `graph()`. + ### Convenience wrapper ```python @@ -100,6 +122,8 @@ asyncio.run(main()) ## Built-in Claude Tools +A run has no Claude Code built-in tools (`Read`, `Bash`, and the rest) unless its config attaches them. Tools you define in the flag config are always available to the model through the handler's MCP server, whatever built-ins are attached. + This package exports `NativeTool` sentinels for Claude Code built-in capabilities. Place them as values in `tool_handlers` to enable the corresponding native Claude tool without writing a handler function: | Export | Claude SDK tool name | diff --git a/packages/claude-agents/agents.md b/packages/claude-agents/agents.md index aa369b4a..05f209ed 100644 --- a/packages/claude-agents/agents.md +++ b/packages/claude-agents/agents.md @@ -39,7 +39,12 @@ That means the LaunchDarkly flag variation must have `provider.name == "Anthropi ```python # Factory — returns a ProviderHandler with provides_for attached -def create_claude_agents_handler() -> ProviderHandler: ... +def create_claude_agents_handler( + *, + capture_content: bool | None = None, + cwd: str | os.PathLike[str] | None = None, + setting_sources: list[SettingSource] | None = None, +) -> ProviderHandler: ... # Convenience wrapper — equivalent to config(key=config_key, handler=create_claude_agents_handler()).invoke(user_input, context) def claude_agents(config_key: str, user_input: str, context: dict, **kwargs) -> ProviderResponse: ... @@ -99,6 +104,12 @@ Tools are delivered via an in-process MCP server, not as raw JSON schema defs. T If `config.tools` is absent, the MCP server is not created and `allowed_tools` is an empty list. +**`tools` is always sent, and it is `[]` when the config attaches no `NativeTool`.** The explicit empty list switches every Claude Code built-in off. Omitting the key would leave the Agent SDK default, the full built-in set, on every run, and the read-only tools in it need no permission. A config turns a built-in on by attaching its sentinel (`ClaudeRead`, `ClaudeBash`, …). User-defined tools go through the MCP server and are not governed by `tools`. `to_claude_agents` follows the same rule. + +### 2a. Host options (`cwd`, `setting_sources`) + +`create_claude_agents_handler(cwd=..., setting_sources=...)` takes two trusted, app-supplied options and passes them to every `query()` call, blocking and streaming. The handler forwards none of `model.parameters` to `ClaudeAgentOptions`, so a config can neither set nor override them. **`setting_sources` is always sent, `[]` unless the application gives sources**: the Agent SDK reads `None` as "load every source", which put the host's plugins, skills, hooks, permission rules and `CLAUDE.md` into every run (AIC-3584). `[]` still lets the SDK read the managed-settings policy tier. `cwd` is sent only when given. The convenience wrappers and the native graph adapter don't take these options; they send `setting_sources=[]`. + ### 3. Agentic Loop The `claude_agent_sdk` handles the loop internally. The handler iterates the `query()` async generator, holding an explicit reference so it can be explicitly closed on early exit: 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 7376e7f5..d52f9bde 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -16,6 +16,7 @@ import asyncio import json +import os from collections.abc import AsyncGenerator, AsyncIterator, Callable from typing import Any @@ -23,6 +24,7 @@ ClaudeAgentOptions, HookMatcher, ResultMessage, + SettingSource, StreamEvent, ToolResultBlock, UserMessage, @@ -519,14 +521,14 @@ def _build_query_options( "allowed_tools": all_allowed if all_allowed else [], "mcp_servers": {TOOL_MCP_NAME: tool_mcp} if tool_mcp else {}, "hooks": hooks or {}, + # Always sent, `[]` when the config attaches no NativeTool. The empty list switches every + # Claude Code built-in off; omitting the key leaves the SDK default, which gives every run + # the full built-in set, and its read-only tools need no permission. A config turns a + # built-in on by attaching its sentinel. User-defined tools go through the MCP server and + # are not governed by `tools`. + "tools": list(native_tool_names), **extra, } - # Omitted rather than passed empty. `tools=[]` is an explicit "no tools", which switches off the - # Claude Code built-ins; leaving the key out keeps the SDK default, which is what a run with only - # MCP tools, or none, has always had. A config with no native tools would otherwise silently lose - # Read, Bash and the rest. - if native_tool_names: - kwargs["tools"] = native_tool_names if system_prompt: kwargs["system_prompt"] = system_prompt return ClaudeAgentOptions(**kwargs) @@ -537,8 +539,29 @@ def _build_query_options( # --------------------------------------------------------------------------- +def _host_query_options( + cwd: str | os.PathLike[str] | None, + setting_sources: list[SettingSource] | None, +) -> dict[str, Any]: + """The host options for ``query()``: ``setting_sources`` always, ``[]`` unless given; ``cwd`` + only if given. + + The Agent SDK reads ``setting_sources=None`` as "load every source", which put the host's + plugins, skills, hooks, permission rules and ``CLAUDE.md`` into every run (AIC-3584). + """ + options: dict[str, Any] = { + "setting_sources": list(setting_sources) if setting_sources is not None else [] + } + if cwd is not None: + options["cwd"] = cwd + return options + + def create_claude_agents_handler( - *, capture_content: bool | None = None + *, + capture_content: bool | None = None, + cwd: str | os.PathLike[str] | None = None, + setting_sources: list[SettingSource] | None = None, ) -> ProviderHandler: """Creates a ``ProviderHandler`` for Anthropic's Claude via the claude-agent-sdk. @@ -546,16 +569,34 @@ def create_claude_agents_handler( emitted spans. ``None`` (the default) reads the process-wide default at invocation, which is off until ``configure`` sets it. ``True`` or ``False`` overrides that default for this handler. See TELEMETRY-CONTRACT.md section 7. + + *cwd* and *setting_sources* configure the Claude Code process the Agent SDK starts, not the + model, so they come from the application and never from a config. Each one given is passed to + every ``query()`` call unchanged. *cwd* not given is not sent, which leaves the process's + working directory. + + Host settings load only when the application opts in. *setting_sources* not given sends + ``[]``, because the Agent SDK's own default loads every source (``user``, ``project``, + ``local``), including that host's plugins, skills, hooks, permission rules and ``CLAUDE.md``. + Pass the sources to load them, e.g. ``["user", "project", "local"]`` for all. The Agent SDK + still reads the managed-settings policy tier, which no option turns off. """ report_usage("claude-agents.createClaudeAgentsHandler", PACKAGE_NAME, __version__) - return _create_claude_agents_handler(capture_content=capture_content) + return _create_claude_agents_handler( + capture_content=capture_content, cwd=cwd, setting_sources=setting_sources + ) def _create_claude_agents_handler( - *, capture_content: bool | None = None + *, + capture_content: bool | None = None, + cwd: str | os.PathLike[str] | None = None, + setting_sources: list[SettingSource] | None = None, ) -> ProviderHandler: """Non-reporting :func:`create_claude_agents_handler`, used by this package's wrappers.""" content_override = capture_content + # Resolved once, so a caller mutating its list afterwards does not change later runs. + host_options = _host_query_options(cwd, setting_sources) async def _call_impl( config: AiConfigRep, @@ -630,6 +671,7 @@ async def _call_impl( mcp_allowed_tools, tool_mcp, hooks, + **host_options, ) output = "" @@ -739,6 +781,7 @@ def _stream_impl( variables or {}, history, capture_content=effective_capture_content(content_override), + host_options=host_options, ) return _create_handler( @@ -802,6 +845,7 @@ async def _stream_gen( history: list[dict[str, Any]] | None = None, *, capture_content: bool = False, + host_options: dict[str, Any] | None = None, ) -> AsyncGenerator[dict[str, Any], None]: """Streams the run, emitting the same span tree as the blocking path. @@ -873,6 +917,7 @@ async def _stream_gen( mcp_allowed_tools, tool_mcp, hooks, + **(host_options or _host_query_options(None, None)), include_partial_messages=True, ) diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py index aea24b7d..8de37cbf 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py @@ -174,6 +174,10 @@ async def _run_query( else [], mcp_servers=mcp_servers, hooks=hooks or {}, + # No host settings: the Agent SDK reads `None` as "load every source", which would put + # the host's plugins, skills, hooks and CLAUDE.md into every node's run. The adapter takes + # no host options; an application that needs them builds the handler itself. + setting_sources=[], **({"system_prompt": system_prompt} if system_prompt else {}), ) diff --git a/packages/claude-agents/tests/test_handler.py b/packages/claude-agents/tests/test_handler.py index b4b67d01..b232e362 100644 --- a/packages/claude-agents/tests/test_handler.py +++ b/packages/claude-agents/tests/test_handler.py @@ -2026,25 +2026,148 @@ async def test_empty_user_input_appends_no_trailing_turn( assert turns[-1]["message"]["content"] == [IMAGE_BLOCK] -class TestBuiltinsSurviveAnEmptyToolList: - """`tools=[]` is not the same as omitting `tools`. +_PATHS = ["invoke", "stream"] - An explicit empty list switches off the Claude Code built-ins. Omitting the key leaves the SDK - default, which is what a run with only MCP tools, or none at all, has always had. Passing the - empty list silently cost such a run Read, Bash and the rest. + +async def _query_options( + monkeypatch: pytest.MonkeyPatch, + handler: Any, + path: str, + config: dict[str, Any] | None = None, + tool_handlers: dict[str, Any] | None = None, +) -> Any: + """Runs one invoke or stream through *handler* and returns the options ``query()`` received.""" + captured: list[Any] = [] + + async def _query(**kwargs: Any) -> AsyncIterator[Any]: + captured.append(kwargs["options"]) + yield result_message() + + monkeypatch.setattr(handler_mod, "query", _query) + cfg = config if config is not None else BASE_CONFIG + if path == "invoke": + await handler(cfg, "q", tool_handlers or {}) + else: + await _collect(await handler.stream(cfg, "q", tool_handlers or {})) + assert len(captured) == 1 + return captured[0] + + +@pytest.mark.parametrize("path", _PATHS) +class TestHostOptionsAndBuiltinToolSet: + """TESTING.md §2.x Claude Agents Handler — host options and built-in tool set. + + ``cwd`` and ``setting_sources`` configure the CLI process, so only the application sets them. + ``setting_sources`` not given sends ``[]``: the Agent SDK's own ``None`` loads every settings + source on the host. ``tools=[]`` is sent, not omitted, when a config attaches no native tool, + because omitting it gives every run the full built-in set. """ - def test_no_native_tools_omits_the_key_entirely(self) -> None: - from launchdarkly_ai_claude_agents.handler import _build_query_options + async def test_forwards_cwd_and_setting_sources( + self, monkeypatch: pytest.MonkeyPatch, path: str + ) -> None: + h = create_claude_agents_handler(cwd="/srv/app", setting_sources=["project"]) + opts = await _query_options(monkeypatch, h, path) + assert opts.cwd == "/srv/app" + assert opts.setting_sources == ["project"] + + async def test_sends_no_setting_sources_and_no_cwd_by_default( + self, monkeypatch: pytest.MonkeyPatch, path: str + ) -> None: + opts = await _query_options(monkeypatch, create_claude_agents_handler(), path) + # `None` is the dataclass default, so "not sent" is `None` here, never "". + assert opts.cwd is None + assert opts.setting_sources == [] - opts = _build_query_options(BASE_CONFIG, None, [], [], None, None) - assert not hasattr(opts, "tools") or getattr(opts, "tools", None) is None + async def test_loads_host_settings_only_when_the_application_opts_in( + self, monkeypatch: pytest.MonkeyPatch, path: str + ) -> None: + all_sources: list[Any] = ["user", "project", "local"] + h = create_claude_agents_handler(setting_sources=all_sources) + opts = await _query_options(monkeypatch, h, path) + assert opts.setting_sources == ["user", "project", "local"] - def test_native_tools_are_still_passed(self) -> None: - from launchdarkly_ai_claude_agents.handler import _build_query_options + async def test_forwards_an_explicit_empty_setting_sources( + self, monkeypatch: pytest.MonkeyPatch, path: str + ) -> None: + h = create_claude_agents_handler(setting_sources=[]) + opts = await _query_options(monkeypatch, h, path) + assert opts.setting_sources == [] - opts = _build_query_options(BASE_CONFIG, None, ["Read"], [], None, None) - assert opts.tools == ["Read"] + async def test_a_path_like_cwd_is_forwarded( + self, monkeypatch: pytest.MonkeyPatch, path: str, tmp_path: Any + ) -> None: + opts = await _query_options( + monkeypatch, create_claude_agents_handler(cwd=tmp_path), path + ) + assert opts.cwd == tmp_path + + async def test_no_config_sets_or_overrides_a_host_option( + self, monkeypatch: pytest.MonkeyPatch, path: str + ) -> None: + config = { + **BASE_CONFIG, + "model": { + "name": "claude-opus-4-5", + "parameters": { + "cwd": "/from-config", + "setting_sources": ["user"], + "settingSources": ["user"], + "plugins": [{"type": "local", "path": "/from-config"}], + "skills": "all", + }, + }, + } + from_config = await _query_options( + monkeypatch, create_claude_agents_handler(), path, config + ) + assert from_config.cwd is None + assert from_config.plugins == [] + assert from_config.skills is None + # The handler's own default, not the config's `["user"]`. + assert from_config.setting_sources == [] + overridden = await _query_options( + monkeypatch, create_claude_agents_handler(cwd="/srv/app"), path, config + ) + assert overridden.cwd == "/srv/app" + + async def test_sends_an_empty_tool_list_when_the_config_has_no_tools( + self, monkeypatch: pytest.MonkeyPatch, path: str + ) -> None: + opts = await _query_options(monkeypatch, create_claude_agents_handler(), path) + assert opts.tools == [] + + async def test_only_user_defined_tools_send_an_empty_tool_list_and_stay_allowed( + self, monkeypatch: pytest.MonkeyPatch, path: str + ) -> None: + opts = await _query_options( + monkeypatch, + create_claude_agents_handler(), + path, + TOOL_CONFIG, + {"search": MagicMock(return_value="ok")}, + ) + assert opts.tools == [] + assert "mcp__tool-mcp__search" in opts.allowed_tools + + async def test_sends_exactly_the_native_tool_provider_names( + self, monkeypatch: pytest.MonkeyPatch, path: str + ) -> None: + from launchdarkly_ai_claude_agents import ClaudeBash, ClaudeRead + from launchdarkly_ai_server import NATIVE_TOOL_KEY + + # The client hands the handler a tracking stub with the sentinel stashed on it. + read, bash = MagicMock(), MagicMock() + setattr(read, NATIVE_TOOL_KEY, ClaudeRead) + setattr(bash, NATIVE_TOOL_KEY, ClaudeBash) + opts = await _query_options( + monkeypatch, + create_claude_agents_handler(), + path, + BASE_CONFIG, + {"read": read, "bash": bash}, + ) + assert sorted(opts.tools) == ["Bash", "Read"] class TestAbandonedToolSpansAreNotErrors: diff --git a/packages/claude-agents/tests/test_native_graph.py b/packages/claude-agents/tests/test_native_graph.py index 81b14831..5af106cd 100644 --- a/packages/claude-agents/tests/test_native_graph.py +++ b/packages/claude-agents/tests/test_native_graph.py @@ -482,6 +482,50 @@ async def test_native_tool_handled_via_native_tool_key(self) -> None: assert "response" in result +class TestToClaudeAgentsHostIsolation: + """Every ``query()`` the adapter makes switches the built-ins off unless a node attaches one, + and loads none of the host's Claude Code settings. The adapter takes no host options, so + ``setting_sources`` is always ``[]``: the Agent SDK's own ``None`` loads every source. + """ + + @staticmethod + async def _root_options( + tool_handlers: dict[str, Any] | None = None, + ) -> dict[str, Any]: + mock_sdk = _make_sdk_mock("done") + 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()), + opts={"tool_handlers": tool_handlers or {}}, + ).invoke("hi") + assert len(captured) == 1 + return captured[0] + + @pytest.mark.asyncio + async def test_sends_an_empty_tool_list_with_no_native_tools(self) -> None: + opts = await self._root_options() + assert opts["tools"] == [] + + @pytest.mark.asyncio + async def test_sends_the_native_tool_names(self) -> None: + opts = await self._root_options({"run-bash": NativeTool("Bash")}) + assert opts["tools"] == ["Bash"] + + @pytest.mark.asyncio + async def test_loads_no_host_settings(self) -> None: + opts = await self._root_options() + assert opts["setting_sources"] == [] + + # --------------------------------------------------------------------------- # Anthropic-specific specs # --------------------------------------------------------------------------- From bcbde98f9224bab92910b3eb9887868dbf28be33 Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Fri, 23 Oct 2026 14:07:37 -0400 Subject: [PATCH 2/2] fix(claude-agents): take cwd and setting_sources on the wrappers and native adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches launchdarkly/js-ai-sdk#135. claude_agents(), claude_graph() and to_claude_agents() hard-coded setting_sources=[], so a wrapper user could not opt back in to host settings, and these signatures freeze at 1.0. - claude_agents and claude_graph take cwd / setting_sources keyword-only, build the handler with them, and pass neither on to config() / graph(). - to_claude_agents reads both from opts and sends _host_query_options(...) to every node's query(), root and sub-agents, in place of the hard-coded []. - The README gains a migration note: [] also drops the settings-file env (ANTHROPIC_BASE_URL, CLAUDE_CODE_USE_BEDROCK / _VERTEX, proxies), apiKeyHelper and model, so a Bedrock, Vertex or gateway deployment configured through ~/.claude/settings.json stops authenticating. - agents.md §2a describes the model-parameter allowlist from main instead of "forwards none of model.parameters". - API report regenerated for the new signatures. Co-Authored-By: Claude Opus 5.5 --- packages/claude-agents/README.md | 22 +++++- packages/claude-agents/agents.md | 15 ++-- .../launchdarkly_ai_claude_agents.txt | 6 +- .../launchdarkly_ai_claude_agents/graph.py | 18 ++++- .../launchdarkly_ai_claude_agents/handler.py | 15 +++- .../native_graph.py | 18 ++++- packages/claude-agents/tests/test_graph.py | 64 +++++++++++++++ packages/claude-agents/tests/test_handler.py | 47 +++++++++++ .../claude-agents/tests/test_native_graph.py | 79 +++++++++++++++++-- 9 files changed, 259 insertions(+), 25 deletions(-) diff --git a/packages/claude-agents/README.md b/packages/claude-agents/README.md index 813a7830..aaaafa12 100644 --- a/packages/claude-agents/README.md +++ b/packages/claude-agents/README.md @@ -44,7 +44,7 @@ asyncio.run(main()) | `cwd` | the process's working directory | Working directory for the Claude Code process the Agent SDK starts. A `str` or path-like. | | `setting_sources` | `[]` (none) | Which Claude Code settings sources to load: `"user"` (`~/.claude/settings.json`), `"project"` (`.claude/settings.json`, and `CLAUDE.md`), `"local"` (`.claude/settings.local.json`). | -These configure the host process, not the model, so only your application can set them. A flag's model parameters can't set or override either one. +These configure the host process, not the model, so only your application can set them. A flag's model parameters can't set or override either one. `claude_agents()`, `claude_graph()` and `to_claude_agents()` take `cwd` and `setting_sources` too, with the same defaults. > **A run loads none of the host's Claude Code settings unless you opt in.** The Claude Agent SDK's own default loads the user, project and local settings of the machine it runs on, including their plugins, skills, hooks, permission rules and `CLAUDE.md`. This handler sends `setting_sources=[]` instead, so an agent runs from its LaunchDarkly config alone. To load host settings, name the sources: > @@ -54,7 +54,25 @@ These configure the host process, not the model, so only your application can se > > The Agent SDK still reads the managed-settings policy tier (managed settings files and MDM) whatever you pass. -`claude_agents()`, `claude_graph()` and `to_claude_agents()` don't take `cwd` or `setting_sources`, and load no host settings. To set either, build the handler yourself and pass it to `config()` or `graph()`. +#### Migrating from a version that loaded host settings + +Earlier versions left `setting_sources` unset, so the Agent SDK loaded every settings source on the host. A run now loads none of them. Anything a deployment configured through those files stops applying, including: + +- plugins, skills, hooks, permission rules and `CLAUDE.md` +- the `env` block, for example `ANTHROPIC_BASE_URL`, `CLAUDE_CODE_USE_BEDROCK` or `CLAUDE_CODE_USE_VERTEX`, and proxy variables +- `apiKeyHelper` and `model` + +A deployment that reaches Bedrock, Vertex or a gateway through `~/.claude/settings.json` fails to authenticate after the upgrade. To fix it, either set those variables in the process environment, or load the source that holds them: + +```python +create_claude_agents_handler(setting_sources=["user"]) +claude_agents("my-ai-config-flag", user_input, context, setting_sources=["user"]) +claude_graph("support-graph", setting_sources=["user"]) +to_claude_agents( + resolve_graph("support-graph", context=context), + {"context": context, "setting_sources": ["user"]}, +) +``` ### Convenience wrapper diff --git a/packages/claude-agents/agents.md b/packages/claude-agents/agents.md index 05f209ed..22d25000 100644 --- a/packages/claude-agents/agents.md +++ b/packages/claude-agents/agents.md @@ -46,13 +46,14 @@ def create_claude_agents_handler( setting_sources: list[SettingSource] | None = None, ) -> ProviderHandler: ... -# Convenience wrapper — equivalent to config(key=config_key, handler=create_claude_agents_handler()).invoke(user_input, context) -def claude_agents(config_key: str, user_input: str, context: dict, **kwargs) -> ProviderResponse: ... +# Convenience wrapper — equivalent to config(key=config_key, handler=create_claude_agents_handler(cwd=cwd, setting_sources=setting_sources)).invoke(user_input, context) +def claude_agents(config_key: str, user_input: str, context: dict, *, cwd=None, setting_sources=None, **kwargs) -> ProviderResponse: ... -# Graph convenience wrapper — equivalent to graph(key, options, handlers=[create_claude_agents_handler()]) -def claude_graph(key: str, options: dict | None = None): ... +# Graph convenience wrapper — equivalent to graph(key, handlers=[create_claude_agents_handler(cwd=cwd, setting_sources=setting_sources)], **options) +def claude_graph(key: str, *, cwd=None, setting_sources=None, **options): ... -# Native graph adapter — builds a Claude code-agents graph from a resolved GraphDefinition +# Native graph adapter — builds a Claude code-agents graph from a resolved GraphDefinition. +# opts: context, tool_handlers, and the host options cwd and setting_sources def to_claude_agents(def_promise: Awaitable[dict], opts: dict | None = None): ... # NativeTool sentinels for Claude Code built-in capabilities @@ -108,7 +109,9 @@ If `config.tools` is absent, the MCP server is not created and `allowed_tools` i ### 2a. Host options (`cwd`, `setting_sources`) -`create_claude_agents_handler(cwd=..., setting_sources=...)` takes two trusted, app-supplied options and passes them to every `query()` call, blocking and streaming. The handler forwards none of `model.parameters` to `ClaudeAgentOptions`, so a config can neither set nor override them. **`setting_sources` is always sent, `[]` unless the application gives sources**: the Agent SDK reads `None` as "load every source", which put the host's plugins, skills, hooks, permission rules and `CLAUDE.md` into every run (AIC-3584). `[]` still lets the SDK read the managed-settings policy tier. `cwd` is sent only when given. The convenience wrappers and the native graph adapter don't take these options; they send `setting_sources=[]`. +`create_claude_agents_handler(cwd=..., setting_sources=...)` takes two trusted, app-supplied options and passes them to every `query()` call, blocking and streaming. `_options_parameters` forwards only the allowlisted model and run settings from `model.parameters`, and both keys are in `_CLAUDE_AGENT_OPTIONS_EXCLUDED_KEYS`, so a config can neither set nor override them. **`setting_sources` is always sent, `[]` unless the application gives sources**: the Agent SDK reads `None` as "load every source", which put the host's plugins, skills, hooks, permission rules and `CLAUDE.md` into every run (AIC-3584). `[]` still lets the SDK read the managed-settings policy tier. `cwd` is sent only when given. Settings-file `env` (`ANTHROPIC_BASE_URL`, `CLAUDE_CODE_USE_BEDROCK` / `_VERTEX`, proxy variables), `apiKeyHelper` and `model` come from those sources too, so `[]` drops them; the README's migration note covers this. + +`claude_agents()` and `claude_graph()` take `cwd` and `setting_sources` keyword-only and build the handler with them, so neither reaches `config()` or `graph()`. `to_claude_agents()` reads them from `opts` and passes `_host_query_options(...)` to every node's `query()`, root and sub-agents. All four entry points share `_host_query_options`, so all four default to `[]`. ### 3. Agentic Loop diff --git a/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt b/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt index f7a24162..d56e2caa 100644 --- a/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt +++ b/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt @@ -1,8 +1,8 @@ # Public API of launchdarkly_ai_claude_agents. Generated by `make api-report`; do not edit. build_prompt(config: AiConfigRep, user_input: str | None, variables: dict[str, Any], history: list[dict[str, Any]] | None = None) -> tuple[str, str | None] build_tool_mcp(config_tools: dict[str, Any], handlers: dict[str, Any]) -> Any [async] -claude_agents(config_key: str, user_input: str, context: LDContext, **kwargs: Any) -> Any -claude_graph(key: str, **options: Any) -> Any +claude_agents(config_key: str, user_input: str, context: LDContext, *, cwd: str | os.PathLike[str] | None = None, setting_sources: list[SettingSource] | None = None, **kwargs: Any) -> Any +claude_graph(key: str, *, cwd: str | os.PathLike[str] | None = None, setting_sources: list[SettingSource] | None = None, **options: Any) -> Any ClaudeBash = NativeTool('Bash') ClaudeEdit = NativeTool('Edit') ClaudeGlob = NativeTool('Glob') @@ -13,6 +13,6 @@ ClaudeTodoWrite = NativeTool('TodoWrite') ClaudeWebFetch = NativeTool('WebFetch') ClaudeWebSearch = NativeTool('WebSearch') ClaudeWrite = NativeTool('Write') -create_claude_agents_handler(*, capture_content: bool | None = None) -> ProviderHandler +create_claude_agents_handler(*, capture_content: bool | None = None, cwd: str | os.PathLike[str] | None = None, setting_sources: list[SettingSource] | None = None) -> ProviderHandler partition_tools(config_tools: dict[str, Any] | None, tool_handlers: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any], list[str]] to_claude_agents(def_promise: Any, opts: dict[str, Any] | None = None) -> Any diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/graph.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/graph.py index 1311a6c2..6a04ea9d 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/graph.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/graph.py @@ -2,20 +2,32 @@ from __future__ import annotations +import os from typing import Any +from claude_agent_sdk import SettingSource + from launchdarkly_ai_claude_agents.handler import _create_claude_agents_handler from launchdarkly_ai_server import _graph, report_usage from ._version import PACKAGE_NAME, __version__ -def claude_graph(key: str, **options: Any) -> Any: +def claude_graph( + key: str, + *, + cwd: str | os.PathLike[str] | None = None, + setting_sources: list[SettingSource] | None = None, + **options: Any, +) -> Any: """ Runs an agent graph with the Claude agent handler pre-bound. - Equivalent to ``graph(key, handlers=[create_claude_agents_handler()], **options)``. + Equivalent to ``graph(key, handlers=[create_claude_agents_handler(cwd=cwd, + setting_sources=setting_sources)], **options)``. *cwd* and *setting_sources* build the + handler, with the same defaults, and are not passed on to ``graph()``. Use the base ``graph()`` directly for multi-provider graphs. """ report_usage("claude-agents.claudeGraph", PACKAGE_NAME, __version__) - return _graph(key, handlers=[_create_claude_agents_handler()], **options) + handler = _create_claude_agents_handler(cwd=cwd, setting_sources=setting_sources) + return _graph(key, handlers=[handler], **options) 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 2acdb6c7..c2a14d77 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -1204,14 +1204,25 @@ def claude_agents( config_key: str, user_input: str, context: LDContext, + *, + cwd: str | os.PathLike[str] | None = None, + setting_sources: list[SettingSource] | None = None, **kwargs: Any, ) -> Any: - """Convenience wrapper: creates a handler and calls config(...).invoke().""" + """Convenience wrapper: creates a handler and calls config(...).invoke(). + + *cwd* and *setting_sources* build the handler, as on :func:`create_claude_agents_handler`, + with the same defaults, and are not passed on to ``config()``. + """ report_usage("claude-agents.claudeAgents", PACKAGE_NAME, __version__) variables = kwargs.pop("variables", None) capture_content = kwargs.pop("capture_content", None) return _config( key=config_key, - handler=_create_claude_agents_handler(capture_content=capture_content), + handler=_create_claude_agents_handler( + capture_content=capture_content, + cwd=cwd, + setting_sources=setting_sources, + ), **kwargs, ).invoke(user_input, context, variables=variables) diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py index 05189171..a1c811f2 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/native_graph.py @@ -37,6 +37,7 @@ from launchdarkly_ai_claude_agents.handler import ( _build_hooks, + _host_query_options, _options_parameters, _raise_if_run_failed, build_prompt, @@ -115,6 +116,7 @@ async def _run_query( run_id: str, child_subagent_tools: list[Any], history: list[dict[str, Any]] | None = None, + host_options: dict[str, Any] | None = None, ) -> dict[str, Any]: import importlib @@ -178,10 +180,10 @@ async def _run_query( else [], mcp_servers=mcp_servers, hooks=hooks or {}, - # No host settings: the Agent SDK reads `None` as "load every source", which would put - # the host's plugins, skills, hooks and CLAUDE.md into every node's run. The adapter takes - # no host options; an application that needs them builds the handler itself. - setting_sources=[], + # `setting_sources` always, `[]` unless the application gave sources: the Agent SDK reads + # `None` as "load every source", which would put the host's plugins, skills, hooks and + # CLAUDE.md into every node's run. `cwd` only if given. + **(host_options or _host_query_options(None, None)), **({"system_prompt": system_prompt} if system_prompt else {}), ) @@ -225,10 +227,16 @@ def to_claude_agents( Converts a resolved ``GraphDefinition`` into a nested query() multi-agent execution where each child node becomes a sub-agent tool. + *opts* takes ``context``, ``tool_handlers``, and the host options ``cwd`` and + ``setting_sources``, which behave as on ``create_claude_agents_handler`` and are sent to + every node's ``query()``, root and sub-agents alike. + Returns an object with a ``.invoke(input, variables)`` coroutine. """ report_usage("claude-agents.toClaudeAgents", PACKAGE_NAME, __version__) _opts = opts or {} + # Resolved once, so a caller mutating its list afterwards does not change later runs. + host_options = _host_query_options(_opts.get("cwd"), _opts.get("setting_sources")) async def invoke( input_text: str = "", @@ -330,6 +338,7 @@ async def _subagent_execute( def_obj.key, run_id, child_tools, + host_options=host_options, ) total_usage["input"] += result["usage"]["input"] total_usage["output"] += result["usage"]["output"] @@ -396,6 +405,7 @@ async def _subagent_execute( run_id, root_child_tools, history, + host_options=host_options, ) final_output = result["output"] diff --git a/packages/claude-agents/tests/test_graph.py b/packages/claude-agents/tests/test_graph.py index 0e6f05d1..c59aae24 100644 --- a/packages/claude-agents/tests/test_graph.py +++ b/packages/claude-agents/tests/test_graph.py @@ -3,8 +3,13 @@ Reference: TESTING.md §2.1 """ +from collections.abc import AsyncIterator +from typing import Any from unittest.mock import MagicMock, patch +import pytest +from claude_agent_sdk import ResultMessage + from launchdarkly_ai_claude_agents.graph import claude_graph @@ -40,3 +45,62 @@ def test_user_cannot_override_handlers(self) -> None: # The graph wrapper always injects its own handler; no user overwrite allowed assert "handlers" in kw assert len(kw["handlers"]) == 1 + + +class TestClaudeGraphHostOptions: + """``cwd`` and ``setting_sources`` build the bound handler and never reach ``graph()``.""" + + @staticmethod + async def _bound_query_options(monkeypatch: pytest.MonkeyPatch, **host: Any) -> Any: + import launchdarkly_ai_claude_agents.handler as handler_mod + + captured: list[Any] = [] + + async def _query(**kwargs: Any) -> AsyncIterator[Any]: + captured.append(kwargs["options"]) + yield ResultMessage( + subtype="success", + duration_ms=1, + duration_api_ms=1, + is_error=False, + num_turns=1, + session_id="s", + usage={"input_tokens": 1, "output_tokens": 1}, + result="done", + ) + + monkeypatch.setattr(handler_mod, "query", _query) + with patch("launchdarkly_ai_claude_agents.graph._graph") as mock_graph: + mock_graph.return_value = MagicMock() + claude_graph("key", **host) + graph_kwargs = mock_graph.call_args[1] + assert "cwd" not in graph_kwargs + assert "setting_sources" not in graph_kwargs + (handler,) = graph_kwargs["handlers"] + await handler( + { + "model": {"name": "claude-opus-4-5"}, + "provider": {"name": "Anthropic"}, + "instructions": "x", + }, + "q", + {}, + ) + assert len(captured) == 1 + return captured[0] + + async def test_binds_a_handler_with_no_host_settings_and_no_cwd_by_default( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + opts = await self._bound_query_options(monkeypatch) + assert opts.setting_sources == [] + assert opts.cwd is None + + async def test_forwards_cwd_and_setting_sources_to_the_bound_handler( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + opts = await self._bound_query_options( + monkeypatch, cwd="/srv/app", setting_sources=["user"] + ) + assert opts.cwd == "/srv/app" + assert opts.setting_sources == ["user"] diff --git a/packages/claude-agents/tests/test_handler.py b/packages/claude-agents/tests/test_handler.py index de102269..0878f3f7 100644 --- a/packages/claude-agents/tests/test_handler.py +++ b/packages/claude-agents/tests/test_handler.py @@ -1958,6 +1958,53 @@ def test_callable_without_extra_kwargs(self) -> None: ) +class TestConvenienceExportHostOptions: + """``cwd`` and ``setting_sources`` build the wrapper's handler and never reach ``config()``.""" + + @staticmethod + async def _bound_query_options(monkeypatch: pytest.MonkeyPatch, **host: Any) -> Any: + from launchdarkly_ai_claude_agents import claude_agents + + captured: list[Any] = [] + + async def _query(**kwargs: Any) -> AsyncIterator[Any]: + captured.append(kwargs["options"]) + yield result_message() + + monkeypatch.setattr(handler_mod, "query", _query) + config_kwargs: dict[str, Any] = {} + + async def _invoke(*_args: Any, **_kwargs: Any) -> Any: + return await config_kwargs["handler"](BASE_CONFIG, "q", {}) + + def _fake_config(**kwargs: Any) -> Any: + config_kwargs.update(kwargs) + return type("Inst", (), {"invoke": staticmethod(_invoke)})() + + monkeypatch.setattr(handler_mod, "_config", _fake_config) + await claude_agents("cfg-key", "q", {"key": "u1"}, **host) + assert "cwd" not in config_kwargs + assert "setting_sources" not in config_kwargs + assert len(captured) == 1 + return captured[0] + + async def test_no_host_settings_and_no_cwd_by_default( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + opts = await self._bound_query_options(monkeypatch) + assert opts.setting_sources == [] + assert opts.cwd is None + + async def test_forwards_cwd_and_setting_sources_to_query( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + opts = await self._bound_query_options( + monkeypatch, cwd="/srv/app", setting_sources=["user"] + ) + assert opts.cwd == "/srv/app" + assert opts.setting_sources == ["user"] + + # --------------------------------------------------------------------------- # §1.2 Path C — None user_input must not produce None prompt. # Restored from the pre-rewrite file (not telemetry). Adapted from the old diff --git a/packages/claude-agents/tests/test_native_graph.py b/packages/claude-agents/tests/test_native_graph.py index c3240b1e..06f1ec3f 100644 --- a/packages/claude-agents/tests/test_native_graph.py +++ b/packages/claude-agents/tests/test_native_graph.py @@ -490,8 +490,8 @@ async def test_native_tool_handled_via_native_tool_key(self) -> None: class TestToClaudeAgentsHostIsolation: """Every ``query()`` the adapter makes switches the built-ins off unless a node attaches one, - and loads none of the host's Claude Code settings. The adapter takes no host options, so - ``setting_sources`` is always ``[]``: the Agent SDK's own ``None`` loads every source. + and loads none of the host's Claude Code settings unless ``opts`` names sources. The host + options in ``opts`` reach every node, root and sub-agents alike. """ @staticmethod @@ -516,6 +516,66 @@ async def _root_options( assert len(captured) == 1 return captured[0] + @staticmethod + async def _root_and_leaf_options( + **host: Any, + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Runs a root with one leaf sub-agent, which the root calls, and returns the options + each node's ``query()`` received.""" + graph_def = _make_graph_def( + nodes={ + "root": { + "key": "root", + "config": {"model": {"name": "claude-3"}, "instructions": "root"}, + "meta": {"variationKey": "v1", "version": 1}, + "edges": [{"source_key": "root", "target_key": "leaf"}], + "is_terminal": False, + }, + "leaf": { + "key": "leaf", + "config": {"model": {"name": "claude-3"}, "instructions": "leaf"}, + "meta": {"variationKey": "v1", "version": 1}, + "edges": [], + "is_terminal": True, + }, + }, + edges=[{"source_key": "root", "target_key": "leaf"}], + ) + leaf_tool: list[Any] = [] + + def _tool_factory(name: str, desc: str, schema: Any) -> Any: + def _dec(fn: Any) -> Any: + if "leaf" in name: + leaf_tool.append(fn) + return fn + + return _dec + + mock_sdk = _make_sdk_mock("done") + mock_sdk.tool = MagicMock(side_effect=_tool_factory) + captured: list[dict[str, Any]] = [] + mock_sdk.ClaudeAgentOptions = MagicMock( + side_effect=lambda **kw: (captured.append(kw), kw)[1] + ) + result_msg = _make_result_msg("done") + + async def _query(**kwargs: Any) -> AsyncIterator[Any]: + if kwargs["options"].get("system_prompt") == "root": + await leaf_tool[0]({"input": "sub-query"}) + yield result_msg + + mock_sdk.query = _query + 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(graph_def), opts=host).invoke("hi") + by_node = {kw["system_prompt"]: kw for kw in captured} + assert sorted(by_node) == ["leaf", "root"] + return by_node["root"], by_node["leaf"] + @pytest.mark.asyncio async def test_sends_an_empty_tool_list_with_no_native_tools(self) -> None: opts = await self._root_options() @@ -527,9 +587,18 @@ async def test_sends_the_native_tool_names(self) -> None: assert opts["tools"] == ["Bash"] @pytest.mark.asyncio - async def test_loads_no_host_settings(self) -> None: - opts = await self._root_options() - assert opts["setting_sources"] == [] + async def test_loads_no_host_settings_and_sends_no_cwd_by_default(self) -> None: + for opts in await self._root_and_leaf_options(): + assert opts["setting_sources"] == [] + assert "cwd" not in opts + + @pytest.mark.asyncio + async def test_forwards_cwd_and_setting_sources_to_root_and_leaf(self) -> None: + for opts in await self._root_and_leaf_options( + cwd="/srv/app", setting_sources=["user"] + ): + assert opts["cwd"] == "/srv/app" + assert opts["setting_sources"] == ["user"] # ---------------------------------------------------------------------------