Conversation
… failures indefinitely - A `none` intent now leaves the FDv2 reader expecting changes, as the base SDK's `ChangeSetBuilder.expect_changes()` does. Previously a put-object or delete-object following `none` on the same stream was dropped while the following payload-transferred still advanced the basis, so a skill revoked after a routine reconnect kept being served. - Remove `max_consecutive_failures`. Recoverable failures are retried on the capped backoff for as long as the store runs; only a fatal status stops delivery. Clamp the backoff exponent so a long outage cannot overflow it. - README: scope the "revoked SKILL.md leaves disk" claim to "*", and say what an explicit skill list does instead. Correct the _resolve_requests docstring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jeffdupont
left a comment
There was a problem hiding this comment.
Follow-up to my GA review of #87. I reviewed f6bb491 against its base, xie/agent-skills at c547be2, not main. make test: 2166 passed, 11 skipped, exit 0. make typecheck is clean. I also reverted each fix on its own and re-ran the new tests. Without the none change, all three none tests fail. Without the exponent clamp, the backoff test fails with OverflowError. With the base branch's skills_fdv2.py put back, the no-option test and both retry-indefinitely tests fail. So the new tests do catch the bugs they're meant to.
Where the four #87 blockers stand:
- (1) Changes after
none: resolved. This now matches the LaunchDarkly Python SDK'sexpect_changes(). - (3) Giving up after 10 failures: resolved. Removing
max_consecutive_failuresis not a breaking change. It never reachedmain(git greponorigin/mainfinds neither it norFDv2SkillStore), sofix(skills)without a!is right. - (2) Skill-key grammar: not touched. Still open. Per your reply on #87, it needs an SDK-or-API decision, and it stays on the GA list until that's made.
- (4) Revocation for explicit lists: docs only. I think that's acceptable for 1.0. What 1.0 freezes is the conservative behaviour: absent keys keep their files and report an
error. The likely fixes are additive and can ship in a minor: letwatch_skillstake a callable that returns the current refs, or add an opt-in prune. The one change that would be hard to make after 1.0 is making explicit lists prune absent keys by default, because 1.x would then delete files that 1.0 kept. If we never want that default, docs-only is fine. A few unscoped claims are left; details inline.
One thing to settle before merge:
- The backoff options aren't validated, and without the budget, a zero or negative value now spins forever. With a fake requester that always fails:
initial_backoff=0made 773,754 attempts in one second;max_backoff=0andmax_backoff=-5made about 775k each.failedstayedNone. On the base branch, the same probe stops after 11 attempts withfailedset. Against the real service, that's a fleet hammering LaunchDarkly over a config typo. JS #108 does the same (about 860 attempts/s). I'd validate both as positive and finite, aspoll_intervalalready is, and add that to the spec. Inline.
Smaller notes:
- Fleet behaviour in an outage is fine for a hard outage. The cap is 30s with subtractive 50% jitter, the same as the LaunchDarkly Python SDK's FDv2 streaming (
MAX_RETRY_DELAY = 30,JITTER_RATIO = 0.5,ldclient/impl/datasourcev2/streaming.py:55-57). I found two gaps, both older than this PR, but they now last for the whole outage instead of 10 attempts. First,Retry-Afteris used without jitter (skills_fdv2.py:1651-1655), so a fleet toldRetry-After: 30reconnects at the same moment. Second, backoff resets on everynone, while the base SDK resets only after a connection has stayed open 60s (BACKOFF_RESET_INTERVAL = 60,streaming.py:56,118). A server that answersnoneand then drops each connection gets reconnects at about 1/s indefinitely. My probe with default backoff saw 27 connections in 20s. Both are spec questions. - The clamp is correct at the boundaries. Attempts up to 1 give the base delay, and attempt 63 and above hit the clamp.
float(2**62)is exact, and attempt 10,000 gives exactly 30.0. - An oversized response is now retried forever. A body over
MAX_RESPONSE_BYTES(64 MiB) is recoverable, so the store downloads up to 64 MiB again every 15-30s and never setsfailed. The rewritten test attest_skills_fdv2.py:2777now asserts exactly that. Before, the budget turned this intofailed. It's unlikely, but it fails the same way every time, so it could be fatal. I haven't checked how large a real payload can get. - Outage visibility.
failedno longer signals an outage; onlyconnection_failuresandlast_errordo. A last-success timestamp onStoreDiagnosticswould help health checks. That's additive, so it can come after 1.0. - A stale comment at
skills_fdv2.py:1711-1712still mentions "exhausting its budget". #108 updated the same comment. - Test parity with #108. JS has tests that Python doesn't, and spec #36 asks for two of them:
waitForSkillsrunning to its timeout during an outage, and fail/succeed/fail retrying at the first backoff step. JS also replaced the two 400-budget tests with "a 400 after a recoverable failure still repairs" and "the second 400 still stops"; this PR deletes them with no replacement. connection_failuresmeans different things in the two languages. Python counts agoodbyeafter a complete answer as a failure: it setslast_error, andtest_skills_fdv2.py:2406-2408allows the count to read 1. JS exempts it (reachedServer, jsskills-fdv2.ts:1810). This predates the PR, but the counter is now the main outage signal, so the two should agree and the spec should say which way.- Spec: launchdarkly/ai-sdks-monorepo#36 matches both implementations on the two behaviour changes, the absence-by-name assertion and the finite-backoff assertion. I'd add four points there: the backoff options must be positive and finite; whether a
goodbyeafter an answer counts towardconnection_failures; when backoff resets; and whetherRetry-Aftergets jitter.
JS counterpart: launchdarkly/js-ai-sdk#108 (same findings, noted there).
| @@ -1307,7 +1315,6 @@ def __init__( | |||
| read_timeout: float | None = None, | |||
| initial_backoff: float = 1.0, | |||
| max_backoff: float = 30.0, | |||
There was a problem hiding this comment.
Without the failure budget, nothing limits the retry loop when these two options are zero, negative or NaN. Reproduced at f6bb491 with a requester that always raises _RecoverableTransportError: initial_backoff=0 gives 773,754 attempts in 1s, and max_backoff=0 or -5 gives about 775k. failed stays None throughout. The base branch stops the same probe at 11 attempts. delay = min(delay, self._max_backoff) (line 1655) turns any non-positive cap into a zero wait.
Could these get the same math.isfinite(x) and x > 0 check poll_interval has, plus initial_backoff <= max_backoff? The docstring at line 1321 already says the constructor raises for an invalid interval. JS #108 needs the same check.
There was a problem hiding this comment.
Done in a06237c: initial_backoff and max_backoff must be positive and finite, with initial_backoff <= max_backoff, checked the same way as poll_interval. JS has the same check in launchdarkly/js-ai-sdk@be65e93, and the spec in launchdarkly/ai-sdks-monorepo#36.
| # copied when the first object arrives. | ||
| self._intent = _INTENT_TRANSFER_CHANGES | ||
| self._pending = None | ||
| return _TransferOutcome(up_to_date=True) |
There was a problem hiding this comment.
This resolves the #87 blocker. Reverting just the _intent line fails all three new tests, including the end-to-end one.
This isn't new, but it now matters for longer: up_to_date=True resets the backoff immediately. The base SDK resets only after a connection has stayed open 60s (ldclient/impl/datasourcev2/streaming.py:56, retry_delay_reset_threshold=BACKOFF_RESET_INTERVAL). A degraded server that answers none and then drops gets reconnected at about 1/s by every process, indefinitely. With default backoff, I saw 27 connections in 20s. Resetting the count here and the delay only after N seconds of uptime would keep the diagnostics honest without the hammering. That's probably a spec #36 decision rather than something to change here.
There was a problem hiding this comment.
Agreed, and done here rather than deferred, since without a budget nothing else stops it. The backoff step is now separate from connection_failures. It resets only after a stream has stayed open 60s (matching BACKOFF_RESET_INTERVAL, and js-core's Backoff) or after a completed poll. The count still clears on a commit or none. Python a06237c, JS launchdarkly/js-ai-sdk@fdc0d81, plus the spec in #36.
| # The fifth queued payload is never asked for. | ||
| assert len(endpoint.requests) == 4 | ||
|
|
||
| def test_a_400_carrying_no_client_state_is_fatal_at_once( |
There was a problem hiding this comment.
The two deleted tests were also the only Python coverage of a 400 that follows a recoverable failure. The repair still has to send a stateless request, and a second 400 still has to be fatal. #108 rewrote them for the no-budget world (asks from scratch after a 400 that follows a recoverable failure, still stops on the second 400 when a recoverable failure sits between them). Could Python keep equivalents, so the two suites stay in step?
There was a problem hiding this comment.
Added in a06237c: test_a_400_after_a_recoverable_failure_still_repairs and test_the_second_400_still_stops_with_a_recoverable_failure_between. Also added from the JS side: wait_for_skills runs to its timeout during an outage, and fail/succeed/fail retries at the first backoff step.
|
|
||
| **With an explicit skill list, revocation does not reach the disk.** Given a list such as | ||
| `skill_refs(config)`, a skill deleted in LaunchDarkly is reported as an `error` action under | ||
| its key, and its `SKILL.md` is kept rather than pruned. The watcher also listens only to the |
There was a problem hiding this comment.
This scoping is right, and I'm fine with docs-only for 1.0 (see the review body). Three places still make the unscoped claim:
skills_watch.pymodule docstring (lines 4-7): "a skill revoked in LaunchDarkly is removed from disk within a debounce interval". feat(AIC-3449): Support pulling in configs for evals from code #108 scoped the matchingskills-watch.tsheader to'*'.agents.md§6 has no explicit-list caveat. feat(AIC-3449): Support pulling in configs for evals from code #108 added one to its §4d.- feat: Agent Skills #87's description example is
watch_skills(refs, ...)with the comment "a revoked skill's SKILL.md leaves disk without a restart". That's the exact case this paragraph says doesn't work, and it will probably end up as the squash commit message.
There was a problem hiding this comment.
All three fixed. The skills_watch.py docstring and agents.md §6 are scoped to "*" in a06237c. The #87 description now drops the "leaves disk" comment and adds a reviewer note on explicit lists. Making the watcher follow config changes is in progress as a separate, additive change.
Review follow-ups on the unbounded-retry change: - Validate initial_backoff and max_backoff: positive, finite, and initial <= max. Without a failure bound they are the only limit on the retry loop, and zero reconnected ~775k times a second. - Reset the backoff delay only after a stream stays open 60s, as the base SDKs do, or after a completed poll. connection_failures still resets on a commit or a none intent. A server that answers and drops is now backed off instead of reconnected about once a second. - A response over MAX_RESPONSE_BYTES is fatal, like 422, instead of being re-downloaded on every backoff step forever. - A goodbye after a completed exchange is not counted or reported as a failure, matching the JS SDK; the reader logs goodbyes at debug. - Tests matching the JS suite: wait_for_skills runs to its timeout during an outage, and the two 400-repair cases. - Docs: scope the skills_watch docstring and agents.md §6 to "*", and fix a stale "budget" comment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review. The items in the review body:
|
Addresses three review comments on #87. Targets
xie/agent-skills.Changes
noneintent (r4167784120). Afternone, the reader now expects changes, as the base SDK'sChangeSetBuilder.expect_changes()does. Before, aput-object/delete-objectafternoneon the same stream was dropped while the followingpayload-transferredstill advanced the basis. A skill revoked after a routine reconnect kept being served, and reconnecting didn't fix it.max_consecutive_failures; retry recoverable failures indefinitely (r4167784131). Only fatal statuses (401/403/404/422 etc., a second 400) stop delivery and setfailed. The 400-retried-once repair is unchanged.float(2 ** n)in_backoff_delayraisesOverflowErrorpast ~1024 consecutive failures. The old budget kept the count from getting there; with unlimited retries a long outage (~8.5 h at the default cap) would have crashed the delivery thread."*"(r4167784141, docs only). With an explicit list, anabsentskill stays requested with anerroraction and isn't pruned, and the watcher doesn't see config changes. Also fixes the_resolve_requestsdocstring, which saidabsentmakes a run incomplete.Tests
none, delete afternone(reader level), and a revocation after a reconnect answerednone(end to end on the stream). All three fail with the fix reverted.max_consecutive_failuresis absent by name; backoff at attempt 10,000 is finite. Tests that used a small budget only to stop the store now use a fatal status. Two tests about the budget's 400 exemption were deleted.make test(2166 passed, 11 skipped),make lint,make format-check,make typecheckall pass. Ran the affected tests 10x with no flakes.The same changes for JS: launchdarkly/js-ai-sdk#108.
Spec: launchdarkly/ai-sdks-monorepo#36
🤖 Generated with Claude Code
Note
Overview
Fixes FDv2 skill delivery so revocations and edits are not dropped after a routine
nonereconnect, aligns retry policy with the base SDKs, and tightens docs around disk revocation.Protocol: After
server-intentwithintentCode: "none", the reader now expects further changes on the same connection (matching base SDKexpect_changes()). Previously,put-object/delete-objectafternonewere ignored whilepayload-transferredstill advanced the basis—so a skill revoked right after an up-to-date reconnect could keep being served.Transport /
FDv2SkillStore:max_consecutive_failuresis removed; recoverable errors retry for as long as the store runs, withinitial_backoff/max_backoffvalidated and a separate backoff attempt counter (reset after a completed poll or a stream held ≥60s). Oversized poll/stream bodies now raise_ResponseTooLargeError(fatal) instead of retrying forever. Goodbyes after a completed exchange are treated as routine recycles (not counted asconnection_failures).Filesystem reconcile: An
absentskill in an explicit ref list no longer marks the run “incomplete” for pruning—files stay and the skill reportserror. README /watch_skillsdocs clarify that onlywatch_skills("*", …)prunes revoked skills on disk.Reviewed by Cursor Bugbot for commit a06237c. Bugbot is set up for automated code reviews on this repo. Configure here.