Skip to content

feat(client): Agent Skills — the FDv2 delivery transport - #83

Merged
XieX merged 4 commits into
xie/skills-fdv2-protocolfrom
xie/fdv2-transport-split
Sep 15, 2026
Merged

XieX merged 4 commits into
xie/skills-fdv2-protocolfrom
xie/fdv2-transport-split

Conversation

@XieX

@XieX XieX commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

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 as FDv2SkillStore.

Why this shape

Skill content arrives over GET /sdk/poll and GET /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 is opentelemetry-api.

What's here

FDv2SkillStore. Authenticates, streams (default) or polls, carries basis across requests, sends If-None-Match and treats 304 as a first-class current answer, and serves get_object / all_objects / add_listener / remove_listener from what the protocol reader has committed. Capped jittered backoff; Retry-After honoured but clamped to max_backoff and 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; diagnostics and failed report the degradation.

close interrupts 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_read shuts 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_MESSAGE now names FDv2SkillStore first, 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; and watch_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.

  • The consecutive-failure counter never reset in stream mode. _stream_once always ends by raising, so a reset on return was unreachable and failures grew for the whole process lifetime. Eleven fully successful payload transfers were enough to trip max_consecutive_failures and stop delivery for good, revocations included. A commit now resets the count, in _apply.
  • A non-finite Retry-After killed the delivery thread. float("inf") parses, and Event.wait(inf) raises OverflowError from inside the recoverable-error handler. Non-finite values are rejected and every honoured delay is clamped to max_backoff.
  • close() during the initial connect waited out its full join timeout. self._connection was assigned after the connect returned, so a close() 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_read above.
  • A stream interrupted by our own close was reported as a delivery failure.

Also: connect_timeout was 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, and TestTimeouts measures the bound against a socket that accepts and never answers.

Tests

Rebase note. The previous push of this branch had silently reverted the protocol PR's last commit (payload identity: payloads_ignored, _is_foreign_payload, TestPayloadIdentity). Rebasing onto the updated protocol branch restored it; the full suite passes with it present.

_FakeFDv2Endpoint is an in-process ThreadingHTTPServer implementing 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, basis round-tripping, reconnect/backoff in both modes, Retry-After including 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, and watch_skills over the transport: a wire-level revocation pruning a file without a restart.

Full suite 1629 passing, 11 skipped; ruff, ruff format, and mypy clean.

Open items, none in this PR's scope

  • 🔴 contentHash is 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.
  • 🔴 Server-side skill delivery is not deployed. The wire shape this store reads — kind skill, key <key>:<version>, generic payload — is what streamer #4681 and gonfalon #70638 emit; both are still open. No account can receive skill objects until they ship and the producer is enabled.
  • 🟡 FDv2 is opt-in per account. A real environment returns 403 today; the store reports it as fatal and explains what to do.
  • 🟡 mv is a guess. Resolved: the request sends no mv. That parameter selects the flag data model and the connection rejects any value but the flag default, while the generic agent-skill payload is served regardless of it. The data_model_version constructor argument is gone with it.
  • 🟡 No payload signing on this channel yet, so Beta is TLS-only.
  • 🟡 The connection also carries the environment's flags. Skipped and counted; a transport property, not fixable here.
  • 🟡 ld-relay does 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 FDv2SkillStore, a production SkillStore that pulls agent skills over LaunchDarkly’s SDK FDv2 /sdk/poll and /sdk/stream endpoints (stdlib HTTP, background delivery thread, stream-by-default). It implements SkillStore (get_object, listeners, etc.) on top of the existing protocol reader, plus StoreDiagnostics, wait_for_skills, capped backoff with Retry-After, and close that interrupts blocked socket reads so shutdown is prompt.

Public surface: FDv2SkillStore and StoreDiagnostics are exported from the package; README documents production setup with init_client and watch_skills. Server-side SDK keys only; mobile/client credentials are rejected. Outages keep last-known-good content; accessors above the store are unchanged.

Delivery-loop fixes bundled here: reset consecutive-failure counts on successful commits / up-to-date answers (so healthy stream recycling does not stop delivery), safe handling of non-finite Retry-After, and not treating intentional close interrupts as transport failures. Removed unused connect_timeout; read_timeout is the single knob with mode-specific defaults.

Tests: in-process fake FDv2 server exercises poll/stream, basis/ETag/304, revocations, retries, timeouts, hashless payloads, and watch_skills over the transport.

Reviewed by Cursor Bugbot for commit 88c225e. Bugbot is set up for automated code reviews on this repo. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread packages/client/src/launchdarkly_ai_server/skills_fdv2.py
XieX and others added 2 commits September 11, 2026 15:27
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>
_Requester.stream wrapped only the connect as recoverable, so a read
timeout, reset or truncated chunk in the body reached the delivery loop
as whatever the socket raised. The loop read that as a bug and gave up:
delivery stopped for the process lifetime, taking updates and
revocations with it, the first time a socket died. read_timeout exists
to bound a stream that has gone quiet so the loop can reconnect, and
tripping it did the opposite.

The body now carries the same promise the connect already did. Wrapping
the line source rather than the whole read keeps protocol reader errors
out of it: those are raised from the consumer's loop body, where they
still surface as the bugs they are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@XieX
XieX force-pushed the xie/fdv2-transport-split branch from 6c527eb to c285ff5 Compare September 11, 2026 19:29

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.

Fix All in Cursor

❌ 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 c285ff5. Configure here.

Comment thread packages/client/src/launchdarkly_ai_server/skills_fdv2.py
Comment thread packages/client/src/launchdarkly_ai_server/skills_fdv2.py
Comment thread packages/client/src/launchdarkly_ai_server/skills_fdv2.py
XieX and others added 2 commits September 14, 2026 14:36
…n promptly

Three faults in the delivery loop, all of which left the store reporting
itself healthy while doing less than it claimed.

**An up-to-date stream tripped the failure cap.** The consecutive-failure
count reset only at a commit, and an environment whose skills are not
changing answers every reconnect with `intentCode: "none"` and transfers
nothing. A stream only ever ends by being dropped, so each recycle of a
perfectly healthy idle connection counted as a failure — announced with a
`goodbye` or not — and `max_consecutive_failures + 1` of them stopped
delivery for the process lifetime, revocations included. The reset on
commit covered only the case where content had changed, which is the case
that was easy to test and not the case that runs in production.

`_TransferOutcome` now reports `up_to_date`, and a complete answer that
transfers nothing breaks the row of failures exactly as a commit does. An
intent this module does not recognise is still not an answer.

**`close` could not interrupt a poll.** The interrupt reached the streaming
connection only, so polling parked in its request with nothing to reach and
`close` returned when its join timed out — on a 300s-class request, long
after the process meant to exit. `_Requester` now tracks the response of a
poll in flight and offers `interrupt`, which `close` calls alongside the
stream's own. A request still inside its connect has no response to reach;
that one is bounded by `read_timeout`, and `start` no longer leaves the
store inert when a join times out around it. An interrupt we asked for is
no longer recorded as a delivery failure.

**`close` left a waiter parked.** `wait_for_skills` waited on the first
payload alone, so a shutdown racing a waiter added the waiter's whole
timeout to it. Delivery ending is now its own event: a waiter is released
by a payload, a give-up or a close, and reports whether a payload actually
arrived rather than merely that it was let go. That also settles what
`_give_up` had been quietly asserting — it set the first-payload flag to
unblock waiters, which made `wait_for_skills` answer `True` for a store
holding nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A stream only ever ends by being dropped, and LaunchDarkly — and any proxy
in between — recycles a long-lived one. Every reconnect therefore logged
"Skill delivery failed" at WARNING, for as long as the process ran. Until
the previous commit that noise was bounded, because an idle stream gave up
after eleven recycles and went quiet; now that delivery correctly survives
them, it would run forever and describe a healthy store as failing.

A connection that got a complete answer before it ended — a committed
payload, or an up-to-date intent — delivered everything it was asked for,
so its reconnect is now DEBUG and says so. A connection that ended without
answering is the case the warning exists for and still gets it: a connect
that never landed, or a transfer that died part-way through.

Filling a customer's logs with a fault they do not have is not merely
untidy; it teaches them that the level which means something can be
ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@andrewklatzke andrewklatzke left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bit of concern re: what the process is going to look like for users (if I just wanna try out skills during the trial and need to contact support for opt-in to fdnv2 etc.)

Same nit/question as on the other PR about whether we thought about making this more generic for future implementations. A lot of the stuff in skills_fdv2 seems like fairly-reusable code. Not a priority right now but we should think about it.

Comment on lines +829 to +833
_FORBIDDEN_ADVICE = (
"The FDv2 protocol is opt-in per LaunchDarkly account and is served as HTTP "
"403 while it is off. Skill delivery needs it enabled; contact LaunchDarkly "
"support to enable it for your account."
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a lot of friction that is going to stop folks in their tracks imo - they need to contact support to opt into this? Is it on by default for new accounts?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, it's a huge barrier. The short answer is I don't know. Mitigating it (turning it on by default for new accounts, turning it on for AgentControl signups etc) is a followup task 👍

@XieX
XieX merged commit d2386c8 into xie/skills-fdv2-protocol Sep 15, 2026
7 checks passed
@XieX
XieX deleted the xie/fdv2-transport-split branch September 15, 2026 14:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants