Repository navigation
Conversation
…ills A fixed reference list never re-reads its config, so a watcher fed skill_refs(config) cannot see a config move a skill from v2 to v3: v2 stays on disk reported `error`, and v3 is never written. SkillSource(refs, config_keys=()) is a requested set read on every reconcile. config_skills(key, context) builds one for a single AI Config, and raises for every failed evaluation rather than answering [], which would prune. write_skills reads a source once, bounded by `timeout`, and treats a raise or a malformed answer as an unavailable retrieval. watch_skills also registers one flag_tracker listener, filtered to config_keys, that only wakes the worker, and raises when the client cannot report config changes. SkillWatcher.notify() is the public trigger for a source without config_keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jeffdupont
left a comment
There was a problem hiding this comment.
Review comment (not an approval). Checked against spec launchdarkly/ai-sdks-monorepo#37 (TESTING.md on xie/watch-skills-follow-config), js-ai-sdk#162, and the skills-to-core PRs (#210 here, js#159, spec #86). CI is green; no review threads yet.
1. absent does not hold the prune: confirmed, and JS does the same. Fix the spec, not the reconcile
- On main,
_resolve_requestssetsincompleteonly for an unavailable reference (skills_fs.py:469-475on this branch, the same text on main). Anabsentreference stays requested with anerroraction, so the reconcile still prunes the dropped key.test_an_absent_reference_does_not_hold_the_prune(tests/test_skills_source.py:1049) pins that. - js-ai-sdk main has the same rule (
packages/client/src/skills-fs.ts:413-415). js#162 tests it the same way. The two SDKs agree with each other. - Spec #37's "Dropping a reference prunes on the next complete reconcile" bullet says an
absentsecond reference keeps the dropped skill on disk. That contradicts §3.22's per-key bullet ("A withheld skill is protected by its key, not by suppressing the prune … the narrowness is the point"). It also leans on §3.22's ambiguous "a reference that did not resolve". - Fix: before this leaves draft, get #37 to change "is
absent" to "is unavailable (the store raised, no store, uninitialized store, exhausted timeout)". In the same edit, get §3.22's "a reference that did not resolve" reworded to "a reference whose retrieval was unavailable". Keep the reconcile as it is. Changing it would let one not-yet-delivered pin block every other cleanup.
2. The spec's "incomplete" half has no test here (JS has one)
TestDroppingAReference(tests/test_skills_source.py:1012) covers the clean prune and theabsentcase. It does not cover the half #37 asks for: a second reference that cannot be resolved holds the prune, and the run after it resolves prunes.- Fix: add the test js#162 uses (
skills-source.test.ts, "prunes it on the next complete reconcile, and not while another reference is unavailable"). Use a store whoseget_objectraises forc. The source dropsband addsc. Assertbstays on disk with noremoved. Then makecreadable, firestore.put, and assertbisremoved.
3. The mixed-trigger debounce gives 2 reconciles; the spec and JS give 1
SkillWatcher._runclears_wakebefore the window (skills_watch.py:137). A burst spread across the window therefore runs a second pass.test_the_debounce_spans_both_trigger_kindsassertsreconciles == 2(tests/test_skills_source.py:883). Spec #37 says "adds exactly one reconcile and exactly one read". §3.26 already says "exactly one reconcile per debounce window". JS restarts its timer on everynotify()(skills-watch.tsnotify), so it gives 1.- Fix (either one): (a) in a separate
fix(skills)PR, make_runa trailing debounce: afterself._stop.wait(self._debounce), loop whileself._wake.is_set()(clear, then wait again) before_reconcile_once(), and change both tests to== 1. Or (b) get #37 to record the Python behavior as an allowed difference. Leaving it as is means a test that contradicts the spec text.
4. An is_offline() that raises skips the remedy message (JS treats it as offline)
skills_watch.py:365-366callsis_offline()without a guard. If it raises, the raw exception escapeswatch_skillswithout the "both remedies" text. JS catches it and treats the client as offline (skills-watch.ts:218-223).- Fix:
try: offline = is_offline() is True/except Exception: offline = True, then raise the existing offlineRuntimeError. Add one test with a fake whoseis_offlineraises.
5. Error classes differ from JS for an empty key
- An empty
config_keysentry raisesValueError(types.py,SkillSource.__init__). JS raisesTypeErrorfor the same input (types.tscreateSkillSource), and so doesconfigSkills(''). A.12's table records only the bare-string case. - Fix: pick one class in both SDKs, or record the split in A.12. Your PR body item 6 already asks this; add it to #37 before merge.
6. Interaction with #210 (Agent Skills to core) and spec #86
#210 deletes experimental/skills.py, its API report and the skills rows in changelog-sections. Spec #86 moves every skills usage ID to client.*. Spec #86 lists seven IDs, and #37 lists eight under experimental.*, so the two spec PRs conflict too. If #210 merges first, this PR needs to:
- export
SkillSource/config_skillsfromlaunchdarkly_ai_server/__init__.pyand drop theexperimental/skills.pyhunk; - run
make api-report, so the two names land inapi-report/launchdarkly_ai_server.txt; - change
report_usage("experimental.configSkills")(skills_source.py, first line ofconfig_skills) and thetest_sdk_usage.pyrow toclient.configSkills; - move the
test_skills.pyexport assertion to the root-surface test; - retitle away from
feat(skills):. After #210 that scope no longer maps to Experimental, so use a core scope such asfeat(client):.
Spec #37 has to be rebased over #86 in the same way (usage-ids.txt, §3.27's count, the "§3.19–§3.26a … experimental" marker). Otherwise the synced lists will disagree with this PR.
Parity checked with no gap found
config_skills classification table and None default; no lazy init; usage reported on the call, not on reads; _skill_refs used for the reads; a source read exactly once per run; malformed answers (an invalid entry fails the whole read); timeout=0 never calls refs; async cancel at the deadline; listener registered before the initial reconcile; the listener only signals; no-signal raises naming both remedies, with the store listener detached; close() detaches; "*" and lists never probe; version bump in both orders with no removed; a real-SDK test through TestDataV2.
Not checked: I did not run the suite locally, and I relied on CI and the diff.
A dropped reference is pruned on the next reconcile whose retrieval is available. Test all three cases: a clean drop removes; a drop beside an absent reference still removes and keeps the absent skill's file; and a drop beside an unreadable reference does not remove until the store can answer. Say the same in the watch_skills docstring and the README. Also treat an is_offline() that raises as offline, so watch_skills still raises with the remedy instead of the client's own exception. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Match the TypeScript createSkillSource, which rejects an empty key with TypeError, so one input gets one error class in both SDKs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Why
watch_skills(skill_refs(config), root)is a fixed list: the watcher never re-reads the config. When an AI Config movespdf-extractionfrom v2 to v3, v2 stays on disk reportederroron every run, and v3 is never written. Unpinning a skill is never seen either.What changed
All new names are exported from
launchdarkly_ai_server.experimental.skillsonly, and none from the package root.SkillSource(refs, config_keys=())(types.py): a frozen dataclass.refsis a zero-argument callable, sync or async, returningSkillReferencevalues and key strings.config_keysis held as a tuple. A non-callablerefs, or a bare string / non-sequence / non-string / empty entry inconfig_keys, raisesTypeError.config_skills(key, context)(newskills_source.py): evaluates the config withvariation(key, context, None)on every read. Only a disabled variation ([]) or a parsed config (its refs) answers. Every other case raisesRuntimeErrornaming the key and the cause, so it never reads as[]: no initialized client,variationraising, the default, a non-object, a missing or non-boolean_ldMeta.enabled, a parse failure, or a malformedskills. It never initializes the client. It reportsexperimental.configSkillswhen it is called, not on each read. It projects through a new non-reporting_skill_refs.write_skills(source, root)reads the source exactly once, after root validation and before the root is pinned. A raise, a timeout, or a malformed answer is a run-level unavailable retrieval: one empty-keyerroraction underon_unavailable="keep", or aRuntimeErrornaming the skill source under"raise". Nothing is written or pruned. A malformed answer isNone, a string ("*"included), bytes, a non-sequence, or any entry that is not a validSkillReferenceor a valid key string.timeout=0never callsrefs. An async source still pending at the deadline is cancelled throughasyncio.wait_for. A sync source cannot be interrupted, and the docs say so.watch_skills(source, root)registers oneflag_tracker.add_listenerbefore the initial reconcile, filtered toconfig_keysbychange.key. The listener only sets the wake flag. The source is read on the watcher's worker thread, never on the base SDK's synchronizer thread, which holds the store lock. Store and config triggers share one debounce. With non-emptyconfig_keys,watch_skillsraises with both remedies named in these cases: no client, noflag_tracker.add_listener, oris_offline()returning true or raising. The store listener is detached first.close()callsflag_tracker.remove_listenerwhen the tracker has one. A fixed list and"*"never probe or subscribe.SkillWatcher.notify()is documented as public: it schedules a reconcile through the same debounce, for a source withconfig_keys=()."*"/ fixed list /config_skills), a Following an AI Config's changes section (failed reads, evaluation events, the change-signal requirement, daemon mode, hand-built sources), and new export rows.agents.mdgets the module row, the documented form, and a new anti-pattern section.experimental.configSkills) are updated.Tests
tests/test_skills_source.py(117 tests): construction, everyconfig_skillsclassification row, and theNonedefault. It also checks that the client is not initialized even withLD_SDK_KEYset, that each read is fresh, and the usage and telemetry rules.write_skillscoverage: source/list equivalence, read-once on a raising source, 15 malformed answers in bothkeepandraise, andNoneafter two writes leaving both on disk. Also an invalid entry failing the read rather than being dropped,timeout=0, and cancellation. Watcher coverage:notify()removed), dropping a reference, and answer → raise → answerThe client is a hand-built fake, not a
MagicMock. One test drives the real base SDK:TestDataV2registered as initializer and synchronizer throughldclient.datasystem.custom().td.update()bumps v2 → v3, and the test waits on the watcher's reconcile counter. That test passes on the lockedldclient9.18.3.make lint format-check typecheck api-report-checkpasses.uv run pytest packages/client testspasses: 1987 passed, 18 skipped, and 5 repeat runs of the threaded modules were green. One test in the fulluv run pytestfails locally:packages/opencode-agents/tests/test_handler.py::TestProcess::test_a_fake_opencode_is_started_spoken_to_and_stopped. It is a path-resolution assertion in a package this PR does not touch.Where the spec was unclear or wrong
absentreference does not hold the prune of a dropped skill. The spec first said a dropped skill stays on disk while another reference in the same answer isabsent. It now matches the reconcile in both SDKs:absentis a per-key outcome, and only an unavailable retrieval (a store that raised, no initial data, an exhausted timeout) holds the prune.TestDroppingAReferencecovers all three cases: a clean drop removes; a drop beside anabsentreference still removes and keeps the absent skill's file; a drop beside an unreadable reference does not remove until the store answers.ldclient.datasystem.custom()needsimport ldclient.datasystem. A plainimport ldclientdoes not expose the submodule.experimental.configSkillsis the only Agent Skills row.write_skillsandwatch_skillstherefore trivially report nothing for a source's reads._skill_refsis in place for whenskill_refsstarts reporting.SkillReferencekeys or versions,Skillvalues, sets and dicts as malformed. An explicit list in the spec would settle it.config_keys(a set or generator) and an empty key both raiseTypeError, ascreateSkillSourcedoes. The spec only names the bare-string and non-callable cases, so it should list these two.ldclient9.16.0; the lock pins 9.18.3, where the behavior matches (FlagChangefires on theFDv2-synchronizersthread).Will conflict with #210, which promotes Agent Skills to the package root. Whichever lands second moves
SkillSource/config_skillstoo.🤖 Generated with Claude Code
Note
Overview
Adds
SkillSourceandconfig_skills(key, context)sowrite_skills/watch_skillscan re-resolve which skills an AI Config pins on every reconcile, instead of freezingskill_refs(config)at startup.write_skillsnow accepts aSkillSource: it readsrefsonce per run (sync or async, bounded bytimeout), and treats raises or malformed answers as a run-level unavailable retrieval—no writes and no pruning—so a failed evaluation never looks like “revoke everything.”watch_skillswires the skill store listener together with the base client’sflag_trackerfor the source’sconfig_keys, waking only from the listener thread and re-reading the source on the watcher worker. It raises when config follow-up is impossible (no client, noflag_tracker, offline); fixed lists and"*"behave as before.Docs and
agents.mdspell out the"*"vs fixed list vsconfig_skillsdisk behavior; exports and API report are updated, with broad tests including a realldclientTestDataV2path.Reviewed by Cursor Bugbot for commit 25f9326. Bugbot is set up for automated code reviews on this repo. Configure here.