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
40 changes: 40 additions & 0 deletions packages/claude-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,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:<key>`), 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.
Expand Down
15 changes: 15 additions & 0 deletions packages/claude-agents/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down Expand Up @@ -168,6 +169,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(<dir>/launchdarkly)`.
2. `write_skills(skill_refs(config), <dir>/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": <dir>/launchdarkly}]`, `skills=["launchdarkly:<key>", ...]` 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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.
"""
Original file line number Diff line number Diff line change
@@ -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:<key>``."""

_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,
)
Loading
Loading