Skip to content
Open
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
67 changes: 67 additions & 0 deletions packages/client/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ No other `launchdarkly-ai-*` package may define or duplicate these. They import
| `src/launchdarkly_ai_server/types_validation.py` | `parse_ai_config` — validates flag variation shape; `is_valid_skill_key` / `is_valid_skill_version` / `skill_key_rejection_reason` (the canonical key-grammar explanation every layer quotes) |
| `src/launchdarkly_ai_server/skills.py` | Agent Skills, retrieval half — `skill_refs`, `get_skill`/`get_skills`/`all_skills`, `InMemorySkillStore`, and the store/telemetry injection points `_set_store` / `_set_emitter_for_testing` |
| `src/launchdarkly_ai_server/skills_core.py` | Shared skills internals — the `SkillStore` seam, module state, the telemetry seam and its three recorders, integrity verification, and store resolution. Imported by both `skills.py` and the materialization layer; imports neither |
| `src/launchdarkly_ai_server/skills_fdv2.py` | Agent Skills, delivery protocol — the wire-key/`version` translation, the held object set, and the pure `_ProtocolReader` that commits a payload's events at `payload-transferred`. Sits **below** the store interface; nothing in the feature imports it |
| `src/launchdarkly_ai_server/skills_watch.py` | Agent Skills, eager re-reconcile — `watch_skills` / `SkillWatcher`, wiring the store's change listener to `write_skills`. Sits **above** `skills_fs` and modifies none of it |
| `src/launchdarkly_ai_server/skills_fs.py` | Agent Skills, materialization half — `write_skills`, request resolution, the manifest format and on-disk filenames, per-skill reconcile, and pruning |
| `src/launchdarkly_ai_server/safe_fs.py` | Descriptor-pinned filesystem primitives — `atomic_write`, `unlink_file`, `pinned_directory`, `open_directory_nofollow`, `open_or_create_directory`, `SymlinkRefused`, and the `*at()` capability probe. Owns the descriptor-vs-path platform split; knows nothing about skills |
Expand Down Expand Up @@ -218,6 +219,72 @@ one place that collapses the result to one object per key, because both whole-st
consumers need it — `all_skills`, since a list holding two versions of one key is not a set
of skills, and the `"*"` reconcile, since `<root>/<key>/SKILL.md` is a single path.

### The delivery transport, and the one field that will bite you

`skills_fdv2.py` translates LaunchDarkly's FDv2 delivery protocol into raw objects in the
shape `skills_core.SkillStore` documents. It lives below the store interface; **nothing above
that interface knows it exists**. If a transport change ever seems to require editing an
accessor, verification, or `write_skills`, the adapter boundary is wrong.

**The skill's version is in the object's `key`. `version` is the payload's.** Each version
of a skill is its own object on the wire, identified as `<key>:<version>`:

```json
{"key":"pdf-extraction:3","kind":"skill","version":42,
"object":{"contentType":"text/markdown","content":"…","contentHash":"…","name":"…"}}
```

The `3` after the delimiter is what a `{key, version}` reference pins and what becomes the
stored `version`, under the stored key `pdf-extraction`. `version` (42) is the version of the
*payload* the object arrived in — it moves when anything in the environment moves,
including a flag with nothing to do with skills. Reading it as the skill's version fails
**silently**: the object verifies, the hash matches, and the caller gets content under a
version number that means nothing. There is no separate field for the skill's version: the
agent-skill payload is a *generic* payload, and generic objects carry only `key`, `kind`,
`version` and `object`, exactly like a flag. `_split_wire_key` is the only place the wire key
is read, `_store_object_from_put` and `_tombstone_from_delete` both go through it, and
`TestVersionTranslation` asserts the translation in both directions. A wire key that will
not split cleanly is *held*, not dropped — version-less, or with the offending text as its
version — so verification withholds it with `invalid_version` under a key the caller
recognises; only a key with nothing before the delimiter is dropped, since there is no
identity to hold it under.

**Skills are identified by `kind == "skill"`; everything else is ignored, not rejected.**
Object kinds on the SDK-facing channel are open strings, and the agent-skill payload is
classified `generic`, so a skill arrives under the kind its producer registered — the bare
category name — not under a broader wrapper kind with a narrowing field. An environment's
payload assignment carries its flag payload alongside its agent-skill payload, so flag and
segment objects arrive as a matter of course. Erroring on an unrecognised kind would turn a
normal payload into a permanent reconnect loop — a flag-delivery outage caused by a skills
rollout.

**Changes commit at `payload-transferred`, not as objects arrive.** A payload version is the
unit of consistency: a half-applied full transfer would publish a state the server never
described, and would briefly empty the store — which, with pruning on, is the difference
between a reconcile and deleting a customer's skill files. An interrupted transfer therefore
leaves last known good intact, and listeners fire once per commit.

**The first payload intent is read, and is assumed to be the skill payload.** Delivery
provides one payload per credential and the protocol requires a client to ignore all but the
first payload intent, so `payloads[0]` is both what arrives and what the protocol says to
read. The cost of that assumption is that an `xfer-full` for somebody *else's* payload would
start an empty pending set, and the next `payload-transferred` would publish it — every skill
reported revoked, and with pruning on, a customer's files deleted. `_ProtocolReader`
therefore learns which payload skills arrive on, from the intent's `id` or from the
`(p:<id>:<version>)` selector, and declines to apply a transfer of any other: once at
WARNING, counted in `diagnostics.payloads_ignored`, holding last known good. A transfer that
names no payload is applied, since one-payload delivery is the common case. The residual is
the first transfer of a connection — before a skill has arrived there is nothing to compare
against — which is what the separate WARNING on a multi-payload intent is for.

**A hashless object is held, not dropped.** Verification withholds it with
`missing_content_hash`; the transport's job is to make that loud (an error per object, a
summary per wholly-hashless payload, `diagnostics.hashless_objects`) rather than to work
around it. Dropping it at the transport would report `absent` — indistinguishable from "no
such skill" — and would let a prune delete the last known-good copy on disk. Never synthesize
a hash from the delivered content: that certifies the content against itself and verifies
nothing.

### The reported outcome vocabulary, and the `Resolution` mapping

`get_skill` returns `Skill | None`; `get_skill_result` returns a frozen `SkillOutcome`
Expand Down
6 changes: 3 additions & 3 deletions packages/client/src/launchdarkly_ai_server/skills_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@
An **internal seam value**, deliberately not exported from the package root. It
is the string ``skills.py`` and ``skills_fs.py`` pass to ``SkillStore.get_object``
and ``SkillStore.all_objects``, and a store adapter is free to map it onto
whatever the transport underneath actually uses — a delivery payload may well
carry skills under a broader kind with a narrower category, in which case
translating that pair to this one value is the adapter's job.
whatever the transport underneath actually uses — the value happens to match
the kind LaunchDarkly's delivery channel uses today, but a transport that spelt
it differently would translate, and that translation is the adapter's job.

Exporting it would publish an SDK-side seam string as though it were the wire
contract, which is a claim this side cannot make and would be hard to walk back
Expand Down
Loading
Loading