Skip to content

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

Open
XieX wants to merge 2 commits into
xie/agent-skillsfrom
xie/python-skills-kinds-param
Open

XieX wants to merge 2 commits into
xie/agent-skillsfrom
xie/python-skills-kinds-param

Conversation

@XieX

@XieX XieX commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Stacks on #94 -> #93 -> #87. Spec: ai-sdks-monorepo#23. TypeScript counterpart: js-ai-sdk#87. 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} (so we need to opt-in to see skills).

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, not for correctness/optimization.

It also fixes something that was already wrong. A skill-enabled environment assigns two payloads, so _ProtocolReader has been warning about the second 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 prints exactly what the recoverable reading gives you:

Skill delivery has stopped and will not retry: gave up after 2 consecutive
failures; last error: LaunchDarkly returned HTTP 422.

...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, caught ahead of _RecoverableTransportError (which it subclasses, so nothing else has to change):

  • counted under the new StoreDiagnostics.payload_unavailable;
  • logged once per store, with a message that names the state rather than the status;
  • retried at max_backoff indefinitely — at the cap because _failures deliberately never moves, so the exponential schedule would otherwise sit at the initial delay forever;
  • kept off connection_failures, last_error and failed.

It commits no payload, so is_initialized() stays false and write_skills("*") still withholds the prune. That is the right answer: "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

Nine, each of which fails with the source reverted:

  • the declaration on /sdk/poll (first request and the one carrying a basis) and on /sdk/stream, and on the from-scratch 400 retry — where the two existing query == {} assertions became {"kinds": ...}, which is a better assertion: the declaration is not client state;
  • FDV2_PAYLOAD_KIND and FDV2_OBJECT_KIND held apart;
  • the 422's classification, and that its message explains the state;
  • that a 422 repeated well past max_consecutive_failures=1 neither stops delivery nor counts, and is said exactly once;
  • that it waits the cap and not the initial delay (recording _stop so the assertion is on the interval asked for, not on wall-clock timing);
  • that a skill payload arriving after two 422s is picked up, with both diagnostics reading as what happened;
  • that it leaves the store uninitialised, so a wildcard reconcile leaves a stale file on disk.

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

Gate

From python/: make test (1888 passed, 11 skipped), make typecheck (clean, 51 source files), make lint, make format-check — all clean.

🤖 Generated with Claude Code, edited by @XieX


Note

Overview
FDv2 skill delivery now opts into the agent-skill payload and treats “no payload yet” as idle, not broken.

Every poll/stream URL includes ?kinds=agent-skill (with basis when present) so connections receive the skill payload instead of default flag data, and stay single-payload for _ProtocolReader. HTTP 422 (no assigned agent-skill payload—typical before the first skill exists) maps to _NoSkillPayloadError: retry forever at max_backoff, increment StoreDiagnostics.payload_unavailable, log once, and do not touch connection_failures, last_error, or failed. Because 422 commits nothing, is_initialized() stays false and wildcard write_skills still avoids pruning stale files.

Docs (README.md, agents.md) describe the waiting state vs delivery failure; tests cover kinds on poll/stream/400-retry, 422 classification and backoff, recovery after skills appear, and prune safety.

Reviewed by Cursor Bugbot for commit b4f29a8. 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
max_consecutive_failures and then report "gave up after N consecutive failures:
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, caught ahead of _RecoverableTransportError, said
once, counted under the new payload_unavailable diagnostic, retried at
max_backoff indefinitely, and kept off connection_failures, last_error and
failed. The retry is at the cap because _failures deliberately never moves, so
the exponential schedule would otherwise sit at the initial delay forever.

Nine tests, each of which fails with the source reverted: the declaration on
both endpoints and on a from-scratch retry, the two kind constants held apart,
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 uninitialised 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
request as carrying `mv`.

Gate from python/: make test (1888 passed, 11 skipped), typecheck, lint,
format-check all clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"The answer for a project in which no skill has ever been created" appeared
seven times in one file: on the diagnostic, on the exception class, in
_classify_status, on the once-per-store flag, and twice in the delivery loop.

_NoSkillPayloadError now owns the explanation, since it is what the other sites
refer to, and each of those states only what is local to it: the diagnostic
names the type and keeps the "not a connection_failures" distinction, the
except block keeps why it is caught first and why it waits the cap, and
_classify_status keeps nothing -- 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. 15 lines of
comment removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
XieX added a commit to launchdarkly/js-ai-sdk that referenced this pull request Sep 24, 2026
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>
Base automatically changed from xie/python-skills-revoke-by-omission to xie/python-agent-skills-review-fixes September 24, 2026 19:33
Base automatically changed from xie/python-agent-skills-review-fixes to xie/agent-skills September 24, 2026 19: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.

1 participant