Skip to content

Commit d0ccb0f

Browse files
XieXclaude
andcommitted
docs(client): Agent Skills — describe the FDv2 wire contract as it ships
agents.md described the earlier streamer draft: `objectVersion` beside `version`, `inline-resource` plus `category`, `basis` plus `mv`. It now describes what the channel delivers — the skill's version folded into the wire key, identification by kind alone, no `mv` — and adds the three transport decisions that arrived with the loop fixes: the payload identity guard, the single read timeout, and which failures are recoverable. README's "Receiving skills from LaunchDarkly" gains `readTimeoutMs` with its per-mode default, `removeListener` on both stores and the seam, the `StoreDiagnostics` fields including `payloadsIgnored`, and the 403 wording that matches what the store now logs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 8a2be9e commit d0ccb0f

2 files changed

Lines changed: 24 additions & 14 deletions

File tree

packages/client/README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -325,9 +325,10 @@ example above, materializes only what the resolved variation actually asked for.
325325
| `getSkills(refs)` | Batch form. Accepts `SkillReference` values and bare key strings (string = latest). Results follow input order; missing or unverifiable entries are omitted. |
326326
| `allSkills()` | Every verified skill the store holds. |
327327
| `writeSkills(skills, root, options?)` | Materialize to `<root>/<key>/SKILL.md`. Accepts `Skill` / `SkillReference` / key strings, or the literal `'*'`. Returns a `ReconcileReport`. Throws for a caller error — an unusable `root`, a bare string other than `'*'` — as distinct from the per-skill `error` actions in the report. |
328-
| `InMemorySkillStore` | A `SkillStore` backed by a plain object. `put(raw)`, `getObject(kind, key, version?)`, `allObjects(kind)`, `addListener(kind, fn)`. Holds one object per key, so it answers a version pin with what it has and lets the accessor refuse a mismatch. |
329-
| `FDv2SkillStore(sdkKey, options?)` | The delivery transport: a `SkillStore` fed by LaunchDarkly over the SDK-facing FDv2 channel. `start()`, `waitForSkills(timeoutMs)`, `close()`, `diagnostics`, `failed`. **Server-side only** — a mobile key or client-side environment ID throws. See [Receiving skills from LaunchDarkly](#receiving-skills-from-launchdarkly). |
330-
| `watchSkills(skills, root, options?)` | `writeSkills` plus a re-reconcile on every delivery change. Resolves to `{ report, watcher }`; `await watcher.close()` when done. Revocation then takes effect within `debounceMs` of arriving rather than at the next restart. |
328+
| `InMemorySkillStore` | A `SkillStore` backed by a plain object. `put(raw)`, `getObject(kind, key, version?)`, `allObjects(kind)`, `addListener(kind, fn)`, `removeListener(kind, fn)`. Holds one object per key, so it answers a version pin with what it has and lets the accessor refuse a mismatch. |
329+
| `FDv2SkillStore(sdkKey, options?)` | The delivery transport: a `SkillStore` fed by LaunchDarkly over the SDK-facing FDv2 channel. `start()`, `waitForSkills(timeoutMs)`, `close()`, `diagnostics`, `failed`, `addListener` / `removeListener`. Options: `mode` (`'stream'` default, or `'poll'`), `baseUri`, `pollIntervalMs`, `readTimeoutMs`, `initialBackoffMs`, `maxBackoffMs`, `maxConsecutiveFailures`. **Server-side only** — a mobile key or client-side environment ID throws. See [Receiving skills from LaunchDarkly](#receiving-skills-from-launchdarkly). |
330+
| `watchSkills(skills, root, options?)` | `writeSkills` plus a re-reconcile on every delivery change. Resolves to `{ report, watcher }`; `await watcher.close()` when done, which also detaches the watcher from the store. Revocation then takes effect within `debounceMs` of arriving rather than at the next restart. |
331+
| `StoreDiagnostics` | What the transport has seen: `payloadsTransferred`, `skillObjectsReceived`, `objectsIgnored`, `objectsRevoked`, `payloadsIgnored`, `hashlessObjects`, `connectionFailures`, `lastError`. |
331332

332333
#### Receiving skills from LaunchDarkly
333334

@@ -356,9 +357,11 @@ try {
356357

357358
**Streaming is the default, and it is what makes revocation fast.** A `delete-object` reaches a live stream in seconds; with `mode: 'poll'` it arrives within one `pollIntervalMs`. Paired with `watchSkills`, a revoked skill's `SKILL.md` leaves the disk without a restart. During an outage the store keeps serving the last content it received and `writeSkills`' default `onUnavailable: 'keep'` leaves managed files alone — an outage must not read as "everything was revoked".
358359

360+
**One network timeout, and its default depends on the mode.** `readTimeoutMs` bounds every step of a request, connecting included. In `mode: 'poll'` it bounds the whole request and defaults to 10 seconds; in `mode: 'stream'` it bounds each wait for the next bytes and defaults to 300 seconds, well beyond LaunchDarkly's heartbeat interval. A stream that goes quiet past it reconnects rather than hanging, and a stream that dies mid-body — a reset, a truncated chunk — reconnects the same way. Every delay between retries, including one the server asks for with `Retry-After`, is capped at `maxBackoffMs`.
361+
359362
**The connection also carries your flags.** A client cannot request only the skill payload, so a skills-enabled environment delivers flag and segment objects on the same connection. They are skipped, not evaluated — this store does no evaluation of any kind — and `diagnostics.objectsIgnored` counts them.
360363

361-
> **Beta caveats, worth knowing before you deploy.** Payload signing does not exist on this channel yet, so delivery is TLS-only and the content hash establishes self-consistency, not origin authenticity. FDv2 is opt-in per account: without it the endpoints return HTTP 403, which the store reports as a fatal error naming the setting. `ld-relay` does not speak the FDv2 endpoints, so relay-only deployments cannot receive skills.
364+
> **Beta caveats, worth knowing before you deploy.** Payload signing does not exist on this channel yet, so delivery is TLS-only and the content hash establishes self-consistency, not origin authenticity. The FDv2 protocol is opt-in per account: without it the endpoints return HTTP 403, which the store reports as a fatal error explaining what to do. `ld-relay` does not speak the FDv2 endpoints, so relay-only deployments cannot receive skills.
362365
363366
**If every skill comes back empty, check `diagnostics.hashlessObjects`.** Verification requires `contentHash` on the delivered object and withholds anything without one, so a nonzero count there means skills are being withheld rather than that the environment has none. The store logs an error per hashless object and one summary per wholly-hashless payload, both naming the reason. There is deliberately no fallback that skips verification: a hash the SDK computed from the content it was handed would certify the content against itself.
364367

@@ -532,7 +535,7 @@ All types are re-exported from this package. Handler packages import them from h
532535
| `SkillReference` | A version-pinned pointer to a skill: `{ key, version }` |
533536
| `SkillOutcome` | What `getSkillResult()` resolves to: `{ skill, reason, detail }`. `skill` is non-null exactly when `reason` is `'ok'` |
534537
| `SkillOutcomeReason` | The closed set of retrieval outcomes: `'absent' \| 'integrity_failure' \| 'ok' \| 'store_unavailable' \| 'wrong_version'` |
535-
| `SkillStore` | The structural seam skill content is retrieved through: `getObject(kind, key, version?)`, `allObjects`, optional `addListener` |
538+
| `SkillStore` | The structural seam skill content is retrieved through: `getObject(kind, key, version?)`, `allObjects`, optional `addListener` / `removeListener` |
536539
| `RawSkillObject` | The wire shape a `SkillStore` serves, before verification. Every field is untrusted. |
537540
| `ReconcileReport` | The result of `writeSkills()`: `{ actions, ok, errors }` |
538541
| `ReconcileAction` | One outcome from a reconcile: `{ key, action, version, path, error }` |

packages/client/agents.md

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ No other `@launchdarkly/ai-*` package may define or duplicate these. They import
3030
| `src/registry.ts` | `Registry`, `globalRegistry`, `compose` |
3131
| `src/skills-core.ts` | Agent Skills internals shared by the two layers above it: the store and telemetry seams, module state, integrity verification, store resolution |
3232
| `src/skills.ts` | `skillRefs`, the content accessors, `InMemorySkillStore`, and the documented test-injection hooks |
33-
| `src/skills-fdv2.ts` | Agent Skills delivery transport — the FDv2 protocol, the `objectVersion`/`version` translation, the held object set, and `FDv2SkillStore`. Sits **below** the store seam; imports `skills-core` only, and nothing imports it |
33+
| `src/skills-fdv2.ts` | Agent Skills delivery transport — the FDv2 protocol, the wire-key/`version` translation, the held object set, and `FDv2SkillStore`. Sits **below** the store seam; imports `skills-core` only, and nothing imports it |
3434
| `src/skills-watch.ts` | Agent Skills eager re-reconcile — `watchSkills` / `SkillWatcher`, wiring the store's change listener to `writeSkills`. Sits **above** `skills-fs` and modifies none of it |
3535
| `src/skills-fs.ts` | `writeSkills` — the manifest format, on-disk filenames, and reconcile semantics |
3636
| `src/safe-fs.ts` | Symlink-refusing filesystem primitives. Knows nothing about skills; owns the single interceptable rename and unlink call sites |
@@ -42,27 +42,34 @@ The Agent Skills modules are a deliberate split, and the dependencies run **one
4242

4343
## Agent Skills — the delivery transport, and the one field that will bite you
4444

45-
`FDv2SkillStore` speaks LaunchDarkly's SDK-facing FDv2 channel (`GET /sdk/poll`, `GET /sdk/stream`, server-side SDK key in `Authorization`, `basis` + `mv` params, `If-None-Match`/304). It lives below the seam and produces raw objects in the shape `SkillStore` documents; **nothing above the seam knows it exists**. The transport design was replaced wholesale late in this feature's life and cost zero changes above this line, which is the strongest evidence the seam is drawn correctly. If a transport change ever seems to require editing an accessor, verification, or `writeSkills`, the adapter boundary is wrong.
45+
`FDv2SkillStore` speaks LaunchDarkly's SDK-facing FDv2 channel (`GET /sdk/poll`, `GET /sdk/stream`, server-side SDK key in `Authorization`, a `basis` param once a payload has committed, `If-None-Match`/304). It lives below the seam and produces raw objects in the shape `SkillStore` documents; **nothing above the seam knows it exists**. The transport design was replaced wholesale late in this feature's life and cost zero changes above this line, which is the strongest evidence the seam is drawn correctly. If a transport change ever seems to require editing an accessor, verification, or `writeSkills`, the adapter boundary is wrong.
4646

47-
**`objectVersion` is the skill's version. `version` is the payload's.** On the wire a skill `put-object` carries both, and they are not interchangeable:
47+
**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>`:
4848

4949
```json
50-
{"key":"pdf-extraction","kind":"inline-resource","category":"skill",
51-
"objectVersion":3,"version":42,
50+
{"key":"pdf-extraction:3","kind":"skill","version":42,
5251
"object":{"contentType":"text/markdown","content":"","contentHash":"","name":""}}
5352
```
5453

55-
`objectVersion` (3) is what a `{key, version}` reference pins and what becomes the seam's `version`. `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. Flags and segments carry only `version` and omit both `category` and `objectVersion`, which is exactly why the two fields look interchangeable. `seamObjectFromPut` is the only place the translation happens, and the `version translation` suite asserts it in both directions.
54+
The `3` after the delimiter is what a `{key, version}` reference pins and what becomes the seam's `version`, under the seam 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. `splitWireKey` is the only place the wire key is read, `seamObjectFromPut` and `tombstoneFromDelete` both go through it, and the `version translation` suite 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.
5655

57-
**Skills are identified by `kind === 'inline-resource' && category === 'skill'`; everything else is ignored, not rejected.** An environment's payload assignment carries the flagging payload alongside the agent-skill payload, so flag and segment objects arrive as a matter of course. Throwing on an unrecognised kind is the unknown-kind reconnect loop this feature must not reproduce — a flag-delivery outage caused by a skills rollout.
56+
**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. Throwing on an unrecognised kind is the unknown-kind reconnect loop this feature must not reproduce — a flag-delivery outage caused by a skills rollout.
57+
58+
**No `mv` parameter, deliberately.** It selects the *flag* data model, the connection rejects any value but the flag default, and the generic agent-skill payload is served regardless of it. Sending `mv=1` — the skill payload's own model version — gets the whole connection refused.
5859

5960
**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.
6061

62+
**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 level, counted in `diagnostics.payloadsIgnored`, 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.
63+
6164
**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.hashlessObjects`) 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.
6265

6366
**`SkillObjectSet.snapshot` collapses to one object per key, and that is load-bearing here in a way it is not in Python.** `<root>/<key>/SKILL.md` is a single path, so a whole-store consumer must see one object per key or a `'*'` reconcile writes the same path twice and `allSkills` returns two versions of one skill. The Python SDK collapses in `newest_by_key`, above the seam; that helper is not in this SDK yet, so the collapse happens in the adapter. End-to-end behaviour is identical, and `getObject` still resolves a pinned version out of the full set. When `newestByKey` lands in `skills-core.ts`, move it and delete the note on the class.
6467

65-
**`close` aborts the signal, it does not just set a flag.** The delivery task spends its life awaiting a stream read, and a flag it never checks would leave a healthy stream running until the process exited. Every backoff timer and the `waitForSkills` timer are `unref`ed for the same reason: a background store must not be why `node` stays up.
68+
**There is one network timeout, not two.** `readTimeoutMs` is applied through a `ReadDeadline` composed with the store's own abort signal, so connect, headers and each body read share it. Its default is per mode: `DEFAULT_POLL_TIMEOUT_MS` bounds a whole poll request, `DEFAULT_STREAM_READ_TIMEOUT_MS` bounds the gap between reads on a stream, and tripping it on a stream that has gone quiet *reconnects* — the `timeouts` suite measures the bound against a socket that accepts and never answers. Do not add a separate connect timeout; the Python SDK cannot honour one and the two transports are kept option-for-option compatible.
69+
70+
**A body read that fails is recoverable; a protocol-reader or dispatch error is not.** `iterSse` wraps the read itself — a reset, a truncated chunk, the read deadline — as `RecoverableTransportError`, because a live stream dies mid-body far more often than it refuses to open, and the delivery loop reads anything else as a bug and stops for the process lifetime. Whatever the consumer's loop body throws while the generator is suspended at a `yield` passes through untouched, so a bug still surfaces as one. The consecutive-failure counter resets at each *committed payload* in `apply`, not when a connection returns: a stream only ever ends by being dropped, so resetting on return would count every healthy, server-recycled connection as a failure and give up on a healthy server. Every retry delay, `Retry-After` included, is clamped to `maxBackoffMs`.
71+
72+
**`close` aborts the signal, it does not just set a flag.** The delivery task spends its life awaiting a stream read, and a flag it never checks would leave a healthy stream running until the process exited. The store's signal is the parent of every `ReadDeadline`, so one abort reaches a pending connect and a pending read alike, and a store closing is passed through rather than reported as a failure. Every backoff timer, deadline timer and the `waitForSkills` timer are `unref`ed for the same reason: a background store must not be why `node` stays up.
6673

6774
---
6875

@@ -90,7 +97,7 @@ export { parseUsage, normalizeMode, parseAiConfig } from './tracking.js';
9097
export { skillRefs, getSkill, getSkillResult, getSkills, allSkills, InMemorySkillStore } from './skills.js';
9198
export { SKILL_OBJECT_KIND, MAX_SKILL_CONTENT_BYTES } from './skills-core.js';
9299
export { writeSkills, SKILL_FILENAME, MANIFEST_FILENAME, MANIFEST_VERSION } from './skills-fs.js';
93-
export { FDv2SkillStore, DEFAULT_BASE_URI, SDK_DATA_MODEL_VERSION } from './skills-fdv2.js';
100+
export { FDv2SkillStore, DEFAULT_BASE_URI } from './skills-fdv2.js';
94101
export { watchSkills, SkillWatcher, DEFAULT_DEBOUNCE_MS } from './skills-watch.js';
95102
export type { WriteSkillsOptions } from './skills-fs.js';
96103
export { createSkill, createSkillOutcome, createSkillReference } from './types.js';

0 commit comments

Comments
 (0)