Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions packages/claude-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,46 @@ 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. `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:
>
> ```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.

#### 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

```python
Expand Down Expand Up @@ -100,6 +140,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 |
Expand Down
26 changes: 20 additions & 6 deletions packages/claude-agents/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,21 @@ 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: ...
# 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
Expand Down Expand Up @@ -99,6 +105,14 @@ 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. `_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

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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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
18 changes: 15 additions & 3 deletions packages/claude-agents/src/launchdarkly_ai_claude_agents/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
78 changes: 67 additions & 11 deletions packages/claude-agents/src/launchdarkly_ai_claude_agents/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@

import asyncio
import json
import os
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping
from typing import Any

from claude_agent_sdk import (
ClaudeAgentOptions,
HookMatcher,
ResultMessage,
SettingSource,
StreamEvent,
ToolResultBlock,
UserMessage,
Expand Down Expand Up @@ -663,14 +665,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)
Expand All @@ -681,25 +683,64 @@ 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.

Set *capture_content* to put prompts, model output, tool arguments and tool results on the
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,
Expand Down Expand Up @@ -774,6 +815,7 @@ async def _call_impl(
mcp_allowed_tools,
tool_mcp,
hooks,
**host_options,
)

output = ""
Expand Down Expand Up @@ -883,6 +925,7 @@ def _stream_impl(
variables or {},
history,
capture_content=effective_capture_content(content_override),
host_options=host_options,
)

return _create_handler(
Expand Down Expand Up @@ -946,6 +989,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.

Expand Down Expand Up @@ -1017,6 +1061,7 @@ async def _stream_gen(
mcp_allowed_tools,
tool_mcp,
hooks,
**(host_options or _host_query_options(None, None)),
include_partial_messages=True,
)

Expand Down Expand Up @@ -1159,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)
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@

from launchdarkly_ai_claude_agents.handler import (
_build_hooks,
_host_query_options,
_options_parameters,
_raise_if_run_failed,
build_prompt,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -178,6 +180,10 @@ async def _run_query(
else [],
mcp_servers=mcp_servers,
hooks=hooks or {},
# `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 {}),
)

Expand Down Expand Up @@ -221,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 = "",
Expand Down Expand Up @@ -326,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"]
Expand Down Expand Up @@ -392,6 +405,7 @@ async def _subagent_execute(
run_id,
root_child_tools,
history,
host_options=host_options,
)

final_output = result["output"]
Expand Down
Loading
Loading