feat(skills): skill sets — assign a named slice of the library as a unit (ent#530) - #3005
Conversation
…nit (ent#530)
A library source's catalog.yaml may declare `sets:` (short form
`name: [skills]`, long form with `requires.env` and suggested `schedules`).
Assigning `set:<name>` materialises each member as an agent_skills row with
individual=0; unassigning removes only members no individual assignment and
no other held set names. Absorbs ent#342 (prerequisites, honest status).
- Pure rules in services/skill_sets.py: a total parser (codes only; partial
and invalid sets never assignable) and plan_member_rows, which FAILS CLOSED
— while any held set is unresolved (missing, invalid, partial upstream, or
now owned by another source) no set-derived row is removed.
- One transactional recompute (db/skill_sets._apply) under a per-agent lock
(PG row lock; SQLite RESERVED lock taken before the reads). The bulk PUT
resolves held sets inside its own transaction; an unticked set member is
demoted, never deleted. `set:` entries add; the `sets` field replaces.
- Routes: GET /skills/library/sets, GET /agents/{a}/skill-sets (?probe=true
checks credentials in a running agent), POST/DELETE
/agents/{a}/skill-sets/{set} behind the ent#596 fence. GET /skills carries
individual + via_sets; injected meta and CLAUDE.md say "via <set>".
- Every inject path (start, manual Sync, fleet re-inject) reconciles set rows
before reading names and prunes after.
- MCP: set:<name> routing, unassign_skill_set, via_sets in get_agent_skills.
- UI: sets in Library → Skills and on the agent Skills tab (status,
prerequisites, suggested schedules — shown, never created).
- Both migration tracks: SQLite agent_skill_sets + Alembic 0073.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…icker resets after assign (ent#530) Found in the screenshot pass: the status badge showed a green "complete" beside the missing-credentials warning, and the picker rendered blank after an assign because the options re-rendered under the still-selected value. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
📸 Walkthrough: https://claude.ai/artifact/2QtMs5kR4NMZWLb4JQD3ei — Library → Skills sets (expanded members + versions, a partial set), the agent Skills tab (set status with a missing credential flagged, assigning a second set, The screenshot pass found two UI issues, fixed in 5c12f71: a green "complete" badge beside the missing-credentials warning (now needs credentials), and the set picker rendering blank after an assign. |
|
✅ Alembic head check clear — merging this PR into Previously flagged; resolved. Advisory — this check does not block merge. · head_sha: |
Renumbers this branch's Alembic revision to 0075 on top of dev's 0074_role_readiness_rollout_seed so the version-line keeps a single head. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The merge-from-dev commit renamed the revision to 0075 but left its down_revision (and the tests/docs naming it) at the 0072 fork point, so the graph still had two heads. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
✅ Nightly unit-suite clean when this PR is merged into |
|
Resolve by merging |
|
merge-train (2026-09-27): not on this run. Rides the next one once the four fixes below are in. Nothing was pushed to this branch. This was the PR's first validation pass: lane B + schema, head To fix before it rides
For your call
When you rebase
The red |
AndriiPasternak31
left a comment
There was a problem hiding this comment.
Requesting changes. There are two things to fix, and both can go in the rebase you already need.
1. The PR no longer merges. alembic-head-watch is correct: dev now has 0075_auto_sync_enabled_backfill … 0079_telegram_group_context, and 0075_agent_skill_sets still sits on 0074, so merging would give two heads. migrations.py and tests/registry.json also conflict. To fix:
- renumber to
0080on0079_telegram_group_context; - re-append the SQLite entry;
- update the pinned pair in
test_the_alembic_revision_extends_the_single_head; - update the stale
0073in the cso report and PR body.
The pg-migrations and schema-parity reds are not from this PR. Both are No module named 'psycopg' (SQLAlchemy 2.1), which #3015 fixed on dev after your last run, so they should clear on the rebase.
2. set_agent_skills reads before it locks (db/skills.py L253-286 read, then lock_agent_rows at L290). A POST /skill-sets/X that commits between those reads and the lock has its member rows deleted. kept_by_set is built from the stale existing, and the delete-all removes the rest. I reproduced it on real SQLite: I stubbed lock_agent_rows so a concurrent assign_set committed first, and the agent ended up holding dev with rows {solo}. The next reconcile heals it, but this is the "a set assigned concurrently is never missed" guarantee from finding 5/9. Taking the lock as the first statement of the transaction (when set_resolver is given) fixes it. Please add a regression test for that interleaving.
Should fix or file as a follow-up:
- MCP has no way to list library sets. An orchestrator can
assign_skill_to_agent("set:…")but cannot discover names or members. Add alist_skill_setstool, and update the# mcp:header ofrouters/skills.py, which also missesunassign_skill_set.
Small items, not blocking:
?probe=trueruns an exec for read-only principals; consider owner-gating it.- A re-assign that re-points
source_idkeeps the oldassigned_by. - The new components use raw
gray-*classes, not semantic tokens. - Upstream
sets:edits now add skill names fleet-wide asassigned_by="system". That is what the issue asks for, but it widens ent#662, and I'll note it there.
Everything else I checked holds up: the ent#596 fence on every set write, including the widened route-graph guard; the #2914 conflict protection for set members; the fail-closed reconcile on every inject path; unassign semantics; both migration tracks; and route order. Nice work on the review and mutation trail.
Also, can we get a one-line OSS-core ruling on ent#530? It's in the enterprise tracker with no gating decision, and I agree it should follow the ungated skills-library precedent.
…ets; renumber to 0080_agent_skill_sets migrations.py and tests/registry.json keep-both, dev's entries first; the registry is rebuilt from the merge stages. The Alembic revision moves to 0080_agent_skill_sets <- 0079_telegram_group_context (one head, 81 revisions), with every literal reference updated. The SQLite registration test asserts membership, not position. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ead (ent#530) existing / kept_by / kept_status were read before lock_agent_rows, so a skill set assigned between those reads and the lock had its member rows removed by the delete-all and only came back at the next reconcile. The lock is now the first statement of the transaction whenever a set_resolver is given. Regression test commits a concurrent assign_set at the moment the lock is requested; red on the old order. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… object details (ent#530)
- DELETE /skills/{name} answers removed:false + retained_via_sets when a
set still names the skill; the store dropped the holder chip anyway.
It now keeps the chip and returns the server's sentence.
- The 409 skill_set_unresolved detail is an object; unassignSkill (and
assignSkill, same shape) now return its message string instead of
handing InlineError a non-string.
Two store specs, both red before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An orchestrator could assign set:<name> but had no way to learn set names or members. list_skill_sets wraps GET /api/skills/library/sets (no agent target, access 'none' like list_skills). The routers/skills.py mcp header now names list_skill_sets and the previously-missing unassign_skill_set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… retry link (ent#530) AgentSkillSets and LibrarySkillSets rendered a failed set read as a <p> with a hand-rolled <button>retry</button>; the contract's failed-fetch primitive is LoadFailed. Same testids, same retry; the library spec now also clicks retry and asserts the re-read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Both blocking items, vybe's four fixes and the "should fix" item are in. Blocking (Andrii)
vybe's four
Should fix
Not changed
Ruling on ent#530: OSS-core, ungated, following the skills-library precedent. Backend: 742 across the ent530/596/2914/parity/migration files, and 657 across every |
|
Ownership moves to @AndriiPasternak31 (ent#530 handover; after ent#500 lands). Everything from both reviews is addressed except the items listed as 'Not changed' in my last comment. |
AndriiPasternak31
left a comment
There was a problem hiding this comment.
Approving. Both blockers from my last review are fixed. I checked the lock-order fix by running its regression test against the old db/skills.py: it fails there and passes on this head. CI is green, and the branch merges cleanly with one Alembic head. What's left is one should-fix and a few small items, and all of them can be follow-ups.
Previous findings
- ✅ Alembic head / rebase. The revision is
0080_agent_skill_sets←0079_telegram_group_context.check_alembic_heads.pyreports 81 revisions and 1 head, and no0080*has landed onorigin/dev. The SQLite entry is re-appended last inMIGRATIONS(db/migrations.py:5013). The pinned pair is updated (test_the_alembic_revision_extends_the_single_head), the registration test now checks membership rather than position (test_ent530_skill_sets.py:423), and the0073references are gone from the cso report and the PR body. - ✅
set_agent_skillsreads before it locks.lock_agent_rowsis now the first statement whenever aset_resolveris given (db/skills.py:252-254), and the router always passes one. The regression testtest_a_set_assigned_while_the_replace_takes_its_lock_keeps_its_membersfails on the pre-fix file with{'solo': True} != {'solo': True, 'backlog': False}and passes now. - ✅ MCP
list_skill_sets. The tool is added (GET /api/skills/library/sets, registered before/{skill_name}). Its access row isnone(access.ts:284), andunassign_skill_setsits behind the ent#596 fence row, with the totality test extended to cover it. The# mcp:header now names both tools. ⚠️ ?probe=truefor read-only principals. Not changed, and it's wider than I thought. See finding 1.- ❌ Re-assign keeps the old
assigned_by. Not changed.db/skill_sets.py:120-123still only updatessource_id. Fine as a follow-up. - ❌ Raw
gray-*classes. Not changed: for exampleAgentSkillSets.vue:3,5,21andLibrarySkillSets.vue. The ratchet passes because gray is allowed in new files, but the design contract asks for semantic tokens. Fine as a follow-up. - ✅ OSS-core ruling. It's posted on this PR (dolho, 2026-09-28): OSS-core and ungated, following the skills-library precedent.
- ✅ vybe's four merge-train items. All fixed. Library unassign keeps the holder chip and shows the server's sentence, and the object
detailis rendered as its message instores/skillsLibrary.js. The lock is the same fix as above, and the migration assertion now checks membership. Each has a store or unit spec.
New findings
- [should-fix]
src/frontend/src/stores/skills.js:180withsrc/backend/routers/skills.py:698-712: the route docstring saysprobeis "off by default, so a plain read is never an exec amplifier (cso L1)". But the first-party Skills tab always sendsprobe: true, on everyload()and after every set assign or unassign (_refreshRows→loadSets). The route is gated only byget_authorized_agent_by_name. So in practice any principal with read access to the agent, including a shared user, runs one in-container exec each time they open or refresh the tab. The exec only runs when the agent is running and a held set declares env prerequisites. The probe returns only booleans, so this is a bounded amplifier, not a leak. Still, the cso L1 mitigation doesn't hold for the path that actually gets used. Fix: honourprobe=trueonly for principals that pass the skill-manager/owner fence and returnunknownfor everyone else, or have the UI probe only when the viewer can manage the agent's skills.
What I checked
- I read the whole diff at
0b3ab51efagainstorigin/dev, focusing on the four commits since my review. - ent#596 (
96795e23c) is in the branch and not bypassed. Every set write (POST/DELETE /skill-sets/{set}, thePUTsetsfield and theset:prefix) goes throughget_skill_managed_agent_by_name. The only callers ofassign,replaceandunassignare those fenced routes. The widened route-graph guard and the refused-agent-key test cover the new routes. - The fail-closed paths hold.
plan_member_rowstreats a set that is held but missing fromresolvedas unresolved, andlibrary_sets()returning{}on failure also fails closed. - No new admin endpoints, so the grant-vs-use and
require_adminrules aren't touched. - Backend tests: 683 passed and 2 skipped on seeds 12345 and 99999. This covers ent530, ent596, ent236, 2914, 2703, ent386, 384, ent237, ent332, ent183, 2991, schema parity, migrations, the Alembic parity/length/heads guards, cleanup and rename-cascade parity, models-centralized, 1310 auth wiring and the 293 admin gate. These ran on Python 3.14, not the image's 3.13. The PG
FOR UPDATElock path isn't exercised by any pytest here or in CI. - MCP:
npm testgives 557/557 andtsc --noEmitis clean. - Frontend:
npm run test:unitgives 3700/3700, ratchets included. - CI and merge state:
gh pr checksis all green or skipped. The PR isMERGEABLE, and it's blocked only by my review. - Merge-order heads-up: #2984, #3021 and #3035 also add an
0080_*revision on0079_telegram_group_context. Whichever lands later has to re-chain onto the live head.
#3005 (skill sets) landed 0080_agent_skill_sets on 0079. This branch's Alembic revision becomes 0081_agent_sync_state_divergence on top of it, and the SQLite entry follows agent_skill_sets in MIGRATIONS. No change to what the migration does; docs updated to the new revision id.
#3005 landed Alembic 0080_agent_skill_sets on 0079_telegram_group_context, the same parent this branch's 0080_pull_sync used, so merging as-is would leave two heads and `upgrade head` would apply nothing. Renumber the revision to 0081_pull_sync, chained on 0080_agent_skill_sets (file, revision, down_revision, docstring, the SQLite mirror note and the revision test). SQLite MIGRATIONS keeps dev's agent_skill_sets entry first, pull_sync after it. tests/registry.json keeps both entries. No logic change.
#3005 (skill sets) landed 0080_agent_skill_sets on 0079. The ent#706 Alembic revision this branch carries becomes 0081_agent_sync_state_divergence on top of it, and the SQLite entry follows agent_skill_sets in MIGRATIONS -- the same resolution as #3035's merge 9229f08. Docs name the new revision id.
Fixes abilityai/trinity-enterprise#530 (absorbs ent#342)
What
A library source's
catalog.yamlmay now declare skill sets: named families of its own skills. Assigningset:<name>assigns every member in one act, and the platform keeps the family current. A member added upstream is injected on the next re-inject; one removed upstream is pruned.agent_skillsrows withindividual = 0. A member also assigned on its own keeps1.not_found,invalid,partial_upstream(which also covers an empty skills root) andsource_changed(a disabled source must never swap in another source's family). A catalog hiccup never strips a fleet's skills.set:entries only add sets; thesetsfield replaces them.ok,partialorunresolved, with per-member state and drift. Prerequisites cover the set's own env keys and its members', checked only while the agent runs (?probe=true), otherwiseunknown.GET /skillscarriesindividualandvia_sets. The injected meta and the agent's CLAUDE.md say "via "./skillso the new routes are covered.Surfaces
GET /api/skills/library/sets,GET /api/agents/{a}/skill-sets,POST/DELETE /api/agents/{a}/skill-sets/{set}; PUT/skillsacceptssets/set:assign_skill_to_agentroutesset:<name>;set_agent_skillsforwardsset:entries (add-only);get_agent_skillsreportsindividual/via_sets/sets; newunassign_skill_set(fenced)agent_skill_sets+agent_skills.individualon both tracks (SQLite migration + Alembic0080_agent_skill_sets←0079_telegram_group_context);AgentRefCASCADEReview and audit
/cso --diff: 0 critical, 0 high, 0 medium, and 3 low, all fixed. The report is indocs/security-reports/cso-diff-2026-09-24-ent530-skill-sets.md.Tests
tests/unit/test_ent530_skill_sets.py: 51 tests. They cover the parser, the fail-closed rule, a real git repo, real SQLite, both migration tracks plus the single head, routes through a real FastAPI app, fleet re-inject, start and manual-inject ordering, and CLAUDE.md.src/frontend/tests/unit/skillSets.spec.js: 16 mounted jsdom specs.skills.test.tsandaccess.test.tspass, andtscis clean.test_ent236that accepted one argument), now fixed.origin/devin this environment: mapped-IPv6 SSRF tests on a Python 3.12 venv.Notes
0080_seat_ask_class_state) and feat(git-sync): the container pulls origin on its own (trinity-enterprise#703) #3021 (0080_pull_sync) also sit on0079_telegram_group_context, so whichever of the three merges later must re-chain onto the live head. The single-head check passes againstdev.sets:rule intrinity-skills. The catalog schema is posted on the issue.🤖 Generated with Claude Code