Repository navigation
feat(client): publish Agent Skills from the experimental entry point - #144
Conversation
Agent Skills is exported only from launchdarkly_ai_server.experimental.skills, and none of its names is exported from the package root. Its names may change in a minor release while it is experimental. - Add set_skill_store(store) and remove the skillStore option from init_client: core client options do not name an experimental feature. - shutdown() still clears the skill store, through an internal hook whose failure is logged rather than raised. - Pin the experimental surface to an explicit allowlist in tests, and assert that no Skills name is reachable from the package root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…_skill_store - init_client logs a warning for any option key it does not read, so a retired option such as skillStore is reported rather than dropped silently. - Inline _resolve_client into init_client; the split existed only to install the skill store after a successful resolve. - set_skill_store takes a SkillStore and raises TypeError on None. - _reset_for_testing clears skills state directly so a failed reset fails the test instead of being logged. - Soften the changelog wording for experimental names, and note in the launchdarkly-ai-python README where experimental features are imported from. - Clarify in agents.md that AiConfigRep still documents skills references: the experimental part is the SDK API, not the data model. - Drop init_client calls that tests no longer need before set_skill_store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jeffdupont
left a comment
There was a problem hiding this comment.
Reviewed at 01bfad1d; re-checked against 56bfacaa (set_skill_store(None) now ignored, matching JS).
The move itself is clean. Every Skills name is gone from the root and from launchdarkly_ai_python, skillStore is gone from code, docs and error messages, and the wheel ships experimental/. At 01bfad1d I get 2210 passed, 11 skipped, and ruff, format and mypy are clean.
I ran mutations against the export tests. These ones fail the tests, which is what we want:
- adding
frontmattertoexperimental/skills.pyand its__all__ - adding
get_skillto the root - adding
SKILL_OBJECT_KINDto the experimental module - adding
MAX_SKILL_CONTENT_BYTESto the root - adding
SKILL_OBJECT_KINDto the root as a bare attribute
Dropping the unknown-option warning fails 2 tests. Bringing skillStore back fails 1.
Two of the mutations get through. Details are inline.
- The root half of the export test is still a denylist. If I add
def frontmatterplus__all__.append("frontmatter")to the root__init__.py, all 8 tests pass (TestPackageExportsandpackages/ai/tests). #33 says neither the root nor the experimental entry point may expose afrontmatteraccessor, and A.14 asks for a checked-in__all__snapshot for the root as well as for each experimental module. js #114 checks in API reports for both entry points. Please pin the root__all__(114 names) the same way you pinnedEXPERIMENTAL_SKILLS_SURFACE. - "Logged, not raised" in shutdown has no test. If I remove the try/except in
_clear_experimental_state(lifecycle.py:292-295), the client suite still passes (1396). Nothing can fail there today, becauseclear_stateonly assigns two globals (skills_core.py:186-190). That makes the test cheap to add now, before a hook that can raise shows up. Patchskills._clear_stateto raise, then assert thatshutdown()returns, the client is closed, and the warning is logged.
Open questions from the PR body
(a) Malformed skills fails core calls. I think this needs a decision before 1.0, not in this PR. I tested skills[0].key = "My_Skill", skills: null, version: 0 and a non-list value. Each one makes extract_variation raise Invalid AI config variation ..., so config().invoke(), config().stream() and graph nodes fail (client.py:81, :184; graph.py:163). inspect_config returns enabled=True, config=None. main has no skills validation at all, so this behaviour arrives with #87. If any existing skill keys fall outside the gonfalon #73433 grammar (still unverified), apps that never import Skills will start failing invoke. My suggestion:
- stop validating
skillsinparse_ai_config(types_validation.py:151-156) - make
skill_refsraise on an array that is present but malformed, instead of dropping entries with a warning
write_skills then still never sees a partial or empty list, so the prune-safety reason for the check holds, and §0.3 is met. The core parse behaviour freezes at 1.0, and JS needs the same change.
(b) Evaluations were released from the root. init_evaluations, EvaluationsModule, EvaluationsError, EvalRunResult, GenerationConfig and RunSummary are in the root __all__ of launchdarkly-ai-server 0.2.2 (PyPI, 8 Sep). 0.2.3 has the same. 0.2.4 (30 Sep) adds Judge, Scorer and Criterion. I checked the wheels. launchdarkly-ai-python 0.1.6-0.1.8 re-exports them through import *. So people can import them from the root today. 1.0 is a major release, so semver allows the move, but deprecated root aliases are cheap (module __getattr__ with a DeprecationWarning). Leaving that out of this PR is fine. #33 needs fixing, though: its register marks Evaluations as experimental and "absent from the package root", and §0 only describes forward moves, so it has no path for demoting a released feature. Either drop Evaluations from the register, or add the alias step. #41 (merged today) also lists client.init_evaluations as a helper string, which clashes with #33's experimental. prefix rule.
Smaller items
- Parity with js #114: two of the three differences I found are now settled.
56bfacaamakesset_skill_store(None)a no-op like JS, and js #114's5f3779b2switches JS to the same generic unknown-option warning as here. The one left: JS adds@launchdarkly/ai-node/experimental, while the Python barrel deliberately doesn't (packages/ai/README.md:78-83). Either is defensible; #33's A.14 should record which. - README quickstart deletes skills when
inspect_configfails (README.md:388, :398). The quickstart passesskill_refs(info["config"])towrite_skillswith the defaultprune=True. Ifinspect_configfails (LaunchDarkly unreachable, or case (a) above),configisNone, the refs come back[], and every managed skill is removed withreport.ok == True. I reproduced this. The bug predates this PR, but this PR rewrites that block. Please checkinfo["config"] is not Nonebefore writing. This is the Python half of the JS README issue. - The
init_clientallowlist is complete. lifecycle.py reads exactly those seven keys, nothing else readsinit_clientoptions, and the SDK's own lazy calls pass no options, so there are no false warnings. The keys aren't actually documented, though. The docstring (lifecycle.py:161-171) mentions onlysdkKey, and the README table (packages/client/README.md:197) lists none. Please list them in both. Also, sinceskillStorenever shipped in a Python release, the generic warning is enough here. set_skill_storeaccepts anything that isn'tNone. A string, a dict or an int all succeed. The first accessor call then logs an ERROR traceback and returnsstore_unavailable("'str' object has no attribute 'get_object'"). Checkingcallable(getattr(store, "get_object", None))would catch this when the store is set. Optional.AiConfigRep's docstring (types.py:96) says "seeskill_refs". That's a core public type pointing at an experimental name. Please point atexperimental.skills.skill_refsor drop the reference.watch_skills's no-store message (skills_watch.py:229-231) leaves out the import path.NO_STORE_MESSAGEincludes it.- Not a change request, but worth a line in the
set_skill_storedocstring. Swapping the store doesn't close the old one, and a running watcher keeps listening on the store it captured (skills_watch.py:227). Thread-safety is fine: there's one global, and each accessor reads it once.
Merge order
This PR implements monorepo #33, which is still open. main's TESTING.md still requires root exports and init_client({"skillStore"}) (:1031, :1539, :1647, :2860). Merging this into #87's branch is fine. #87 shouldn't reach main before #33. #33 also needs work before it lands:
- It needs a new appendix number, because
mainnow has "A.14 Helper usage strings". - #35 needs rebasing onto #33. #35's A.12 paragraph names
_resolve_clientandtest_failed_init_client_leaves_no_store_configured, and this PR removes both. #35's §3.9skillStorebullet is the one #33 deletes. The retry rule still holds:test_retries_after_a_failed_init_instead_of_replaying_itis at test_lifecycle.py:297.
…tal move - Pin the root `__all__` to a checked-in snapshot, so a root export such as a `frontmatter` accessor fails a test rather than relying on a denylist. The experimental module's `dir()` is pinned too, not only its `__all__`. - Test that a failure clearing experimental state in `shutdown()` is logged, not raised, and the client is still closed. - `set_skill_store` raises `TypeError` for a value without callable `get_object` and `all_objects`, instead of failing later as `store_unavailable`. Its docstring says replacing a store does not close the old one and a running watcher keeps the store it started with. - Document all seven `init_client` options in the docstring and README. - The README quickstart stops before `write_skills` when `inspect_config` returns no config, since an empty reference list would prune every skill. - `AiConfigRep` points at `experimental.skills.skill_refs`, and the `watch_skills` no-store message names the import path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: c9bcdca8-149b-407d-b97e-265a8e2509b8) |
|
Thanks @jeffdupont. f5c9aa2 covers the two mutations that got through and the smaller items. Inline replies have the details.
Not changed here:
|
`parse_ai_config` rejected a whole AI Config when its `skills` field was malformed, so a bad experimental field failed `config().invoke()`, `stream()`, graph nodes and `extract_variation` for apps that never use Agent Skills. That breaks TESTING.md §0.3: experimental behaviour must not break a core call. The parser now passes `skills` through unchecked. `skill_refs` validates it instead and raises `ValueError` when the field is present but malformed, including `skills: null` and a single bad entry. It used to drop bad entries with a warning, which was safe only because the parser had already rejected them. Raising keeps the original reason for the check: `write_skills` with `prune=True` never receives an empty or partial list for a field that is present. An absent field, or a config that is not a dict, still returns `[]`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…146) ## Summary A malformed `skills` field no longer fails core config calls. Today `parse_ai_config` rejects the whole AI Config when `skills` is malformed. `config().invoke()`, `stream()`, graph nodes and `extract_variation` then raise, and `inspect_config` returns `config: None`, even for apps that never use Agent Skills. Agent Skills is experimental, and TESTING.md §0.3 says experimental behaviour must not break a core call. - **`parse_ai_config` passes `skills` through unchecked.** - **`skill_refs` validates the field and raises `ValueError`** when it is present but malformed, including `skills: null` and a single bad entry. Before, it dropped bad entries with a warning, which was only safe because the parser had already rejected them. - **An absent field, or a config that is not a dict, still returns `[]`.** Raising rather than dropping keeps the reason the parser checked in the first place. `write_skills` with the default `prune=True` deletes every managed skill missing from the list it is given, so `skill_refs` never returns an empty or partial list for a field that is present. Stacked on #144. JS counterpart: launchdarkly/js-ai-sdk#117. ## Tests - **Parser:** a malformed `skills` field of every kind (null, non-list, bad entry, one bad entry among good ones) parses successfully and passes through unchanged. - **`skill_refs`:** the cases the parser used to reject, including `null`, now raise `ValueError` naming the field. One bad entry rejects the whole field. The error does not echo the rejected key. - **Core calls:** `inspect_config` and `extract_variation` succeed on a variation with malformed `skills`. ## Open question `skill_refs(None)` still returns `[]`, as documented. That is the case where a failed `inspect_config` feeds an empty list to a pruning `write_skills`. The README quickstart guards against it, but raising would remove the trap for every caller. That is a contract change, so it is not in this PR. ## Test plan - [x] `make lint`, `make format-check`, `make typecheck` - [x] `uv run pytest`: 2209 passed, 11 skipped 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > **Agent Skills validation moves off the core config parser** so experimental `skills` data cannot break `config().invoke()`, `inspect_config`, or `extract_variation` when apps never use skills (TESTING.md §0.3). > > `parse_ai_config` now **passes `skills` through unchanged**; `_parse_skills` was removed from the parse path and exposed as `skills_field_rejection_reason` for reference discovery only. **`skill_refs` validates the field** and raises `ValueError` when `skills` is present but malformed (including `null` or one bad entry in a list), instead of dropping bad entries with warnings—avoiding a shortened list that would authorize `write_skills(..., prune=True)` to delete on-disk skills the config still references. > > Docs (`README.md`, `agents.md`, `AiConfigRep` docstring) and tests were updated: parser tests expect malformed `skills` to parse successfully; `skill_refs` and lifecycle tests cover the new raise behavior and that core calls succeed with invalid `skills`. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 0f303f4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
…114) Refactors Agent Skills to ship as an **experimental** feature. Skills features are exported **only** from `@launchdarkly/ai-server/experimental` and are absent from the package root. Additionally, the skill store is now configured via a `setSkillStore(store)` setter rather than injected as an option to `initClient`. Passing the removed `skillStore` option to `initClient` (from JavaScript, or past the type) logs a generic warning, `Ignoring unrecognized initClient option(s): skillStore`. The same warning covers any key `initClient` does not read and does not name the experimental feature or its setter, matching the Python SDK. Other changes: - `AiConfigRep.skills` was `SkillReference[]`; it is now typed structurally (`ReadonlyArray<{ readonly key: string; readonly version: number }>`). - **Core never imports experimental.** `shutdown()` clears Skills state through a shutdown-hook registry (`src/shutdown-hooks.ts`) kept on a `globalThis` symbol slot. `skills-core.ts` registers its cleanup when it loads, so the root entry point imports no Skills module, and the built `dist/index.{js,cjs}` contain no Skills code. Each hook runs in its own try/catch: a throw is logged and fails neither the other hooks nor `shutdown()`. - **`@launchdarkly/ai-node/experimental`.** ai-node re-exports the root with `export *`, so Skills left it too. It now has its own `./experimental` subpath re-exporting `@launchdarkly/ai-server/experimental`, so Node users who install only ai-node can reach Skills under strict package managers (pnpm, Yarn PnP). - **`typesVersions`.** Both packages map `experimental` to `dist/experimental.d.ts`, so the subpath's types resolve under `moduleResolution: node` (node10), which ignores `exports` and is still the default for `module: commonjs`. - **`setSkillStore` checks its argument.** A non-nullish value without `getObject` and `allObjects` methods throws `TypeError`, matching the Python SDK. - **API reports.** The repo had no API Extractor setup, so this adds one. API Extractor 7.x takes one entry point per config, so there are two configs and two checked-in reports: `etc/ai-server.api.md` and `etc/ai-server-experimental.api.md`. New `api:check` / `api:update` scripts; CI runs `api:check` in the typecheck job. The reports are generated from `tsc` declaration output rather than tsup's, whose hash-named chunks would churn the report. - **Docs.** README and `agents.md` use the experimental import path and `setSkillStore`. The `agents.md` export blocks are regenerated for both entry points, and the stage rules are written down, including that `/experimental` is the one sub-path anyone may import (deep `dist/*` imports stay banned). The README does not promise an "Experimental" changelog section, since release-please doesn't produce one yet (open question 5). ## Build note: no code splitting Code splitting would let both entry points share one copy of each module, but in CJS tsup rewrites the dynamic `import()` of the optional peers into `require()`. That is the AIC-3370 webpack regression, and the commonjs-webpack integration test caught it. Splitting stays off, so modules both entries reach (`types.ts`, `shutdown-hooks.ts`) are copied into each. The Skills modules are only in the experimental bundle now that the root no longer imports them. This is safe because the skill store, the shutdown hooks, and the client singleton live on `globalThis` symbol slots (the A.12 design for multiple module instances). Verified: a store set through the experimental bundle is cleared by the root `shutdown()`. ## Tests - The experimental entry point's runtime exports equal the documented set exactly (an allowlist, which also covers the §3.23.7 "no reader over `content`" rule). None of those names is reachable from the root. - The root API report names no Skills type and no `skillStore`; the experimental report names every Skills name. - The `setSkillStore` behaviour above, including that the SDK `init` runs exactly once. - Shutdown hooks: every registered hook runs, including before a client exists; a throwing hook is logged and fails neither the other hooks nor client teardown; re-registering a name replaces the hook. Loading the package root registers no Skills hook, so the root imports no Skills module (checked by temporarily adding the import, which makes the test fail). - `initClient` warns about an unrecognized option, `skillStore` included, on both overloads, and stays quiet for the documented options. - ai-node's experimental entry point re-exports every `@launchdarkly/ai-server/experimental` name as the same reference, and its root has none of them. - The package root's runtime exports equal a checked-in snapshot. - The integration test type-checks root and experimental imports against the packed tarballs under `node10`, `node16` and `bundler`, and asserts every loaded declaration is the consumer's own. - The integration test resolves `@launchdarkly/ai-server/experimental` and `@launchdarkly/ai-node/experimental` through `require` and `import`, asserts `getSkill` is absent from both roots, and checks both subpaths' manifest entries and packed files. Build, typecheck, Biome, Sherif, all workspace tests, `test:integration`, and `api:check` pass locally. The §3.23.2 swap-race tests skip on macOS and run only on Linux CI, as before. ## Open questions - **`parseAiConfig` still validates `skills`.** It fails a whole config when its `skills` array is malformed (`types.ts`), so a bad Skills field can fail an ordinary `config().invoke()`. That conflicts with "experimental behaviour must not break a core call". launchdarkly/python-ai-sdk#144 has the same open question; it should be decided once for both SDKs. - **Land order.** This conflicts with #105 in `lifecycle.ts` and `skills.test.ts`. Whichever lands second keeps #105's retry and shutdown shape, calls `runShutdownHooks()` instead of `_clearState()`, and drops `resolveClient`. `main`'s TESTING.md still requires Skills at the root, so this lands with launchdarkly/ai-sdks-monorepo#33, not before it. 🤖 Generated with [Claude Code](https://claude.com/claude-code), updated by @XieX <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > Moves **Agent Skills** off the stable package root onto published **`@launchdarkly/ai-server/experimental`** and **`@launchdarkly/ai-node/experimental`** subpaths (dual ESM/CJS builds, `typesVersions` for node10). Consumers import skills APIs from `/experimental`; the root barrel no longer exports them. > > **Configuration and core/experimental boundary:** Skill storage is configured with **`setSkillStore(store)`** instead of `initClient({ skillStore })`. Unknown `initClient` keys (including `skillStore`) log a generic warning. **`AiConfigRep.skills`** is **`unknown`** and **`parseAiConfig`** no longer validates it, so malformed skills do not fail core config paths; **`skillRefs`** validates and **throws** on bad data (fail-closed for prune). Core clears skills state via **`shutdown-hooks`** on `globalThis` without importing experimental modules. > > **Tooling and tests:** Adds **API Extractor** reports for root and experimental surfaces, **`api:check`** in CI, and expands **commonjs-webpack** integration coverage (subpath resolution, packed artifacts, consumer `tsc` under multiple `moduleResolution` modes). Renames skills default URI/debounce exports to **`SKILLS_DEFAULT_*`**. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f2f9f51. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
Summary
Reorganizes Agent Skills to ship as an experimental feature.
Every Skills name is exported from
launchdarkly_ai_server.experimental.skills, and none from the package root. Applications opt in by importing from that module. While the feature is experimental, its names may change in a minor release, so review the changelog when you upgrade.Additionally, the store is configured via a
set_skill_store(store)setter rather than injected intoinit_client, which logs a warning for any option key it does not read, so a retiredskillStoreoption is reported rather than dropped silently. The check is generic and does not name the feature.Other changes:
shutdown()clears the skill store in a way that logs a failure rather than raising it into the core call. The test reset clears it directly, so a failed reset fails the test.agents.mdnow point atset_skill_storeand the experimental import path. Theinit_clientdocstring and README list all seven options it reads. The README quickstart stops beforewrite_skillswheninspect_configreturns no config, since an empty reference list would prune every managed skill. Thelaunchdarkly-ai-pythonREADME notes that experimental features are not re-exported and are imported fromlaunchdarkly_ai_server.experimental.agents.mdclarifies that the experimental part is the SDK's API, not the LaunchDarkly data model, soAiConfigRepstill documents a config'sskillsreferences.dir()is pinned too.__all__is pinned to a checked-in snapshot (tests/test_public_surface.py), so a new root export fails.shutdown()is logged, and the client is still closed.set_skill_storeraisesTypeErrorfor a value without callableget_objectandall_objects, as in the JS SDK.init_clientignores askillStoreoption and warns about it; unknown options warn and documented ones do not.set_skill_store(None)is ignored and leaves the configured store in place, as in the JS SDK.SKILL_OBJECT_KINDandMAX_SKILL_CONTENT_BYTESstay unexported from both the root and the experimental module.Open questions
skills.parse_ai_configfails a whole config when itsskillsarray is malformed, so a bad Skills field can fail an ordinaryconfig().invoke(). That conflicts with "experimental behaviour must not break a core call". The behaviour is unchanged here, pending a decision.experimental.evaluationsneeds deprecated aliases. That is left for a separate change.Test plan
make lint,make format-check,make typecheckuv run pytest: 2217 passed, 11 skipped🤖 Generated with Claude Code, updated by @XieX
Note
Overview
Moves Agent Skills off the stable package root into
launchdarkly_ai_server.experimental.skills, so those APIs may change in minor releases while the root export list stays pinned in tests.Configuration: skill stores are wired with
set_skill_store(store)instead ofinit_client(options={"skillStore": ...}).init_clientonly accepts seven documented option keys and logs a warning for anything else (including retiredskillStore).shutdown()clears experimental state in a path that logs failures instead of breaking core teardown.Validation split:
parse_ai_configno longer rejects variations for a malformedskillsfield (coreconfig().invoke()/inspect_configkeep working).skill_refsnow validates fail-closed and raisesValueError(including forskills: null) so pruning reconciles never treat a bad field as “no skills.”Docs and tests are updated for the experimental import path,
set_skill_store, and frozen snapshots of root vs experimental__all__.Reviewed by Cursor Bugbot for commit 40fd647. Bugbot is set up for automated code reviews on this repo. Configure here.