Skip to content

feat(client): declare ?kinds=agent-skill, and treat a 422 as "no skills yet" - #87

Open
XieX wants to merge 3 commits into
xie/skills-14-review-closeoutfrom
xie/skills-kinds-param
Open

XieX wants to merge 3 commits into
xie/skills-14-review-closeoutfrom
xie/skills-kinds-param

Conversation

@XieX

@XieX XieX commented Sep 24, 2026 •

Copy link
Copy Markdown

Stacks on #67. Spec: ai-sdks-monorepo#23. Python counterpart: python-ai-sdk#109. Server side: streamer#4730.

This is the SDK half of the release blocker: other SDKs retry forever when they see a skill payload. Flag Delivery's answer is ?kinds=, which narrows a connection to the payload kinds it declares, defaulting to {flagging}.

The declaration

Every request now carries kinds=agent-skill — both endpoints, and on the first request as well as the ones after it, since it selects what the connection is served rather than describing what the store already holds. Without it the store receives the environment's flag payload and no skills at all, so this is a functional requirement rather than a courtesy.

It also fixes something that was already wrong. A skill-enabled environment assigns two payloads, so ProtocolReader has been hitting warnMultiplePayloads on every connection, reading only the first intent, and never adopting a basis for the flag payload — re-downloading and discarding it on every reconnect. Declaring one kind makes the connection single-payload, which is the shape the reader is built for. (That is also why declaring flagging,agent-skill is not the safe-looking option it appears to be.)

No mv, still — but for a corrected reason. It selects the flag data model, and objectQueryForCommand overrides whatever a request asks for with the payload's own default for any non-flagging payload, so sending it would state a preference that is ignored. The old comment said the connection would be refused over it.

The 422

Delivery answers 422 no_accepted_payloads when the credential's assignment holds no payload of the declared kind. That is every project in which no skill has ever been created: gonfalon's payloadIDsForEnvironment appends the agent-skill payload ID only "if one already exists" and never lazily creates it.

Both obvious classifications are wrong. Reverting just the 422 case and rerunning the new tests gives you the recoverable reading: gave up after N consecutive failures: LaunchDarkly returned HTTP 422, permanently, for an environment whose only problem is that nobody has made a skill yet. Fatal is no better: the skill created a minute later never arrives until the process restarts.

So NoSkillPayloadError, which reuses the expected flag the recycled-stream case already established:

  • counted under the new StoreDiagnostics.payloadUnavailable;
  • logged once per store, with a message that names the state rather than the status;
  • retried at maxBackoffMs indefinitely — at the cap because failures deliberately never moves, so the exponential schedule would otherwise sit at the initial delay forever;
  • kept off connectionFailures, lastError, failed and the per-attempt warning, which expected handles for free.

It commits no payload, so isInitialized() stays false and writeSkills('*') still withholds the prune — the readiness gate #67 added, doing exactly what it was added for. "LaunchDarkly has no skill payload for this environment" and "this environment's every skill was revoked" are the two readings of an empty answer, and only the second may delete a customer's files.

Tests

Seven, each of which fails with the source reverted (checked one mutation at a time, including unpinning the delay and removing the log-once guard separately):

  • the declaration on /sdk/poll (first request and the one carrying a basis) and on /sdk/stream, plus the four endpoints URL assertions, which now pin the full query string on both hosts;
  • FDV2_PAYLOAD_KIND held apart from FDV2_OBJECT_KIND by source text, the way the seam-kind test already does it — an alias would satisfy a value assertion on its own;
  • the 422's classification, and that its message explains the state;
  • that a 422 repeated well past maxConsecutiveFailures: 1 neither stops delivery nor counts, and is said exactly once;
  • that it waits the cap and not the initial delay, asserted by counting requests in a window rather than by timing one: at the 5 ms initial delay that window holds dozens, at the cap it holds the one;
  • that a skill payload arriving after two 422s is picked up, with both diagnostics reading as what happened;
  • that it leaves the store uninitialized, so a wildcard reconcile leaves a stale file on disk.

The fake endpoint gained defaultPollStatus, since "every request is answered 422" is not something a queue can express.

Gate

Check Result
tsc --noEmit clean
biome check . clean (118 files)
vitest run in packages/client 1024 passed / 10 skipped — the ten capability-gated TOCTOU tests
yarn test from the root every workspace green

🤖 Generated with Claude Code


Note

Overview
FDv2 skill delivery now declares kinds=agent-skill on every poll and stream request so LaunchDarkly serves the agent-skill payload instead of defaulting to flags. A new FDV2_PAYLOAD_KIND constant is kept separate from wire object kind skill, and StoreDiagnostics gains payloadUnavailable for this idle state.

HTTP 422 (“no payload of that kind”) is treated as no skills yet, not a broken connection: NoSkillPayloadError increments payloadUnavailable, logs once, retries indefinitely at maxBackoffMs without touching connectionFailures, lastError, or failed. The store stays uninitialized, so wildcard reconciles still do not prune disk.

Docs (README, agents.md) and skills-fdv2 tests cover the query param on both endpoints, 422 classification, backoff behavior, recovery when a payload appears, and reconcile safety.

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

…ls yet"

Delivery now narrows a connection to the payload kinds it declares and defaults
to flags (launchdarkly/streamer#4730), so the store has to ask for the
agent-skill payload or receive the environment's flags and no skills at all.
The declaration goes on every request, before any basis exists as well as
alongside one: it selects what the connection is served rather than describing
what the store already holds.

It also fixes something that was already wrong. A skill-enabled environment
assigns two payloads, so the reader has been warning about the second and
reading only the first intent, and the flag payload was re-downloaded and
discarded on every reconnect because its basis was never adopted. Declaring one
kind makes the connection single-payload, which is the shape the reader is
built for.

The 422 that comes with it is the interesting half. It is the answer when the
credential is assigned no agent-skill payload, which is every project where no
skill has ever been created -- gonfalon creates that row with the first skill
and never lazily. As an ordinary recoverable failure it would spend
maxConsecutiveFailures and then report "gave up after N consecutive failures:
LaunchDarkly returned HTTP 422" for an ordinary configuration; as a fatal one,
the skill created a minute later would never arrive without a process restart.
So it is its own class: NoSkillPayloadError, which reuses the existing
`expected` flag to stay off connectionFailures, lastError, failed and the
per-attempt warning, is said once per store, counted under the new
payloadUnavailable diagnostic, and retried at maxBackoffMs indefinitely. The
retry is at the cap because `failures` deliberately never moves, so the
exponential schedule would otherwise sit at the initial delay forever.

Seven tests, each of which fails with the source reverted: the declaration on
both endpoints and on both hosts, the two kind constants held apart by source
text, the 422's classification, that it never stops delivery and never counts,
that it waits the cap and not the initial delay, that a skill arriving after it
is picked up, and that it leaves the store uninitialized so a wildcard
reconcile prunes nothing. The fake endpoint gained a standing default status,
since "every request is answered 422" is not something a queue can express.

Also corrects docs that described the payload as classified `generic`, and the
`mv` rationale: delivery overrides the requested model version for any
non-flagging payload rather than refusing the connection over it.

Gate: tsc --noEmit clean, biome check clean, vitest run 1024 passed / 10
skipped in packages/client (the ten are the capability-gated TOCTOU tests),
and every workspace's suite green from the root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mirrors the Python trim (launchdarkly/python-ai-sdk#109): the same sentence had
been repeated at every site that touches the 422, so NoSkillPayloadError now
owns the explanation and the others state only what is local to them — the
diagnostic links the type and keeps the "not a connectionFailures" distinction,
the loop keeps why it waits the cap, and classifyStatus keeps nothing, since
the type it returns and the message it builds already say it twice over.

No behaviour change, and the user-facing 422 message is untouched. 12 lines of
comment removed.

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

Not this PR's change -- the case is byte-identical to the one on the base
branch -- but it went red on this PR's CI run and will keep doing so on a
loaded runner.

It queued the good payload and the hashless one up front, 20ms apart, then
relied on `waitForSkills` before reconciling. `waitForSkills` promises the
*first* commit and nothing about the second, so any pause longer than the poll
interval between it and the first `writeSkills` -- an `mkdtemp` on a busy
runner is enough -- lets the hashless payload commit first. Verification then
withholds every object, the reconcile writes nothing, and the assertion fails
as `ENOENT` on the read rather than as anything that names the cause.

Queueing the hashless payload only after the first reconcile has been asserted
removes the race without changing what the case asserts. Reproduced before the
fix by standing a 60ms sleep in for the runner (identical ENOENT), and the
fixed case survives 300ms in the same spot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant