Skip to content
Closed
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
124 changes: 124 additions & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,126 @@ asyncio.run(main())

---

### Agent Skills

Skills are versioned `SKILL.md` documents managed in LaunchDarkly and attached to AI Config
variations by reference. The SDK surfaces which skills a config references, retrieves their
content, and materializes them onto disk where agent runtimes (Claude Agent SDK, and
anything else following the `<root>/<key>/SKILL.md` convention) discover them.

```python
import asyncio
import hashlib
from pathlib import Path

from launchdarkly_ai_server import (
init_client, inspect_config, skill_refs, get_skill, write_skills,
InMemorySkillStore,
)

SKILL_MD = "---\nname: PDF Extraction\n---\nExtract text from PDFs.\n"

async def main():
# A store supplies skill content. InMemorySkillStore is the dict-backed
# store for local development, testing, and bring-your-own-content use.
store = InMemorySkillStore()
store.put({
"key": "pdf-extraction",
"version": 2,
"content": SKILL_MD,
# sha256, lowercase hex, over the verbatim utf-8 bytes. Content whose hash
# does not match is withheld, so this is not optional.
"contentHash": hashlib.sha256(SKILL_MD.encode("utf-8")).hexdigest(),
})
await init_client(options={"skillStore": store})

# 1. Which skills does this config reference? Pure projection — no I/O.
info = await inspect_config("doc-agent", {"kind": "user", "key": "user-123"})
refs = skill_refs(info["config"]) # [SkillReference(key='pdf-extraction', version=2)]

# 2. Fetch content. Returns None rather than raising when a skill is unavailable.
skill = await get_skill("pdf-extraction")
if skill is not None:
print(skill.content)

# 3. Write them where the agent runtime will look. Only the leaf directory is
# created, so the parent must already exist.
Path(".claude").mkdir(exist_ok=True)
report = await write_skills(refs, ".claude/skills")
for action in report.errors:
print(f"skill {action.key or '<run>'}: {action.error}")

asyncio.run(main())
```

Pass `"*"` instead of a reference list to materialize every skill the store holds.

**`skills` is now a validated field.** Config parsing fails closed on a `skills` value that
is not a list of `{key, version}` objects (key matching `^[a-z0-9][a-z0-9-]*$`, version an
integer ≥ 1): the whole variation is rejected, `inspect_config` returns `config: None`, and
`extract_variation` raises. A variation that previously carried its own custom `skills`
field of a different shape must rename it before upgrading.

**The root's parent must exist.** `write_skills` creates the root itself but never its
ancestors, so a typo cannot scatter a directory tree across your project. An absent parent,
a root that is an existing file, and a root that is a symlink each raise `ValueError` —
these are caller errors, distinct from the per-skill `error` actions in the report.

**Integrity is not optional.** Content is only returned after its sha256 (lowercase hex,
over the verbatim UTF-8 bytes) matches the delivered `contentHash`, its key and version
revalidate, and its size is within 64 KiB. Anything that fails is withheld and treated as
missing — no unverified content ever reaches your code.

**`write_skills` is deliberately conservative** about your filesystem. It writes only
`<root>/<key>/SKILL.md`, tracks what it owns in a manifest at
`<root>/.launchdarkly-skills.json`, and will overwrite or delete **only** paths that
manifest records. A file you placed yourself is reported as an error and left untouched; it
never writes through a symlink; writes are atomic (temp file, `fsync`, rename) at mode
`0644`; and if the manifest is unreadable it performs no destructive action at all. Removing
a skill from a variation is how revocation works — the next reconcile prunes it.

| Export | Description |
|---|---|
| `skill_refs(config)` | Project a config's `skills` array into `list[SkillReference]`. Pure — no client, store, or network needed. Returns `[]` when absent. |
| `get_skill(key, *, version=None)` | One verified skill, or `None`. `version=None` means newest available; a specific `version` matches exactly. Raises only when no store is configured. |
| `get_skills(refs)` | Batch form. Accepts `SkillReference` values and bare key strings (string = latest). Results follow input order; missing or unverifiable entries are omitted. |
| `all_skills()` | Every verified skill the store holds. |
| `write_skills(skills, root, *, prune=True, timeout=10.0, on_unavailable="keep")` | Materialize skills under `root`, returning a `ReconcileReport`. `prune` removes formerly-managed skills no longer requested. `on_unavailable="raise"` raises instead of reporting when content cannot be retrieved. Raises `ValueError` for an unusable root, a negative `timeout`, or an unrecognised `on_unavailable`. **Performs synchronous filesystem I/O — see the note below.** |
| `SkillStore` | The structural interface content arrives through: `get_object(kind, key)`, `all_objects(kind)`, optional `add_listener(kind, fn)`. |
| `InMemorySkillStore(objects=None)` | A dict-backed store with `put(raw)`, for local development and testing. |

Configure the store with `init_client(options={"skillStore": store})`. With none configured,
the three accessors raise `RuntimeError` explaining what to do; `write_skills` reports the
failure in its report (or raises, with `on_unavailable="raise"`).

`ReconcileReport.actions` holds one `ReconcileAction` per outcome — `written`, `updated`,
`skipped_current`, `removed`, or `error` — each carrying `key`, `version`, the resolved
`path`, and `error`. `report.ok` is `True` when no action is an `error`, and
`report.errors` is just the `error` actions, so you rarely need to filter `actions`
yourself. A failure that belongs to the whole run rather than to one skill — an unreadable
manifest, for instance — carries the empty string as its `key`.

The fixed on-disk and on-the-wire values are exported too, so you do not have to hardcode
them: `MANIFEST_FILENAME` (`.launchdarkly-skills.json`, handy for a `.gitignore`),
`SKILL_FILENAME`, `MANIFEST_VERSION`, and `SKILL_OBJECT_KIND`. So are the two closed-set
types, for annotating your own helpers: `ReconcileActionKind` (`written` / `updated` /
`skipped_current` / `removed` / `error`) and `OnUnavailable` (`keep` / `raise`).

**`write_skills` blocks.** It is `async` for parity with the other accessors and with the
TypeScript SDK, but it awaits nothing: every read, write, `fsync` and rename runs inline,
so a large reconcile holds the event loop for its duration. Wrap it in
`asyncio.to_thread` if that matters. For the same reason `timeout` is checked between
steps rather than interrupting one already in progress. Reconcile one root at a time,
though: because nothing yields today, a run is atomic against the rest of your loop, and
wrapping it to run concurrently makes two runs against the same root race on the manifest.

> `Skill.frontmatter()` parses the leading YAML block as a convenience. It needs `pyyaml`,
> which is **not** a dependency of this package — install it yourself if you want that
> method to return anything. It returns `None` rather than raising when the library is
> missing, the block is absent or oversize, or the YAML is hostile.

---

### Utility Helpers

```python
Expand Down Expand Up @@ -250,3 +370,7 @@ All types are exported from this package. Handler packages import them from here
| `GraphNode` / `GraphEdge` | A dataclass node (`.key`, `.config`, `.meta`, `.edges`, `.is_terminal`) and a dataclass directed edge (`.key`, `.source_key`, `.target_key`, `.handoff`) |
| `ProviderGraphResponse` | A dataclass returned by `graph(...).invoke()`: `.response`, `.usage`, `.judge_results` |
| `GraphTopology` | The parsed graph flag shape (`root` + `edges`) |
| `Skill` | A frozen `SKILL.md` document: `.key`, `.version`, `.content`, `.content_hash`, `.name?`, `.description?`, and `.frontmatter()` |
| `SkillReference` | A frozen version-pinned pointer to a skill: `.key`, `.version` |
| `ReconcileAction` | One `write_skills` outcome: `.key`, `.action`, `.version?`, `.path?`, `.error?` |
| `ReconcileReport` | The `write_skills` result: `.actions`, `.ok`, and `.errors` |
Loading
Loading