Skip to content

Commit 866fdcf

Browse files
XieXclaude
andcommitted
Merge origin/main into xie/skills-config-source
Resolves two conflicts with #217 (Claude Agents skills handler on the package root): `__init__.py` keeps both the `_write_skills` re-export and the `config_skills` import, and `_skill_refs`'s docstring names both of its callers, the handler and the source `config_skills` builds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 parents fb5e799 + 5d46dbb commit 866fdcf

12 files changed

Lines changed: 116 additions & 63 deletions

File tree

‎packages/claude-agents/README.md‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -131,18 +131,14 @@ async def main():
131131
asyncio.run(main())
132132
```
133133

134-
## Agent Skills (experimental)
135-
136-
> **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.
134+
## Agent Skills
137135

138136
`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`.
139137

140138
```python
141139
import os
142140
from launchdarkly_ai_server import FDv2SkillStore, config, set_skill_store
143-
from launchdarkly_ai_claude_agents.experimental.skills import (
144-
create_claude_agents_skills_handler,
145-
)
141+
from launchdarkly_ai_claude_agents import create_claude_agents_skills_handler
146142

147143
store = FDv2SkillStore(os.environ["LD_SDK_KEY"]).start()
148144
store.wait_for_skills(timeout=10)

‎packages/claude-agents/agents.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ That means the LaunchDarkly flag variation must have `provider.name == "Anthropi
3030
| `src/launchdarkly_ai_claude_agents/graph.py` | `claude_graph()` convenience wrapper around `graph()` |
3131
| `src/launchdarkly_ai_claude_agents/native_graph.py` | `to_claude_agents()` native graph adapter |
3232
| `src/launchdarkly_ai_claude_agents/builtins.py` | Pre-constructed `NativeTool` sentinels for Claude built-in tools |
33-
| `src/launchdarkly_ai_claude_agents/experimental/skills.py` | Experimental `create_claude_agents_skills_handler()`. Not imported by the package `__init__.py` |
33+
| `src/launchdarkly_ai_claude_agents/skills.py` | `create_claude_agents_skills_handler()`, exported from the package root |
3434
| `src/launchdarkly_ai_claude_agents/__init__.py` | Package exports |
3535
| `src/launchdarkly_ai_claude_agents/_version.py` | `PACKAGE_NAME` and `__version__`, sent with `$ld:ai:sdk:info` and `$ld:ai:sdk:usage` |
3636

@@ -169,11 +169,11 @@ On error: `span.record_exception(exc)`, status set to ERROR, span ended, error r
169169
id wins. An app that opens a fresh CLI session per turn and re-feeds history must pass its own
170170
conversation id, or each turn becomes its own conversation.
171171

172-
### 5. Agent Skills (experimental, `experimental/skills.py`)
172+
### 5. Agent Skills (`skills.py`)
173173

174-
`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=...)`.
174+
`create_claude_agents_skills_handler(...)` is exported from the package root. It reports `claude-agents.createClaudeAgentsSkillsHandler` and builds the handler through `_create_claude_agents_handler(..., _prepare=...)`.
175175

176-
`_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`.
176+
`_prepare` is a package-internal hook, a private keyword argument of the internal factory only, so `create_claude_agents_handler`'s signature stays unchanged. 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`.
177177

178178
For a variation with skills, `_prepare_skills`:
179179

‎packages/claude-agents/api-report/launchdarkly_ai_claude_agents.experimental.skills.txt‎

Lines changed: 0 additions & 2 deletions
This file was deleted.

‎packages/claude-agents/api-report/launchdarkly_ai_claude_agents.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ ClaudeWebFetch = NativeTool('WebFetch')
1414
ClaudeWebSearch = NativeTool('WebSearch')
1515
ClaudeWrite = NativeTool('Write')
1616
create_claude_agents_handler(*, capture_content: bool | None = None, cwd: str | os.PathLike[str] | None = None, setting_sources: list[SettingSource] | None = None) -> ProviderHandler
17+
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
1718
partition_tools(config_tools: dict[str, Any] | None, tool_handlers: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any], list[str]]
1819
to_claude_agents(def_promise: Any, opts: dict[str, Any] | None = None) -> Any

‎packages/claude-agents/src/launchdarkly_ai_claude_agents/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
partition_tools,
2929
)
3030
from .native_graph import to_claude_agents
31+
from .skills import create_claude_agents_skills_handler
3132

3233
__all__ = [
3334
"ClaudeBash",
@@ -45,6 +46,7 @@
4546
"claude_agents",
4647
"claude_graph",
4748
"create_claude_agents_handler",
49+
"create_claude_agents_skills_handler",
4850
"partition_tools",
4951
"to_claude_agents",
5052
]

‎packages/claude-agents/src/launchdarkly_ai_claude_agents/experimental/__init__.py‎

Lines changed: 0 additions & 7 deletions
This file was deleted.

packages/claude-agents/src/launchdarkly_ai_claude_agents/experimental/skills.py renamed to packages/claude-agents/src/launchdarkly_ai_claude_agents/skills.py

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
"""
2-
Agent Skills for Claude Agents (experimental).
2+
Agent Skills for Claude Agents.
33
44
``create_claude_agents_skills_handler`` builds a Claude Agents handler that gives each run the
5-
skills attached to its variation. This name may change in a minor release, so review the
6-
changelog when you upgrade.
5+
skills attached to its variation. Exported from the package root.
76
"""
87

98
from __future__ import annotations
@@ -16,18 +15,22 @@
1615

1716
from claude_agent_sdk import SettingSource
1817

18+
# ``_skill_refs`` and ``_write_skills`` are the non-reporting forms of ``skill_refs`` and
19+
# ``write_skills``. A run reports only the factory's own helper event: the customer called
20+
# ``create_claude_agents_skills_handler``, not ``skill_refs`` or ``write_skills``, so sending
21+
# ``client.skillRefs`` or ``client.writeSkills`` from here would attribute calls they never made.
1922
from launchdarkly_ai_server import (
2023
AiConfigRep,
2124
ProviderHandler,
2225
ReconcileReport,
2326
SkillReference,
27+
_skill_refs,
28+
_write_skills,
2429
report_usage,
25-
skill_refs,
26-
write_skills,
2730
)
2831

29-
from .._version import PACKAGE_NAME, __version__
30-
from ..handler import QueryPreparation, _create_claude_agents_handler
32+
from ._version import PACKAGE_NAME, __version__
33+
from .handler import QueryPreparation, _create_claude_agents_handler
3134

3235
__all__ = ["create_claude_agents_skills_handler"]
3336

@@ -88,17 +91,17 @@ async def _prepare_skills(
8891
Fails closed: if any skill cannot be written, the run does not start. The variation names
8992
that skill, so running without it would run a different agent from the one configured.
9093
"""
91-
refs = skill_refs(config)
94+
refs = _skill_refs(config)
9295
if not refs:
9396
return None
9497

9598
run_dir = os.path.realpath(tempfile.mkdtemp(prefix=_RUN_DIR_PREFIX, dir=parent))
9699
try:
97100
plugin_root = os.path.join(run_dir, PLUGIN_NAME)
98-
# ``write_skills`` creates only the missing leaf of its root.
101+
# ``_write_skills`` creates only the missing leaf of its root.
99102
os.mkdir(plugin_root)
100103
# A fresh directory: there is nothing to prune, and nothing else writes here.
101-
report = await write_skills(
104+
report = await _write_skills(
102105
refs, os.path.join(plugin_root, "skills"), prune=False
103106
)
104107
if not report.ok:
@@ -134,11 +137,12 @@ def create_claude_agents_skills_handler(
134137
*skills_temp_dir*: the parent of the directory each run writes its skills into, by default
135138
``tempfile.gettempdir()``.
136139
137-
On every invoke and stream, the variation's ``skills`` are written with ``write_skills``,
138-
through the store configured with ``set_skill_store``, into a new directory under
139-
*skills_temp_dir*. That directory is passed to ``query()`` as a local plugin, only those
140-
skills are enabled, the ``Skill`` tool is turned on, and the directory is removed when the
141-
run ends. A variation with no skills runs exactly as with ``create_claude_agents_handler``.
140+
On every invoke and stream, the variation's ``skills`` are written, as ``write_skills``
141+
writes them, through the store configured with ``set_skill_store`` into a new directory
142+
under *skills_temp_dir*. That directory is passed to ``query()`` as a local plugin, only
143+
those skills are enabled, the ``Skill`` tool is turned on, and the directory is removed
144+
when the run ends. A variation with no skills runs exactly as with
145+
``create_claude_agents_handler``.
142146
143147
``setting_sources`` is always sent, ``[]`` unless given. ``claude-agent-sdk`` would
144148
otherwise load the ``user`` and ``project`` settings sources whenever ``skills`` is set.
@@ -152,9 +156,7 @@ def create_claude_agents_skills_handler(
152156
import os
153157
154158
from launchdarkly_ai_server import FDv2SkillStore, config, set_skill_store
155-
from launchdarkly_ai_claude_agents.experimental.skills import (
156-
create_claude_agents_skills_handler,
157-
)
159+
from launchdarkly_ai_claude_agents import create_claude_agents_skills_handler
158160
159161
store = FDv2SkillStore(os.environ["LD_SDK_KEY"]).start()
160162
store.wait_for_skills(timeout=10)
@@ -163,7 +165,7 @@ def create_claude_agents_skills_handler(
163165
triage = config(key="triage-agent", handler=create_claude_agents_skills_handler())
164166
"""
165167
report_usage(
166-
"experimental.createClaudeAgentsSkillsHandler", PACKAGE_NAME, __version__
168+
"claude-agents.createClaudeAgentsSkillsHandler", PACKAGE_NAME, __version__
167169
)
168170

169171
async def _prepare(config: AiConfigRep) -> QueryPreparation | None:

‎packages/claude-agents/tests/test_sdk_usage.py‎

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,33 @@
11
"""Tests for TESTING.md §3.27 — claude-agents."""
22

3+
import hashlib
4+
from pathlib import Path
35
from types import SimpleNamespace
46
from typing import Any
57
from unittest.mock import AsyncMock, MagicMock, patch
68

79
import pytest
10+
from claude_agent_sdk import ResultMessage
811

12+
import launchdarkly_ai_claude_agents.handler as handler_mod
913
import launchdarkly_ai_server.lifecycle as lifecycle_module
1014
from launchdarkly_ai_claude_agents import (
1115
claude_agents,
1216
claude_graph,
1317
create_claude_agents_handler,
18+
create_claude_agents_skills_handler,
1419
to_claude_agents,
1520
)
1621
from launchdarkly_ai_claude_agents._version import PACKAGE_NAME
1722
from launchdarkly_ai_claude_agents._version import __version__ as PACKAGE_VERSION
18-
from launchdarkly_ai_claude_agents.experimental.skills import (
19-
create_claude_agents_skills_handler,
23+
from launchdarkly_ai_server import (
24+
SDK_INFO_CONTEXT,
25+
InMemorySkillStore,
26+
__version__,
27+
init_client,
28+
set_skill_store,
29+
shutdown,
2030
)
21-
from launchdarkly_ai_server import SDK_INFO_CONTEXT, __version__, init_client, shutdown
2231

2332
CONTEXT = {"kind": "user", "key": "user-1"}
2433

@@ -60,7 +69,7 @@ async def client() -> Any:
6069
("claude-agents.toClaudeAgents", lambda: to_claude_agents(None)),
6170
# Its own ID, and not also the core factory's.
6271
(
63-
"experimental.createClaudeAgentsSkillsHandler",
72+
"claude-agents.createClaudeAgentsSkillsHandler",
6473
create_claude_agents_skills_handler,
6574
),
6675
],
@@ -128,6 +137,54 @@ async def _definition() -> Any:
128137
assert _helpers(client) == ["claude-agents.toClaudeAgents"]
129138

130139

140+
_SKILL_BODY = "---\nname: a\n---\nSkill a.\n"
141+
_SKILLS_CONFIG: dict[str, Any] = {
142+
"model": {"name": "claude-opus-4-5"},
143+
"provider": {"name": "Anthropic"},
144+
"instructions": "You are helpful.",
145+
"skills": [{"key": "a", "version": 1}],
146+
}
147+
148+
149+
async def _fake_query(**_kwargs: Any) -> Any:
150+
yield ResultMessage(
151+
subtype="success",
152+
duration_ms=1,
153+
duration_api_ms=1,
154+
is_error=False,
155+
num_turns=1,
156+
session_id="sess-1",
157+
usage={"input_tokens": 3, "output_tokens": 2},
158+
result="done",
159+
)
160+
161+
162+
@pytest.mark.parametrize("path", ["invoke", "stream"])
163+
async def test_skills_run_reports_only_the_factory(
164+
client: MagicMock, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, path: str
165+
) -> None:
166+
"""A run with skills reads the variation's references and writes the skills on the
167+
customer's behalf. They never called ``skill_refs`` or ``write_skills``, so neither
168+
``client.skillRefs`` nor ``client.writeSkills`` is sent for them."""
169+
store = InMemorySkillStore()
170+
store.put(
171+
{
172+
"key": "a",
173+
"version": 1,
174+
"content": _SKILL_BODY,
175+
"contentHash": hashlib.sha256(_SKILL_BODY.encode()).hexdigest(),
176+
}
177+
)
178+
set_skill_store(store)
179+
monkeypatch.setattr(handler_mod, "query", _fake_query)
180+
handler = create_claude_agents_skills_handler(skills_temp_dir=tmp_path)
181+
if path == "invoke":
182+
await handler(_SKILLS_CONFIG, "q", {})
183+
else:
184+
await _drain(await handler.stream(_SKILLS_CONFIG, "q", {}))
185+
assert _helpers(client) == ["claude-agents.createClaudeAgentsSkillsHandler"]
186+
187+
131188
async def test_held_helper_flushes_with_its_package() -> None:
132189
"""A helper called before init keeps its package fields until the flush."""
133190
await shutdown()

‎packages/claude-agents/tests/test_skills.py‎

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@
1212
import asyncio
1313
import hashlib
1414
import os
15-
import subprocess
16-
import sys
1715
from collections.abc import AsyncIterator, Awaitable, Callable
1816
from pathlib import Path
1917
from typing import Any
@@ -28,8 +26,9 @@
2826

2927
import launchdarkly_ai_claude_agents.handler as handler_mod
3028
import launchdarkly_ai_server.lifecycle as lifecycle_module
31-
from launchdarkly_ai_claude_agents import ClaudeRead, create_claude_agents_handler
32-
from launchdarkly_ai_claude_agents.experimental.skills import (
29+
from launchdarkly_ai_claude_agents import (
30+
ClaudeRead,
31+
create_claude_agents_handler,
3332
create_claude_agents_skills_handler,
3433
)
3534
from launchdarkly_ai_server import (
@@ -198,15 +197,15 @@ def test_routes_exactly_as_the_core_handler_does(self) -> None:
198197
"agent",
199198
)
200199

201-
def test_is_absent_from_the_package_root(self) -> None:
202-
"""A fresh interpreter, so this module's own import of the entry point doesn't count."""
203-
probe = (
204-
"import sys, launchdarkly_ai_claude_agents as p;"
205-
"assert 'launchdarkly_ai_claude_agents.experimental' not in sys.modules;"
206-
"assert not hasattr(p, 'create_claude_agents_skills_handler');"
207-
"assert 'create_claude_agents_skills_handler' not in p.__all__"
200+
def test_is_exported_from_the_package_root(self) -> None:
201+
import launchdarkly_ai_claude_agents as package_root
202+
import launchdarkly_ai_claude_agents.skills as skills_mod
203+
204+
assert "create_claude_agents_skills_handler" in package_root.__all__
205+
assert (
206+
package_root.create_claude_agents_skills_handler
207+
is skills_mod.create_claude_agents_skills_handler
208208
)
209-
subprocess.run([sys.executable, "-c", probe], check=True)
210209

211210
@pytest.mark.parametrize("path", _PATHS)
212211
async def test_forwards_the_host_options(
@@ -247,21 +246,21 @@ async def test_a_config_with_no_skills_runs_as_the_core_handler_would(
247246
) -> None:
248247
"""No store is configured: a variation with no skills needs none."""
249248
cfg = dict(BASE_CONFIG) if skills is None else {**BASE_CONFIG, "skills": skills}
250-
core, experimental = _FakeQuery(), _FakeQuery()
249+
core, with_skills = _FakeQuery(), _FakeQuery()
251250
monkeypatch.setattr(handler_mod, "query", core)
252251
await _run(create_claude_agents_handler(cwd="/srv/app"), path, cfg)
253-
monkeypatch.setattr(handler_mod, "query", experimental)
252+
monkeypatch.setattr(handler_mod, "query", with_skills)
254253
await _run(
255254
create_claude_agents_skills_handler(
256255
cwd="/srv/app", skills_temp_dir=tmp_path
257256
),
258257
path,
259258
cfg,
260259
)
261-
assert experimental.options == core.options
262-
assert experimental.options.plugins == []
263-
assert experimental.options.skills is None
264-
assert "Skill" not in experimental.options.tools
260+
assert with_skills.options == core.options
261+
assert with_skills.options.plugins == []
262+
assert with_skills.options.skills is None
263+
assert "Skill" not in with_skills.options.tools
265264
assert list(tmp_path.iterdir()) == []
266265

267266

@@ -457,7 +456,7 @@ async def during(kwargs: dict[str, Any]) -> None:
457456
async def test_a_failed_removal_is_logged_and_does_not_replace_the_outcome(
458457
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
459458
) -> None:
460-
import launchdarkly_ai_claude_agents.experimental.skills as skills_mod
459+
import launchdarkly_ai_claude_agents.skills as skills_mod
461460

462461
_store(_raw("a", 1))
463462
monkeypatch.setattr(handler_mod, "query", _FakeQuery())

‎packages/client/src/launchdarkly_ai_server/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
set_skill_store,
6565
skill_refs,
6666
)
67+
from .skills import _skill_refs as _skill_refs
6768
from .skills_core import SkillStore
6869
from .skills_fdv2 import FDv2SkillStore, StoreDiagnostics
6970
from .skills_fs import (
@@ -73,6 +74,7 @@
7374
OnUnavailable,
7475
write_skills,
7576
)
77+
from .skills_fs import _write_skills as _write_skills
7678
from .skills_source import config_skills
7779
from .skills_watch import SkillWatcher, watch_skills
7880
from .tracking import execute_and_stream, execute_and_track, wrap_tool_handlers

0 commit comments

Comments
 (0)