feat(client): Agent Skills — the FDv2 delivery transport - #83
Conversation
FDv2SkillStore puts LaunchDarkly's SDK-facing FDv2 channel underneath the protocol layer: GET /sdk/poll and GET /sdk/stream, authenticated with the environment's server-side SDK key, streaming by default. It carries basis across requests, sends If-None-Match and treats 304 as a current answer, retries with capped jittered backoff, honours Retry-After only up to max_backoff, gives up after a bounded run of consecutive failures where a committed payload resets the count, and keeps serving last known good through every failure. A mobile key or client-side environment ID is refused in the constructor. Standard library only. close interrupts the socket rather than only setting a flag, because the delivery thread lives in a read no flag can reach; without that every shutdown of a healthy stream waited out the full join timeout. The no-store message now names FDv2SkillStore first, and watch_skills points at it as the store with a delivery transport. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a442a7c. Configure here.
| if self._stop.is_set(): | ||
| # ``close`` interrupted the read on purpose. | ||
| return | ||
| raise |
There was a problem hiding this comment.
Stream read errors stop delivery
High Severity
A timeout, reset, or incomplete read on a live stream is re-raised as a raw exception. _run then treats it as unexpected and calls _give_up, so delivery stops for the process lifetime. Connect failures on the same stream are already wrapped as _RecoverableTransportError; only the body path is not. read_timeout exists to bound a dead stream, but tripping it permanently disables updates and revocations.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit a442a7c. Configure here.


Stacked on the protocol-layer PR (
xie/skills-fdv2-protocol). Third of three PRs split out of #69, and the one that makes the feature real: the network underneath the protocol reader, exported asFDv2SkillStore.Why this shape
Skill content arrives over
GET /sdk/pollandGET /sdk/stream, authenticated with the environment's server-side SDK key. These are the SDK-facing endpoints the base SDK's FDv2 data source uses, and the channel that payload signing will eventually cover. No private route is involved, and no credential other than the environment's own SDK key ships to a customer host. Standard library only, so the content path adds no dependency to a package whose sole runtime dependency isopentelemetry-api.What's here
FDv2SkillStore. Authenticates, streams (default) or polls, carriesbasisacross requests, sendsIf-None-Matchand treats 304 as a first-class current answer, and servesget_object/all_objects/add_listener/remove_listenerfrom what the protocol reader has committed. Capped jittered backoff;Retry-Afterhonoured but clamped tomax_backoffand rejected when non-finite; bounded consecutive-failure retries, where a committed payload resets the count. One network timeout,read_timeout, whose default follows the mode: 10s for a whole poll, 300s between reads on a stream. A mobile key or client-side environment ID raises from the constructor. Last known good survives every failure;diagnosticsandfailedreport the degradation.closeinterrupts the socket. The delivery thread parks in a read no flag can reach, and closing a urllib response from another thread does not unblock CPython's buffered reader, so_interrupt_readshuts the socket down underneath it. Without that every shutdown of a healthy stream blocked for the full join timeout.Above the interface, two strings:
NO_STORE_MESSAGEnow namesFDv2SkillStorefirst, since it is the first thing a user sees on a missing store and offering only the development store was wrong once a production transport existed; andwatch_skills' refusal message names it as the store with a delivery transport.Bugs found and fixed while testing the loop
Five, all sharing one shape: the store stopped delivering while continuing to report itself healthy.
_stream_oncealways ends by raising, so a reset on return was unreachable andfailuresgrew for the whole process lifetime. Eleven fully successful payload transfers were enough to tripmax_consecutive_failuresand stop delivery for good, revocations included. A commit now resets the count, in_apply.Retry-Afterkilled the delivery thread.float("inf")parses, andEvent.wait(inf)raisesOverflowErrorfrom inside the recoverable-error handler. Non-finite values are rejected and every honoured delay is clamped tomax_backoff.close()during the initial connect waited out its full join timeout.self._connectionwas assigned after the connect returned, so aclose()in that window found nothing to interrupt. The stop flag is re-checked immediately after the assignment.close()blocked for the full join timeout on every healthy stream. See_interrupt_readabove.closewas reported as a delivery failure.Also:
connect_timeoutwas accepted and never used, so a poll against a black-holed host hung for 300s rather than 10. It is gone, with the request timeout now chosen by mode, andTestTimeoutsmeasures the bound against a socket that accepts and never answers.Tests
_FakeFDv2Endpointis an in-processThreadingHTTPServerimplementing the wire contract, so request construction and header handling are exercised over real sockets rather than mocked. Covers skill put/delete over the wire, mixed payloads, 304,basisround-tripping, reconnect/backoff in both modes,Retry-Afterincluding non-finite and oversized values, bounded retries and the reset on commit, prompt shutdown during connect and during a healthy stream, hashless envelopes end to end through the accessors, server-side-only credentials, timeouts, andwatch_skillsover the transport: a wire-level revocation pruning a file without a restart.Full suite 1629 passing, 11 skipped;
ruff,ruff format, andmypyclean.Open items, none in this PR's scope
contentHashis not on the wire yet. Against a real environment today every skill resolves to nothing. This PR makes that loud (an error per hashless object, a summary per wholly-hashless payload,diagnostics.hashless_objects) rather than surviving it.inline-resourcedelivery is not deployed. No account can receive skill objects until it ships.mvis a guess. Defaulted to1, overridable via constructor. The most likely thing to be wrong on first contact with a real server.ld-relaydoes not speak the FDv2 endpoints, so relay-only deployments cannot receive skills in Beta.Nothing here has touched a real LaunchDarkly environment, because it cannot yet.
🤖 Generated with Claude Code
Note
Overview
Adds production skill delivery via
FDv2SkillStore, aSkillStorethat pulls agent skills from LaunchDarkly over the SDK-facing FDv2GET /sdk/pollandGET /sdk/streamendpoints (stdlib HTTP/SSE, server-side SDK key only). Streaming is the default; poll mode is optional. The store runs delivery on a background thread, commits payloads atpayload-transferred, keeps last known good on outages, exposesStoreDiagnostics/failed, and notifieswatch_skillsthroughadd_listener.Lifecycle and resilience:
start(),wait_for_skills(), context-manager support, mode-specificread_timeout, capped exponential backoff with boundedRetry-After, andclose()that shuts down the socket so blocked reads unblock promptly.Public surface:
FDv2SkillStoreandStoreDiagnosticsare exported; README/agents docs describe production setup, beta caveats, and hashless-object diagnostics.NO_STORE_MESSAGEandwatch_skillserrors now point atFDv2SkillStorefirst.Tests: In-process fake FDv2 server plus broad coverage (basis/ETag/304, revocations, retries, timeouts, credentials, end-to-end accessors and
watch_skills).Reviewed by Cursor Bugbot for commit a442a7c. Bugbot is set up for automated code reviews on this repo. Configure here.