Skip to content

fix(client): Agent Skills — transport, lifecycle and spec fixes from the TypeScript review - #93

Merged
XieX merged 18 commits into
xie/agent-skillsfrom
xie/python-agent-skills-review-fixes
Sep 24, 2026
Merged

XieX merged 18 commits into
xie/agent-skillsfrom
xie/python-agent-skills-review-fixes

Conversation

@XieX

@XieX XieX commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Fixes surfaced by the TypeScript code review that needed fixing on the Python side. Includes bugs discovered with the Python implementation, and contract changes made during the review that needed to be ported over to Python.

Commit What
6ac1577 Streaming host and stream_uri, above
c69d880 Retry-After: 0 floored at initial_backoff; objects_revoked counts only revocations that removed something
86fd04c close() is final; HTTP 404 fatal; HTTP 400 retried exactly once from scratch, then fatal (§3.25)
2f59b9a add_listener on a kind the store never delivers raises, on both shipped stores (§3.21)
daeedec Non-finite debounce rejected; the §3.26 watcher test gaps filled and the coalescing test de-vacuumed (§3.26)
4a204f8 skills: null fails the config parse instead of reading as absent (§3.5)
030e8c3 Verification checks size before encoding, so over-cap content with a lone surrogate reports over_size_cap (§3.21)
4092de7 agents.md's stale cross-language parity claim, and the README

Behaviour changes worth a second look

  • close() no longer resumes. start() on a closed store raises; construct
    a new one. Giving up at the failure bound is not closing, so that restart
    path is unchanged. Three existing tests pinned the old answer and moved with
    the change.
  • add_listener("flag", fn) now raises ValueError where it used to record
    a listener that never fired.
  • skills: null now fails the whole config parse. Previously accepted.

🤖 Generated with Claude Code, updated by @XieX


Note

Overview
Ports TypeScript review fixes into the Python Agent Skills stack: FDv2 delivery, store lifecycle, integrity telemetry, and config parsing, with matching README/agents.md updates.

FDv2SkillStore now treats polling and streaming as separate HTTPS origins (base_uri / stream_uri, defaulting to sdk.launchdarkly.com vs stream.launchdarkly.com). Transport behavior is tightened: HTTP 404 is fatal; 400 with client state triggers one full reconnect with state cleared, then fatals; declined foreign payloads no longer advance the resume basis; full transfers synthesize revocation tombstones when skills drop by omission; objects_revoked counts only keys actually removed; poll ETags are tied to the basis they were issued for; Retry-After: 0 is floored at initial_backoff. close() is final — start() on a closed store raises (construct a new store to resume).

Integrity and retrieval: new key_mismatch reason when a store returns verified content under the wrong key — emits the ld.skills.integrity_failure log (with served_key) but not the product integrity signal. verified_bytes uses a fixed order (size → encoding → hash) so over-cap surrogate content reports over_size_cap. add_listener on non-skill kinds raises on both shipped stores instead of registering a no-op listener.

Config / watcher: explicit skills: null fails the whole AI config parse (avoids prune deleting materialized files). watch_skills rejects non-finite debounce; tests and docs cover debouncing, on_reconcile, and boot-only revocation limits.

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

XieX and others added 8 commits September 17, 2026 09:56
`FDv2SkillStore` built both `/sdk/poll` and `/sdk/stream` from a single
`base_uri`, so the default configuration sent its streaming request to
`sdk.launchdarkly.com`. LaunchDarkly serves streaming from a separate
host, and `mode="stream"` is this store's default (TESTING.md §3.25), so
this was the default path rather than an edge case: the first contact
with a real environment would have gone to the wrong host.

Both base server-side SDKs ship the two as distinct defaults, which is
the evidence this follows:

  ldclient.config.Config        base_uri='https://app.launchdarkly.com'
                                stream_uri='https://stream.launchdarkly.com'
  @launchdarkly/js-server-sdk-common
                                baseUri: 'https://sdk.launchdarkly.com'
                                streamUri: 'https://stream.launchdarkly.com'

`DEFAULT_BASE_URI` here already pointed at the FDv2 polling host
(`sdk.launchdarkly.com`, not `ldclient`'s older `app.` value), so only
the streaming host is new. Adds `DEFAULT_STREAM_URI` and a `stream_uri`
keyword, mirroring `skills-fdv2.ts`: a `base_uri` given on its own still
applies to both endpoints, so a relay or a private instance serving both
from one host needs only the one option, and naming both overrides them
independently. `_require_https_base_uri` becomes `_require_https_uri`
and takes the option name, so a cleartext `stream_uri` is refused under
its own name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two independent transport defects.

**`Retry-After: 0` had no floor.** `_retry_after_seconds` returns
`max(0.0, seconds)`, and the retry site capped the value but never
floored it, so a server — or an intermediate proxy — answering `0` had
the delivery loop reconnect as fast as it could schedule, spending the
whole of `max_consecutive_failures` in milliseconds and hammering the
endpoint on the way. It is bounded, so nothing runs away, but the retry
budget is gone and `Retry-After` is honoured in the least useful
direction. Floored at `initial_backoff`, the same floor our own backoff
starts from, matching `skills-fdv2.ts`.

A short `Retry-After` is therefore raised to `initial_backoff`, which is
what made `test_a_retry_after_header_is_honoured` discriminate before:
it asked for 0.25s against a 5s initial backoff. Rewritten to ask for
*longer* than our own backoff instead, so honouring the header is still
the only way the assertion can pass, and
`test_a_retry_after_header_is_parsed_off_the_wire` likewise — it was
passing only because `poll_store`'s 0.05s `max_backoff` capped the
floored value, so it no longer told the two apart.

**`objects_revoked` counted tombstones for keys the store never held.**
The increment fired whenever a `delete-object` parsed, whether or not it
took anything away. The counter is operator-facing and gets read exactly
when somebody is working out whether a revocation landed (TESTING.md
§3.25, "Counters are assertable facts"), so an inflated figure misleads
at the worst moment. Gated on `_SkillObjectSet.delete` having returned
something. `changes` still carries every tombstone, so listeners are
unaffected.

Note this is a divergence from TypeScript rather than a port of it:
`skills-fdv2.ts`'s `deleteObject` increments unconditionally too, and
its `keysFullyRevoked` applies to the full-transfer revoke-by-omission
diff, which this side does not implement. The same fix is owed there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three §3.25 deviations, all in the transport's lifecycle and reconnection
rules. Grouped because each one's existing test pinned the old answer and
had to move in the same change.

**`close()` is final** (§3.25 lifecycle). A subsequent `start()` now
raises instead of resuming delivery. Finality is what gives `close()` a
postcondition a caller can rely on — delivery has stopped — including
when the join times out on a thread parked somewhere no interrupt
reaches. A store that could be restarted from there leaves the caller
unable to tell whether delivery stopped, and a restart that silently
never delivered again is the failure this forecloses. To resume, build a
new store. Giving up at the failure bound is *not* closing, so a store
that gave up can still be restarted and the re-arm path is unchanged.
`test_a_restarted_store_waits_again` therefore drives the give-up path
now, and `test_a_close_that_timed_out_leaves_the_store_restartable`
becomes `..._is_still_final` — it was asserting exactly the behaviour
finality removes.

**HTTP 404 is fatal** (§3.25 reconnection). A 404 on `/sdk/poll` or
`/sdk/stream` means the endpoint does not exist for this credential or
instance — a mistyped base URI, typically — and no reconnect produces
one. It was recoverable, so it burned the whole retry budget first.

**HTTP 400 is retried exactly once, then fatal** (§3.25 reconnection). A
400 is what a stale `basis` selector looks like: the request carried
state the server no longer recognises, and a connection built from
nothing fixes it. It was fatal, so that repair never ran. Implemented as
`_StaleRequestStateError`, a `_RecoverableTransportError` subclass whose
handling drops `basis` and `etag` and asks for a full transfer — and
gives up when there was neither to drop, since a request carrying no
client state was refused on its own terms. That bound is what keeps this
from being "400 is recoverable", and it needs no counter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`add_listener` recorded a registration for any kind and then never
notified it, on both shipped stores — `InMemorySkillStore`, whose `put`
only accepts skill objects, and `FDv2SkillStore`, which only ever
delivers `SKILL_OBJECT_KIND`. TESTING.md §3.21 now requires both to
raise.

The argument is §3.26's own, the one that already makes `watch_skills`
refuse a store with no `add_listener` at all rather than degrading to a
one-shot reconcile: a listener that silently never fires "looks exactly
like a watcher whose skills never changed". Registering on a kind the
store will never deliver is the same failure with the same signature,
and a store that accepted the registration has promised something it
cannot keep.

`ValueError` rather than `TypeError`: `kind` is a `str`, which is the
accepted type, carrying a value the store cannot serve — the same
distinction A.12 records for `write_skills` versus `get_skills`.
`remove_listener` for an unregistered kind stays a no-op, so a caller
tearing down need not track what it attached.

`test_put_does_not_notify_other_kind_listeners` pinned the old
behaviour and is replaced by the raise assertion.

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

**`debounce=float("nan")` defeated the guard** (TESTING.md §3.26). `nan
< 0` is `False`, so it passed `if debounce < 0` and then collapsed the
coalescing window to nothing: `threading.Event().wait(nan)` returns
immediately, so every delivered object reconciled on its own with no
coalescing at all — the opposite of what the option is for. Now
`math.isfinite` is checked alongside the sign, matching how
`write_skills` already guards its `timeout`. `inf` is refused for the
same reason it is refused there: a window nothing ever leaves.

Also fills the §3.26 test gaps, which had no coverage either way:
negative and non-finite debounce, `on_reconcile` firing for subsequent
reports and not the initial one, a reconcile that raises not killing the
watcher, `prune` / `on_unavailable` pass-through, and an invalid root
raising out of `watch_skills` rather than into a worker thread's log.

**The coalescing test was one of the vacuous shapes §3.26 warns about**
— twelve puts inside a single synchronous listener pass, asserted with
an upper bound (`reconciles <= 2`). A burst that arrives before the
worker has woken collapses to one reconcile whether or not the debounce
exists, so that shape passes against an implementation with the
debouncing deleted. Replaced with six puts spread across one window, an
exact figure, and an assertion that the counter moved at all; verified
by mutation — removing the debounce sleep turns it red.

The exact figure is two, not one, and that is a real deviation from
§3.26's "exactly one reconcile per debounce window": this watcher clears
its wake flag *before* the window rather than after, so a change
arriving inside the window schedules a further pass instead of merging
into the reconcile that is about to run. TypeScript restarts its timer
on each notification and so does merge them. The behaviour is
deliberate and documented in `_run` (a redundant reconcile converges;
a missed one does not) and it avoids the starvation a restart-the-timer
debounce admits, so it is left alone here and raised separately rather
than changed under a test-coverage commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`parse_ai_config` gated on `if skills is not None`, so `skills: null`
was accepted and read as absent. TESTING.md §3.5 now calls this case out
specifically, and the reason is the reason it is worth a commit of its
own: read as absent, it makes `skill_refs` return `[]`, and a
`prune=True` reconcile then **deletes** previously-materialized skill
files on the strength of a field the SDK could not parse.

§3.21 establishes exactly this principle on the store path — reading a
malformed thing as absent "would let a tampered object look like a
deleted one … and would let prune delete the last known-good copy on
disk" — and it applies identically here. Failing the whole parse is the
louder and safer outcome, and it is what TypeScript already does.

Gated on key presence instead, so `_parse_skills` sees the `None` and
rejects it as a non-array, with `skills` in the message. There was no
Python test either way; both halves are now pinned, including that an
*absent* `skills` key is still valid, since rejecting `null` must not
cost backward compatibility for configs that simply have no `skills`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`verified_bytes` encoded before checking the size cap, so content that
is both over the cap and carries a lone surrogate reported `not_utf8`.
TESTING.md §3.21 fixes the order as shape, then **size**, then
**encoding**, then hash, and that input is the one boundary where the
order is observable: without it, the code is whichever check the
implementation happens to reach first, and §3.21's "one code per failure
class" rule cannot be tested against it. Size first is also the cheap
check — running an encoding pass over a 10 MiB body before rejecting it
for being 10 MiB is a DoS foothold rather than a nicety.

Python cannot reorder these literally, because the size is a count of
encoded bytes and `str.encode("utf-8")` raises on an unpaired surrogate
rather than substituting. So the unencodable case now encodes a second
time with `errors="replace"`, purely to measure, and reports
`over_size_cap` or `not_utf8` in that order. Those replacement bytes are
never hashed and never returned — the `not_utf8` branch returns before
the hash comparison — which is what makes `errors="replace"` safe here
where `errors="surrogatepass"` is not safe anywhere: fabricated bytes
that reached the comparison could satisfy it. TypeScript arrives at the
same order for free, since `TextEncoder` substitutes U+FFFD silently
and its guard is an explicit round-trip check (A.12).

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

`agents.md`'s POSIX-only justification claimed the TypeScript SDK "could
not match them at all — Node exposes no `*at()` family on *any*
platform, so its racy floor is universal rather than Windows-only." The
first half is still true; the second is not. TypeScript commit `0a15b10`
added a `SUPPORTS_PROC_FD` probe and `/proc/self/fd/<fd>/<name>`
addressing, which the Linux kernel resolves from the inode a descriptor
holds rather than from the name it was opened under — closing the swap
window there exactly as `*at()` does here. TypeScript's `lstat` floor
now applies on macOS and Windows only, the same shape as this side's.

The passage is load-bearing — it is why the Windows reparse-point checks
are not implemented — so the rewrite says explicitly that the decision
is unchanged and why: it never rested on TypeScript being equally
exposed, it rests on there being no Windows CI runner to verify the
checks against, which is still true of both repositories.

README, for the behaviour changes in this branch: the two transport
hosts and the `stream_uri` option, `close()` being final, `watch_skills`'
`debounce` unit and bound and its `on_reconcile` contract,
`add_listener` refusing a kind it will never deliver, and a non-array
`skills` field failing the config parse rather than reading as absent.

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

@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 1 potential issue.

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 4092de7. Configure here.

Comment thread packages/client/src/launchdarkly_ai_server/skills_fdv2.py
`_ProtocolReader` built a fresh empty `_pending` for `xfer-full`, so a skill
the full transfer omitted was correctly dropped at `_payload_transferred` —
but `_changes` only ever accumulated from `_put_object` and `_delete_object`,
so nothing was published for the omitted skill.

A full transfer carrying other puts still wakes a watcher through those puts,
so the observable gap was the transfer carrying no puts at all: the
environment's last skill revoked. The store emptied while `changes` stayed
empty and `objects_revoked` stayed zero, `watch_skills` never reconciled, and
the skill's `SKILL.md` survived on disk until some unrelated change — in
exactly the case pruning exists for.

Port TypeScript's `revocationsBetween` / `keysFullyRevoked`: diff the
committed set against the incoming one before the swap, at `(key, version)`
granularity so a key whose version moved yields both a put for the arrival and
a tombstone for the departure, and count only keys that left the payload
entirely toward `objects_revoked` — the same rule `_delete_object` follows in
counting only a tombstone that took something away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
XieX and others added 2 commits September 17, 2026 13:06
An HTTP 400 for a request carrying a `basis` selector or an etag means that
state may be one the server no longer accepts, so the transport drops it and
asks for a full transfer once before treating the status as fatal. That one
request was not guaranteed to go out: it was prepared and then measured against
the consecutive-failure bound like any other retry, so a 400 arriving on a
budget an outage had already spent gave up while holding the only request known
to repair it, and delivery stopped for the process lifetime over state the
store had just dropped.

The repair is now exempt from the bound. It cannot unbound the loop: the
repaired request carries no state, so a second 400 is fatal on its own, and any
other failure after it meets a budget still over the bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
XieX and others added 6 commits September 17, 2026 15:58
Tests for TESTING.md §3.21/§3.24 as just reconciled: a store answering
under a different key records the `ld.skills.integrity_failure` log record
with `reason_code: key_mismatch` and records no product signal.

`key_mismatch` cannot join `REASON_CODE_CASES` — that table is uniformly
driven through `all_skills`, and this code is decided at the retrieval
boundary after `verify_raw_skill` has passed, so it is unreachable from a
listing. It gets its own test and is unioned into the vocabulary
assertion, with the reason recorded there so the next reader does not try
to move it into the table.

Adds the redaction guard, called directly since no store can drive it, and
the listing-path case: an object listed under a disagreeing map key is
filed under its own key with neither surface firing. That one passes
already; it is a regression guard against a "fix" that would break every
multi-version store.

3 failing: no record is emitted yet, the vocabulary is short a token, and
`record_key_mismatch` does not exist. The no-signal half already passes —
the implementation is additive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements the §3.21/§3.24 decision the previous commit's tests pin: a
store answering under a different key now writes the
`ld.skills.integrity_failure` log record with `reason_code: key_mismatch`,
and still records no product signal.

Adds `record_key_mismatch` beside `record_integrity_failure`, so the
single-emission-site rule still holds by reading one module, and widens
`IntegrityReasonCode` to nine tokens. The check stays where it was, at the
retrieval boundary: `verify_raw_skill` is unary and this comparison is
relational, so moving it inside would mean an optional expected-key
parameter that silently disables the check when a caller omits it.

The record carries both keys. `skill_key` keeps the meaning it has
everywhere else — the key requested — and the key the store answered under
goes in `served_key`, the one record-only field beyond the four, since it
is what makes a broken adapter diagnosable and prose in `reason` is not
parseable. No hash fields and no `version`: verification passed, so
neither the hashes nor the version is what disqualified the answer, and
reporting a served version beside a requested key would mix two frames in
one record.

Docs corrected in the same change: the README's `reason_code` table and
record field table gain rows, `skill_key` is described as the *requested*
key, and `AGENTS.md` records why `key_mismatch` cannot join
`REASON_CODE_CASES` — that table is driven uniformly through `all_skills`,
which cannot reach a code decided after verification passes.

Verified the emitted JSON is byte-identical to the TypeScript SDK's for
the same input, modulo `language`.

1875 tests passing, mypy and ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A transfer this layer declines as foreign kept its contents out of the
store but still handed its selector up as the new basis, so the next poll
or stream resumed from someone else's payload while every diagnostic read
healthy. The check was also gated on a pending set, which a `none` intent
never builds — so a `none` for another payload was not even recognised as
foreign, and its selector was adopted outright.

Ask the question unconditionally and drop the basis of a payload whose
contents were thrown away, matching js-ai-sdk#67.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
``_poll_once`` held one ``_etag`` with no record of which request produced
it, and adopted it before the body was applied. Two consequences:

An ETag validates one representation of one resource, and the ``basis``
selector is part of the request that names it. Once a payload moved the
basis on, the etag from the response before it was still offered, so a
server validating it would be answering the question the store had stopped
asking. Held as a pair now, and offered only while the pair holds — the
base SDK keys its etag cache by request URL for the same reason. The cost
is one unconditional request after each commit, which was never going to be
a 304.

A body that broke off partway — an ``error`` or ``goodbye`` after an
announced transfer — left the payload it described unapplied, but its etag
was already stored. The next 304 then reported that store as current and
reset the failure count, where the 200 it replaced would have been retried
and eventually given up on. Adopted after the body is applied in full.

Ports js-ai-sdk#67.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The branch was two commits behind its base, which blocked CI: GitHub could
not build a merge ref, so no workflow ran on the head.

One conflict, in the stale-selector repair, where both sides added a line to
the same block and both belong — the base's `repairing_state = True`, which
keeps the one-shot repair out of the retry budget, and this branch's
`self._etag_basis = None`, which drops the etag's pairing along with the
etag. Resolved by keeping both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes row 18 (AV-1, Beta revocation propagation bound) of the security design
review. The README already states the fast path: with the watcher running, a
delete-object removes a revoked SKILL.md within the debounce interval on a stream,
or within one poll interval. This adds the other half: a deployment that
reconciles once at boot and never runs the watcher has a revocation bound of
process lifetime, restart or an explicit re-run is the incident-response action,
and content an agent has already read into a conversation is out of reach at
this layer. The same pitfall is added to agents.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…94)

Stacks on #93 -> #87

This should be the last of the python SDK fixes, ported over from the
Typescript SDK work.

## The gap

`_ProtocolReader._server_intent` builds a fresh empty `_pending` for
`xfer-full`, so a skill the full transfer omits is correctly dropped
from the committed set at `_payload_transferred`. But `_changes` only
ever accumulated from `_put_object` and `_delete_object`, so nothing was
published for an omitted skill.

Verified against the reader directly before the fix: after a full
transfer carrying a put for `pdf:3`, a second `xfer-full` carrying
**no** objects emptied the store while `outcome.changes` was `[]` and
`diagnostics.objects_revoked` stayed `0`.

A full transfer that carries other puts still wakes a watcher through
those puts, so the observable gap is the transfer carrying no puts at
all — the environment's last skill revoked. `watch_skills` then never
reconciles and the skill's `SKILL.md` stays on disk until some unrelated
change, which contradicts TESTING.md §3.26 ("a revocation delivered over
a live stream prunes the skill's files within `debounce` of arriving")
in exactly the case pruning exists for.

## The fix

Ports the shape TypeScript already has (`revocationsBetween` /
`keysFullyRevoked` in `typescript/packages/client/src/skills-fdv2.ts`,
called from `payloadTransferred` under `intent ===
INTENT_TRANSFER_FULL`):

- `_identity_of`, `_revocations_between`, `_keys_fully_revoked` —
module-level, placed where TypeScript has them, just above
`_ProtocolReader`.
- `_payload_transferred` diffs the committed set against the incoming
one **before** the swap when the intent is `xfer-full`, extends
`_changes` with the tombstones, and adds `_keys_fully_revoked(...)` to
`objects_revoked`.
- The diff is at `(key, version)` granularity to match `delete-object`,
so a key whose version moved yields both a put for the arrival and a
tombstone for the departure.

The two counting rules stay consistent: **per key** for the omission
diff (a version move counts zero), **per applied revocation** for
`delete-object` (#93's change, where `objects_revoked` only increments
when `_SkillObjectSet.delete` actually removed something).
`_keys_fully_revoked`'s docstring names that as the shared rule.

One doc touch: `add_listener` now says both ways of stating a revocation
arrive as a tombstone — a `delete-object`, and a full transfer that
stopped carrying the object.

## Tests

Four protocol-reader cases:

- a full transfer omitting a previously-held skill publishes a `{key,
version}` tombstone and increments `objects_revoked`;
- an omitted version-less object is reported with `version: None`, the
way a `delete-object` naming no version spells it;
- a version move publishes both ends and increments `objects_revoked` by
zero;
- an `xfer-changes` that mentions nothing revokes nothing — diffing a
delta would drop every skill it simply had no reason to mention.

Plus an end-to-end `test_a_full_transfer_that_omits_every_skill_prunes`
over the fake FDv2 endpoint: queue a put, then
`full_payload(state="basis-2")` with no objects, and assert the
`SKILL.md` is pruned.

These discriminate — with the source reverted, all four of the new cases
fail. (The `xfer-changes` guard passes either way, as it should; it
guards against applying the diff to the wrong intent.) The e2e case was
run five times for flakiness.

## Gate

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

🤖 Generated with [Claude Code](https://claude.com/claude-code), edited
by @XieX


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> **FDv2 full transfers now surface revocations-by-omission** so
`watch_skills` and pruning behave like explicit `delete-object` events:
on `xfer-full`, the reader diffs committed vs pending at `(key,
version)`, extends `changes` with tombstones, and counts true key
removals in `objects_revoked` (version bumps emit both put and tombstone
but do not count as revoked).
> 
> **Polling/stream resume state is tightened** — declined or foreign
payloads no longer advance `basis`; ETags are only sent when they match
the current basis and are adopted only after a payload fully applies,
avoiding false `304` “healthy” states.
> 
> **Store key substitution is a first-class integrity outcome**: new
`key_mismatch` `reason_code` with `served_key` on the
`ld.skills.integrity_failure` log line only (no product signal), wired
from `resolve_from_store` when verification passed but the object’s key
≠ the requested key. Docs and tests cover the nine-token vocabulary,
listing vs pinned asymmetry, and boot-only `write_skills` revocation
limits.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
1ce4e53. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
@XieX
XieX merged commit 43ff5c0 into xie/agent-skills Sep 24, 2026
6 checks passed
@XieX
XieX deleted the xie/python-agent-skills-review-fixes branch 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.

3 participants