From 4037b12f305c1c75bfa5a7c7b5e9b091fe21c1e8 Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Wed, 21 Oct 2026 14:56:34 -0400 Subject: [PATCH 1/6] 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 951400751d1d236a33c6fa5ed57708826fa8a6be Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Thu, 22 Oct 2026 15:51:06 -0400 Subject: [PATCH 2/6] feat(skills): give Claude Agents runs the skills attached to their variation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A skill attached to an agent-mode variation never reached a model run through create_claude_agents_handler: tools=[] removes the Skill tool, and nothing hands the CLI the skill files. create_claude_agents_skills_handler(), from the new launchdarkly_ai_claude_agents.experimental.skills module, does. On every invoke and stream it: - writes skill_refs(config) with write_skills, through the store configured with set_skill_store, into mkdtemp(/ld-skills-)/launchdarkly/skills; - passes plugins=[{"type": "local", "path": /launchdarkly}] and skills=["launchdarkly:", ...], and appends Skill to tools (not to allowed_tools); - removes the directory when the run ends, however it ends. It fails closed: a reconcile that is not ok raises before query(), naming each skill. A variation with no skills runs exactly as with the core handler and needs no store. setting_sources is always sent, so claude-agent-sdk never falls back to user,project once skills is set. Core reaches this only through a private _prepare keyword on the internal factory. The package root does not import the experimental module. Spec: TESTING.md §2.x Claude Agents Skills Integration and A.18 (launchdarkly/ai-sdks-monorepo#59). Co-Authored-By: Claude Opus 5.5 --- packages/claude-agents/README.md | 40 + packages/claude-agents/agents.md | 15 + .../experimental/__init__.py | 7 + .../experimental/skills.py | 176 +++++ .../launchdarkly_ai_claude_agents/handler.py | 77 +- .../claude-agents/tests/test_sdk_usage.py | 8 + packages/claude-agents/tests/test_skills.py | 721 ++++++++++++++++++ 7 files changed, 1035 insertions(+), 9 deletions(-) create mode 100644 packages/claude-agents/src/launchdarkly_ai_claude_agents/experimental/__init__.py create mode 100644 packages/claude-agents/src/launchdarkly_ai_claude_agents/experimental/skills.py create mode 100644 packages/claude-agents/tests/test_skills.py diff --git a/packages/claude-agents/README.md b/packages/claude-agents/README.md index 813a7830..729cf943 100644 --- a/packages/claude-agents/README.md +++ b/packages/claude-agents/README.md @@ -113,6 +113,46 @@ async def main(): asyncio.run(main()) ``` +## Agent Skills (experimental) + +> **Experimental.** Imported from `launchdarkly_ai_claude_agents.experimental.skills`. The name and behavior may change in a minor release, and each change is listed under **Experimental** in the changelog. + +`create_claude_agents_handler()` doesn't give the model a variation's skills. `create_claude_agents_skills_handler()` does. It takes the same options, plus `skills_temp_dir`. + +```python +import os +from launchdarkly_ai_server import config +from launchdarkly_ai_server.experimental.skills import FDv2SkillStore, set_skill_store +from launchdarkly_ai_claude_agents.experimental.skills import ( + create_claude_agents_skills_handler, +) + +store = FDv2SkillStore(os.environ["LD_SDK_KEY"]).start() +store.wait_for_skills(timeout=10) +set_skill_store(store) + +triage = config( + key="triage-agent", + handler=create_claude_agents_skills_handler(setting_sources=[]), +) +``` + +On every invoke and stream it: + +1. Writes the variation's `skills` into a new directory under `skills_temp_dir` (default: `tempfile.gettempdir()`), through the store you configured with `set_skill_store`. Pinned versions are honored. +2. Hands that directory to the Claude Agent SDK as a local plugin named `launchdarkly`, enables only those skills (`launchdarkly:`), and turns on the `Skill` tool. +3. Removes the directory when the run ends, whether it succeeded, failed, or a stream was abandoned. + +Each run has its own directory, so concurrent runs and different variations never share files, and nothing needs to watch for changes. Detaching a skill from a variation takes effect on the next run. + +`setting_sources` behaves as on the core factory and is always sent, `[]` unless you give sources. Without it, `claude-agent-sdk` would load the `user` and `project` settings sources whenever skills are enabled. + +**A run whose skills can't be written doesn't start.** The handler raises before the model is called if no store is configured, the store hasn't received its first payload, a skill fails integrity verification, or a pinned version isn't in the store. The error names each skill. A variation with no skills runs exactly as it would with `create_claude_agents_handler()`, and needs no store. + +A `Skill` call shows up in traces as an `execute_tool Skill` span. + +**Not covered yet:** `claude_agents()`, `claude_graph()` and `to_claude_agents()` use the core handler, so a variation run through them doesn't get its skills. + ## How It Works - Uses the system prompt and conversation history defined in your LaunchDarkly flag config. diff --git a/packages/claude-agents/agents.md b/packages/claude-agents/agents.md index 05f209ed..4978cece 100644 --- a/packages/claude-agents/agents.md +++ b/packages/claude-agents/agents.md @@ -30,6 +30,7 @@ That means the LaunchDarkly flag variation must have `provider.name == "Anthropi | `src/launchdarkly_ai_claude_agents/graph.py` | `claude_graph()` convenience wrapper around `graph()` | | `src/launchdarkly_ai_claude_agents/native_graph.py` | `to_claude_agents()` native graph adapter | | `src/launchdarkly_ai_claude_agents/builtins.py` | Pre-constructed `NativeTool` sentinels for Claude built-in tools | +| `src/launchdarkly_ai_claude_agents/experimental/skills.py` | Experimental `create_claude_agents_skills_handler()`. Not imported by the package `__init__.py` | | `src/launchdarkly_ai_claude_agents/__init__.py` | Package exports | | `src/launchdarkly_ai_claude_agents/_version.py` | `PACKAGE_NAME` and `__version__`, sent with `$ld:ai:sdk:info` and `$ld:ai:sdk:usage` | @@ -165,6 +166,20 @@ On error: `span.record_exception(exc)`, status set to ERROR, span ended, error r id wins. An app that opens a fresh CLI session per turn and re-feeds history must pass its own conversation id, or each turn becomes its own conversation. +### 5. Agent Skills (experimental, `experimental/skills.py`) + +`create_claude_agents_skills_handler(...)` is importable only from `launchdarkly_ai_claude_agents.experimental.skills`, and the package root never imports it (a test checks this in a fresh interpreter). It reports `experimental.createClaudeAgentsSkillsHandler` and builds the handler through `_create_claude_agents_handler(..., _prepare=...)`. + +`_prepare` is a package-internal hook, a private keyword argument of the internal factory only, so core names nothing experimental. It runs at the start of every invoke and stream, inside the `invoke_agent` span, and returns `None` (the run is exactly the core handler's) or a `QueryPreparation`. That holds extra `query()` options, extra built-ins appended to `tools` (never to `allowed_tools`), and a `cleanup` that both paths await last in their `finally`. + +For a variation with skills, `_prepare_skills`: + +1. `tempfile.mkdtemp(prefix="ld-skills-", dir=skills_temp_dir)`, then `os.path.realpath`, then `os.mkdir(/launchdarkly)`. +2. `write_skills(skill_refs(config), /launchdarkly/skills, prune=False)`. A report that is not `ok` raises, naming each failed key, and `query()` is never called. +3. Returns `plugins=[{"type": "local", "path": /launchdarkly}]`, `skills=["launchdarkly:", ...]` and `extra_builtins=["Skill"]`. + +The plugin's name comes from its directory's base name, so no manifest is written. The skill names are qualified because a bare name would also match a host plugin's skill. `claude-agent-sdk`'s `SdkPluginConfig` has no `skipMcpDiscovery` key, so unlike TypeScript the plugin entry carries only `type` and `path`. The directory holds no `.mcp.json`, so there is nothing to discover. `setting_sources` is never `None` here, because the core handler always sends it. With `skills` set and `setting_sources=None`, `claude-agent-sdk` sends `--setting-sources=user,project`. The tool hooks are installed whenever `Skill` is in `tools`, so a `Skill` call gets an `execute_tool Skill` span. The directory is removed with a synchronous `shutil.rmtree`, so a cancellation during teardown cannot interrupt it. There's no shared root, no watcher and no prune: every run writes exactly its own variation's references, which is also how detaching a skill takes effect. + --- ## OTel Setup diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/experimental/__init__.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/experimental/__init__.py new file mode 100644 index 00000000..e6457fd8 --- /dev/null +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/experimental/__init__.py @@ -0,0 +1,7 @@ +""" +Experimental features of ``launchdarkly-ai-claude-agents``. + +Each feature is a submodule, for example ``launchdarkly_ai_claude_agents.experimental.skills``. +Names imported from here may change in a minor release, so review the changelog when you +upgrade. None of them is exported from the package root. +""" diff --git a/packages/claude-agents/src/launchdarkly_ai_claude_agents/experimental/skills.py b/packages/claude-agents/src/launchdarkly_ai_claude_agents/experimental/skills.py new file mode 100644 index 00000000..5dba59e7 --- /dev/null +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/experimental/skills.py @@ -0,0 +1,176 @@ +""" +Agent Skills for Claude Agents (experimental). + +``create_claude_agents_skills_handler`` builds a Claude Agents handler that gives each run the +skills attached to its variation. This name may change in a minor release, so review the +changelog when you upgrade. +""" + +from __future__ import annotations + +import logging +import os +import shutil +import tempfile +from collections.abc import Sequence + +from claude_agent_sdk import SettingSource + +from launchdarkly_ai_server import AiConfigRep, ProviderHandler, report_usage +from launchdarkly_ai_server.experimental.skills import ( + ReconcileReport, + SkillReference, + skill_refs, + write_skills, +) + +from .._version import PACKAGE_NAME, __version__ +from ..handler import QueryPreparation, _create_claude_agents_handler + +__all__ = ["create_claude_agents_skills_handler"] + +logger = logging.getLogger(__name__) + +PLUGIN_NAME = "launchdarkly" +"""The local plugin's directory name, and so its name: the CLI names a plugin with no manifest +after its directory, and lists its skills as ``launchdarkly:``.""" + +_RUN_DIR_PREFIX = "ld-skills-" +"""Prefix of each per-invoke directory under *skills_temp_dir*.""" + + +def _materialization_error( + report: ReconcileReport, refs: Sequence[SkillReference] +) -> RuntimeError: + """Why a run did not start, naming each skill that could not be written. + + Built from the report's action errors, which carry no skill content. A run-level error + carries an empty key, so the requested keys are named as well. + """ + failures = [ + f"{action.key}: {action.error}" if action.key else f"{action.error}" + for action in report.errors + ] + requested = ", ".join(ref.key for ref in refs) + return RuntimeError( + "Claude agent run not started: the skills its variation references could not be " + f"written (requested: {requested}). {'; '.join(failures)}" + ) + + +def _remove_run_directory(path: str) -> None: + """Removes one run's directory. A failure is logged, never raised over the run's own outcome. + + Synchronous on purpose: with no ``await`` in it, a cancellation arriving during teardown + cannot interrupt it and leave the directory behind. + """ + try: + shutil.rmtree(path) + except FileNotFoundError: + pass + except Exception as exc: + logger.warning( + "[LaunchDarkly] could not remove the Claude agent skills directory %s: %s", + path, + exc, + ) + + +async def _prepare_skills( + config: AiConfigRep, parent: str | os.PathLike[str] | None +) -> QueryPreparation | None: + """Writes the variation's skills into a directory private to this run, and returns the + options that hand it to ``query()`` as a local plugin. ``None`` when the variation has no + skills, so that run is exactly the core handler's. + + Fails closed: if any skill cannot be written, the run does not start. The variation names + that skill, so running without it would run a different agent from the one configured. + """ + refs = skill_refs(config) + if not refs: + return None + + run_dir = os.path.realpath(tempfile.mkdtemp(prefix=_RUN_DIR_PREFIX, dir=parent)) + try: + plugin_root = os.path.join(run_dir, PLUGIN_NAME) + # ``write_skills`` creates only the missing leaf of its root. + os.mkdir(plugin_root) + # A fresh directory: there is nothing to prune, and nothing else writes here. + report = await write_skills( + refs, os.path.join(plugin_root, "skills"), prune=False + ) + if not report.ok: + raise _materialization_error(report, refs) + except BaseException: + _remove_run_directory(run_dir) + raise + + async def _cleanup() -> None: + _remove_run_directory(run_dir) + + return QueryPreparation( + options={ + "plugins": [{"type": "local", "path": plugin_root}], + # Qualified: a bare name would also match a host plugin's skill of the same name. + "skills": [f"{PLUGIN_NAME}:{ref.key}" for ref in refs], + }, + extra_builtins=["Skill"], + cleanup=_cleanup, + ) + + +def create_claude_agents_skills_handler( + *, + capture_content: bool | None = None, + cwd: str | os.PathLike[str] | None = None, + setting_sources: list[SettingSource] | None = None, + skills_temp_dir: str | os.PathLike[str] | None = None, +) -> ProviderHandler: + """A Claude Agents handler that gives each run the skills attached to its variation. + + Takes the options of ``create_claude_agents_handler``, which behave the same way here, plus + *skills_temp_dir*: the parent of the directory each run writes its skills into, by default + ``tempfile.gettempdir()``. + + On every invoke and stream, the variation's ``skills`` are written with ``write_skills``, + through the store configured with ``set_skill_store``, into a new directory under + *skills_temp_dir*. That directory is passed to ``query()`` as a local plugin, only those + skills are enabled, the ``Skill`` tool is turned on, and the directory is removed when the + run ends. A variation with no skills runs exactly as with ``create_claude_agents_handler``. + + ``setting_sources`` is always sent, ``[]`` unless given. ``claude-agent-sdk`` would + otherwise load the ``user`` and ``project`` settings sources whenever ``skills`` is set. + + If any of the variation's skills cannot be written, because no store is configured, the + store has not received its first payload, a skill fails verification, or a pinned version + is absent, the run raises before the model is called. + + .. code-block:: python + + import os + + from launchdarkly_ai_server import config + from launchdarkly_ai_server.experimental.skills import FDv2SkillStore, set_skill_store + from launchdarkly_ai_claude_agents.experimental.skills import ( + create_claude_agents_skills_handler, + ) + + store = FDv2SkillStore(os.environ["LD_SDK_KEY"]).start() + store.wait_for_skills(timeout=10) + set_skill_store(store) + + triage = config(key="triage-agent", handler=create_claude_agents_skills_handler()) + """ + report_usage( + "experimental.createClaudeAgentsSkillsHandler", PACKAGE_NAME, __version__ + ) + + async def _prepare(config: AiConfigRep) -> QueryPreparation | None: + return await _prepare_skills(config, skills_temp_dir) + + return _create_claude_agents_handler( + capture_content=capture_content, + cwd=cwd, + setting_sources=setting_sources, + _prepare=_prepare, + ) 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 d52f9bde..a0b88fb7 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -17,7 +17,8 @@ import asyncio import json import os -from collections.abc import AsyncGenerator, AsyncIterator, Callable +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable +from dataclasses import dataclass from typing import Any from claude_agent_sdk import ( @@ -513,8 +514,14 @@ def _build_query_options( mcp_allowed_tools: list[str], tool_mcp: Any, hooks: dict[str, list[HookMatcher]] | None, + extra_builtins: list[str] | None = None, **extra: Any, ) -> ClaudeAgentOptions: + """*extra_builtins* are built-ins the handler itself turns on, after the config's native tools, + because a per-invoke preparation asked for them (``Skill``, for example). They go in ``tools`` + only, never ``allowed_tools``: a built-in the Agent SDK enables through its own option needs no + permission entry here. + """ all_allowed = [*mcp_allowed_tools, *native_tool_names] kwargs: dict[str, Any] = { "model": config["model"]["name"], @@ -526,7 +533,7 @@ def _build_query_options( # 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), + "tools": [*native_tool_names, *(extra_builtins or [])], **extra, } if system_prompt: @@ -539,6 +546,27 @@ def _build_query_options( # --------------------------------------------------------------------------- +@dataclass(frozen=True) +class QueryPreparation: + """What a per-invoke preparation adds to one run. + + Package-internal and generic: it names no feature, so code at a later stage can register + through it without this module importing or naming that code. + """ + + options: dict[str, Any] + """Extra ``query()`` options, set with the handler's own host options.""" + extra_builtins: list[str] + """Built-in tool names appended to the config's native tools.""" + cleanup: Callable[[], Awaitable[None]] + """Runs once when the run ends, however it ends. Must not raise.""" + + +PrepareQuery = Callable[[AiConfigRep], Awaitable[QueryPreparation | None]] +"""Called at the start of every invoke and stream, inside the run's span, so a raise fails the run +like any other error. ``None`` means nothing to add, and the run is exactly the core handler's.""" + + def _host_query_options( cwd: str | os.PathLike[str] | None, setting_sources: list[SettingSource] | None, @@ -592,8 +620,13 @@ def _create_claude_agents_handler( capture_content: bool | None = None, cwd: str | os.PathLike[str] | None = None, setting_sources: list[SettingSource] | None = None, + _prepare: PrepareQuery | None = None, ) -> ProviderHandler: - """Non-reporting :func:`create_claude_agents_handler`, used by this package's wrappers.""" + """Non-reporting :func:`create_claude_agents_handler`, used by this package's wrappers. + + *_prepare* is a package-internal hook, never an option of the public factory: see + :data:`PrepareQuery`. + """ 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) @@ -639,6 +672,7 @@ async def _call_impl( # already reported an authoritative run-level total and must not overwrite it. root_usage_written = False gen: AsyncIterator[Any] | None = None + prepared: QueryPreparation | None = None try: # Inside the guard, because serialising the prompt raises on anything that is not # JSON-serialisable and a raise out here would leave the root open: never ended, never @@ -650,6 +684,8 @@ async def _call_impl( messages=opening.messages, tool_definitions=catalog.current, ) + prepared = await _prepare(config) if _prepare is not None else None + extra_builtins = prepared.extra_builtins if prepared is not None else [] tool_mcp = ( await build_tool_mcp(user_config_tools, th) if user_config_tools @@ -657,7 +693,7 @@ async def _call_impl( ) mcp_allowed_tools = [MCP_TOOL_PREFIX + n for n in user_config_tools] hooks = None - if native_tool_names or mcp_allowed_tools: + if native_tool_names or mcp_allowed_tools or extra_builtins: # The blocking path cannot be abandoned, so it takes no abandon hook, but it can # still be cancelled, so it keeps cancel_open_spans. hooks, tool_telemetry, _, cancel_tool_spans = build_tool_hooks( @@ -671,7 +707,13 @@ async def _call_impl( mcp_allowed_tools, tool_mcp, hooks, - **host_options, + extra_builtins, + # Merged first, so a preparation's key replaces a host option's rather than + # colliding with it. + **{ + **host_options, + **(prepared.options if prepared is not None else {}), + }, ) output = "" @@ -766,6 +808,9 @@ async def _call_impl( # them. finish_root_span(open_root_span, config, inference.run_usage["total"]) end_unfinished_spans(open_root_span) + # Last, after the span tree is closed: the run is over however it ended. + if prepared is not None: + await prepared.cleanup() def _stream_impl( config: AiConfigRep, @@ -782,6 +827,7 @@ def _stream_impl( history, capture_content=effective_capture_content(content_override), host_options=host_options, + prepare=_prepare, ) return _create_handler( @@ -846,6 +892,7 @@ async def _stream_gen( *, capture_content: bool = False, host_options: dict[str, Any] | None = None, + prepare: PrepareQuery | None = None, ) -> AsyncGenerator[dict[str, Any], None]: """Streams the run, emitting the same span tree as the blocking path. @@ -883,6 +930,7 @@ async def _stream_gen( pending: list[dict[str, Any]] = [] block_ids: dict[int, str] = {} saw_args: set[str] = set() + prepared: QueryPreparation | None = None try: # Inside the guard, because serialising the prompt raises on anything that is not # JSON-serialisable. A raise out here would leave the root open with the `finally` never @@ -894,6 +942,8 @@ async def _stream_gen( messages=opening.messages, tool_definitions=catalog.current, ) + prepared = await prepare(config) if prepare is not None else None + extra_builtins = prepared.extra_builtins if prepared is not None else [] tool_mcp = ( await build_tool_mcp(user_config_tools, tool_handlers) if user_config_tools @@ -901,7 +951,7 @@ async def _stream_gen( ) mcp_allowed_tools = [MCP_TOOL_PREFIX + n for n in user_config_tools] hooks = None - if native_tool_names or mcp_allowed_tools: + if native_tool_names or mcp_allowed_tools or extra_builtins: hooks, tool_telemetry, abandon_tool_spans, _ = build_tool_hooks( native_tool_map, parent, @@ -917,7 +967,11 @@ async def _stream_gen( mcp_allowed_tools, tool_mcp, hooks, - **(host_options or _host_query_options(None, None)), + extra_builtins, + **{ + **(host_options or _host_query_options(None, None)), + **(prepared.options if prepared is not None else {}), + }, include_partial_messages=True, ) @@ -1052,8 +1106,13 @@ async def _stream_gen( end_span_once(span, ended, abandoned=True, cancelled=cancelled) # Same reasoning as the blocking path: a bare exit through this generator's boundary # abandons the vendor's own generator if it is not closed explicitly. - if gen is not None: - await gen.aclose() # type: ignore[attr-defined] + try: + if gen is not None: + await gen.aclose() # type: ignore[attr-defined] + finally: + # Last, after the span tree is closed: the run is over however it ended. + if prepared is not None: + await prepared.cleanup() def claude_agents( diff --git a/packages/claude-agents/tests/test_sdk_usage.py b/packages/claude-agents/tests/test_sdk_usage.py index fd576f0f..dff6c77c 100644 --- a/packages/claude-agents/tests/test_sdk_usage.py +++ b/packages/claude-agents/tests/test_sdk_usage.py @@ -15,6 +15,9 @@ ) from launchdarkly_ai_claude_agents._version import PACKAGE_NAME from launchdarkly_ai_claude_agents._version import __version__ as PACKAGE_VERSION +from launchdarkly_ai_claude_agents.experimental.skills import ( + create_claude_agents_skills_handler, +) from launchdarkly_ai_server import SDK_INFO_CONTEXT, __version__, init_client, shutdown CONTEXT = {"kind": "user", "key": "user-1"} @@ -55,6 +58,11 @@ async def client() -> Any: ("claude-agents.createClaudeAgentsHandler", create_claude_agents_handler), ("claude-agents.claudeGraph", lambda: claude_graph("k")), ("claude-agents.toClaudeAgents", lambda: to_claude_agents(None)), + # Its own ID, and not also the core factory's. + ( + "experimental.createClaudeAgentsSkillsHandler", + create_claude_agents_skills_handler, + ), ], ) async def test_helper_sends_one_usage_event( diff --git a/packages/claude-agents/tests/test_skills.py b/packages/claude-agents/tests/test_skills.py new file mode 100644 index 00000000..4ae69201 --- /dev/null +++ b/packages/claude-agents/tests/test_skills.py @@ -0,0 +1,721 @@ +""" +Tests for TESTING.md §2.x Claude Agents Skills Integration (A.18). + +``query()`` is replaced with a fake async generator that records the options it was given and can +look at the filesystem while the run is in progress. The skill store is a real +``InMemorySkillStore``, the filesystem is a per-test ``tmp_path`` passed as ``skills_temp_dir``, +and spans go to a real ``TracerProvider``. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import os +import subprocess +import sys +from collections.abc import AsyncIterator, Awaitable, Callable +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from claude_agent_sdk import ResultMessage, StreamEvent +from opentelemetry import trace +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +import launchdarkly_ai_claude_agents.handler as handler_mod +import launchdarkly_ai_server.lifecycle as lifecycle_module +from launchdarkly_ai_claude_agents import ClaudeRead, create_claude_agents_handler +from launchdarkly_ai_claude_agents.experimental.skills import ( + create_claude_agents_skills_handler, +) +from launchdarkly_ai_server import NATIVE_TOOL_KEY, config, init_client, shutdown +from launchdarkly_ai_server.experimental.skills import ( + InMemorySkillStore, + set_skill_store, +) + +# --------------------------------------------------------------------------- +# Spans: share whichever SDK provider is installed, or install one +# --------------------------------------------------------------------------- + +_exporter = InMemorySpanExporter() +_provider = trace.get_tracer_provider() +if not isinstance(_provider, TracerProvider): + _provider = TracerProvider() + trace.set_tracer_provider(_provider) +_provider.add_span_processor(SimpleSpanProcessor(_exporter)) + + +@pytest.fixture(autouse=True) +def _reset() -> Any: + """Clears the exporter and the configured skill store around each test.""" + _exporter.clear() + lifecycle_module._reset_for_testing() + yield + lifecycle_module._reset_for_testing() + + +_PATHS = ["invoke", "stream"] + +SKILL_BODY = "---\nname: {key}\n---\nVersion {version} of {key}.\n" + +BASE_CONFIG: dict[str, Any] = { + "model": {"name": "claude-opus-4-5"}, + "provider": {"name": "Anthropic"}, + "instructions": "You are helpful.", +} + + +def _config(*refs: tuple[str, int], **extra: Any) -> dict[str, Any]: + return { + **BASE_CONFIG, + "skills": [{"key": key, "version": version} for key, version in refs], + **extra, + } + + +def _body(key: str, version: int) -> str: + return SKILL_BODY.format(key=key, version=version) + + +def _raw(key: str, version: int, *, content_hash: str | None = None) -> dict[str, Any]: + body = _body(key, version) + return { + "key": key, + "version": version, + "content": body, + "contentHash": content_hash or hashlib.sha256(body.encode()).hexdigest(), + } + + +def _store(*objects: dict[str, Any]) -> InMemorySkillStore: + store = InMemorySkillStore() + for raw in objects: + store.put(raw) + set_skill_store(store) + return store + + +def _result(subtype: str = "success") -> ResultMessage: + return ResultMessage( + subtype=subtype, + duration_ms=1, + duration_api_ms=1, + is_error=subtype != "success", + num_turns=1, + session_id="sess-1", + usage={"input_tokens": 3, "output_tokens": 2}, + result="done", + ) + + +def _text_event(text: str) -> StreamEvent: + return StreamEvent( + uuid="u1", + session_id="sess-1", + event={ + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": text}, + }, + ) + + +During = Callable[[dict[str, Any]], Awaitable[None]] + + +class _FakeQuery: + """Stands in for ``query()``: records each call's options, runs *during* while the run is in + progress, then replays *messages*.""" + + def __init__( + self, messages: list[Any] | None = None, during: During | None = None + ) -> None: + self.calls: list[dict[str, Any]] = [] + self.messages = messages if messages is not None else [_result()] + self.during = during + + @property + def options(self) -> Any: + assert len(self.calls) == 1 + return self.calls[0]["options"] + + async def __call__(self, **kwargs: Any) -> AsyncIterator[Any]: + self.calls.append(kwargs) + if self.during is not None: + await self.during(kwargs) + for message in self.messages: + yield message + + +async def _run( + handler: Any, + path: str, + cfg: dict[str, Any], + tool_handlers: dict[str, Any] | None = None, + user_input: str = "q", +) -> Any: + if path == "invoke": + return await handler(cfg, user_input, tool_handlers or {}) + events = [] + async for event in await handler.stream(cfg, user_input, tool_handlers or {}): + events.append(event) + return events + + +def _plugin_root(options: Any) -> Path: + return Path(options.plugins[0]["path"]) + + +def _run_dir(options: Any) -> Path: + return _plugin_root(options).parent + + +def _read_stub() -> MagicMock: + stub = MagicMock() + setattr(stub, NATIVE_TOOL_KEY, ClaudeRead) + return stub + + +# --------------------------------------------------------------------------- +# Routing, options and the no-skills path +# --------------------------------------------------------------------------- + + +class TestRoutingAndOptions: + def test_routes_exactly_as_the_core_handler_does(self) -> None: + assert create_claude_agents_skills_handler().provides_for == ( + create_claude_agents_handler().provides_for + ) + assert tuple(create_claude_agents_skills_handler().provides_for) == ( + "Anthropic", + "agent", + ) + + def test_is_absent_from_the_package_root(self) -> None: + """A fresh interpreter, so this module's own import of the entry point doesn't count.""" + probe = ( + "import sys, launchdarkly_ai_claude_agents as p;" + "assert 'launchdarkly_ai_claude_agents.experimental' not in sys.modules;" + "assert not hasattr(p, 'create_claude_agents_skills_handler');" + "assert 'create_claude_agents_skills_handler' not in p.__all__" + ) + subprocess.run([sys.executable, "-c", probe], check=True) + + @pytest.mark.parametrize("path", _PATHS) + async def test_forwards_the_host_options( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, path: str + ) -> None: + _store(_raw("a", 1)) + fake = _FakeQuery() + monkeypatch.setattr(handler_mod, "query", fake) + handler = create_claude_agents_skills_handler( + cwd="/srv/app", setting_sources=["project"], skills_temp_dir=tmp_path + ) + await _run(handler, path, _config(("a", 1))) + assert fake.options.cwd == "/srv/app" + assert fake.options.setting_sources == ["project"] + + @pytest.mark.parametrize("path", _PATHS) + async def test_sends_an_explicit_empty_setting_sources_with_skills( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, path: str + ) -> None: + """``claude-agent-sdk`` sends ``--setting-sources=user,project`` when ``skills`` is set and + ``setting_sources`` is ``None``, so the handler must never leave it ``None``.""" + _store(_raw("a", 1)) + fake = _FakeQuery() + monkeypatch.setattr(handler_mod, "query", fake) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + await _run(handler, path, _config(("a", 1))) + assert fake.options.skills == ["launchdarkly:a"] + assert fake.options.setting_sources == [] + + @pytest.mark.parametrize("path", _PATHS) + @pytest.mark.parametrize("skills", [None, []]) + async def test_a_config_with_no_skills_runs_as_the_core_handler_would( + self, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + path: str, + skills: list[Any] | None, + ) -> None: + """No store is configured: a variation with no skills needs none.""" + cfg = dict(BASE_CONFIG) if skills is None else {**BASE_CONFIG, "skills": skills} + core, experimental = _FakeQuery(), _FakeQuery() + monkeypatch.setattr(handler_mod, "query", core) + await _run(create_claude_agents_handler(cwd="/srv/app"), path, cfg) + monkeypatch.setattr(handler_mod, "query", experimental) + await _run( + create_claude_agents_skills_handler( + cwd="/srv/app", skills_temp_dir=tmp_path + ), + path, + cfg, + ) + assert experimental.options == core.options + assert experimental.options.plugins == [] + assert experimental.options.skills is None + assert "Skill" not in experimental.options.tools + assert list(tmp_path.iterdir()) == [] + + +# --------------------------------------------------------------------------- +# Materialization: one private directory per invoke +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("path", _PATHS) +class TestMaterialization: + async def test_writes_each_skill_into_a_private_plugin_directory( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, path: str + ) -> None: + _store(_raw("a", 1), _raw("b", 1)) + seen: dict[str, Any] = {} + + async def during(kwargs: dict[str, Any]) -> None: + plugin_root = _plugin_root(kwargs["options"]) + seen["run_dir"] = plugin_root.parent + seen["a"] = (plugin_root / "skills" / "a" / "SKILL.md").read_text() + seen["b"] = (plugin_root / "skills" / "b" / "SKILL.md").read_text() + + fake = _FakeQuery(during=during) + monkeypatch.setattr(handler_mod, "query", fake) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + await _run(handler, path, _config(("a", 1), ("b", 1))) + + run_dir = seen["run_dir"] + assert run_dir.parent == Path(os.path.realpath(tmp_path)) + assert run_dir.name.startswith("ld-skills-") + assert str(run_dir) == os.path.realpath(run_dir) + assert _plugin_root(fake.options).name == "launchdarkly" + assert seen["a"] == _body("a", 1) + assert seen["b"] == _body("b", 1) + + async def test_honors_a_pinned_version( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, path: str + ) -> None: + _store(_raw("a", 1), _raw("a", 2), _raw("a", 3)) + seen: list[str] = [] + + async def during(kwargs: dict[str, Any]) -> None: + skill = _plugin_root(kwargs["options"]) / "skills" / "a" / "SKILL.md" + seen.append(skill.read_text()) + + monkeypatch.setattr(handler_mod, "query", _FakeQuery(during=during)) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + await _run(handler, path, _config(("a", 2))) + assert seen == [_body("a", 2)] + + async def test_removes_the_directory_after_a_successful_run( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, path: str + ) -> None: + _store(_raw("a", 1)) + fake = _FakeQuery() + monkeypatch.setattr(handler_mod, "query", fake) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + await _run(handler, path, _config(("a", 1))) + assert not _run_dir(fake.options).exists() + assert list(tmp_path.iterdir()) == [] + + async def test_removes_the_directory_when_query_raises( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, path: str + ) -> None: + _store(_raw("a", 1)) + + async def during(_kwargs: dict[str, Any]) -> None: + raise RuntimeError("the CLI went away") + + monkeypatch.setattr(handler_mod, "query", _FakeQuery(during=during)) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + with pytest.raises(RuntimeError, match="the CLI went away"): + await _run(handler, path, _config(("a", 1))) + assert list(tmp_path.iterdir()) == [] + + async def test_removes_the_directory_after_a_non_success_result( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, path: str + ) -> None: + _store(_raw("a", 1)) + monkeypatch.setattr( + handler_mod, "query", _FakeQuery([_result("error_max_turns")]) + ) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + with pytest.raises(RuntimeError, match="error_max_turns"): + await _run(handler, path, _config(("a", 1))) + assert list(tmp_path.iterdir()) == [] + + async def test_a_new_invoke_does_not_write_a_detached_skill( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, path: str + ) -> None: + """Detaching a skill is a config change: the next invoke's ``skill_refs`` no longer names + it, so it is not written.""" + _store(_raw("a", 1), _raw("b", 1)) + held: list[list[str]] = [] + + async def during(kwargs: dict[str, Any]) -> None: + skills_dir = _plugin_root(kwargs["options"]) / "skills" + held.append(sorted(p.name for p in skills_dir.iterdir() if p.is_dir())) + + monkeypatch.setattr(handler_mod, "query", _FakeQuery(during=during)) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + await _run(handler, path, _config(("a", 1), ("b", 1))) + await _run(handler, path, _config(("a", 1))) + assert held == [["a", "b"], ["a"]] + + +async def test_removes_the_directory_when_a_stream_consumer_stops_early( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _store(_raw("a", 1)) + fake = _FakeQuery([_text_event("hel"), _text_event("lo"), _result()]) + monkeypatch.setattr(handler_mod, "query", fake) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + gen = await handler.stream(_config(("a", 1)), "q", {}) + async for _event in gen: + assert _run_dir(fake.options).exists() + break + await gen.aclose() + assert list(tmp_path.iterdir()) == [] + + +async def test_concurrent_invokes_do_not_share_a_directory( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _store(_raw("a", 1)) + started = {"first": asyncio.Event(), "second": asyncio.Event()} + release = {"first": asyncio.Event(), "second": asyncio.Event()} + dirs: dict[str, Path] = {} + + async def during(kwargs: dict[str, Any]) -> None: + name = kwargs["prompt"] + dirs[name] = _run_dir(kwargs["options"]) + started[name].set() + await release[name].wait() + + monkeypatch.setattr(handler_mod, "query", _FakeQuery(during=during)) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + cfg = _config(("a", 1)) + first = asyncio.create_task(handler(cfg, "first", {})) + second = asyncio.create_task(handler(cfg, "second", {})) + await started["first"].wait() + await started["second"].wait() + + assert dirs["first"] != dirs["second"] + assert dirs["first"].exists() and dirs["second"].exists() + + release["first"].set() + await first + assert not dirs["first"].exists() + assert (dirs["second"] / "launchdarkly" / "skills" / "a" / "SKILL.md").exists() + + release["second"].set() + await second + assert list(tmp_path.iterdir()) == [] + + +async def test_a_failed_removal_is_logged_and_does_not_replace_the_outcome( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + import launchdarkly_ai_claude_agents.experimental.skills as skills_mod + + _store(_raw("a", 1)) + monkeypatch.setattr(handler_mod, "query", _FakeQuery()) + + def refuse(_path: str) -> None: + raise PermissionError("read-only") + + monkeypatch.setattr(skills_mod.shutil, "rmtree", refuse) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + result = await handler(_config(("a", 1)), "q", {}) + assert result["output"] == "done" + assert any("could not remove" in record.message for record in caplog.records) + + +# --------------------------------------------------------------------------- +# Fail closed +# --------------------------------------------------------------------------- + + +class _NotYetInitialized(InMemorySkillStore): + def is_initialized(self) -> bool: + return False + + +def _no_store() -> None: + pass + + +def _uninitialized_store() -> None: + store = _NotYetInitialized() + store.put(_raw("a", 1)) + set_skill_store(store) + + +def _withheld_by_verification() -> None: + _store(_raw("a", 1, content_hash="0" * 64)) + + +def _pin_not_held() -> None: + _store(_raw("a", 1), _raw("a", 3)) + + +@pytest.mark.parametrize("path", _PATHS) +@pytest.mark.parametrize( + "arrange", + [_no_store, _uninitialized_store, _withheld_by_verification, _pin_not_held], + ids=["no store", "not initialized", "withheld", "absent pin"], +) +async def test_a_reconcile_that_is_not_ok_fails_before_query( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + path: str, + arrange: Callable[[], None], +) -> None: + arrange() + fake = _FakeQuery() + monkeypatch.setattr(handler_mod, "query", fake) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + with pytest.raises(RuntimeError, match="not started") as excinfo: + await _run(handler, path, _config(("a", 2))) + assert fake.calls == [] + # Each failed key, with its action's error text. + assert "a: skill " in str(excinfo.value) + # The directory is removed on this path too. + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize("path", _PATHS) +async def test_one_failed_skill_stops_the_run_and_is_named( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, path: str +) -> None: + _store(_raw("good", 1), _raw("bad", 1, content_hash="0" * 64)) + fake = _FakeQuery() + monkeypatch.setattr(handler_mod, "query", fake) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + with pytest.raises(RuntimeError) as excinfo: + await _run(handler, path, _config(("good", 1), ("bad", 1))) + message = str(excinfo.value) + # The run does not start with the skill that could be written. + assert fake.calls == [] + assert "bad: skill 'bad' failed integrity verification" in message + assert "good: " not in message + # No skill content, and no path. + assert _body("good", 1) not in message + assert _body("bad", 1) not in message + assert str(tmp_path) not in message + assert list(tmp_path.iterdir()) == [] + + +# --------------------------------------------------------------------------- +# What query() receives +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("path", _PATHS) +class TestQueryOptions: + async def test_one_local_plugin_with_no_manifest( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, path: str + ) -> None: + _store(_raw("a", 1)) + manifests: list[bool] = [] + + async def during(kwargs: dict[str, Any]) -> None: + root = _plugin_root(kwargs["options"]) + manifests.append((root / ".claude-plugin").exists()) + + fake = _FakeQuery(during=during) + monkeypatch.setattr(handler_mod, "query", fake) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + await _run(handler, path, _config(("a", 1))) + root = _plugin_root(fake.options) + assert fake.options.plugins == [{"type": "local", "path": str(root)}] + assert root.name == "launchdarkly" + assert manifests == [False] + + async def test_enables_only_the_variation_skills_by_qualified_name( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, path: str + ) -> None: + _store(_raw("b", 1), _raw("a", 1), _raw("unattached", 1)) + fake = _FakeQuery() + monkeypatch.setattr(handler_mod, "query", fake) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + await _run(handler, path, _config(("b", 1), ("a", 1))) + assert fake.options.skills == ["launchdarkly:b", "launchdarkly:a"] + + async def test_skill_joins_the_builtin_set_once_last( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, path: str + ) -> None: + _store(_raw("a", 1)) + fake = _FakeQuery() + monkeypatch.setattr(handler_mod, "query", fake) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + await _run(handler, path, _config(("a", 1))) + assert fake.options.tools == ["Skill"] + assert "Skill" not in fake.options.allowed_tools + + with_read = _FakeQuery() + monkeypatch.setattr(handler_mod, "query", with_read) + await _run(handler, path, _config(("a", 1)), {"read": _read_stub()}) + assert with_read.options.tools == ["Read", "Skill"] + assert "Skill" not in with_read.options.allowed_tools + + async def test_no_config_parameter_reaches_the_handler_owned_keys( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, path: str + ) -> None: + _store(_raw("a", 1)) + cfg = _config( + ("a", 1), + model={ + "name": "claude-opus-4-5", + "parameters": { + "plugins": [{"type": "local", "path": "/from-config"}], + "skills": "all", + "tools": ["Bash"], + "setting_sources": ["user"], + }, + }, + ) + fake = _FakeQuery() + monkeypatch.setattr(handler_mod, "query", fake) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + await _run(handler, path, cfg) + assert fake.options.plugins == [ + {"type": "local", "path": str(_plugin_root(fake.options))} + ] + assert fake.options.skills == ["launchdarkly:a"] + assert fake.options.tools == ["Skill"] + assert fake.options.setting_sources == [] + + async def test_a_skill_call_gets_an_execute_tool_span( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, path: str + ) -> None: + """The hooks are installed whenever ``Skill`` is in ``tools``, even with no other tool.""" + _store(_raw("a", 1)) + + async def during(kwargs: dict[str, Any]) -> None: + hooks = kwargs["options"].hooks + event = { + "tool_name": "Skill", + "tool_use_id": "tu-skill", + "tool_input": {"skill": "launchdarkly:a"}, + "session_id": "sess-1", + } + await hooks["PreToolUse"][0].hooks[0]( + {**event, "hook_event_name": "PreToolUse"}, "tu-skill", None + ) + await hooks["PostToolUse"][0].hooks[0]( + {**event, "hook_event_name": "PostToolUse", "tool_response": "ok"}, + "tu-skill", + None, + ) + + monkeypatch.setattr(handler_mod, "query", _FakeQuery(during=during)) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + await _run(handler, path, _config(("a", 1))) + + finished = list(_exporter.get_finished_spans()) + root = next(s for s in finished if s.name == "invoke_agent") + skill = next(s for s in finished if s.name == "execute_tool Skill") + assert skill.attributes["gen_ai.tool.name"] == "Skill" + assert skill.parent is not None + assert skill.parent.span_id == root.context.span_id + + +# --------------------------------------------------------------------------- +# Through config(): the normal tracking path +# --------------------------------------------------------------------------- + +CONTEXT = {"kind": "user", "key": "user-1"} + + +@pytest.fixture +async def ld_client() -> Any: + await shutdown() + client = MagicMock() + client.track = MagicMock() + client.flush = MagicMock() + client.close = MagicMock() + with patch.object(lifecycle_module, "_setup_telemetry", return_value=None): + await init_client(client=client) + yield client + await shutdown() + + +def _variation(client: MagicMock, *refs: tuple[str, int]) -> None: + async def variation(*_args: Any) -> dict[str, Any]: + return { + **_config(*refs), + "_ldMeta": { + "enabled": True, + "variationKey": "v1", + "version": 1, + "mode": "agent", + }, + } + + client.variation = variation + + +def _tracked(client: MagicMock) -> list[str]: + return [entry.args[0] for entry in client.track.call_args_list] + + +async def test_a_fail_closed_throw_is_tracked_as_a_generation_error( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ld_client: MagicMock +) -> None: + _variation(ld_client, ("a", 1)) + fake = _FakeQuery() + monkeypatch.setattr(handler_mod, "query", fake) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + with pytest.raises(RuntimeError, match="not started"): + await config(key="triage", handler=handler).invoke("q", CONTEXT) + assert fake.calls == [] + assert "$ld:ai:generation:error" in _tracked(ld_client) + + +async def test_a_skill_call_reports_no_tool_call_event( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ld_client: MagicMock +) -> None: + """``Skill`` is not an AI Config tool, so its call gets a span and no ``$ld:ai:tool_call``.""" + _variation(ld_client, ("a", 1)) + _store(_raw("a", 1)) + + async def during(kwargs: dict[str, Any]) -> None: + hooks = kwargs["options"].hooks + await hooks["PreToolUse"][0].hooks[0]( + { + "hook_event_name": "PreToolUse", + "tool_name": "Skill", + "tool_use_id": "tu-skill", + "tool_input": {"skill": "launchdarkly:a"}, + }, + "tu-skill", + None, + ) + + monkeypatch.setattr(handler_mod, "query", _FakeQuery(during=during)) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + await config(key="triage", handler=handler).invoke("q", CONTEXT) + tracked = _tracked(ld_client) + assert "$ld:ai:generation:success" in tracked + assert "$ld:ai:tool_call" not in tracked + + +# --------------------------------------------------------------------------- +# Not covered: the core handler never materializes skills +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("path", _PATHS) +async def test_the_core_handler_ignores_a_variation_skills( + monkeypatch: pytest.MonkeyPatch, path: str +) -> None: + _store(_raw("a", 1)) + fake = _FakeQuery() + monkeypatch.setattr(handler_mod, "query", fake) + await _run(create_claude_agents_handler(), path, _config(("a", 1))) + assert fake.options.plugins == [] + assert fake.options.skills is None + assert fake.options.tools == [] From bcbde98f9224bab92910b3eb9887868dbf28be33 Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Fri, 23 Oct 2026 14:07:37 -0400 Subject: [PATCH 3/6] 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"] # --------------------------------------------------------------------------- From da4fbca2fb95bf58a2f757efae1a6132545df4dd Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Fri, 23 Oct 2026 14:08:29 -0400 Subject: [PATCH 4/6] chore(skills): add the API report for launchdarkly_ai_claude_agents.experimental.skills Co-Authored-By: Claude Opus 5.5 --- .../launchdarkly_ai_claude_agents.experimental.skills.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 packages/claude-agents/api-report/launchdarkly_ai_claude_agents.experimental.skills.txt diff --git a/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.experimental.skills.txt b/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.experimental.skills.txt new file mode 100644 index 00000000..76e498e4 --- /dev/null +++ b/packages/claude-agents/api-report/launchdarkly_ai_claude_agents.experimental.skills.txt @@ -0,0 +1,2 @@ +# Public API of launchdarkly_ai_claude_agents.experimental.skills. Generated by `make api-report`; do not edit. +create_claude_agents_skills_handler(*, capture_content: bool | None = None, cwd: str | os.PathLike[str] | None = None, setting_sources: list[SettingSource] | None = None, skills_temp_dir: str | os.PathLike[str] | None = None) -> ProviderHandler From 85d37ee480357f5b8f4d4adc85a5e23e14c8ca1b Mon Sep 17 00:00:00 2001 From: Jeff Dupont Date: Fri, 23 Oct 2026 12:15:35 -0700 Subject: [PATCH 5/6] fix(claude-agents): remove the skills temp dir even when closing the spans raises The blocking path ran cleanup after finish_root_span and end_unfinished_spans in the same finally, so a raise from either leaked the run's plugin directory. Cleanup now has its own finally. Lists the experimental skills module in FORWARDED_LISTS: it delegates to the core handler, which forwards the parameters. Co-Authored-By: Claude Opus 5.5 --- .../launchdarkly_ai_claude_agents/handler.py | 35 +++++++++++-------- packages/claude-agents/tests/test_skills.py | 16 +++++++++ tests/test_never_forwarded_parameters.py | 2 ++ 3 files changed, 38 insertions(+), 15 deletions(-) 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 bd66dfad..ae460605 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -940,21 +940,26 @@ async def _call_impl( # the root is the only span carrying the feature_flag event and the launchdarkly.* # attributes, so the whole run would vanish from AI Config Monitoring rather than show # as incomplete. - inference.finish() - if cancel_tool_spans is not None: - cancel_tool_spans() - if ( - open_root_span is not None - and not root_usage_written - and inference.run_usage["reported"] - ): - # The turns that completed were billed, the same reason the failure path reports - # them. - finish_root_span(open_root_span, config, inference.run_usage["total"]) - end_unfinished_spans(open_root_span) - # Last, after the span tree is closed: the run is over however it ended. - if prepared is not None: - await prepared.cleanup() + try: + inference.finish() + if cancel_tool_spans is not None: + cancel_tool_spans() + if ( + open_root_span is not None + and not root_usage_written + and inference.run_usage["reported"] + ): + # The turns that completed were billed, the same reason the failure path + # reports them. + finish_root_span( + open_root_span, config, inference.run_usage["total"] + ) + end_unfinished_spans(open_root_span) + finally: + # Last, after the span tree is closed, and in its own finally so a raise above + # still removes the preparation's temp dir. + if prepared is not None: + await prepared.cleanup() def _stream_impl( config: AiConfigRep, diff --git a/packages/claude-agents/tests/test_skills.py b/packages/claude-agents/tests/test_skills.py index 4ae69201..8f92473d 100644 --- a/packages/claude-agents/tests/test_skills.py +++ b/packages/claude-agents/tests/test_skills.py @@ -365,6 +365,22 @@ async def during(kwargs: dict[str, Any]) -> None: assert held == [["a", "b"], ["a"]] +async def test_removes_the_directory_when_closing_the_blocking_spans_raises( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _store(_raw("a", 1)) + monkeypatch.setattr(handler_mod, "query", _FakeQuery()) + + def boom(*_spans: Any) -> None: + raise RuntimeError("span cleanup failed") + + monkeypatch.setattr(handler_mod, "end_unfinished_spans", boom) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + with pytest.raises(RuntimeError, match="span cleanup failed"): + await _run(handler, "invoke", _config(("a", 1))) + assert list(tmp_path.iterdir()) == [] + + async def test_removes_the_directory_when_a_stream_consumer_stops_early( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/tests/test_never_forwarded_parameters.py b/tests/test_never_forwarded_parameters.py index df3d6297..d91ae208 100644 --- a/tests/test_never_forwarded_parameters.py +++ b/tests/test_never_forwarded_parameters.py @@ -29,6 +29,8 @@ #: checked against ``discover_handler_modules`` so a new handler module must be added here. FORWARDED_LISTS: dict[str, set[str]] = { "launchdarkly_ai_claude_agents.handler": {"_CLAUDE_AGENT_OPTIONS_FORWARDED_KEYS"}, + # Delegates to the core handler above, which forwards the parameters. + "launchdarkly_ai_claude_agents.experimental.skills": set(), "launchdarkly_ai_claude_messages.handler": {"_MESSAGES_FORWARDED_KEYS"}, "launchdarkly_ai_openai_agents.handler": {"_MODEL_SETTINGS_FORWARDED_KEYS"}, "launchdarkly_ai_openai_messages.handler": {"_RESPONSES_FORWARDED_KEYS"}, From ac743e05bd4c9833534e9794263543470e4e0776 Mon Sep 17 00:00:00 2001 From: Christie Williams Date: Fri, 23 Oct 2026 15:30:05 -0400 Subject: [PATCH 6/6] fix(claude-agents): remove the skills temp dir even when closing the stream spans raises The stream path closed the vendor generator and ran the preparation's cleanup after inference.finish(), abandon_tool_spans and end_span_once in the same finally, so a raise from any of them skipped both and leaked the run's plugin directory. They now run in their own finally, as on the blocking path. Co-Authored-By: Claude Opus 5.5 --- .../launchdarkly_ai_claude_agents/handler.py | 48 ++++++++++--------- packages/claude-agents/tests/test_skills.py | 20 ++++++++ 2 files changed, 46 insertions(+), 22 deletions(-) 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 ae460605..7dd92792 100644 --- a/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py +++ b/packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py @@ -1238,30 +1238,34 @@ async def _stream_gen( yield rich raise finally: - # A no-op on the success path, where the result message already closed the last response. - # On the error and abandonment paths this is the only thing that ends a chat span still - # open, and the exporter only receives ended spans. - inference.finish() - # A no-op on the success and failure paths; on abandonment it is the only chance to close - # the tree, including any tool span whose PostToolUse hook never fired, and the only chance - # to report what the responses that did arrive cost. - if id(span) not in ended: - # Abandonment, not failure: UNSET plus the abandoned marker, the same as the model - # span and the root get just below. - if abandon_tool_spans is not None: - abandon_tool_spans(ended, cancelled=cancelled) - if not root_usage_written and inference.run_usage["reported"]: - finish_root_span(span, config, inference.run_usage["total"]) - end_span_once(span, ended, abandoned=True, cancelled=cancelled) - # Same reasoning as the blocking path: a bare exit through this generator's boundary - # abandons the vendor's own generator if it is not closed explicitly. try: - if gen is not None: - await gen.aclose() # type: ignore[attr-defined] + # A no-op on the success path, where the result message already closed the last + # response. On the error and abandonment paths this is the only thing that ends a chat + # span still open, and the exporter only receives ended spans. + inference.finish() + # A no-op on the success and failure paths; on abandonment it is the only chance to + # close the tree, including any tool span whose PostToolUse hook never fired, and the + # only chance to report what the responses that did arrive cost. + if id(span) not in ended: + # Abandonment, not failure: UNSET plus the abandoned marker, the same as the model + # span and the root get just below. + if abandon_tool_spans is not None: + abandon_tool_spans(ended, cancelled=cancelled) + if not root_usage_written and inference.run_usage["reported"]: + finish_root_span(span, config, inference.run_usage["total"]) + end_span_once(span, ended, abandoned=True, cancelled=cancelled) finally: - # Last, after the span tree is closed: the run is over however it ended. - if prepared is not None: - await prepared.cleanup() + # In their own finally, so a raise closing the spans still closes the vendor's + # generator and removes the preparation's temp dir. Same reasoning as the blocking + # path: a bare exit through this generator's boundary abandons the vendor's own + # generator if it is not closed explicitly. + try: + if gen is not None: + await gen.aclose() # type: ignore[attr-defined] + finally: + # Last, after the span tree is closed: the run is over however it ended. + if prepared is not None: + await prepared.cleanup() def claude_agents( diff --git a/packages/claude-agents/tests/test_skills.py b/packages/claude-agents/tests/test_skills.py index 8f92473d..2c88a035 100644 --- a/packages/claude-agents/tests/test_skills.py +++ b/packages/claude-agents/tests/test_skills.py @@ -381,6 +381,26 @@ def boom(*_spans: Any) -> None: assert list(tmp_path.iterdir()) == [] +async def test_removes_the_directory_when_closing_the_stream_spans_raises( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + _store(_raw("a", 1)) + monkeypatch.setattr(handler_mod, "query", _FakeQuery()) + end_span_once = handler_mod.end_span_once + + def boom(span: Any, ended: set[int], **kwargs: Any) -> None: + # Only the teardown call: the success path ends the root without `abandoned`. + if kwargs.get("abandoned"): + raise RuntimeError("span cleanup failed") + end_span_once(span, ended, **kwargs) + + monkeypatch.setattr(handler_mod, "end_span_once", boom) + handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path) + with pytest.raises(RuntimeError, match="span cleanup failed"): + await _run(handler, "stream", _config(("a", 1))) + assert list(tmp_path.iterdir()) == [] + + async def test_removes_the_directory_when_a_stream_consumer_stops_early( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: