Skip to content

bug(skills): a library skill assigned to an agent is missing from the Workspace and every playbook list until a manual Sync or restart — and the lists never refresh on assign/unassign #2703

Description

@vybe

Summary

A skill assigned from the Skills Library does not appear in the agent's "available skills" lists — the Workspace / typeahead and hint cards, the Agent Detail chat / popup and empty-state quick actions, the Playbooks tab, the connector playbook list, public chat — until someone presses Sync on that agent's Skills tab or the agent is restarted. And once it does land, no open surface refreshes: the lists are fetched once and never invalidated. Unassign has the mirror problem — the package is removed (ent#236) but the surfaces keep listing it.

Expected: a library skill shared with an agent shows up on every surface the same way a native agent skill does, and those lists update when library skills are assigned or unassigned.

Context

Verified against the code, not assumed:

Assign writes a row and stops. Every assign path persists the agent_skills row and returns — POST /api/agents/{name}/skills/{skill} (src/backend/routers/skills.py, assign_skill), the bulk PUT (update_agent_skills — which does remove packages for dropped names but injects nothing for added ones), the Library page's assign control (src/frontend/src/stores/skillsLibrary.js, assignSkill, ent#386), and MCP assign_skill_to_agent / set_agent_skills (src/mcp-server/src/tools/skills.ts). Injection only happens on manual POST .../skills/inject, on agent start (reconcile_agent_skills in services/agent_service/lifecycle.py), or on the library fleet sweep when the library commit moves (services/skills_sync_service.py). DELETE already delivers — _remove_unassigned_skills removes the package best-effort — so the write path is asymmetric.

Every list reads the container. All playbook surfaces proxy the agent-server's GET /api/skills (docker/base-image/agent_server/routers/skills.py), which scans ~/.claude/skills/: the Workspace roster (src/backend/client_portal/service.py, _agent_briefing → playbooks / searchable_playbooks), GET /api/agents/{name}/playbooks (routers/agent_files.py, consumed by ChatPanel.vue, PlaybooksPanel.vue, usePlaybookAutocomplete.js), routers/connector.py via connector_service.fetch_live_playbooks, and routers/public.py. Library packages are injected to exactly that directory (skill_service.py: "packages ALWAYS land at ~/.claude/skills/<name>/"), so an injected library skill already renders identically to a native one — the gap is purely delivery + invalidation.

Nothing refreshes. The Workspace roster is fetched once at mount (portalUtils.js: "the roster is fetched once at mount"); ChatPanel.vue and PlaybooksPanel.vue reload playbooks only on mount, on status→running, or on agent change. There is no WebSocket event for skill changes, while the sibling settings already have thin-trigger events (agent_tags_changed, agent_label_changed).

Why now: ent#386 (in dev) puts an assign control on the Library page with no Sync button next to it, so the most natural flow — assign from the Library, open the Workspace, type / — shows nothing. The Skills tab at least says "Saved. Sync now, or the agent picks them up on next start."; the Library page and MCP say nothing.

Prior art: ent#385 (incubating) sketches an opt-in ?deliver=true on the single-assign endpoint with an honest delivery: injected | pending_start | not_delivered body. This issue is the product expectation that delivery is the default and that the surfaces follow — ent#385's design (always 200 once the row is written, row never rolled back, SkillInjectionBusy → not_delivered never "queued") is the right shape for the backend half.

Acceptance Criteria

  • Assigning a library skill to a running agent — via the Skills tab, the Library page, POST /api/agents/{name}/skills/{skill}, PUT /api/agents/{name}/skills, or MCP assign_skill_to_agent / set_agent_skills — delivers that skill's package to the agent without a separate Sync, and the response carries an honest per-skill delivery status (injected | pending_start | not_delivered + reason); the agent_skills row is never rolled back on a delivery failure and the HTTP status stays 200 once the row is written
  • Assigning to a stopped agent reports pending_start; the existing start-path reconcile delivers it (no regression)
  • PUT bulk replace is symmetric: added names are injected, dropped names are removed (today only the latter happens)
  • After assign or unassign, the Workspace / typeahead and hint cards, the Agent Detail chat / popup and empty-state quick actions, and the Playbooks tab reflect the change without a page reload — via a thin-trigger WebSocket event (identifiers only, listeners refetch through the access-controlled route, per the /ws broadcast rule) or an equivalent invalidation
  • A library skill that is assigned and delivered is indistinguishable from a native .claude/skills/ skill on every playbook surface (Workspace, chat, Playbooks tab, connector playbooks, public chat, MCP list_runnable_skills)
  • The Library page's assign control (ent#386) surfaces the delivery outcome inline — "assigned and delivered" vs "assigned, applies on next start" vs a named failure — rather than presenting assignment as silently complete
  • MCP assign_skill_to_agent / set_agent_skills return the delivery status (three-surface sync, Invariant feat: SMARTS trading pipeline with Telegram notifications and Miro visualization #13)
  • Existing named validation errors are preserved (unknown skill → 404, invalid name → 422)
  • Tests: assign-on-running injects exactly that skill (not a fleet force=True re-inject); assign-on-stopped is pending_start; the WS trigger fires on assign and unassign; a surface listening to it refetches

Technical Notes

  • skill_service.inject_skills(agent_name, skill_names=[name], force=...) already accepts a single-name list — no new injection machinery needed
  • SkillInjectionBusy is a per-agent Redis lock, not a queue: contention must read as not_delivered ("agent busy — applies on next start or manual Sync"), never "queued"; the manual inject route's 409 mapping must not be reused once the row is committed
  • Removal-on-unassign (_remove_unassigned_skills) is the contract to mirror: best-effort, never fails the committed write, start-path reconcile is the retry
  • Frontend invalidation: ChatPanel.vue / PlaybooksPanel.vue already own a loadPlaybooks(); the Workspace roster needs a per-agent refetch path rather than a full roster reload (layout stability — no scroll/state resets, per the design-system contract)
  • Related: ent#385 (opt-in delivery sketch), ent#236 (removal-on-unassign), ent#386 (Library assign control), ent#182 (skills epic)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions