Skip to content

feat(client): Agent Skills — re-reconcile on delivery with watch_skills - #81

Open
XieX wants to merge 2 commits into
split/skills-review-closeoutfrom
xie/skills-watch
Open

feat(client): Agent Skills — re-reconcile on delivery with watch_skills#81
XieX wants to merge 2 commits into
split/skills-review-closeoutfrom
xie/skills-watch

Conversation

@XieX

@XieX XieX commented Sep 10, 2026

Copy link
Copy Markdown

Stacked on #66 (split/skills-review-closeout). First of three PRs split out of #69; the FDv2 protocol layer and the transport follow on top of this one.

write_skills is a one-shot reconcile, so a revocation takes effect at the next process restart. watch_skills runs that reconcile now and again whenever the configured store reports a change, so a revoked skill's SKILL.md leaves the disk within a debounce interval of the store learning about it, rather than at the next restart.

What's here

skills_watch.pywatch_skills / SkillWatcher. Wired to the SkillStore interface, not to any one transport: it needs a store that implements add_listener and nothing more. It refuses loudly when the store does not, because a watcher that silently never fires looks exactly like one whose skills never changed. The listener itself only sets an event; the reconcile runs on a single worker thread, debounced, so a burst of changes coalesces into one pass and a slow disk never stalls the store's delivery thread. A reconcile that raises is logged and the watcher continues. on_unavailable="keep" stays the default: an outage must not read as "everything was revoked".

remove_listener(kind, fn) joins add_listener as the optional second half of change notification, on the SkillStore contract and on InMemorySkillStore. SkillWatcher.close() needs it to detach; without it a store held every watcher ever created for the rest of its life. The watcher probes for it and skips detaching when a store does not implement it, so a customer's own store keeps working.

Tests

test_skills_watch.py drives the watcher through InMemorySkillStore, whose put notifies synchronously, and through small store doubles: initial reconcile, coalescing, refusal of a store without add_listener, detaching on close, and a store without remove_listener. Three tests on InMemorySkillStore.remove_listener join test_skills.py. The end-to-end case, a delete-object arriving over a live connection and pruning a file, lands with the transport PR where the fake endpoint lives.

🤖 Generated with Claude Code


Note

Overview
Adds watch_skills and SkillWatcher so agent skill files stay in sync with the configured skill store without waiting for a process restart. The API runs an initial write_skills reconcile, then listens for store delivery changes and re-reconciles on a debounced background thread (listener only wakes the worker; disk I/O never blocks the delivery thread). Revocations can prune SKILL.md within the debounce window. on_unavailable="keep" remains the default so transport outages are not treated as mass revocations.

Extends the optional SkillStore change-notification contract with remove_listener(kind, fn), implemented on InMemorySkillStore, so SkillWatcher.close() can detach; stores without it still work but keep listeners registered. watch_skills raises if no store is configured or the store lacks add_listener, and cleans up the listener if the initial reconcile fails.

Public exports and README/agents docs are updated; new tests cover watcher behavior (coalescing, mid-startup revocations, close/detach) and remove_listener semantics.

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

write_skills is a one-shot reconcile, so a revocation takes effect at the
next process restart. watch_skills runs that reconcile now and again
whenever the configured store reports a change, so a revoked skill's
files leave the disk within a debounce interval of the store learning
about it. It is wired to the SkillStore interface, not to any one
transport: it needs a store that implements add_listener and nothing
more, and refuses loudly when the store does not, since a watcher that
silently never fires looks exactly like one whose skills never changed.

Above the interface, remove_listener joins add_listener as the optional
second half of change notification, on the SkillStore contract and on
InMemorySkillStore. SkillWatcher.close needs it to detach; without it a
store held every watcher ever created for the rest of its life. The
watcher probes for it, so a store without it keeps working.

Co-Authored-By: Claude Fable 5.1 <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 dd2d55c. Configure here.

Comment thread packages/client/src/launchdarkly_ai_server/skills_watch.py
…oncile

watch_skills awaited write_skills and only then constructed SkillWatcher,
which is where the store listener attaches. The reconcile snapshots the
store as its first step and then spends the rest of its time on the
filesystem, so every write, fsync, prune and manifest rewrite in that
first pass ran with nothing listening. A change delivered in that window
was never seen, and since nothing re-reconciles on a timer, a revocation
that landed there waited for the next unrelated change — on a quiet root,
the next restart. Exactly the gap watch_skills exists to close.

The watcher now attaches its listener before the initial reconcile and
starts its worker after. notify only sets an event, so a change arriving
mid-reconcile is recorded and picked up by the worker's first pass, while
holding the thread back keeps write_skills's one-root-one-reconcile
contract: the worker cannot race the caller's own reconcile over the same
manifest. A reconcile that raises detaches the listener on the way out,
since the caller is handed an exception rather than a watcher to close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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