Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,8 +458,9 @@ Windows.
| `get_skills(refs)` | Batch form. Accepts `SkillReference` values and bare key strings (string = latest). Results follow input order; missing or unverifiable entries are omitted. |
| `all_skills()` | Every verified skill the store holds, one per key at its newest version. |
| `write_skills(skills, root, *, prune=True, timeout=10.0, on_unavailable="keep")` | Materialize skills under `root`, returning a `ReconcileReport`. `prune` removes formerly-managed skills no longer requested. `on_unavailable="raise"` raises instead of reporting when content cannot be retrieved. Raises `ValueError` for an unusable root, a negative `timeout`, or an unrecognised `on_unavailable`. **Performs synchronous filesystem I/O — see the note below.** |
| `SkillStore` | The structural interface content arrives through: `get_object(kind, key, version=None)`, `all_objects(kind)`, optional `add_listener(kind, fn)`. |
| `SkillStore` | The structural interface content arrives through: `get_object(kind, key, version=None)`, `all_objects(kind)`, optional `add_listener(kind, fn)` / `remove_listener(kind, fn)`. |
| `InMemorySkillStore(objects=None)` | A dict-backed store with `put(raw)`, for local development and testing. Holds several versions of a key. |
| `watch_skills(skills, root, …)` | `write_skills` plus a re-reconcile on every delivery change. Returns `(initial report, SkillWatcher)`; close the watcher when done. Revocation then takes effect within `debounce` of arriving rather than at the next restart. |

Configure the store with `init_client(options={"skillStore": store})`. With none configured,
the accessors raise `RuntimeError` explaining what to do and `write_skills` reports the
Expand Down
11 changes: 6 additions & 5 deletions packages/client/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ No other `launchdarkly-ai-*` package may define or duplicate these. They import
| `src/launchdarkly_ai_server/types_validation.py` | `parse_ai_config` — validates flag variation shape; `is_valid_skill_key` / `is_valid_skill_version` / `skill_key_rejection_reason` (the canonical key-grammar explanation every layer quotes) |
| `src/launchdarkly_ai_server/skills.py` | Agent Skills, retrieval half — `skill_refs`, `get_skill`/`get_skills`/`all_skills`, `InMemorySkillStore`, and the store/telemetry injection points `_set_store` / `_set_emitter_for_testing` |
| `src/launchdarkly_ai_server/skills_core.py` | Shared skills internals — the `SkillStore` seam, module state, the telemetry seam and its three recorders, integrity verification, and store resolution. Imported by both `skills.py` and the materialization layer; imports neither |
| `src/launchdarkly_ai_server/skills_watch.py` | Agent Skills, eager re-reconcile — `watch_skills` / `SkillWatcher`, wiring the store's change listener to `write_skills`. Sits **above** `skills_fs` and modifies none of it |
| `src/launchdarkly_ai_server/skills_fs.py` | Agent Skills, materialization half — `write_skills`, request resolution, the manifest format and on-disk filenames, per-skill reconcile, and pruning |
| `src/launchdarkly_ai_server/safe_fs.py` | Descriptor-pinned filesystem primitives — `atomic_write`, `unlink_file`, `pinned_directory`, `open_directory_nofollow`, `open_or_create_directory`, `SymlinkRefused`, and the `*at()` capability probe. Owns the descriptor-vs-path platform split; knows nothing about skills |
| `src/launchdarkly_ai_server/utils.py` | `parse_template`, `parse_json_with_possible_fences`, `create_handler`, `parse_usage`, `make_track_data`, `to_ld_context` |
Expand Down Expand Up @@ -198,11 +199,11 @@ Three layers, in increasing order of blast radius:

### The store seam, and why version is part of the lookup

`SkillStore` is `get_object(kind, key, version=None)`, `all_objects(kind)`, and an optional
`add_listener(kind, fn)`. Version is part of the **lookup identity**, not a filter applied
to the answer, and that is load-bearing: a delivery payload carries the newest version of
every skill *plus* every version any variation currently pins, so two versions of one key
coexist routinely. A seam keyed by key alone would answer a pinned reference with the newest
`SkillStore` is `get_object(kind, key, version=None)`, `all_objects(kind)`, and the optional
pair `add_listener(kind, fn)` / `remove_listener(kind, fn)`. Version is part of the **lookup
identity**, not a filter applied to the answer, and that is load-bearing: a delivery payload
carries the newest version of every skill *plus* every version any variation currently pins,
so two versions of one key coexist routinely. A store keyed by key alone would answer a pinned reference with the newest
object, and the caller would then have to reject it — turning the primary use case, a
version-pinned attachment, into a missing skill. `version=None` asks for the newest held.

Expand Down
4 changes: 4 additions & 0 deletions packages/client/src/launchdarkly_ai_server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
OnUnavailable,
write_skills,
)
from .skills_watch import SkillWatcher, watch_skills
from .tracking import execute_and_stream, execute_and_track, wrap_tool_handlers
from .types import (
NATIVE_TOOL_KEY,
Expand Down Expand Up @@ -237,6 +238,9 @@
"write_skills",
"SkillStore",
"InMemorySkillStore",
# skills — the eager re-reconcile
"watch_skills",
"SkillWatcher",
# skills — the three closed-set unions a typed consumer needs to name
"ReconcileActionKind",
"OnUnavailable",
Expand Down
16 changes: 16 additions & 0 deletions packages/client/src/launchdarkly_ai_server/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,22 @@ def add_listener(self, kind: str, fn: Callable[[dict[str, Any]], Any]) -> None:
"""
self._listeners.setdefault(kind, []).append(fn)

def remove_listener(self, kind: str, fn: Callable[[dict[str, Any]], Any]) -> None:
"""
Unregisters *fn* from *kind*, so a subsequent ``put`` no longer calls it.

Removes one occurrence: a callable registered twice must be removed twice.
Removing a callable that is not registered is a no-op, not an error, so a
consumer that detaches on close can do so unconditionally.
"""
listeners = self._listeners.get(kind)
if listeners is None:
return
try:
listeners.remove(fn)
except ValueError:
return


# ---------------------------------------------------------------------------
# Reference discovery
Expand Down
16 changes: 11 additions & 5 deletions packages/client/src/launchdarkly_ai_server/skills_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,17 @@ class SkillStore(Protocol):
Duck-typed on purpose, mirroring how the LaunchDarkly client interface works
in this package: pass any object carrying these methods.

``add_listener(kind, fn)`` is part of the seam but
**optional**, which is why it is deliberately not declared here: a Protocol
member is required for structural compatibility, so declaring it would reject
every store that does not implement it. Nothing in this module calls it — it
exists for the delivery transport to push updates through.
``add_listener(kind, fn)`` and ``remove_listener(kind, fn)`` are part of the
interface but **optional**, which is why they are deliberately not declared
here: a Protocol member is required for structural compatibility, so declaring
them would reject every store that does not implement them. Nothing in this
module calls either — they exist for the delivery transport to push updates
through, and for a consumer such as ``watch_skills`` to stop receiving them.
A store that implements ``add_listener`` should implement ``remove_listener``
too; consumers probe for it and skip detaching when it is absent, so an
older store keeps working at the cost of a listener that lives as long as
the store does. ``remove_listener`` removes one occurrence of *fn* under
*kind* and is a no-op when *fn* is not registered.

The raw objects a store serves are wire-shaped, with camelCase field names
identical across language implementations::
Expand Down
Loading
Loading