Repository navigation
fix(client): Agent Skills pre-release must-fix findings - #148
Conversation
`_iter_sse` logged and skipped an event whose data did not parse, so the `payload-transferred` after it committed the transfer without it and advanced the basis. A corrupted `delete-object` then left a revoked skill served indefinitely, because the reconnect asked only for changes since a basis that already claimed the revocation; a corrupted `put-object` in an `xfer-full` revoked that skill by omission. The event now raises `_RecoverableTransportError`, as the base SDK's FDv2 stream interrupts on a `JSONDecodeError`. The delivery loop abandons the in-flight payload and reconnects from the last committed basis, so the server retransmits the whole transfer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…topping A `goodbye` with `catastrophe: true` was raised as a fatal error, so delivery stopped until something called `start()`. Neither base SDK does that: Python's `ldclient` does not read the flag, and the Go SDK only logs it. It is now a recoverable disconnect logged at ERROR. It is not marked `recycled`, so the delivery loop counts it as a failure even after a completed exchange, where an ordinary goodbye is a quiet recycle. The reconnect resumes from the last committed basis. `_TransferOutcome.fatal` had no other producer and is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`skill_refs(None)` returned `[]`. `None` is the config a failed `inspect_config` returns, so the obvious pipeline `write_skills(skill_refs(info["config"]), root)` reached `write_skills`, which prunes by default, with an empty list and deleted every managed skill during an outage. A non-dict config now raises `ValueError`, as a malformed `skills` field already does. A dict with no `skills` key still returns `[]`. The docstring, README, and agents.md no longer promise `[]` for `None`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tal stage The README promised that `ld.skills.integrity_failure` "will not be renamed". Agent Skills ships from the experimental entry point, whose names may change in a minor release with a changelog entry, so the record's name, fields, and `reason_code` values carry the same terms. agents.md now says a rename needs every SDK at once and a changelog entry, rather than never. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jeffdupont
left a comment
There was a problem hiding this comment.
All four fixes do what the PR body says, and each named test fails when its fix is reverted. Two things should still change before this merges: a dropped stream is now logged only at debug in the common case, and an unknown event with non-JSON data now drops the connection. At ecb21b21 I get 2297 passed, 11 skipped. ruff, format (134 files) and mypy (60 files) are clean, and CI is green. The base hasn't moved. main is already contained in it, so the trial merges onto xie/agent-skills and onto main are both clean.
Reverting each fix fails its tests:
- fix 1 (skip the event with a warning, as before): 2 tests fail,
test_a_malformed_event_abandons_its_transfer_and_keeps_the_basisand the_iter_ssetest - fix 2 (catastrophe raises fatal): 2 fail, both catastrophe tests
- fix 3 (
return []for a non-dict): 3 fail, all three params
Near-miss mutations that also fail a test: a fatal error instead of a recoverable one, yielding None instead of raising, a recycled catastrophe, a catastrophe logged at WARNING, a catastrophe that clears the held set, None-only [] (a list or string still raising), TypeError, raising on an absent skills key, and a truthiness check ([] for any falsy config).
I also ran a probe against the real _iter_sse and delivery loop:
- A truncated
put-objectinside anxfer-fullcommits nothing. The held set is unchanged, and the reconnect sendsbasis-1. So the drop does apply toxfer-full. - A repeated catastrophic goodbye backs off 1, 2, 3, 4, counts 4 failures, logs 4 ERRORs, and never sets
failed. Withsilent: trueit still logs at ERROR, as js #120 does. - 401, 403, 404 and 422 still stop delivery after one connection.
fatalhad no other producer, and_classify_status(skills_fdv2.py:931-977) and_ResponseTooLargeErrorstill reach_give_up. Removing it leaves nothing that should stop unstopped. - 304/etag and restart: the change is stream-only. A poll body that isn't JSON was already recoverable (:1194), and an etag is adopted only after a completed exchange (:1838-1846). A recoverable error never reaches
_give_up, so the #129/#139 restart rules aren't involved.
- A non-JSON event after a commit on the same connection is logged only at debug. That is the normal case for a revocation: the stream commits the first payload, and later changes arrive on the same connection.
_runlogs every recoverable error at debug once the connection has answered (skills_fdv2.py:1748-1755,if answered:), whether or not it was counted. My probe logsDEBUG The FDv2 stream ended after a complete answer (an FDv2 'delete-object' event's data was not JSON ...), and nothing at WARNING. Before this PR,_iter_sselogged a WARNING. Only a non-JSON event before any answer gets the "Skill delivery failed" warning. js #120 warns in both cases, because its loop warns on any disconnect that isn'texpected(skills-fdv2.ts:1861-1862). The catastrophe fix already handles this by logging in the reader. The smallest fix is alogger.warningin theexceptat :1266, before the raise. A test for it would be the malformed-event test withcaplog. - An unknown event, or a
heart-beat, with non-JSON data now drops the connection._iter_sseparses every event beforehandleroutes it. Soevent: some-future-eventwithdata: helloreconnects, and the same goes forheart-beatwithdata: ping(probed: 2 connections,connection_failures1). That contradictshandle's "Unknown event names are ignored, by contract" (:565), and TESTING.md :1943 on #48, the line right after the new non-JSON bullet: "Assert an unrecognised event neither raises nor disturbs state".ldclient9.16 doesn't parse first. It callsjson.loadsinside each known-event branch and only logs an unknown one (ldclient/impl/datasourcev2/streaming.py:339-391). That makes the PR's "matchingldclient" true only for known events. LaunchDarkly's streamer sends heartbeats as the comment:\n(streamerv2_common.go:13), so this is a forward-compatibility risk, not a live one. Still, it's cheap to fix: raise only for the six nameshandlereads, and pass any other event through without parsing it. js #120 has the same issue (dispatch, skills-fdv2.ts:1217-1231), so both should change, and #48 :1942 should say "a known event". - The
xfer-fullhalf of fix 1 has no test. If I drop the connection only for a non-JSONdelete-objectand skip any other non-JSON event, every test still passes (1471 passed). The PR body,agents.md(:303-309) and #48 all say a lostput-objectin anxfer-fullrevokes that skill by omission, andagents.mdnames this test as asserting it. Could you parametrize the test, or add a second one, with a truncatedput-objectinside anxfer-full? That probe passes here.
Smaller items
- A delete that is valid JSON but the wrong shape still commits the rest of the transfer. I put these into an
xfer-changesin place ofdelete-object revoked:1: data"revoked:1",[1], empty,{kind, version}with nokey, an integerkey, and nokind. In all six,payload-transferredcommits,revokedis still held, and the basis moves tobasis-2. That's the bug fix 1 closes, reached through a server bug instead of corrupt bytes.ldclientinterrupts on five of the six (DeleteObject.from_dictraises on a missing field, andjson.loads("")raises). Ignoring a delete with nokindfollows this SDK's rule for unknown kinds, so that one is a choice. A skill-kind delete with no usable key (_tombstone_from_delete, :376-382), or empty data on a put or delete, is a lost revocation, though. Whether these should drop the transfer is worth deciding in #48. js #120 behaves the same (tombstoneFromDelete, skills-fdv2.ts:441-446). Not a blocker. skill_refsstill acceptsNonein its type (skills.py:202,AiConfigRep | None). SinceNonenow always raises,config: AiConfigRepwould let mypy flagskill_refs(info["config"])with no guard, before it runs. JS has the same| null | undefinedin its API report. Optional, and it should change in both or neither.- The README's changelog promise is weaker than the spec's. README :574 says "called out in the changelog". #48 and js #120 say "a changelog entry under Experimental". Neither repo's
release-please-config.jsondefines an Experimental section, so as written, nothing produces that heading. Worth one line in #48 on how it gets made. - Two
_abandon_in_flightcalls survive removal: the loop's (:1720) and the goodbye's (:739). The nextserver-intentresets the pending set anyway, so neither one is a bug. Both predate this PR.
Cross-SDK and spec
js #120 (fa44c1a9) makes the same four changes with the same semantics: the non-JSON drop, the counted and recoverable catastrophe logged at error whatever silent says, skillRefs throwing TypeError for null, undefined, arrays and primitives while an object with no skills returns [], and the experimental-stage wording. It shares issues 2 and 3. It doesn't share issue 1, because JS warns. Its fifth fix (the referenced waitForSkills timer) has no Python equivalent, and #48 says so. Its PR body still lists "Python's skill_refs(None) still returns []" under divergences, and this PR resolves that.
#48 (35e6a6e2) specifies all four fixes, and the code matches it. The one conflict is :1942 against :1943 (issue 2). Before this, main's TESTING.md said nothing about skill_refs(None). §3.20 (:1596-1599) covers only an absent field, an empty field and malformed entries. #48 adds the rule (:1597). #48 still has the row-6 TODO for js #121's option names, and like #46 it leaves the stale malformed-skills rules alone (§3.5 :968-980, §3.20 :1599).
The READMEs and agents.md in both repos now match the new behaviour, and I found no stale "[] when nullish" text. The public docs page (ld-docs-private #8635, merged) needs a follow-up:
- It guards on
info["meta"] is None(agent-skills.mdx:233, :255) andinfo.meta === null(:375).inspect_configreturns a non-Nonemetawithconfig: Nonefor a disabled config and for one that fails to parse (lifecycle.py:431, :440). In both cases the docs example now raisesValueError/TypeErrorout of theelsebranch. Before this PR it pruned every managed skill. The guard should be onconfig. - The JS watcher example (:394) and both "discover" examples (:165, :336) call
skillRefs(info.config)with no guard at all. - :566 still says the event name "will not change outside a major release". That contradicts fix 4 and #48's "Neither README may promise more than its stage allows".
- Pre-existing, but it's the same page: :246 says
watch_skills(skill_refs(...))prunes a revoked skill within seconds, while the README says an explicit list never prunes on revocation (README.md:756-760).
#87
This PR doesn't change my 5 Oct approval of #87's code, and it removes the catastrophe fatal I hadn't flagged. #87 still shouldn't reach main before:
…on a dropped one A heart-beat or an unknown event whose data is not JSON no longer drops the stream; it is yielded with None data, as ldclient parses only inside its known-event branches. A known event whose data is not JSON is now logged at WARNING before the connection drops, because the delivery loop logs a disconnect after a completed exchange at debug. The malformed-event test now also covers a truncated put-object inside an xfer-full, which would otherwise revoke that skill by omission. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ailure changes Matches TESTING.md §0.1 and js-ai-sdk's README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@jeffdupont, thanks. Issues 1–3 are fixed at On the smaller items:
ai-sdks-monorepo#48 is updated too ( |
jeffdupont
left a comment
There was a problem hiding this comment.
Re-reviewed at fca6f051. All three fixes I asked for are in, and I checked each of your replies against the code. They're accurate. One small test gap (below) should be closed before merge, and then I'd approve.
At fca6f051 I get 2300 passed and 11 skipped. ruff, format (134 files) and mypy (60 files) are clean, and CI is green. The head already contains xie/agent-skills (9c22ba9) and main (0b4df05).
Each fix, reverted:
- Warning on a dropped event (
skills_fdv2.py:1291): I deleted thelogger.warningline, and both cases oftest_a_malformed_event_abandons_its_transfer_and_keeps_the_basisfail. - Parse only known events (
_EVENTS_WITH_DATA, :114-123): this is exactly the six nameshandlepasses data to (:580-591).heart-beatnever reads its data (:592), and an unknown event only logs at debug (:594), soNonedata is safe for both. When I restored parse-every-event, both cases oftest_an_event_handle_does_not_read_is_not_parsedfailed. xfer-fullcase: my earlier mutation (drop the connection only for a non-JSONdelete-object) now fails[put-object-in-xfer-full]and nothing else.
Fix before merge: nothing tests that a goodbye reaches handle with its data parsed. I deleted _EVENT_GOODBYE from _EVENTS_WITH_DATA, and the client suite still passes (1472 passed). A goodbye would then arrive as None, catastrophe: true would never be read, and a catastrophic goodbye would become an uncounted recycle. That quietly undoes fix 2. Both catastrophe tests skip the SSE parser. One calls handle directly (:926), and the store test feeds already-parsed tuples (:2722). The parse set is new in this PR, so this is a regression path this PR opened. One case through _iter_sse closes it. Inline. js #120 has the same gap.
Your two "leave as is" replies hold up too:
| Noneonskill_refs:inspect_configreturnsdict[str, Any](lifecycle.py:385-388). I triedconfig: AiConfigRepwith an unguardedskill_refs(info["config"]), and mypy reports no issues. Fine to leave it.- Changelog wording: README :574-575 now matches TESTING.md §0.1 on
main(:118). Producing the Experimental heading is still a release-process gap, but it isn't this PR's to fix.
Nit, inline: a non-JSON event that arrives before the first answer now logs two WARNINGs for one disconnect.
Still open outside this PR:
…ye through SSE Each of the six events handle reads is asserted to arrive parsed, and the store-level catastrophe test now goes through _iter_sse. Dropping goodbye from _EVENTS_WITH_DATA, which would read a catastrophe as a quiet recycle, now fails both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…true TESTING.md §3.25 now requires a catastrophe to be logged at ERROR even when silent, unlike Go, which logs only a goodbye that is not silent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pin the keyless-object rule
Empty data on one of the six events handle reads now drops the connection,
as json.loads("") does in ldclient. Read as None, a delete-object was ignored
and a payload-transferred committed with no selector.
Tests pin the experimental-stage rule for a put or delete with no usable key:
it is warned and ignored and its transfer commits (TESTING.md §3.25).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@jeffdupont, thanks. The goodbye gap is closed (inline), and I've left the double-warning nit as it is (inline). Since your review there are three new commits:
Wrong-shape objects, decided.
At |
|
@jeffdupont Mind confirming the fixes here? |
Fixes the must-fix-before-merge findings from the pre-release review of Agent Skills (#87, reviewed at
0306c69). One commit per fix. Each code fix has a test that fails on0306c69and passes here.1. A stream event whose data isn't JSON no longer commits the rest of its transfer
_iter_sseused to log the event and skip it. Thepayload-transferredthat followed then committed the transfer without the event and advancedbasis. A corrupteddelete-objectleft a revoked skill served indefinitely, because the reconnect asked only for changes after a basis that already covered the revocation. A corruptedput-objectin anxfer-fullrevoked that skill by omission.It now raises
_RecoverableTransportError, matchingldclient's FDv2 stream, which interrupts onJSONDecodeError. The in-flight payload is dropped and the reconnect sends the last committed basis. The drop is logged at WARNING in_iter_sse, because_runlogs a disconnect after a completed exchange at debug, and a revocation usually arrives after a commit on the same connection.Only the six events
handlereads are parsed (server-intent,put-object,delete-object,payload-transferred,goodbye,error). Aheart-beator an unknown event is yielded withNonedata, so its data can't drop the connection.ldclientlikewise callsjson.loadsonly inside its known-event branches.Tests:
test_a_malformed_event_abandons_its_transfer_and_keeps_the_basis, parametrized over two cases:delete-object-in-xfer-changescommitsa:1andrevoked:1, then sends anxfer-changescontaining a cleanput fresh:1and a truncateddelete revoked:1, thenpayload-transferred. At the reconnect, nothing from that transfer is committed:revokedis still held andfreshis absent.put-object-in-xfer-fullcommitsa:1, then sends anxfer-fullwith a cleanput b:1and a truncatedput a:1. At the reconnect,ais still held andbis absent.In both cases the reconnect carries
basis-1, a clean retransmission then applies in full, and a WARNING names the event.test_an_event_handle_does_not_read_is_not_parsedcoversheart-beatandx-future-eventwithdata: not json.test_an_event_handle_reads_is_parsedpins the parse set (all six), and the store-level catastrophe test goes through_iter_sse.Empty data on a known event is not JSON either (
bf559fb), asjson.loads("")raises inldclient. Read asNone, adelete-objectwas ignored and apayload-transferredcommitted with no selector. The malformed-event test has a third case,empty-delete-object-in-xfer-changes, andtest_an_event_handle_reads_with_no_data_drops_the_connectioncovers all six names. Two reader tests pin the experimental-stage rule for a put or delete with no usablekey: it is warned and ignored, and its transfer commits (TESTING.md §3.25, ai-sdks-monorepo#48).2. A
goodbyewithcatastrophe: trueno longer stops deliveryIt was raised as fatal, so delivery stopped until something called
start(). Pythonldclient9.16 never readscatastrophe, and the Go SDK only logs it. It is now a recoverable disconnect, logged at ERROR. It is not markedrecycled, so it counts towardconnection_failureseven after a completed exchange._TransferOutcome.fatalhad no other producer, so it is removed.Tests:
test_a_catastrophic_goodbye_is_fatalis flipped totest_a_catastrophic_goodbye_is_a_counted_disconnect. New:test_a_catastrophic_goodbye_reconnects_and_is_counted. No docstring or README said the goodbye was fatal; only the code and that test did.3.
skill_refsraises on a config that is not a dictskill_refs(None)returned[], andNoneis the config a failedinspect_configreturns. Sowrite_skills(skill_refs(info["config"]), root)pruned every managed skill during an outage. A non-dict config now raisesValueError, as a malformedskillsfield already does. A dict without askillskey still returns[]. The docstring, the README (quick-start comment, validation paragraph, API table), andagents.mdare updated.Test:
test_a_config_that_is_not_a_dict_raises, parametrized overNone, a list, and a string.4. Docs: the
ld.skills.integrity_failurecommitmentThe README said the event "will not be renamed". It now says that during the experimental stage the name, fields, and
reason_codevalues may change in a minor release, with a changelog entry under Experimental (TESTING.md §0.1's wording, and js-ai-sdk's).agents.mdis updated to match. Docs only, so there is no test.Checks
uv run pytest(repo root): 2321 passed, 11 skipped.make lint,make format-check, andmake typecheckare clean.Spec: https://github.com/launchdarkly/ai-sdks-monorepo/pull/48. JS counterparts: launchdarkly/js-ai-sdk#120.
🤖 Generated with Claude Code
Note
Overview
Addresses pre-release review must-fixes for Agent Skills in the Python client: safer reference discovery, FDv2 stream consistency, and doc alignment.
skill_refsnow raisesValueErrorwhen the config is not a dict (includingNonefrom a failedinspect_config), instead of returning[]. That stopswrite_skills(skill_refs(...), root)from pruning every managed skill during a config outage. A dict with noskillsfield still returns[].FDv2 streaming treats non-JSON data on the six events the protocol reader consumes as a recoverable disconnect (
_RecoverableTransportError), matching baseldclientbehavior and abandoning the in-flight payload so a laterpayload-transferredcannot commit a partial transfer and advancebasis.heart-beatand unknown events are not parsed.goodbyewithcatastrophe: truereconnects with backoff (ERROR log, counted failure) instead of stopping delivery fatally;_TransferOutcome.fatalis removed.Docs (
README.md,agents.md) describe theskill_refscontract, stream/keyless-object behavior, and soften theld.skills.integrity_failurestability wording for the experimental stage (minor-release changes with changelog). Tests cover malformed SSE, catastrophic goodbye, non-dict configs, and keyless put/delete experimental rules.Reviewed by Cursor Bugbot for commit bf559fb. Bugbot is set up for automated code reviews on this repo. Configure here.