From aaa95099b576eeba0139b187829a0e956278990a Mon Sep 17 00:00:00 2001 From: Phil Merrell Date: Sun, 19 Jul 2026 12:34:47 -0600 Subject: [PATCH] Release/1.8.0 (#694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(schedules): let schedule edits clear assistantId/enabledTools B1's update_scheduled_prompt skipped None, so a PATCH could never detach a schedule's assistant or reset its tool restriction — the SPA's clear checkboxes were inert (a bare null reads as 'leave unchanged'). Add an explicit clear contract: an UNSET sentinel in the service distinguishes 'omitted' (leave) from None (clear -> REMOVE the attribute). UpdateScheduleRequest gains clearAssistant/clearTools booleans (rejected if combined with a value). clearAssistant reverts to the default agent; clearTools re-snapshots the caller's current RBAC-allowed tools, mirroring creation so a schedule never stores an unresolved None. SPA sends the flags. Co-Authored-By: Claude Opus 4.8 * fix(app-api): grant bedrock:ListFoundationModels to task role The admin GET /admin/bedrock/models endpoint calls the Bedrock control plane's ListFoundationModels, but the App API Fargate task role only had bedrock:InvokeModel. In deployed environments the call raised AccessDeniedException, which the app-wide AWS-error handler maps to a generic 502 ("Upstream service error."). It only worked locally because local dev runs with the developer's broader AWS credentials. Add a BedrockListFoundationModels statement granting bedrock:ListFoundationModels and bedrock:GetFoundationModel. These are account-level list/read actions that do not support resource-level permissions, so they are granted on `*`. Requires a platform.yml (CDK) deploy to take effect. Co-Authored-By: Claude Opus 4.8 * feat(sessions): unread indicator for scheduled-run deliveries A scheduled (unattended) run delivers a session the user wasn't watching. Surface it: the sidebar shows an unread dot until they open it. - sessions/metadata.py: set_session_unread / mark_session_read — a targeted single-attribute UpdateExpression on the row's current SK (GSI-resolved), concurrent-safe with the title/activity writes; preview-session guarded; best-effort (never raises). - harness/runner.py: mark the delivered session unread only on a *completed* run with trigger == "schedule" — attended "Run now" and failed/consent- blocked runs never set the dot (the user is present / there's nothing to read). - app_api POST /sessions/{id}/read: clears the durable flag when the user opens the session; idempotent, ownership-enforced via the GSI lookup. - SPA: durable server-persisted unread on SessionMetadata (survives reload, reaches other devices) ORed with ChatStateService's ephemeral in-tab unread for interactive background completions; session-list renders the dot. Backend 108 + frontend 25 tests pass. Co-Authored-By: Claude Opus 4.8 * docs(kaizen): queue Bedrock Mantle endpoint watchlist item Phil-initiated kaizen focus: scope a future bedrock-runtime (Converse) -> bedrock-mantle endpoint migration. Watchlist/Defer — strategic alignment with where Bedrock capability lands first, not near-term need; Claude-on- Mantle is Messages-API-only today and lacks cross-region + native CountTokens + Guardrails, so the primary chat path stays on bedrock-runtime. Interim low-risk value = finishing the already-scaffolded non-Claude OpenAI-compatible Mantle lane. Co-Authored-By: Claude Opus 4.8 * docs(kaizen): managed-Harness spike findings — 3 gating questions answered Complete the #570 build-vs-adopt spike for the headless/scheduled lane. Answered from the now-GA AWS managed Harness docs cross-checked against our code and the proven F1 entrypoint spike: - Q1 RBAC -> allowedTools: qualified yes (per-invoke globs; we already snapshot the RBAC-narrowed set statically at the app-api boundary). Non-membership gates relocate (quota/cost -> dispatcher; approval -> exclude on headless; consent -> Identity outbound). - Q2 per-user tokens: yes on mechanism (OAuth-inbound customJWTAuthorizer == our authorizer; Gateway outbound == our USER_FEDERATION exchange). SigV4 cannot do per-user identity. One residual: customParameters vault-key pinning through the Gateway-managed exchange -> live probe. - Q3 lose MCP Apps + SSE on headless: yes (interactive-only affordances; SPA loads the delivered session, not the harness stream). Recommendation: green-light a narrow InvokeHarness probe to close the Q2 residual; interactive inference-api untouched. Co-Authored-By: Claude Opus 4.8 * feat(sidenav): gate Scheduled Runs nav entry to system_admin Hide the "Scheduled Runs" menu option from non-admins while the feature is still maturing. Adds an isAdmin() check to the existing capability gate, mirroring the admin-dashboard nav pattern. isAdmin is already wired into the sidenav component from UserService; showSchedules keeps its accessibility-probe behavior. Co-Authored-By: Claude Opus 4.8 * docs(kaizen): managed-Harness Q2 live-probe result — GO-with-boundary Records the live dev-ai probe (2026-07-06) that closes the Q2 customParameters residual from the spike findings. Confirmed live (real CreateHarness/InvokeHarness via boto3): our exact customJWTAuthorizer is accepted (harness READY); outboundAuth.oauth customParameters is a first-class field persisted verbatim on GetHarness (so we CAN pin the same params the consent flow uses); OAuth-inbound runs as the owner (HTTP 200); the exchange calls the same GetResourceOauth2Token our get_token_for_user uses; a failed exchange surfaces legibly as a typed runtimeClientError stream event (maps to paused_reauth). Boundary found: the managed Gateway 3LO (AUTHORIZATION_CODE) exchange fails with "must provide a ResourceOauth2ReturnUrl" and does not source that URL from defaultReturnUrl / OAuth2CallbackUrl header / workload AllowedResourceOauth2ReturnUrl — a GA wiring gap. Cross-workload token visibility (platform vs harness-own workload identity) unreached past it. Decision: GO to adopt Harness on the headless lane, but keep customParameters-sensitive / all 3LO connectors on our own get_token_for_user until the return-URL wiring is resolved with AWS. Aside: managed memory is on by default per harness (relevant to F5). Co-Authored-By: Claude Opus 4.8 * feat(sessions): add mark-as-read/unread toggle with sidebar dot fixes Adds a "Mark as read / Mark as unread" toggle to the session options menu in both the top nav and the sidebar session list, backed by a new POST /sessions/{id}/unread endpoint (mark_session_unread) that mirrors the existing /read verb. The client surfaces the dot instantly via the ChatStateService unread signal while the durable server flag lands async. Fixes two sidebar-only bugs where the in-row options trigger lives inside the row's group, so the CDK menu restoring focus to it on close tripped group-focus-within: - Bug 1: the ellipsis trigger stayed visible after a menu action. Switch its reveal from :focus / group-focus-within to :focus-visible, so mouse-restored focus no longer reveals it while keyboard nav still does. - Bug 2: a freshly marked-unread dot didn't appear until the next browser interaction. The dot was being hidden by group-focus-within (restored trigger focus); scope that to group-has-[button:focus-visible] instead, and kick a synchronous refreshSessions() in the mark-unread branch (mirroring mark-read) so the OnPush row re-renders immediately. The top-nav toggle was unaffected because its trigger sits outside the row. Co-Authored-By: Claude Opus 4.8 * feat(schedules): interval cadence + "Run now" with background-task toasts Two additions to the Scheduled Runs feature: - Interval cadence: schedules can now run every N minutes/hours in addition to daily/weekly. Adds interval_value/interval_unit to the scheduled-prompt model and API, a MIN_INTERVAL_MINUTES floor enforced on create/update/ resume, and interval_to_minutes() feeding compute_next_run_at. The schedule form gains the interval option with matching validation. - "Run now": run a schedule's prompt headlessly on demand from the schedule form. RunNowService fires POST /runs/now (existing app-api endpoint) as fire-and-forget and reports progress through a new app-wide toast system — BackgroundTaskService (signal-backed task list) rendered by the BackgroundTaskToastsComponent mounted in app.html. On completion the run materializes as a real session; the list is refreshed and the toast offers a "View" affordance. Tests: backend test_schedules_routes / test_scheduled_prompts / test_harness_runner (100 passed); frontend schedule-form, run-api, run-now, background-task, and background-task-toasts specs (30 passed). Co-Authored-By: Claude Opus 4.8 * docs(memory): reframe spec as Memory Spaces — bindable, templated, shareable Reframes the per-user markdown memory spec into the Memory Space primitive: a named, first-class, bindable, templated, and shareable markdown wiki. Oliver becomes a Chief-of-Staff template + a bound agent rather than a special-cased feature. - Adds the three-layer abstraction (Agent / Memory Space / declarative binding) and the structural-config vs. semantic-MEMORY.md split. - Space-keyed storage (SPACE#{id} + membership records + UserSpacesIndex GSI) so sharing is expressible from day one. - Entry types (entity / episodic / fact), Space Templates, and manifest- indexed fields for relational/temporal queries ("who owes what"). - Sharing via owner/editor/viewer grants mirroring assistant collab-edit, with run-as-user write attribution. - Data governance is proportionate: same data class as sessions/artifacts behind the same Entra JWT + RBAC — identity-based, no content-inspection gate. Deletion-purge + inherited encryption are the only real items. - 8-PR phasing; PR-1 (data layer) is the next buildout phase. Co-Authored-By: Claude Opus 4.8 * docs(kaizen): recover resourceOauth2ReturnUrl shape section into harness findings PR #576 merged from a state prior to commit 908f7e18, orphaning the resourceOauth2ReturnUrl parameter-shape + harness-security cross-check subsection. This re-applies it onto develop. Co-Authored-By: Claude Opus 4.8 * docs(memory): bake in full-ownership zip export of a Memory Space Add a first-class "download the entire space as a .zip of raw markdown" capability (index + all entries, structure preserved, metadata.json), framed as the user-ownership / zero-lock-in property. Promotes the stubbed export endpoint into §9 with contents, access, streaming mechanics, and an import-friendly round-trip note; folds the zip export into PR-5. Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Spaces data layer (PR-1) Adds the F5 Memory Space primitive's data layer — no runtime wiring, gated by MEMORY_SPACES_ENABLED (default off). Backend (apis/shared/memory/): - store.py: S3 content-addressed byte store (sibling of the skills store) - models.py: MemorySpace / MemoryIndex / MemoryEntryRef / SpaceMember - templates.py: Blank / Chief-of-Staff / Research-Notebook presets - repository.py: dedicated memory-spaces table CRUD (META/INDEX/MEMBER rows, OwnerIndex + MemberIndex GSIs, Decimal handling) - service.py: permission-gated lifecycle + sharing + entry/index I/O, resolve_permission chokepoint (viewer reads, editor writes, owner shares/deletes), content-addressed writes with GC-on-replace - feature_flags.py: memory_spaces_enabled() (default off) - 47 moto-backed tests; import boundaries clean Infrastructure: - MemorySpacesConstruct: S3 bucket + dedicated memory-spaces DynamoDB table (OwnerIndex/MemberIndex GSIs), a per-domain table matching the project's actual pattern - Threaded via PlatformComputeRefs to both compute roles (readwrite S3 + DynamoDB); env vars S3_MEMORY_SPACES_BUCKET_NAME / DYNAMODB_MEMORY_SPACES_TABLE_NAME / MEMORY_SPACES_ENABLED - config.ts flag default-off; resource-count assertions updated - tsc + 429 jest tests pass Spec updated to reflect the dedicated-table + two-GSI decisions. Co-Authored-By: Claude Opus 4.8 * docs(memory): re-slice phasing into primitive vs. agent-consumption workstreams Splits the plan into two workstreams to keep the Memory Space a clean bindable primitive: Workstream A (this epic) delivers the primitive + the user-facing "own your data" surface (data layer, app-api CRUD, export, sharing, SPA panel, consolidation); Workstream B (Agent/Harness layer) delivers agent-consumption — the memory_* tools, declarative binding, and system-prompt index injection — so any run surface can bind the same primitive rather than welding it to inference-api. Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Spaces user surface — /memory/spaces CRUD (A2) Workstream A2 of the re-sliced memory epic: the user-facing "own your data" surface over the Memory Space primitive. No agent-consumption (tools / binding / prompt injection) — that's the Agent/Harness workstream. app-api (apis/app_api/memory_spaces/): - routes.py: /memory/spaces CRUD over MemorySpaceService — list (with templates + accurate per-space role), create-from-template, get (index + entry manifest), delete-or-leave, entry read/list/upsert/delete, index read/update. Sync handlers (FastAPI threadpools the sync boto3 service). - Gated by require_memory_spaces_user: 404 while MEMORY_SPACES_ENABLED off (surface behaves as unmounted); cookie auth via get_current_user_from_session. - Service errors translated NotFound->404, Permission->403, Error->400. - models.py: camelCase request/response models. - Mounted before the existing /memory (AgentCore Memory) router; paths are non-overlapping (/memory/spaces vs /memory/{record_id}). shared service: - leave_space(): a member drops their own grant (the shared-in forget-me case); owner cannot leave. - list_spaces_for_user() now returns (space, role) so shared-in spaces carry the member's real viewer/editor grant (only consumer is the new route). Tests: 12 route tests (moto-backed real service, flag gate, CRUD, 403/404, member-leaves-via-delete) + leave_space service tests. Full memory suite + import boundaries green (69 passing). Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Space zip export — /memory/spaces/{id}/export (A3) The "own your data" leg of Workstream A: a loss-free `.zip` download of a space's raw markdown (§9). `MemorySpaceService.export_space` gathers the corpus once (index + every entry's bytes) behind the viewer+ permission gate, including the member grant list only for editor+ callers (mirrors `list_members`). The app-api route builds the archive in a `SpooledTemporaryFile` — spilling to disk beyond 8 MiB so a large space never pins memory — and streams it back. Zip mirrors the S3 layout (`{name}/MEMORY.md`, `entries//.md`, `metadata.json`) so it is self-contained and re-importable later. Archive path components are sanitized against zip-slip. Route tests cover layout, verbatim frontmatter, owner-vs-viewer member disclosure, 403/404/flag-off, and the hostile-slug case. Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Space sharing + optimistic manifest concurrency (A4) The sharing leg of Workstream A, plus the concurrency guarantee that makes multi-editor spaces safe. Sharing surface (app-api, over the existing service grant methods): - GET /memory/spaces/{id}/shares list grants (editor+) - POST /memory/spaces/{id}/shares grant viewer|editor (owner) - PATCH /memory/spaces/{id}/shares/{email} change a grant's role (owner) - DELETE /memory/spaces/{id}/shares/{email} revoke (owner, idempotent) New `MemorySpaceService.update_share` gives PATCH proper not-found semantics and preserves the grant's original createdAt (distinct from share's upsert). Optimistic manifest concurrency (the real design content): - `MemorySpaceRepository.put_index(expected_version=…)` does a conditional DynamoDB write on the manifest `version`, raising the repository-local `OptimisticLockError` on a mismatch. - `write_entry`/`delete_entry` route through a new `_mutate_index` helper: a bounded read-modify-conditional-write retry loop. Because an entry write touches a single slug, re-reading the fresh manifest and re-applying is safe; it converges on transient races and raises `MemorySpaceConcurrencyError` (→ 409) only on a sustained one. Behavior is unchanged for single-writer spaces. Tests: 6 route tests (share CRUD, member gains access, non-owner 403, viewer can't list, PATCH-unknown 404, owner-role 422) + 7 service tests (update_share role/origin/owner-gate + version-increments, stale-write rejected, retry converges, gives-up-after-max). 76 memory + import-boundary tests green. Co-Authored-By: Claude Opus 4.8 * feat(memory): Memory Spaces SPA panel — list/detail/create/share/export (A5) The user-facing "Memory" surface for the Memory Space primitive, under frontend/ai.client/src/app/memory-spaces/. Makes A2–A4 visible to users. - List page: owned + shared-in spaces as cards with role/template badges; per-card open, share (owner), download .zip, delete/leave. Empty, loading, error, and feature-unavailable states. - Detail page: view/edit the MEMORY.md index (editor+) and the entry list; entries open in a dialog to view (viewer) or edit/create (editor+); delete per entry. Header carries share/download/delete-or-leave. - Create-from-template dialog and a share dialog (add-by-email + per-row role + delta-on-save over the A4 /shares endpoints). Viewer access is read-only throughout; the share dialog fails soft for non-owners. - Signal facade (MemorySpaceService) + thin API service mirror the assistants/schedules pattern. The nav entry rides a live accessible$ probe: a 404 (MEMORY_SPACES_ENABLED off) hides it, matching showSchedules. - Routes memory-spaces + memory-spaces/:id (authGuard); redesign-tokens and @angular/cdk/dialog conventions throughout. Facade spec (7 tests) green; dev build + tsc clean; sidenav specs still pass. Co-Authored-By: Claude Opus 4.8 * test(memory): mock MemorySpaceService in sidenav spec (fix unhandled rejection) The A5 sidenav now injects MemorySpaceService and probes `loadSpaces()` in the auth effect. The sidenav spec mocked ScheduleService but not the new service, so the authenticated-probe test constructed the real MemorySpaceService, which fired a real XHR to /memory/spaces (status 0, no backend); `loadSpaces` then re-threw, surfacing as a Vitest "unhandled rejection" at suite level. Mock MemorySpaceService exactly as ScheduleService is mocked, and add matching coverage: probe fires once authenticated (not while unauthenticated) and the `showMemorySpaces` nav gate resolves null→false, false→false, true→true. Full suite: 1422 passed, 0 errors. Co-Authored-By: Claude Opus 4.8 * fix(memory): wire memory-spaces table/bucket names onto app-api app-api owns the Memory Spaces CRUD surface (`/memory/spaces/*`) but its container environment only set MEMORY_SPACES_ENABLED — never the table or bucket names the service reads (DYNAMODB_MEMORY_SPACES_TABLE_NAME / S3_MEMORY_SPACES_BUCKET_NAME). Without them the repository falls back to the default "memory-spaces" table name, which doesn't exist, so every read throws a boto3 ResourceNotFoundException that the centralized handler maps to a 502 "Upstream service error." (inference-api already sets the identical trio, but per the service-boundary rule it isn't the one serving these routes.) Thread `refs.memorySpacesTable`/`.memorySpacesBucket` through AppApiSsmParams and emit both names next to the flag in buildAppApiEnvironment. Names are always wired (read lazily); only MEMORY_SPACES_ENABLED gates route mounting, so flipping the switch on later needs no env change. New unit test guards the wiring; tsc + 431 infra jest tests green. Co-Authored-By: Claude Opus 4.8 * docs(cdk): capture the "wire resource name to every compute" rule Fold the lesson from the app-api env-wiring fix into the cdk-infrastructure skill so the next construct-author sees it while wiring, not after a 502. Adds a "Cross-Construct References" subsection: set a resource's name env var on every compute that reads it (one doesn't imply the other), the silent-502 failure mode (default-name fallback → ResourceNotFoundException → generic 502, invisible to synth/CI), and the env-map test guard + service-boundary caveat. Co-Authored-By: Claude Opus 4.8 * feat(memory): deterministic consolidation health pass (A6) The safe, non-LLM slice of Workstream A6. `MemorySpaceService.consolidate` (editor+) + `POST /memory/spaces/{id}/consolidate` → a `ConsolidationReport`. Auto-fixes only storage hygiene: orphaned content-addressed objects — keys under a space's prefix that no manifest entry or the index pointer references (leaks from crashed/raced writes) — are GC'd (new `MemorySpaceStore.list_keys` drives it). Everything that needs a judgment call is *reported, not mutated*: - duplicate content across slugs (same content hash) — which slug survives is semantic, so it's flagged, never auto-merged; - dead `[[slug]]` wikilinks in MEMORY.md — reported; opt-in `stripDeadLinks` unlinks them (they point nowhere) while preserving the surrounding prose; - over-cap entry counts (`MEMORY_SPACE_INDEX_CAP`, default 200) — flagged, never auto-evicted. This deliberately does not merge/evict/rewrite durable memory — that's deferred to the LLM consolidation pass (Workstream B era), which extends this exact `consolidate()` seam once agentic writes create real duplication/staleness to act on. On-demand only for now; scheduler/threshold auto-run and SPA surfacing are follow-ups. Tests: 8 service (healthy report, orphan GC + skip, dup-report-no-merge, dead-link report + strip-keeps-prose, over-cap flag, editor gate) + 4 route (report shape, no-body, viewer 403, flag-off 404) + 3 store (list_keys prefix scoping / empty / disabled). 104 memory + boundary tests green. Co-Authored-By: Claude Opus 4.8 * docs(agent): Agent Designer spec — unified primitive-binding surface Captures the strategy for the "Agent Designer" (Agent Harness Editor): a new authoring surface that composes an Agent from RBAC-governed primitives (instructions, model, KBs, tools, skills, Memory Spaces, + future), replacing the term/feature "Assistant." Locks the load-bearing decisions: own a primitive-agnostic Agent contract and federate AgentCore Registry later rather than build on it (adopt-with-boundary precedent); evolve the assistant store in place (no parallel table); a uniform bindings[] model with the model as a governed single-select; RBAC = compose the five existing per-primitive access checks (incl. ModelAccessService), not a new system; design-time filter + run-time re-resolution per invoker with block-on- missing v1; ship memory-consumption as a thin vertical slice before the full Designer. Phasing 0–5 + later AWS federation; supersedes the memory spec's "extend the Assistant" §B1 framing. Co-Authored-By: Claude Opus 4.8 * feat(agents): Agent contract + compat mapping in shared assistants models Phase 1 (PR-1) of the Agent Designer. Pure library, zero behavior change: legacy Assistants read unchanged and no caller passes the new fields yet. - AgentModelConfig (D3 governed single-select; field is model_settings/ alias modelConfig to dodge pydantic's reserved model_config — R3) - AgentBinding (open kind on read, KNOWN_BINDING_KINDS for request validation) - optional model_settings + bindings on Assistant (additive) - compat.effective_bindings/to_agent_view (D2): absent bindings synthesize a knowledge_base binding reffing the assistant id (KB's only stable identity, F4 deferred — R4); absent model maps to None, never fabricated (R1) - Decimal-safe serialization for modelConfig.params floats Co-Authored-By: Claude Opus 4.8 * feat(agents): persist bindings + modelConfig with design-time validation Phase 1 (PR-2). The Agent fields now round-trip through the rag-assistants store and are validated at write time by composing existing RBAC checks (D4). Legacy clients are unaffected: the SPA sends none of the new fields and the AssistantResponse surface is unchanged. - service.create_assistant/update_assistant thread bindings + model_settings; to_ddb_safe on write / from_ddb on read so modelConfig.params floats survive DynamoDB (Decimal); explicit [] replaces bindings, absent leaves them untouched - app_api/agents/services/binding_validation.py composes model access (ModelAccessService), memory resolve_permission (viewer+/editor+), the implicit-KB rejection, and inert shape-only checks for tool/skill (D4/D5) - assistants POST/PUT validate then pass through; validation raises 4xx outside the create handler's generic except so it isn't masked as 500 - tests: validation matrix (incl. inert no-RBAC guarantee), persistence round trip, legacy no-field read Co-Authored-By: Claude Opus 4.8 * feat(agents): /agents alias router behind AGENTS_API_ENABLED (dark) Phase 1 (PR-3). A governed Agent read/write surface over the evolved assistant store: same shared service functions and identity-based access gates as /assistants, but returning the Agent shape (compat.to_agent_view -> AgentResponse) so callers see modelConfig + bindings. Legacy ids valid unchanged. - feature_flags.agents_enabled(): AGENTS_API_ENABLED, default OFF (memory-spaces pattern) — surface 404s while off, ships incrementally, /assistants unaffected - app_api/agents/routes.py: require_agents_enabled 404-gate; draft/create/list/ get/update/delete + 4 shares endpoints, delegating to apis.shared.assistants service and reprojecting via to_agent_view; create/update run binding_validation - AgentResponse/AgentsListResponse/AgentSharesResponse (agentId == assistantId) - main.py mounts the router - test-chat + document sub-routes deliberately excluded (would force a 2nd architecture import-boundary exception); list is owner+shared (public/pagination parity deferred to the Phase-4 Designer) - tests: 404-gate, agentId/bindings projection, CRUD permission gating, shares Co-Authored-By: Claude Opus 4.8 * feat(agents): wire AGENTS_API_ENABLED through CDK + Phase 1 docs Phase 1 (PR-4). Completes Phase 1 deployability: the /agents surface can now be turned on per environment. No new AWS resources — the flag only gates whether the routes 404 (the assistant store it reads is always present). - config.ts: AgentsConfig { enabled }; CDK_AGENTS_API_ENABLED (default off, empty-string-safe) or an `agents.enabled` cdk.json context, mirroring memorySpaces exactly - app-api-environment.ts: AGENTS_API_ENABLED env on app-api - infra tests: default-off / opt-on assertion; mock-config default - docs: agent-designer.md Phase-1 status (+ the two refinements and the Oliver-dogfood-gated-on-Phase-3 note); CHANGELOG [Unreleased] The live Oliver dogfood (D6) is deliberately NOT included: it needs Phase 3 harness resolution + Memory Spaces deployed to the target env before a memory_space binding resolves at invocation. Tracked as the Phase 3 payoff. Co-Authored-By: Claude Opus 4.8 * feat(agents): thread AGENTS_API_ENABLED to the inference runtime (Phase 3 PR-0) Phase 3 harness resolution runs inside inference-api, so the runtime needs the same flag the app-api surface got in #593. Default off, mirrors the app-api wiring; without it the harness ignores Agent bindings entirely (today's behavior). Co-Authored-By: Claude Opus 4.8 * feat(agents): resolve Agent modelConfig at invocation, per invoker (Phase 3 PR-A) The Harness now re-resolves an Agent's governed modelConfig against the INVOKING user (D5) and applies it to model selection. Absent modelConfig ⇒ the model resolves exactly as today; gated on AGENTS_API_ENABLED (off in all envs still). - agent_binding_resolver.py: resolve_agent_invocation() checks the pinned model against AppRoleService.can_access_model for the invoker (R2 — same gate the harness uses elsewhere), returns a model_override or raises AgentBindingBlockedError. inference-api imports apis.shared only (boundary-safe) - routes.py /invocations: resolve after assistant load, before the KB search; on block, stream a conversational stream_error via stream_conversational_message (D5 block-with-message, no silent downgrade). Override wins at model resolution; agent params sit beneath request params, still flowing through admin bounds/locks - tests: allowed→override, denied→block (checked vs invoker), no-modelConfig→ empty plan (no RBAC call); 57 existing inference/chat tests unchanged Co-Authored-By: Claude Opus 4.8 * feat(agents): Memory-Space hydration helper for prompt injection (Phase 3 PR-B) Shared, sync helper that resolves a memory_space binding's alwaysLoad specs into injectable text fragments — the read side of Workstream B. - resolve_always_load(): MEMORY.md → index; latest:/ → most-recent matching manifest entry (defines that scheme, which had no resolver); bare slug → entry. Missing entries skipped (never fails a turn). Byte-budgeted with a truncation marker pointing at memory_read (MEMORY_INJECTION_MAX_BYTES, ~24KB) - render_memory_block(): delimited system-prompt block; empty for a fresh space - reads go through MemorySpaceService (re-checks viewer+ internally) — no leak - 11 unit tests against a fake service Co-Authored-By: Claude Opus 4.8 * feat(agents): inject bound Memory Space into the prompt, per invoker (Phase 3 PR-C) The Harness now resolves an Agent's memory_space binding against the invoking user and injects the space's alwaysLoad content (read-only) into the system prompt — the first half of the Workstream B / Oliver payoff. - agent_binding_resolver: _resolve_memory() checks the invoker's grant via MemorySpaceService.resolve_permission (D4); blocks (D5) when the flag is off, the space is gone, or a readwrite binding meets a below-editor invoker (no silent read-only downgrade). Returns ResolvedMemoryBinding (v1: first binding) - routes.py: after prompt assembly, hydrate via resolve_always_load (asyncio.to_ thread; MemorySpaceService re-checks viewer+) and append render_memory_block; best-effort — a memory-read hiccup never fails the turn - tests: memory grant matrix (none/flag-off/missing/read-viewer/readwrite-viewer- block/readwrite-editor/invoker-identity); 78 inference+compat tests green Co-Authored-By: Claude Opus 4.8 * fix(agents): rename app_api.agents package to avoid shadowing top-level agents run-app-api.sh launches app-api with `cd src/apis/app_api && python main.py`, putting that directory on sys.path[0]. The new apis/app_api/agents/ package (Agent Designer surface, #591/#592) then shadowed the top-level `agents` package, so `admin/quota/routes.py`'s `from agents.main_agent...` resolved into it and crashed startup with `ModuleNotFoundError: No module named 'agents.main_agent'`. Tests never caught it — pytest runs from backend/ where `agents` resolves correctly. Production is unaffected (the container runs `uvicorn apis.app_api.main:app` from WORKDIR /app, so sys.path[0] is /app). Rename the package apis/app_api/agents → apis/app_api/agent_designer (and its test dir) so its name can't collide with the top-level `agents` package. Pure rename: the /agents URL surface, router, and behavior are unchanged. Verified: `import agents.main_agent.quota.repository` and the full app-api module now load from src/apis/app_api; 34 agent/boundary tests pass. Co-Authored-By: Claude Opus 4.8 * feat(agents): memory_* tools scoped to an Agent's bound Memory Space (Phase 3) Completes the Workstream B write side: an Agent with a memory_space binding now gets memory_list / memory_read (always) and memory_write (readwrite bindings only) at invocation — Oliver can read AND write his space. - agents/builtin_tools/memory_spaces/: closure-scoped factories capturing the binding's space id + invoker identity, MemorySpaceService via asyncio.to_thread (artifact-tools pattern). Every call re-checks the grant inside the service (viewer+ read / editor+ write), so a revoked grant becomes an error tool-result mid-session, never a leak - routes.py _build_memory_tools(): appended to the extra_tools seam only when a memory binding resolved; write tool gated on access==readwrite. extra_tools agents are never cached → tools closed over user A can't be served to user B - not gated on enabled_tools: the governing capability is the Agent's binding, not the user's tool picker (same reasoning as artifact tools) - tests: tool success/permission-error/not-found matrix + seam counts (none=0, read=2, readwrite=3); 84 inference+tool tests green Co-Authored-By: Claude Opus 4.8 * chore(memory): default Memory Spaces ON with a kill switch Memory Spaces is a complete feature (CRUD + SPA panel + agent binding), so it should ship enabled for every deployer/forker — opt-out, not opt-in — matching the kbSync / scheduledRuns convention. The table + bucket are already provisioned unconditionally in PlatformStack, so this only flips the runtime MEMORY_SPACES_ENABLED env var; no new infra footprint. - config.ts: memorySpaces.enabled default true (empty/unset workflow var = on, only literal "false" disables); interface + block docs updated - platform.yml: forward CDK_MEMORY_SPACES_ENABLED (kill switch) and CDK_AGENTS_API_ENABLED (per-env enable for the still-default-off /agents surface, so dev can turn it on for dogfooding without a code change) - app-api-environment.ts: comment reflects default-on - config.test.ts: 5 tests locking default-on + kill-switch + context override Agent Designer (AGENTS_API_ENABLED) stays default OFF until the Phase-4 Designer UI ships — a headless /agents surface helps no forker. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): Phase 4 — Agent Designer UI + bindable catalog API Ships the Agent Designer authoring surface (Phase 4) and its Phase-2 precursor, the bindable-primitives catalog. The pickers can't exist without the catalog, so both land together. Backend (Phase 2 catalog): - GET /agents/bindable?kind=model|tool|skill|knowledge_base|memory_space returns an RBAC-filtered palette, composing the 5 existing per-primitive access services (D4); no new RBAC invented. Uniform BindableItem shape so every picker consumes one contract. Route declared before /{agent_id} so the literal path isn't captured. knowledge_base → empty (welded/synthesized); skill/memory_space → empty when their feature flag is off. Behind AGENTS_API_ENABLED. - Fix binding_validation._validate_model: it resolved models via get_managed_model() — a primary-key lookup on the internal UUID — but modelConfig.modelId is the Bedrock model_id that the runtime resolver, RBAC (permissions.models) and invocation all key on. A valid model would have been rejected 400 on save the moment the picker set one. Now matches by model_id, consistent with the whole chain. Frontend (Phase 4 UI): - New agents/ feature dir (separate from the Assistants editor): Agent + Binding + BindableItem TS contracts, a thin AgentApiService, and an AgentService signal facade with the accessible$ 404-probe idiom + a per-kind bindable cache. - Agents list page (model + binding-count badges) and an agent-form page: persona/emoji/tags/starters, a required single-select model picker (D3), tool/skill multi-select chips, and a memory-space picker with access (read/read+write — write disabled unless editor+ on the space, per D5) and an alwaysLoad MEMORY.md toggle. KB shown read-only. Sharing reuses the assistants share dialog (agentId == assistantId). - Routes agents / agents/new / agents/:id/edit, plus a sidenav "Agents" entry gated on the accessible$ probe. Tests: backend 1552 pass (9 new catalog + 4 new route + 3 updated model-validation); SPA build + tsc clean, ng test 7 AgentService + 11 sidenav specs pass. Co-Authored-By: Claude Opus 4.8 * test(sidenav): stub AgentService probe to fix unhandled HTTP rejection The sidenav constructor now probes agent accessibility (void agentService.loadAgents()), but sidenav.spec.ts didn't provide a mock AgentService, so the real service fired an unstubbed HTTP GET /agents that rejected with status 0 — vitest fails the run on unhandled errors even though all assertions passed. Provide a mock AgentService (accessible$ signal + no-op loadAgents) mirroring the schedule/memory stubs, plus parity tests for the probe + showAgents gate. Co-Authored-By: Claude Opus 4.8 * fix(agent-designer): align model write-check with the bindable catalog The catalog lists models via ModelAccessService.filter_accessible_models, but design-time write validation used can_access_model — and the two disagree. filter_accessible_models grants access whenever the model id is in the user's AppRole permissions.models; can_access_model only honors that membership when the model record ALSO carries a non-empty allowed_app_roles. So a model granted purely via the user's AppRole (empty allowed_app_roles) was listed by the picker but rejected on save with a 403 — and the runtime resolver (membership-based) would actually have allowed it. Validate the model with the same filter_accessible_models predicate the catalog uses, so 'if the palette offers it, the write accepts it' holds by construction. Adds a regression test for the empty-allowed_app_roles grant. Co-Authored-By: Claude Opus 4.8 * feat(sidenav): gate Memory Spaces + Agents to system-admin, add Preview badges Match the Scheduled Runs treatment for the two other preview surfaces: Memory Spaces and Agents now also require the system_admin AppRole (showX() && isAdmin()) in addition to their accessibility probe. Adds a small amber 'Preview' badge to all three nav entries (Agents, Memory Spaces, Scheduled Runs) so their preview status is visible. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): resolve tool bindings at invocation (replace + per-invoker RBAC) An Agent's `tool` bindings were stored by the Designer but inert at run time — the free-select tool picker fully drove the toolset regardless of what the Agent bound. This resolves them, mirroring the shipped `modelConfig` override: - Run-time (inference-api): `resolve_agent_invocation` now returns `plan.tools` (`ResolvedTools`). When an Agent binds tools they *replace* the request's `enabled_tools` for the turn; each bound tool is re-checked against the INVOKING user via `AppRoleService.can_access_tool` (the same AppRole gate the harness uses for model, R2) and a missing tool blocks the turn with a message (D5). No tool binding ⇒ `plan.tools is None` ⇒ the request drives the toolset exactly as today. Wired at the existing `extra_tools`/`get_agent` seam via `effective_enabled_tools` (also feeds the spreadsheet/artifact tool gates + attachment guidance/inventory). - Design-time (app-api): `tool` dropped from `_INERT_KINDS`; a bound tool must be in the author's palette (`ToolCatalogService.get_user_accessible_tools`, the same source the picker fetches — "if the palette offers it, the write accepts it", cf. the model check). The palette is resolved once per write. `skill` bindings stay inert here (their run-time fold interacts with agent_type/skill resolution — a follow-up slice). Tests: 6 resolver cases (override, dedupe, block-on-missing, per-invoker, none→passthrough) + 5 validation cases (accessible/inaccessible/empty-ref/fetch-once/lazy). Full backend suite green (4621 passed). Co-Authored-By: Claude Opus 4.8 * feat(topnav): surface active assistant in the top nav Move the assistant/agent indicator out of the chat-input footer and into the top nav, beside the session title, so an attached assistant is visible throughout the conversation. - Add a compact 'variant' to app-assistant-indicator: a subtle name-only pill (emoji + name) that opens the same actions menu (New session / Edit / Share) on click. The full card style is preserved behind variant="card". - Add a menuPlacement input so the actions dropdown opens downward in the top nav instead of clipping off-screen. - Thread the assistant/owner/loading state and action outputs from the chat container into app-topnav; render the pill (with a loading shimmer) to the right of the title. - Remove the now-orphaned footer indicator and loading skeletons from the full-page chat container (embedded preview footer left intact). - Assistant card: move conversation starters into a collapsible accordion (expanded by default) to keep the card compact. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): resolve skill bindings at invocation (replace + force skill-mode) Completes the tool/skill runtime-resolution gap (tools landed in #601). An Agent's `skill` bindings were stored by the Designer but inert at run time. This resolves them, mirroring the tool/model overrides: - Run-time (inference-api): `resolve_agent_invocation` now returns `plan.skills` (`ResolvedSkills`). When an Agent binds skills they *replace* the request's skills for the turn AND the route forces `agent_type="skill"` so the SkillAgent discloses exactly the bound set. Each bound skill is re-checked against the INVOKING user via `AppRoleService.can_access_skill`; a missing skill — or the Skills feature being disabled in this environment — blocks the turn with a message (D5). No skill binding ⇒ `plan.skills is None` ⇒ the request's agent_type/enabled_skills drive the turn as today. Wired by reassigning `effective_agent_type`/`effective_skill_ids` before the main-turn get_agent, so the values flow into the construction snapshot and a bound-skill agent resumes on the same skills_hash (resume-safe, same mechanism the tool slice relies on). - Design-time (app-api): `skill` dropped from inert (no inert kinds remain). A bound skill is flag-gated (`skills_enabled()`) and must be in the author's palette (`resolve_accessible_skill_ids`, the same source the picker fetches — cf. the tool check); the palette is resolved once per write and only when skills are enabled. Tests: +6 resolver (override, dedupe, flag-off block, block-on-missing, per-invoker, none →passthrough) + 6 validation (accessible/inaccessible/empty-ref/flag-off/fetch-once/lazy). Full backend suite green (4631 passed). Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): reflect governed agent bindings in the chat-input (lock pickers) The backend governs an Agent's model/tool/skill bindings at invocation (#601, #602) — the agent's set wins regardless of what the client sends. The chat-input still showed the model/tool/skill pickers as free-select, which was dishonest (a change the backend ignores). This locks each picker to the active Agent's bindings, per primitive. - Session page (`session.page.ts`): inject AgentService/ToolService/SkillService; fetch the governed Agent alongside the assistant (agentId == assistantId) in `loadAssistant`; apply per-primitive locks from `modelConfig`/`bindings`, and release them when navigating to plain chat. Best-effort: the /agents surface may be disabled (404) or the assistant may be a legacy assistant with no bindings — every failure leaves the pickers free-select. - ModelService/ToolService/SkillService: add a small agent-lock API (`lockToAgent*` / `clearAgentLock` + `agentLocked`/`agentModelLocked`). While locked, `enabledToolIds`/ `enabledSkillIds` return the bound set (replace semantics, matching the backend), toggles no-op, and `isToolShownEnabled`/`isSkillShownEnabled` render the bound set honestly. - UI: model-dropdown shows a locked read-only chip ("set by this agent"); model-settings shows a "Set by agent" model row and a "This agent uses a fixed set of tools/skills" banner, with tool/skill/sub-tool toggles disabled + greyed while locked. This is UI honesty, not enforcement — the backend remains the authority. Per-primitive: an agent that binds a model but no tools locks only the model; the rest stay free-select. Tests: +5 tool-lock, +5 skill-lock, +4 model-lock service specs (ng test, 51 pass); `tsc` clean; production build (AOT template check) clean. Known limitations (documented for follow-up): a model race if the pinned model isn't in the user's loaded set yet (dropdown disables but may show the fallback name until models load); the skill-lock banner only shows in skills chat-mode. Co-Authored-By: Claude Opus 4.8 * fix(agent-designer): release chat-input picker locks on new conversation The agent-binding picker locks live in root singleton services (Model/Tool/Skill Service) that outlive the session component. Clicking "New chat" navigates to `/`, which recreates the session component with fresh assistant()/agent() signals (both null). The lock-release lived inside the `if (loadedAssistant || … || agent())` guard, which is false on that fresh component — so the stale locks from the previous agent conversation were never released, leaving the model + tools pickers stuck. Move `clearAgentBindingLocks()` out of the guard so it always runs when there is no assistant in the URL. Idempotent — a no-op when nothing is locked. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): show only the bound tools/skills when an agent locks the settings When an Agent dictates a fixed toolset/skillset, the settings panel listed every accessible tool/skill with the bound ones toggled on and the rest greyed off — a long, noisy list. Filter to show ONLY the bound (enabled) tools/skills so the panel reflects exactly what the agent uses. - ToolService.visibleTools / SkillService.visibleSkills: agent-locked → filter to the bound ids; otherwise the full accessible list. - model-settings template iterates the visible* lists. Tests: +1 tool-lock, +1 skill-lock spec (ng test green); tsc + AOT build clean. Co-Authored-By: Claude Opus 4.8 * chore(agent-designer): default AGENTS_API_ENABLED on with a kill switch The Agent Designer is complete (contract → surface → resolution → Designer UI → binding reflection), so flip the feature flag from opt-in to default-on, matching the house style for shipped features (scheduled_runs / memorySpaces). - backend `agents_enabled()`: empty-string-safe default-on — unset/empty ⇒ enabled, only the literal "false" disables (was `== "true"`, default off). - CDK `config.agents.enabled`: mirror the memorySpaces/scheduledRuns ternary (`!== 'false'` + context fallback `?? true`), so an unset/empty GitHub Actions var can't silently disable it. - Tests: add the Agents API default-on/empty/kill-switch/context suite to config.test.ts (mirrors Memory Spaces); rename the app-api-environment threading test (no longer "default off"). The `/agents/*` API now ships everywhere; the SPA nav stays preview-gated (system-admin + "Preview" badge) until Assistants are deprecated, so this doesn't broaden user-facing exposure — it just stops the API 404ing per-environment. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): manage an agent's knowledge base from the Agent Designer Extract the assistant editor's inline "Knowledge base" section into a standalone, reusable KnowledgeBaseSectionComponent and use it in both the assistant form and the Agent Designer — replacing the agent form's read-only "managed automatically" card with the live document/web-crawl/connector flow. This closes the last Agent migration blocker. The gap was frontend-only: the document upload/ingestion/retrieval pipeline already keys on the record id and agentId == assistantId, so /assistants/{id}/documents backs an agent unchanged. No backend or data-model changes (Option 1, not the deferred F4 first-class KB primitive). The component owns record identity via a createDraft callback so the first content-adding action can mint a draft in create mode; a permissionResolved input gates the edit-only sync-policy calls so a viewer never 403s on the default owner guess. The assistant form keeps createDraftAssistant as its callback (shedding ~1000 lines); the agent form adds createDraftAgent and drops the read-only kbBinding path. Verified: ng build clean, ng test 1449 specs green (incl. assistant-form spec). Co-Authored-By: Claude Opus 4.8 * test(scheduled-runs): freeze dispatcher clock to de-flake cadence rearm test test_next_run_at_uses_schedule_cadence asserted the daily-9am re-arm delta fell in (1h, 48h), which fails when CI runs in the hour before 9am Boise (the next daily run is legitimately <1h away). Freeze dispatcher._now to a fixed instant and assert next_run_at equals compute_next_run_at recomputed from the same instant, making the test time-of-day independent. Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): govern model params + live editor preview Model-params governance: - binding_validation._validate_model_params rejects params that are unsupported / locked / out-of-[min,max] / out-of-allowed against the model's admin supported_params (belt-and-suspenders to the runtime merge; author-facing 400 instead of a silent clamp). +9 tests. - Data-driven Parameters subsection under the model picker reading meta.supportedParams (numeric inputs, enum selects, locked read-only); empty params omit `params` (today's exact resolution). Live side-by-side preview in the agent editor: - New AgentPreviewComponent reuses PreviewChatService and streams the SAVED agent through the real /chat/stream invocation path, so all bindings (model/params/tools/skills/memory) resolve server-side. Capability strip + dirty banner make the resolved context and the save-to-apply semantics explicit. - Agents send a minimal request body (message/session_id/agent id) and opt out of the assistant preview's system_prompt + owner-tools injection, which fought the bindings and blew the 8KB system_prompt cap for long personas (422). PreviewChatService gains a backward- compatible opts flag; assistant preview behavior unchanged. - Two-column editor shell mirroring the assistant editor. Verified: backend 59 pass (9 new), ng build clean, 16 SPA specs (7 agents + 9 preview-chat). Co-Authored-By: Claude Opus 4.8 * feat(agent-designer): lock preview model picker; trim preview nav The Agent Designer preview reused the main chat-input, whose model dropdown reads the root ModelService — so it showed the user's global model (e.g. Sonnet 5) and let them switch it, even though the harness resolves the model from the agent's binding server-side. Wire the preview to lock that picker to the agent's model via the same lockToAgentModel mechanism the session page uses for a real agent conversation, released on destroy (and idempotently on the next plain chat via the session page's self-heal effect). Also hide the Memory Spaces and Scheduled Runs side-nav entries for now (routes/pages and their capability probes are unchanged, so re-enabling is just re-adding the template blocks). Agents stays system-admin only. Co-Authored-By: Claude Opus 4.8 * fix(memory-spaces): route namespaced entry slugs via :path converter Entry slugs are namespaced with a slash (e.g. `people/brian-bolt`), but the app-api entry routes declared a plain `{slug}` param whose converter stops at `/`. Uvicorn percent-decodes `%2F`→`/` before routing, so `/entries/people/ brian-bolt` never matched `/entries/{slug}` and returned 404 on view/edit/delete. Switch the GET/PUT/DELETE entry routes to the `{slug:path}` converter so the embedded slash is captured and the slug arrives matching the manifest. Adds a route test exercising upsert→read→delete with a slashed slug. Co-Authored-By: Claude Opus 4.8 * feat(memory-spaces): let the agent read/write MEMORY.md via reserved slug MEMORY.md is the space's human-readable index — a standalone S3 object outside the entries manifest, injected into the agent's context each session via hydration. It never appears in `memory_list`, and the agent had no tool to read it back or keep it in sync with the entries it writes, so the machine-readable manifest and the human-readable index could silently drift. Route the reserved `"MEMORY.md"` slug (case-insensitive) through the existing service methods: `memory_read("MEMORY.md")` → `read_index` (viewer+), `memory_write("MEMORY.md", body)` → `update_index` (editor+, body only). No new tool surface; matches the literal hydration already uses. The slug is reserved — the agent cannot create an ordinary entry named MEMORY.md. Write stays gated identically to entry writes (only bound when the binding grants readwrite; service re-checks editor+). Docstrings + spec §4/§5 updated. Co-Authored-By: Claude Opus 4.8 * feat(schedules): target Agents instead of Assistants on scheduled runs The scheduled-run form's target selector now lists Agents (the Agent Designer primitive that supersedes the Assistant) instead of Assistants. Same underlying record — agentId == assistantId — so the wire field stays `assistantId` and no backend change is needed for the swap. Because an Agent's `tool` bindings replace the run's `enabled_tools` at invocation (agent_binding_resolver / routes.py effective_enabled_tools), the manual tool picker is now hidden whenever an Agent is selected — showing it would let the user pick tools that get silently discarded. The picker (and its snapshot semantics) remains only for the "Default agent" case. Submit drops any stale snapshot when an Agent is targeted. Also fixes "Run now" to target the selected Agent via ragAssistantId (the /runs/now backend already accepts it) — previously it ignored the target, so the attended test surface didn't match what the schedule would actually run. Co-Authored-By: Claude Opus 4.8 * chore(kaizen): weekly research scan 2026-07-10 Generated by the kaizen-research skill. Top 5 ideas appended to docs/kaizen/review-queue.md for the kaizen-review-prep run later this morning. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(deps): upgrade Strands to 1.47.0 and add aws-bedrock-token-generator Bumps strands-agents 1.40.0 -> 1.47.0 (and the [bidi] extra to match) and adds aws-bedrock-token-generator==1.1.0 (bounded >=1.1.0,<2.0.0 by strands' openai extra). strands-agents-tools stays at 0.5.2 (resolver-confirmed compatible). Unblocks Bedrock Mantle work that needs the newer SDK: - OpenAIResponsesModel (Responses API) for models that don't support Chat Completions (e.g. openai.gpt-5.x on Mantle). - bedrock_mantle_config, which mints the Mantle bearer token via aws-bedrock-token-generator and derives the base URL + model-family base path (openai.gpt-5.* -> /openai/v1, else -> /v1). Full backend suite green on 1.47.0 (2306 passed). Co-Authored-By: Claude Opus 4.8 * fix(scheduled-runs): remove RBAC gate causing prod 403 "Access Denied" Regular users hit a 403 "You do not have access to scheduled runs" toast on page load. The `/schedules` and `/runs/*` surfaces were gated by the `scheduled-runs` RBAC capability, granted only to a beta cohort's AppRole (admins passed via the `*` wildcard). The sidenav ran a background `loadSchedules()` probe on every load, and the global errorInterceptor popped the toast on the 403 before the schedule service's graceful catch ran. The feature doesn't need admin/beta gating — keep it low-key and reachable only by direct URL for now: - Drop the capability check from both `require_scheduled_runs_user` gates; only the `SCHEDULED_RUNS_ENABLED` kill switch remains (404 when off). Runs still execute with the caller's own RBAC-allowed tools, so this widens who can reach the surface, not what any one caller can do. - Remove the vestigial sidenav schedules probe and dead showSchedules/navigateToSchedules wiring (the template never rendered a "Scheduled runs" link). - Update route + sidenav tests accordingly. `apis/shared/rbac/capabilities.py` is now unreferenced; left in place as generic RBAC infra so re-gating is a two-line revert. Co-Authored-By: Claude Opus 4.8 * docs: consolidate release workflow into one auto-invoked steering doc + skill Fold the versioning and release-notes guidance into a single 'cutting a release' guide covering the branch workflow, SemVer bump + version sync, change identification across the divergent main/develop histories, writing both release docs, the squash-merge PR into main, and the required backmerge into develop. - Add .kiro/steering/cutting-a-release.md (inclusion: auto — name + description, intent-triggered) - Add .claude/skills/cutting-a-release/ (SKILL.md auto-invoked via description) with references/{release-notes-format,changelog-format}.md for progressive disclosure - Remove superseded .kiro/steering/{versioning,release-notes}.md and .claude/skills/{versioning,release-notes}/ - Repoint .github/copilot-instructions.md at the consolidated skill/steering * feat(models): Mantle Responses API + per-model region; drop endpoint-path knob Refactors the admin "mantle" provider onto Strands' bedrock_mantle_config so the SDK owns the base URL, model-family base path, and bearer-token minting — removing hand-rolled inference plumbing. Adds the two things the library can't infer as declarative per-model fields: - apiMode (chat | responses): selects OpenAIModel vs OpenAIResponsesModel. Some Mantle models (e.g. openai.gpt-5.x) only serve the Responses API and reject Chat Completions, which the endpoint-path knob could never satisfy. - region: optional override into bedrock_mantle_config["region"], driving both the Mantle endpoint host and the SigV4 region the token is signed for — so a model can pin inference to its host region (e.g. gpt-5.x in us-east-1) independent of where the app runs. mantleEndpointPath is kept as an accept-but-ignore deprecated schema field (no stored record breaks) and removed from the UI + runtime. The Responses API uses different native param names, so to_mantle_config selects a Responses map (max_output_tokens, nested reasoning.effort) by mode. Runtime fields (mantle_api_mode/mantle_region) thread through model_config, the agent factory, base_agent, the paused-turn snapshot, stream_coordinator, and the chat service/routes. get_mantle_base_url/generate_bedrock_bearer_token are retained for the admin model-browse list (not inference). Gemma 4 (google.gemma-4-31b) is temporarily un-curated: it needs the /openai/v1 base path but the SDK only routes openai.gpt-5.* there, and bedrock_mantle_config forbids a base_url override. Re-add once the "google.gemma-" family prefix lands upstream in strands-agents/sdk-python. Backend suite green (2342). Frontend typecheck + manage-models specs green. Co-Authored-By: Claude Opus 4.8 * fix(api-converse): serve /chat/api-converse from app-api, not via inference proxy The API-key converse endpoint was broken in cloud. app-api proxied POST /chat/api-converse to `{INFERENCE_API_URL}/chat/api-converse`, but inference-api now runs inside an AgentCore Runtime whose data plane only serves POST /invocations and GET /ping — any other path returns UnknownOperationException (404) before reaching the container. It worked locally only because localhost:8001 bypasses the runtime gateway. Relocate the handler onto app-api as a self-contained route (validate key -> RBAC -> bedrock-runtime.converse -> cost accounting), reusing the shared services it already depends on. app-api reaches Bedrock directly via its task role, so there is no inference-api hop and no INFERENCE_API_URL dependency. Delete the proxy, the now-dead inference-api route, and its DTOs (moved to app_api/chat/models.py). Repoint the converse tests at the app-api module. Verified: 263 backend tests pass, import-boundary test clean, and a real un-mocked smoke against a Bedrock model returns 200 (stream + non-stream). Co-Authored-By: Claude Opus 4.8 * fix(app-api): grant Bedrock streaming + inference-profile invoke The relocated /chat/api-converse handler calls Bedrock Converse from app-api, so the task role's invoke grant must cover what the catalog's model IDs need. Expand the BedrockInvokeModel statement to add bedrock:InvokeModelWithResponseStream (the stream=true path) and broaden resources to all-region foundation models plus the account-level inference-profile ARN, since the catalog uses `us.*` cross-region inference profiles. Mirrors inference-api's BedrockModelInvocation grant. Verified: infra tsc clean, 442 infra jest tests pass. Co-Authored-By: Claude Opus 4.8 * fix(settings): point API-key snippets at /api/chat/api-converse After the BFF refactor, CloudFront only routes /api/* to the backend; other paths hit the SPA origin, which rejects POST with a CloudFront 403. The generated curl/Python/JS examples emitted the bare origin, producing `/chat/api-converse`. Resolve a relative/empty appApiUrl against the current origin so snippets target `/api/chat/api-converse`; leave an already-absolute value (local dev's http://localhost:8000) untouched. Co-Authored-By: Claude Opus 4.8 * feat(api-converse): route Bedrock Mantle models via a shared builder The API-key /chat/api-converse handler was Bedrock-only; provider="mantle" models (e.g. openai.gpt-5.4) 400'd because it always called bedrock-runtime.converse. Add a Mantle path so the full model catalog works. Extract the Mantle model construction (class-pick + bedrock_mantle_config) and its param maps + MantleApiMode enum out of agents/main_agent/core into a new apis/shared/models/mantle.py, so the agent factory and the API-key handler share ONE implementation (app-api can't import agents/). The factory now delegates to build_mantle_model. The handler resolves the requested model's provider from the catalog and branches: bedrock -> boto3 converse (unchanged); mantle -> the shared builder + the bare Strands model's .stream(), which yields the same Converse-shaped events the Bedrock path already emits — so SSE translation and usage/cost accounting are shared (cost is now tagged with the real provider). Unknown / lookup-failure ids fail safe to the Bedrock path. Verified: shared builder + factory-delegation + handler mantle-path unit tests; and real dev-ai smokes — chat-mode Mantle and Responses-API Mantle (openai.gpt-5.4) both return 200 (stream + non-stream) against the live endpoint. Co-Authored-By: Claude Opus 4.8 * feat(app-api): grant bedrock-mantle:CreateInference for api-converse The api-converse Mantle path invokes a Mantle model directly from app-api, so the task role needs bedrock-mantle:CreateInference (Mantle's own IAM namespace) — without it, mantle requests AccessDeny. Fold it into the existing project-scoped Mantle statement (was browse-only Get*/List*), renamed BedrockMantleInference to mirror the runtime role's grant. Verified: infra tsc clean, integration jest green (24 passed). Co-Authored-By: Claude Opus 4.8 * feat(identity): MCP user identity forwarding via access-token enrichment Add an opt-in Cognito Pre-Token-Generation v2 Lambda that copies configured user-pool attributes into namespaced claims on the ACCESS token, so personalized MCP tools can identify the caller. The access token is the only token forwarded end-to-end to MCP servers, so enrichment needs no changes to the SPA -> app-api -> inference-api -> MCP forwarding path. Shipped disabled by default (opt-in): a fork that configures nothing gets zero resources and the token is forwarded as before. Enabling requires the Cognito Essentials feature plan (pinned on the pool) plus two GitHub Actions variables (CDK_MCP_TOKEN_ENRICHMENT_ENABLED + CDK_MCP_TOKEN_ENRICHMENT_CLAIMS), keeping the committed cdk.context.json inert. - config: McpIdentityConfig (enabled + accessTokenClaims); claim map settable via JSON env var or context; parseJsonRecordEnv helper. - handler: stdlib-only, fail-open Pre-Token-Gen v2 trigger (returns event unchanged on any error so login is never blocked). - construct: real-code Lambda (fromAsset) attached via addTrigger V2_0; pool featurePlan pinned to ESSENTIALS. - wired conditionally into PlatformStack; platform.yml job-level env. - docs: spec updated (open questions resolved) + implementation summary, incl. the mcp-servers follow-on handoff. Ref: docs/specs/MCP_USER_IDENTITY_FORWARDING_SPEC.md * fix(app-api): grant bedrock-agentcore:CreateTokenVault for OAuth provider create Admin "add OAuth provider" (POST /admin/oauth-providers/) returned a 502 Bad Gateway. dev-ai app-api logs showed the real cause: an AccessDeniedException on bedrock-agentcore:CreateTokenVault against token-vault/default. AgentCore's CreateOauth2CredentialProvider ensures the default token vault exists on the first provider create, which requires CreateTokenVault (+ GetTokenVault) on the caller. The app-api task role had the ...Oauth2CredentialProvider actions but not the TokenVault ones. The shared error handler maps an uncaught AWS ClientError to HTTP 502, so the missing permission surfaced as a 502 rather than a 403. Add CreateTokenVault + GetTokenVault to the AgentCoreWorkloadIdentityAccess statement. The resource scope (token-vault/*) already covered token-vault/default; only the actions were missing. Requires a platform.yml (CDK) redeploy to take effect. Co-Authored-By: Claude Opus 4.8 * fix(scripts): make sync-version.sh portable across GNU and BSD tools The version-sync script only ran inside the dev container / CI (GNU coreutils); on macOS (BSD sed/grep) it errored out and silently left the manifests un-synced, so a release cut locally had to hand-edit every manifest. Replace the three GNU-only constructs with POSIX equivalents: - `grep -oP ... \K` (Perl regex) -> `sed -n 's/.../\1/p'` / awk field split - `sed -i "expr"` (GNU in-place) -> sed_inplace helper (temp file + mv) - `sed "0,/re/s/..."` (GNU-only address) -> awk first-match replace Behavior is unchanged on GNU; the script now runs identically on macOS. Verified both --check and the write path (incl. shields.io `--` hyphen doubling and SemVer->PEP 440 lock conversion) round-trip on BSD tools. Co-Authored-By: Claude Opus 4.8 * feat(admin): make admin sidebar nav sticky on desktop Pin the admin layout aside below the sticky top bar so the section nav stays in view while the content area scrolls. Uses lg:self-start so the aside shrinks to its content (flex items stretch to full height by default, which defeats position:sticky), plus a max-height + overflow so a long nav scrolls internally. Mobile dropdown is untouched. Co-Authored-By: Claude Opus 4.8 * feat(frontend): redesign 404 page to match auth screens Rework the not-found page onto the same design system as the login and first-boot pages: the primary-derived lava-lamp parallax backdrop (six depth-tiered morphing blobs), the masked graph-paper grid overlay, and the frosted-glass card. The oversized 404 sits above the card where the auth pages place the logo, so all three screens read as one system. Preserves existing behavior (sidenav hide/show, Return Home, Go Back) and respects prefers-reduced-motion. Classes are nf-prefixed and component-scoped via view encapsulation. Co-Authored-By: Claude Opus 4.8 * fix(frontend): make shell scroll container real so sticky nav engages The admin aside's lg:sticky never engaged because its nearest scrolling ancestor was the app shell's `flex-1 overflow-y-auto` div, which had no bounded height — it grew to content and the window scrolled instead, so sticky bound to a box that never moved. Pin
to h-dvh so that div becomes a genuine scroll container; the admin aside and top bar now stick. Also apply the admin bar's frosted-glass treatment (bg-*/opacity + backdrop-blur-sm) to the session topnav so the two surfaces match. Co-Authored-By: Claude Opus 4.8 * docs(specs): quota cooldown windows + platform ceiling spec and committee one-pager Replaces the hard monthly quota cutoff with a three-layer model: anchored 5-hour cooldown windows (Claude-style, exact reset times), a hard admin-adjustable platform-wide monthly ceiling as the fiscal guarantee, and the per-user monthly limit demoted to a generous anti-runaway backstop with degrade-to-economy-model as the target behavior. Backstop horizon (monthly vs weekly) is a per-tier choice. Includes an admin pilot tuning playbook with an observe-only phase, a user-facing quota status endpoint, recommended opening numbers, and a 7-PR implementation breakdown. The one-pager is the committee-facing rationale. Co-Authored-By: Claude Fable 5 * fix(frontend): guarantee JIT compiler in vitest runs to stop PlatformLocation flake The unit-test builder keeps Angular packages external, so vitest evaluates raw fesm2022 chunks whose partial declarations (ɵɵngDeclareInjectable/ ɵɵngDeclareFactory) compile eagerly and require @angular/compiler. Its presence was incidental — loaded transitively via @angular/core/testing in the builder's init-testbed setup — so specs with no static Angular imports (app.spec.ts dynamic-imports './app') could evaluate an unlinked @angular/common chunk first and fail with "The injectable 'PlatformLocation' needs to be compiled using the JIT compiler, but '@angular/compiler' is not available" (angular/angular-cli#31993). - add src/test-setup.ts importing @angular/compiler, wired via the test target's setupFiles and included in tsconfig.spec.json - add src/test-setup.spec.ts guarding the invariant deterministically - bump the first shared-view.page spec to 15s: it pays the one-time dynamic page-chunk import, which can exceed 5s under full-suite load Co-Authored-By: Claude Fable 5 * fix(frontend): size chat scroll space to the response, adapt to shell scroll container Replace the fixed viewport-tall bottom spacer in the message list with a min-height on the last turn group (user message + its assistant responses). The response streams into the reserved space instead of pushing a static spacer further down: a short response leaves exactly the room needed to pin the user message at the top, and a response taller than the viewport leaves zero dead scroll below it. Turn groups are keyed by their first message id so a finished turn's DOM (including live MCP App iframes) never remounts when the next turn starts, and the end-of-conversation sections (loader, consent/approval prompts, compaction, orphan artifacts) render inside the reserved space so they stay visible next to the response. Also adapt the session page to the real shell scroll container introduced by #634 (frosted sticky nav): the window no longer scrolls, which had silently broken submit scroll-to-message and scroll save/restore. scrollToMessage now uses scrollIntoView with a scroll-mt-20 header offset, and save/restore reads the shell container's scrollTop via a stable #app-scroll-container hook. Co-Authored-By: Claude Fable 5 * feat(settings): make user settings sidebar nav sticky on desktop Mirror the admin layout change (#632): pin the settings aside below the sticky top bar so the section nav stays in view while the content area scrolls. Uses lg:self-start so the aside shrinks to its content (grid items stretch to full row height by default, which defeats position:sticky), plus a max-height + overflow so a long nav scrolls internally. Mobile dropdown is untouched. Co-Authored-By: Claude Opus 4.8 * feat(admin-tools): discover OAuth-gated MCP servers with the admin's vaulted token The admin tool "Discover" flow refused OAuth-gated MCP servers outright, so servers like the GitHub remote MCP server (api.githubcopilot.com/mcp/) could not be discovered — discovery either 400'd on auth_type=oauth2 or connected unauthenticated and got a 401 from the server (wrapped to a 400). Discovery now accepts the OAuth provider id and connects using the admin's own vaulted 3LO token for that provider, fetched via AgentCore Identity (get_token_for_user) and injected as a bearer — mirroring how the agent loop attaches the end-user's provider token at runtime, and reusing the exact path connector_status already uses. This validates the admin's own connection and lists the tools their token can see (providers such as GitHub scope-filter the tool list to the token's grants). It fetches the admin's token only; it cannot mint an arbitrary end-user's token. Backend: - Add requires_oauth_provider (alias requiresOauthProvider) to MCPDiscoverRequest. - Handler loads the provider, fetches the admin's vaulted token, injects it as oauth_token into create_external_mcp_client. requires_consent -> 409, unknown provider / conflict with forward_auth / oauth2-without-provider -> 400. Frontend: - Send requiresOauthProvider in the discover payload (the form control already existed) and the OAuth2CallbackUrl header (bare /oauth-complete, no query string) so the backend can resolve the admin's token. Tests: 5 backend tests for the OAuth-provider discovery path; 2 SPA specs for the discover payload. Co-Authored-By: Claude Opus 4.8 * feat(manage-models): add Sonnet 5 + GPT-5.4 curated cards, order by capability Add two curated model catalog cards: - Claude Sonnet 5 (bedrock, global.anthropic.claude-sonnet-5) — 1M context, effort-based reasoning, caching on. - GPT-5.4 (mantle, openai.gpt-5.4) — Responses API surface; the openai.gpt-5.* model id matches the SDK's /openai/v1 routing prefixes, so one-click create routes correctly (unlike the commented-out Gemma card). Order the Bedrock Claude cards most-capable-first (Opus 4.7, Sonnet 5, Sonnet 4.6, Haiku 4.5) and place GPT-5.4 ahead of Qwen in the Mantle list. Move the "Bedrock Mantle" provider tab next to "Bedrock" in the catalog selector. Co-Authored-By: Claude Opus 4.8 * fix(mantle): route google.gemma-4-* to /openai/v1 base path Gemma 4 is served ONLY on Mantle's /openai/v1 path (per its AWS model card), but the Strands SDK's _OPENAI_PATH_MODEL_PREFIXES ships only "openai.gpt-5.", so google.gemma-4-* fell through to /v1 and inference 401'd with access_denied ("... is not enabled for this account"). Append "google.gemma-4-" to the SDK's prefix table at build time (_ensure_gemma4_openai_v1_routing: lazy, idempotent, guarded) until it lands upstream. Scoped to the 4.x family — Gemma 3 stays on /v1. - Guard tests: prefix registers on build, all three Gemma 4 variants resolve to /openai/v1, Gemma 3 stays on /v1, registration idempotent. - Correct the stale curated-models.ts note (the "would fail at chat time" claim is obsolete; its "google.gemma-" re-add hint would have misrouted Gemma 3). - Add design note proposing mantleEndpointPath as a live admin setting as the durable alternative to chasing the SDK's hardcoded table. Co-Authored-By: Claude Opus 4.8 * feat(manage-models): make max output tokens optional Newer reasoning / Responses-API models (GPT-5.x, Claude with adaptive thinking) don't publish a discrete max-output-tokens value — output shares the context budget with reasoning tokens, so there's no fixed cap to enter. Our own GPT-5.4 curated card already carries a decorative value with no backing max_tokens spec. maxOutputTokens is only a ceiling for the admin-configured max_tokens inference param and is never sent to the provider, so leaving it unset is safe at inference time. This makes the admin form field optional to match. - ManagedModelCreate / ManagedModel: max_output_tokens -> Optional[int] - DynamoDB write: omit maxOutputTokens when absent (matches other optionals) - Form control: drop Validators.required, default null (number | null); the 0-default + min(1) combo would otherwise still block submit - SPA interfaces typed number | null; catalog card null-guarded (shows "— out") - Both ceiling validators already skipped an absent value — no change needed Co-Authored-By: Claude Opus 4.8 * fix(docker): float curl security patch to survive Debian mirror purges Debian removes the superseded point version of curl from the trixie mirror on each security update, so an exact +deb13uN pin breaks every build once the next CVE lands. Pin to +deb13u* to track the live patch while keeping the minor version fixed; the digest-pinned base image is what actually provides reproducibility. Co-Authored-By: Claude Opus 4.8 * feat(web-sources): allow removing a web source A web source could be added but never removed. There was no DELETE route, no client method, and no UI affordance — the only way to drop one was to delete every page document it produced and let the orphan cascade in cleanup_service pick up the crawl row as a side effect. Add the operation as a first-class one, inverting that existing cascade: DELETE /assistants/{id}/web-sources/crawls/{crawl_id} removes the crawl's sync policy, soft-deletes every page under its root URL (vectors and S3 teardown hand off to the same background cleanup the single-document path uses), then hard-deletes the crawl row. A crawl that is genuinely in flight is refused with a 409 rather than raced — the crawler would keep writing pages we just enumerated. A crawl stuck at 'running' because its process died is not in flight and stays deletable, so a zombie source can't become permanently undeletable. The route is edit-gated (owner or editor), matching the documents surface that renders the list. Co-Authored-By: Claude Opus 4.8 * fix(web-sources): let editors start and view crawls start_crawl, list_crawls and get_crawl gated on the owner-keyed get_assistant(), which returns None for a user holding only an editor share — so an editor got a 404 from the "Add web content" button the SPA already renders for them (canManageSync() shows it to anyone who isn't a viewer). Route them through the same _require_edit_permission helper the documents, sync-policies and delete-crawl surfaces use, so owner|editor is the gate and a viewer gets a 403 instead of a misleading 404. No owner_id threading is needed on these three: the document writes are keyed on the assistant (PK=AST#), not its owner, and imported_by_user_id/started_by_user_id intentionally record the *acting* user — substituting the owner there would credit an editor's import to the owner. owner_id stays confined to the delete path, whose soft_delete_document/_list_crawl_pages calls really are owner-keyed. Co-Authored-By: Claude Opus 4.8 * fix(rbac): make AppRole the single source of truth for model access The model admin page and the role admin page wrote to two different, unlinked fields. Enabling a model for a role on the model page wrote `allowedAppRoles` onto the model record — a field no access check ever read — so the grant silently did nothing: the role page still showed the model unchecked, and users never saw it in the chat picker. Only editing the role's `grantedModels` had any effect. Make the role record the single source of truth, matching the pattern tools and skills already use: - The model form's role picker now writes THROUGH to each selected role's `grantedModels` (new ModelRoleService.set_roles_for_model), mirroring set_roles_for_tool. Create/update/delete routes wire it up, migrating grants on a modelId rename and revoking them on delete. - `allowedAppRoles` is no longer persisted on the model; it is derived from the role records on read (hydrate_model_roles), so the model page and role page can no longer disagree. Adds `inheritedAppRoles` for wildcard/inherited grants, surfaced read-only in the form. - can_access_model and filter_accessible_models both delegate to one `_grants_access` predicate. They previously diverged (one gated on allowed_app_roles, one didn't), so a model could be listed by the catalog yet denied on use. - Removes the dead POST /sync-roles endpoint (never called; only existed to paper over the drift); replaces it with GET /managed-models/{id}/roles. - Drops Validators.required on the picker, since a model reachable only via a wildcard grant legitimately has zero direct grants. Adds regression coverage for the write-through, the derived read, and the two access checks agreeing. Full backend + frontend suites green (the 8 pre-existing get_metadata_storage failures are unrelated). Co-Authored-By: Claude Opus 4.8 * fix(tests): repoint storage patch target and harden integration gate Two pre-existing failures on develop, both unrelated to the code under test: - test_cache_savings.py patched apis.app_api.storage.get_metadata_storage, but that accessor moved to apis.shared.storage (the app_api.storage module is now an empty stub). Repoint all 5 patch targets. Production code in sessions/services/metadata.py already imports from the new location. - test_compaction_integration.py gated its real-AWS integration tests on AGENTCORE_MEMORY_ID. That variable leaks into the process mid-suite when other tests reload apis.app_api.main (load_dotenv(override=True) injects a local backend/src/.env), so the tests ran order-dependently against invalid credentials instead of skipping. Gate on an explicit RUN_AGENTCORE_INTEGRATION_TESTS=1 opt-in instead. Full suite: 4771 passed, 6 skipped. Co-Authored-By: Claude Opus 4.8 * fix(chat): keep SSE stream open across tab switches @microsoft/fetch-event-source defaults to openWhenHidden:false, which aborts the SSE connection on visibilitychange-to-hidden and reopens it — issuing a fresh POST /invocations for the SAME turn — when the tab becomes visible again. That reopen happens inside the library, reusing the request and bypassing the SPA's per-session double-submit and streamId supersession guards. Because a client abort does not propagate through the AgentCore Runtime data plane, the original backend agent keeps running while the reopened one runs the same turn concurrently. Both persist tool-use/tool-result events to the same AgentCore Memory session, corrupting history with duplicate / interleaved toolResult turns and bricking the conversation with a Bedrock "toolResult blocks exceed toolUse blocks" ValidationException. Set openWhenHidden:true on both fetchEventSource call sites so a single stream stays alive across tab switches (also correct for long agentic turns). The server-side restore-time repair is the safety net for already -corrupted histories. Co-Authored-By: Claude Opus 4.8 * fix(sessions): repair tool-use/tool-result pairing on restore Bedrock Converse rejects any history where a user turn's toolResult blocks do not exactly match the preceding assistant turn's toolUse blocks ("The number of toolResult blocks at messages.N exceeds the number of toolUse blocks of previous turn"). A single such violation anywhere in a session's persisted history makes every subsequent turn fail, permanently bricking the conversation. Such corruption can be written by concurrent/interrupted turns with parallel tool calls (e.g. a duplicate invocation spawned by a tab switch): duplicate toolResult turns, toolResults reordered away from their toolUse turn (assistant/assistant/user/user), or toolResults orphaned after a synthetic error turn. The SDK's own _fix_broken_tool_use only rebuilds the single message after each toolUse turn, so it does not repair these shapes. Add TurnBasedSessionManager._repair_tool_pairing, an unconditional restore-time normalizer (sibling to _strip_document_bytes) that rebuilds a Bedrock-valid history on the final agent.messages: every toolUse turn is immediately followed by exactly one matching result turn (missing ones synthesized as errors), duplicate/orphaned result turns are dropped, and consecutive same-role turns are merged. No-op (identity) on healthy history. Kill switch: AGENTCORE_MEMORY_HISTORY_REPAIR_ENABLED=false. Validated against a real bricked production history (24 violations -> 0, idempotent). Self-heals affected sessions on their next turn. Co-Authored-By: Claude Opus 4.8 * test(sessions): make compaction fixtures valid Converse histories Two pre-existing compaction tests fed the session manager a user toolResult turn with no preceding assistant toolUse (make_tool_result_message alone) — an invalid Converse history that Bedrock would also reject. The new restore-time _repair_tool_pairing correctly drops/merges those orphaned turns, changing the message counts the tests asserted. Give each fixture a matching toolUse turn before the toolResult so the repair no-ops and the tests exercise compaction/truncation and checkpoint slicing in isolation. Counts updated accordingly (4->5 kept; slice 2->3). Co-Authored-By: Claude Opus 4.8 * fix(sessions): guard synthetic error persistence against role-alternation breaks When a turn errors inside the agent stream, the handler persists a synthetic "⚠️ Something went wrong" assistant turn. If the last persisted message was already an assistant turn (a dangling assistant toolUse, or a prior synthetic error turn), this appends a second consecutive assistant message, breaking Bedrock's strict user/assistant alternation. The next turn then fails and persists yet another assistant error turn — an amplifier that turns one bad turn into a permanently bricked session. Add a centralized role-alternation guard in persist_synthetic_messages via a new last_persisted_role param: any synthetic turn that would land adjacent to a same-role turn is dropped (the error stays a live-only UI affordance, the same choice the max_tokens path already makes). Callers in stream_coordinator pass the history tail role via a new _last_persisted_role(agent) helper. Complements PR #653's restore-time _repair_tool_pairing, which masks this on the model-request path; this fixes the write side so storage and the message display stay clean too. Co-Authored-By: Claude Opus 4.8 * fix(chat): reject duplicate concurrent turns with a per-session single-flight lease A client-side abort (Stop, tab switch, dropped socket, retry) does not propagate through the AgentCore Runtime data plane, and the Runtime can route a duplicate POST /invocations to a different container. Two agent loops then run concurrently against one AgentCore Memory session and corrupt tool-pairing history, which Bedrock Converse rejects on every subsequent turn ("toolResult blocks exceed toolUse blocks"). This bricked prod session f761f59b. Follow-up to PR #653, which closed the frontend tab-switch vector. Add a distributed single-flight guard at the inference-api /invocations turn-start chokepoint: - session_lease.py: acquire/renew/release on a dedicated sessions-metadata item (PK=USER#{uid}, SK=LEASE#{sid}) via an atomic conditional write. leaseExpiresAt is the app-level check; ttl is a coarse auto-reap backstop. Owner-scoped renew and release. Fail-open on any non-conflict DynamoDB error. - routes.py: acquire at turn-start; reject a duplicate with 409. Resume / max-tokens continuation re-enter an already-ended loop, so they acquire with force=True (never blocked, still install a lease). Heartbeat renews the lease while the turn streams; release in the generator finally + both except handlers. Preview / no-DynamoDB paths skip the guard. - SPA: handle the 409 as a soft "Already responding" notice (AlreadyStreamingError) instead of a hard "Chat Request Failed" toast; unwrap the BFF's double-encoded detail; loading clears so the user can retry once the prior turn finishes. Design note + distributed-cancel follow-on: docs/specs/session-single-flight-guard.md Co-Authored-By: Claude Opus 4.8 * feat(chat): distributed turn cancellation — make Stop actually stop the server turn Follow-on to the single-flight lease (#655). A client abort doesn't propagate through the AgentCore Runtime data plane, so Stop was cosmetic server-side: the container ran to completion, held the lease, and burned model/tool spend. That left "Stop → resend" returning 409 until the prior turn finished naturally. Reuse the lease as the cross-container signalling channel: - Signal: the app-api user_stopped endpoint calls request_session_cancel, which stamps cancelRequestedFor= on the lease item (owner-scoped, so a stale Stop can't kill a later turn). Best-effort — never fails the Stop. - Observe: the inference-api lease heartbeat (tightened 30s→10s) renews with ReturnValues=ALL_NEW and, on cancelRequestedFor==owner, flips session_manager.cancelled. - Effect A (tools): the always-on StopHook cancels the next tool call. - Effect B (model stream): a cooperative check at the top of the StreamCoordinator loop raises _CooperativeStopSignal; a dedicated arm persists the partial via _persist_interruption (marked user_stopped), emits terminal SSE frames, and ends cleanly (no re-raise) so a still-connected client closes and the lease releases. This is what ends a pure-chat turn, which has no tool boundary for StopHook. - acquire clears any stale cancel marker (REMOVE) on takeover. Net: Stop ends the server turn; the 409-on-resend window shrinks from a full turn to ~one heartbeat (10s), and wasted spend after Stop is halted. Rides the existing interrupted-turn teardown/persist path (hardened by #653's _repair_tool_pairing), so stopping mid-stream never orphans or corrupts history. Residual (documented): in-flight tool calls finish before cancel is seen; already- generated Bedrock tokens are billed. Design note: docs/specs/session-single-flight-guard.md Co-Authored-By: Claude Opus 4.8 * fix(infra): grant app-api task role access to shared-conversations table The shared-conversations DynamoDB table was threaded into the app-api container as an env var (SHARED_CONVERSATIONS_TABLE_NAME) but never granted on the task role. Every conversation-share operation therefore failed against DynamoDB: - POST /conversations/{id}/share -> PutItem AccessDeniedException - GET /conversations/{id}/shares -> Query AccessDeniedException on the SessionShareIndex GSI Both surfaced to users as a generic 500 "Failed to create share". Add `SharedConversationsAccess` to the app-api coreTables grant list so the role gets the standard DynamoDB action set on the table and its GSIs (index/*), matching every other table the app-api touches. Also add a regression test that synthesizes PlatformStack and asserts the app-api role has a SharedConversationsAccess statement granting PutItem/Query/GetItem with a GSI resource and no wildcard. Verified the test fails without the grant. Co-Authored-By: Claude Opus 4.8 * feat(shares): offload large-conversation snapshots to S3 Sharing a large conversation failed: ShareService.create_share inlined the full message list into a single DynamoDB item, exceeding the 400 KB item limit and surfacing to users as a bare 500 (observed in prod-ai as a PutItem ValidationException). This is separate from the IAM-grant bug in PR #657. Offload the snapshot body (messages + metadata) to a new private shared-conversations S3 bucket, keeping only control fields plus a body_ref pointer in DynamoDB — mirroring the Memory Spaces / Artifacts / Skills S3-offload pattern. Reads fall back to inline for legacy shares, so existing shares keep working with no migration and the SPA contract is unchanged. - New ShareSnapshotStore (content-addressed S3 put/get/delete, SSE-S3, dedupe) - create_share writes body to S3 + body_ref item; revoke/session-cleanup best-effort delete the object - _load_snapshot_body reads from S3 or falls back to legacy inline items - ShareStorageUnavailableError -> friendly 503 instead of a bare 500 - CDK: shared-conversations bucket + SSM param, compute-ref, app-api env (SHARED_CONVERSATIONS_BUCKET_NAME), and SharedConversationsBucketReadWrite IAM grant (app-api only) - Tests: store round-trip/dedupe, >400 KB regression, S3 + legacy reads, export-from-S3, revoke cleanup, storage-unavailable Spec: docs/specs/share-large-conversations-s3-offload.md Co-Authored-By: Claude Opus 4.8 * fix(agents): resolve model provider for agent-bound invocations Agent (assistant) model bindings persist only `model_id` — never `provider` — so previewing/invoking an agent bound to a Mantle model (e.g. `openai.gpt-5.4`) resolved to provider=None. That misroutes the model to Bedrock ConverseStream, which rejects it with "The provided model identifier is invalid", even though the same model works from the normal chat path (which always sends `provider` alongside `model_id`). Two complementary fixes: - Backend (server-authoritative): `_resolve_model_settings` now also returns the model's registered `provider` from the managed-model registry, and the invocation path backfills `effective_provider` from it when the request/binding didn't carry one. This fixes all existing agents with a provider-less stored binding — no data backfill needed — and mirrors how `mantle_api_mode`/`mantle_region` are already recovered. The app-tool-call / app-context-update rebuild paths get the same fallback so a rebuilt agent keys on the same provider as its main turn. - Frontend: the Agent Designer save payload now persists the selected model's `provider` (from the catalog `meta.provider`) alongside `modelId`, so newly created/edited bindings are self-describing. Co-Authored-By: Claude Opus 4.8 * fix(inference): bind effective_enabled_tools on resume path Resume turns (interrupt_responses set — OAuth-gated MCP consent or tool-approval) crashed with `NameError: cannot access free variable 'effective_enabled_tools'`. The variable is referenced unconditionally by the `stream_with_quota_warning` streaming closure (attachment guidance + tabular inventory) but was only assigned in the non-resume branch. On resume the closure raised before its first yield, the inference-api container returned 500, and the AgentCore Runtime data plane translated that into a 424 Failed Dependency to app-api and the SPA. This broke every interrupt-resume turn since the agent-designer tool-binding refactor (0b9b039a) — most visibly "connect to Gmail for employees", which completes via an OAuth-consent resume. Bind effective_enabled_tools from the paused-turn snapshot on the resume branch (the same source the resume get_agent call uses). Adds a resume-path regression test to tests/routes/test_inference.py that drives /invocations with interrupt_responses and asserts a 200 stream; without the fix it fails with the NameError. Co-Authored-By: Claude Opus 4.8 * chore(kaizen): weekly research scan 2026-07-17 Generated by the kaizen-research skill. Top 5 ideas appended to docs/kaizen/review-queue.md for the kaizen-review-prep run later this morning. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: add session-metadata static sort key spec (issue #175) Root-cause spec for the SessionMetadata parse-failure warnings: the session row's sort key encodes lastMessageAt, forcing a delete+put row move every turn. Concurrent writers race that move and upsert bare ghost rows. Also drives the first-turn duplicate-row race. Fix: static SK (S#{session_id}) + sparse SessionRecencyIndex GSI for recency listing. Covers the expand -> migrate -> backfill -> contract migration, downstream/forked-deployment safety (marker gate + graceful GSI-missing fallback), pagination-token compatibility, and the test matrix. Co-Authored-By: Claude Opus 4.8 * feat(infra): add SessionRecencyIndex GSI to sessions-metadata (issue #175 Phase 0) Sparse recency index (GSI4_PK=USER#{id}, GSI4_SK={lastMessageAt}#{session_id}, projection ALL) for newest-first active-session listing once the base sort key becomes static. Phase 0 of the static-sort-key migration: adding the index is a no-op until rows populate GSI4 keys, so it deploys safely ahead of any code change. IAM already covers it via the SessionsMetadataAccess /index/* wildcard. Update tables-detailed test to assert all four GSIs (the "2 GSIs" title was already stale after DueScheduleIndex) and the new index's key schema. Co-Authored-By: Claude Opus 4.8 * feat(sessions): dual-scheme union read for session listing (issue #175 Phase 1a) Expand-read step of the static-sort-key migration. list_user_sessions now reads the UNION of two disjoint sources so a session is visible whether or not its base sort key has been migrated to the static S#{session_id} form: - legacy (un-migrated): base table, SK begins_with 'S#ACTIVE#' - migrated: SessionRecencyIndex GSI (GSI4_PK=USER#{id}, GSI4_SK={lastMessageAt}#{id}) Pagination switches to a value cursor ({lastMessageAt}#{session_id}) so each page is derived independently from the last returned position, with no cross-page buffering; fetching limit+1 valid rows per source is provably enough to detect a next page. The cursor decoder is tolerant — legacy/undecodable tokens fall back to first page (a harmless reset across the deploy boundary). Degrades to legacy-only if SessionRecencyIndex doesn't exist yet (code ahead of the CDK GSI): the GSI query's ResourceNotFoundException is caught. No writes change and no row migrates in this phase — this only teaches every reader to cope with both schemes, which must be fully rolled out before Phase 1b turns on self-migrating writes. Tests: union ordering, cross-union pagination (no dupes/gaps), migrated-only via GSI, ghost/preview skip, and graceful fallback when the index is absent. conftest sessions_metadata_table fixture gains the SessionRecencyIndex GSI to match prod. Co-Authored-By: Claude Opus 4.8 * fix(deps): bump strands-agents to 1.48.0 for cachePoint-attachment fix Auto prompt caching (CacheConfig strategy=auto) appended its cachePoint after the last user message's content, so any turn attaching a non-PDF document (txt/docx/csv/...) sent [text, document, cachePoint] and Bedrock's Anthropic adapter rejected it with "ValidationException ... messages.N.content.M.type: Field required", surfacing to users as "Agent force-stopped" (prod incidents Jul 14-16, e.g. session dd1a647a on a .txt transcript upload). strands 1.48.0 places the cache point before the first non-PDF document block instead (upstream issue #1966); every placement it produces was verified live against global.anthropic.claude-sonnet-4-6 ConverseStream. Also corrects the model_config comment that credited PR #1438/1.39.0 with this fix - #1438 was the auto-caching feature itself. Co-Authored-By: Claude Fable 5 * fix(sessions): degrade to legacy-only on real ValidationException for missing GSI (issue #175) The Phase 1a dual-scheme read (PR #667) catches a missing SessionRecencyIndex to fall back to legacy-only listing, but only handled ResourceNotFoundException — what moto raises. Real DynamoDB raises ValidationException ("The table does not have the specified index") for a missing GSI (verified against the prod table). So if the 1a backend deployed to an environment before the CDK GSI existed, list_user_sessions would 503 instead of degrading. Broaden the catch to also handle ValidationException (scoped by the "specified index" message so genuinely malformed queries still surface). This restores the intended order-independence: 1a is safe whether or not SessionRecencyIndex exists yet, which matters for prod deploy ordering (backend.yml vs platform.yml) and for forked deployments. Add a test that reproduces the real ValidationException on the index query (moto masks it), asserting fallback to legacy-only results. Co-Authored-By: Claude Opus 4.8 * Add Word document tools (create/modify/list/read) Provision a full Word (.docx) toolset behind the single create_word_document capability toggle. Each tool runs python-docx in Bedrock Code Interpreter and uses the existing user-files store (S3 + DynamoDB) for persistence and delivery. - create/modify/list/read tools in agents/builtin_tools/word_document_tool.py, injected per-request via _build_word_document_tools (inference_api/chat/routes.py). - Frontend inline-visual 'word_document' renderer with an accessible download button (Tailwind utilities, no scoped CSS). - Restore-time content-block sanitizer in TurnBasedSessionManager: drops empty/typeless blocks from restored history that caused Bedrock ConverseStream 'messages.N.content.M.type: Field required'. - Seed create_word_document in bootstrap DEFAULT_TOOLS ('Word Documents') + updated seed tests. * feat(sessions): static-SK write path — born static, self-migrate, no rotation (issue #175 Phase 1b) Turns on the write side of the static-sort-key migration. Sessions stop encoding lastMessageAt in the sort key, so the row never moves and the ghost-row race that produced "Failed to parse session item" warnings is structurally eliminated for every migrated row. Changed (all resolve the row via GSI, which is SK-scheme-agnostic): - ensure_session_metadata_exists: new sessions born at static SK S#{id} + GSI4 keys, with a real attribute_not_exists(PK) conditional put. The deterministic SK makes the guard meaningful, closing the first-turn duplicate-row race the old timestamped SK made impossible to gate. - update_session_activity: drops the per-turn Phase-B rotation. Static rows update in place (SET GSI4_SK re-positions the sparse recency index — no row move); a still-legacy row does its one-time final rotation to the static SK, carrying any concurrent write. - _store_session_metadata_cloud: static SK; migrate legacy->static on move; SET GSI4 for active, REMOVE for deleted; never un-migrates a static row. - session_service.delete_session: resolves the raw SK via _get_session_by_gsi instead of reconstructing S#ACTIVE#{lastMessageAt}#{id} (which misses migrated rows). Non-rotating soft-delete: SET status=deleted + REMOVE GSI4 in place, or migrate a legacy row to a static tombstone. Drops the S#DELETED# prefix (nothing reads it). The ~10 other writers resolve-then-update-in-place on the current SK and need no change — they already work on a static SK and never rotate. Tests: TestWriteSideMigration (born-static, one-time migrate, no rotation, soft-delete in-place/legacy, end-to-end create->activity->list->delete) plus the real ConditionalCheckFailedException contract (moto raises it for a failed conditional put). Updated three tests that encoded the old rotation contract. Full shared+routes suites: 1689 passed. Co-Authored-By: Claude Opus 4.8 * Fix S3 PutObject PermanentRedirect in Word tools The user-files S3 client pinned its endpoint to https://s3.{AWS_REGION}.amazonaws.com. In the AgentCore Runtime AWS_REGION does not reliably match the bucket region, and the explicit endpoint_url disables botocore's automatic S3 region redirect, so PutObject failed with PermanentRedirect. Resolve the bucket's real region via HeadBucket (x-amz-bucket-region header; maps to s3:ListBucket, which the runtime role already has — GetBucketLocation is not granted) and pin the client to it, dropping the hardcoded endpoint_url. Fixes both the save and the presigned download URL region. * feat(scripts): static-SK backfill for the cold tail + ghost cleanup (issue #175 Phase 2) One-shot, idempotent, throttled backfill that finishes the migration for rows the lazy write-path (Phase 1b) hasn't touched: rewrites legacy S#ACTIVE#/S#DELETED# session rows to the static S#{id} scheme (populating GSI4 for active, none for deleted), deletes the ghost/stub rows the old rotating-SK writers produced, and — only once a fresh scan finds zero legacy rows — writes the migration-complete marker that unblocks Phase 3. Safety: - Dry-run by default; --apply required to write. - Static put uses attribute_not_exists(SK) so it never clobbers a row a live writer already migrated with fresher data; the legacy delete is an idempotent no-op if already gone. - --sleep throttles; re-runnable to convergence. - Marker gated: --set-marker re-scans and withholds the marker while any legacy row remains, so Phase 3 can't be unblocked on partially-migrated data. Tested: 10 moto cases (classification, dry-run no-op, active+deleted migrate, ghost delete, idempotency, conditional-put skip of a live-migrated row, marker gating). Also validated as a dry-run against real dev-ai data (145 legacy rows, 0 ghosts) to confirm the real-DynamoDB scan/filter behavior. Co-Authored-By: Claude Opus 4.8 * feat(sessions): contract session list to GSI-only once migration completes (issue #175 Phase 3) Final phase of the static-sort-key migration. list_user_sessions now reads the SessionRecencyIndex GSI alone once the Phase 2 backfill has set the migration- complete marker (PK=MIGRATION#session-sk, SK=STATE, complete=true) — the legacy S#ACTIVE# union branch is only queried until then. - The marker check is memoised per-process (the marker only ever goes unset->set, never back), so migrated deployments pay no extra read after the first observation; a container that started pre-backfill picks up the flip on a later call. - Fails open: any error reading the marker keeps dual-read. Downstream/forked deployments that haven't run the backfill stay in dual-read, so removing the legacy branch here can never blank an un-migrated sidebar. - Safety net: even with the marker set, if the GSI query itself errors (transient ValidationException/ResourceNotFound) the legacy branch is still queried, so a flaky index never returns an empty list. The legacy code path is retained behind the marker rather than deleted, per the downstream-safety design; a later release can drop it once all deployments report the marker set. Tests: dual-read when marker absent, GSI-only (legacy row excluded) when set, marker memoisation, and GSI-failure-falls-back-to-legacy-even-with-marker. Full sessions+routes+backfill+architecture suites: 136 passed. Co-Authored-By: Claude Opus 4.8 * docs(specs): skills v2 — skills as a pure knowledge primitive bound on Agents Supersedes the tool-binding sections of admin-skills-rbac-tool-binding.md and the mode-toggle design in skills-mode.md. Skills become agentskills.io knowledge bundles (no bound_tool_ids), bound on Agents via the Designer, with a user-uploaded tier, opt-in chat selection, Strands AgentSkills plugin runtime, and invoke-through sharing for shared Agents. Co-Authored-By: Claude Fable 5 * refactor(agents): delete MCP tool-folding machinery and hook shims (skills v2 PR-1) Skills no longer bind tools, so the entire fold stack that served bound_tool_ids goes: mcp_binding.py (FoldedMCPTool, resolve_mcp_bindings, the two folded-tool lookup factories), mcp_tool_folding.py and its drop_folded_tools calls in FilteredMCPClient / UICapableMCPClient, the tool_use_provider_lookup / tool_use_approval_lookup shims on OAuthConsentHook / MCPExternalApprovalHook (+ FoldedToolApproval), and their wiring in base_agent. SkillAgent is neutered, not deleted (PR-2): DB-backed skills are instructions-only; the file/dev @skill binding path is unchanged. SkillRegistry loses all_bound_tool_ids/bind_catalog_tools. Note: the spec's stream_coordinator skill_executor unwrap item has no corresponding code — nothing existed to delete there. Per docs/specs/skills-as-agent-primitive.md §2/§8 (PR-1). Co-Authored-By: Claude Opus 4.8 * refactor(skills): remove bound_tool_ids end-to-end (skills v2 PR-1) A skill is a pure knowledge bundle: drop bound_tool_ids from SkillDefinition, the create/update/response DTOs and the Dynamo (de)serialization; delete _validate_bound_tools and the boundToolCount projection; stop emitting boundToolIds in the bindable-catalog meta; seed web_research as an instructions+reference-file bundle with no bound tool. SPA: remove the boundToolIds field/model plumbing, the skill-form bound tools section, the list-page badge, and the tool-picker dialog. Existing rows with boundToolIds deserialize fine (attribute ignored); no production data binds tools to skills (SKILLS_ENABLED=false everywhere). Co-Authored-By: Claude Opus 4.8 * refactor(chat): remove skills mode — policy, toggle, and preference (skills v2 PR-1) Skills are opt-in per turn, not a mode. Delete the admin chat-mode policy (platform_settings sentinel + admin /settings/chat routes + public /system/chat-settings), _resolve_effective_agent_type, and the preferred_agent_mode user setting. DEFAULT_AGENT_TYPE flips to "chat"; an explicit agent_type="skill" (future picker / agent-binding resolver) still resolves skills, and the enabled_skills request plumbing + skills_hash caching are kept verbatim for the PR-4 picker. SPA: delete ChatModeService and the Skills/Tools capabilities toggle; Skills + Tools sections render unconditionally (Skills gated on having skills or an agent lock, so it stays inert while the feature is off); stop sending agent_type/enabled_skills; drop preferredAgentMode and the session-preference agentType plumbing. Agent-bound skill locks now load the skill list on demand so locked rows render their names. Co-Authored-By: Claude Opus 4.8 * docs(specs): mark skills-mode and tool-binding specs superseded by skills v2 Co-Authored-By: Claude Opus 4.8 * feat(skills): swap runtime to Strands AgentSkills plugin (skills v2 PR-2) Spike gate PASSED (20/20, docs/specs/skills-v2-pr2-spike-findings.md): the vended AgentSkills plugin composes with our prompt assembly (block-level injection preserves cache points), the skills_hash cache key, paused-turn resume, and agent.state round-trips our TurnBasedSessionManager unchanged. Runtime swap: - Map DB SkillDefinition -> strands.Skill via a new skills/strands_mapping.py (slugged name for agentskills.io validity + S3/harness portability; true skill_id + human display_name carried in metadata). Add advisory allowed_tools + skill_metadata frontmatter passthrough to the model (D1/D4). - ChatAgent conditionally adds AgentSkills(skills=[...]) when the turn carries accessible_skill_ids; AgentFactory.create_agent gains a plugins param. - Retire the homegrown disclosure stack: SkillAgent, skill_registry.py, skill_tools.py (skill_dispatcher/skill_executor), the @skill decorator + file/dev definitions, and their tests. "skill" stays a registered alias -> ChatAgent so the existing agent_type/skills_hash cache-key + resume path (which the spec keeps) resolve to a ChatAgent-with-plugin unchanged. read_skill_file (L3 reference bytes) and the S3 SKILL.md write-through projection follow as separate PR-2 commits. Full backend suite green (4611 passed, 3 skipped). Co-Authored-By: Claude Opus 4.8 * feat(skills): standard bundle layout + read_skill_file (skills v2 PR-2) Completes the PR-2 runtime: reference-file disclosure (L3) and the agentskills.io bundle layout that makes each skill a portable artifact. S3 bundle layout + SKILL.md projection (#6): - SkillResourceRef gains `kind` (reference|script|asset); dynamo round-trips it, old rows default to reference. - resource_store keys files at skills/{id}/{references|scripts|assets}/{filename} (path-based, dedupe dropped for the readable standard layout); add put_skill_md and resource_key/skill_md_key helpers. - create_skill/update_skill write a SKILL.md projection generated from the row (new apis/shared/skills/bundle.py: slugify + generate_skill_md) — best-effort, never fails the catalog write. Admin upload route gains an optional `kind`. read_skill_file (#4): - New per-turn tool (agents/main_agent/skills/strands_mapping.build_skills_runtime returns plugin + tool from one record fetch). Resolves `path` against the skill's manifest (no filesystem/traversal), serves bytes from SkillResourceStore, labels scripts inert (D5), describes binary assets instead of dumping bytes, and is implicitly access-gated (bound only to the turn's effective records — richer §6 invoke-through is PR-4). Skill.instructions gain an "Available reference files" listing so the model knows what to request. - ChatAgent wires read_skill_file alongside the AgentSkills plugin. slugify moved to apis/shared/skills/bundle (shared by the app-api projection and the agents runtime; import-boundary safe). Full backend suite green (4634 passed). Co-Authored-By: Claude Opus 4.8 * docs(specs): mark skills v2 PR-2 done in the plan Co-Authored-By: Claude Opus 4.8 * fix(agents): correct stale skills copy and surface invalid-save feedback Two issues found while smoke-testing skills v2 PR-2 (#681). Skills v2 decision D1 removed skill->tool binding entirely: a skill is a pure knowledge bundle and never grants or carries a tool. Two surfaces still claimed otherwise -- the Agent Designer skills section ("with their own bound tools") and the admin skill edit form ("and bound tools"). Saving an invalid agent form also no-opped silently: persist() marked the controls touched and returned, so the only feedback was an inline error that is usually below the fold once the author has scrolled to the Model/Skills sections. The dirty banner stayed up, making the click look ignored. Now an invalid save also toasts and scrolls the first invalid control into view. The reveal helper matches on input/textarea/select rather than [formControlName]: the starters array binds [formControlName]="$index", a property binding that renders no attribute to select on. Verified with npm run build and ng test (127 files, 1452 tests passing). Co-Authored-By: Claude Opus 4.8 * feat(skills): user-authored skills tier (skills v2 PR-3) Adds the owner-scoped half of the skill catalog: any user can author their own agentskills.io knowledge bundles and reach them at runtime, without an admin RBAC grant. Backend - `list_skills_by_owner` — GSI4 (SkillOwnerIndex) partition query, the "list my skills" path. `list_skills` gains an `owner_id` filter. - `UserSkillService` — owner-scoped CRUD. Ownership is resolved on every path; a skill you do not own is 404, never 403, so the surface never confirms someone else's skill exists. Resource handling (caps, manifest, bundle layout, orphan GC) delegates to SkillCatalogService so both tiers emit identical bundles. - `/skills/mine/*` routes on the existing session-auth router, so they inherit the SKILLS_ENABLED mount gate. - Skill ids are allocated server-side from the display name and suffixed on collision (docx -> docx_2). Ids stay globally unique because the runtime activation key is the slugified id, and a 409 would disclose the existence of a skill the user cannot see. - `resolve_accessible_skill_ids` now returns catalog ∪ own — ownership is its own grant. This is what makes an authored skill usable at all. Two tier boundaries closed, both of which would have leaked private skills: - `get_all_skill_ids` (RBAC "*" wildcard expansion) now lists only catalog skills; a wildcard grant must not sweep in other users' authored skills. - The admin role-grant endpoints refuse user-authored skills, since granting one to an AppRole would hand a private document to a whole role. The admin catalog list is likewise scoped to owner_id == "system". Frontend - My Skills page (list + create/edit form) with SKILL.md import prefill and reference/script/asset uploads; scripts are labeled non-executable. - Nav entry gated on the same 404 accessibility probe memory-spaces uses. Co-Authored-By: Claude Opus 4.8 * fix(skills): preserve SKILL.md frontmatter through import (skills v2 PR-3) Live clickthrough found an imported bundle losing everything outside name/description: a SKILL.md carrying `license: MIT` round-tripped back out of S3 without it. Spec D2 requires import/export to be round-trip-faithful, and the backend already had `skill_metadata` + `allowed_tools` columns for exactly this — only the client-side import never populated them, leaving both fields dead on the user tier. - `parseSkillMarkdown` now also returns `allowedTools` (comma-separated or inline-array forms) and `metadata` (every non-reserved frontmatter key). Additive to the DTO, so the admin form is unaffected. - The My Skills form carries both through create and update, including for a loaded skill, so an edit never silently drops them. - Advisory tools render as chips with the D1 disclaimer — skills never grant tools; the bound agent decides. Also fixes a `capitalize` on the staged-file line title-casing the whole string ("Uploads When You Save"); only the kind should capitalize. Verified: a bundle with license + compatibility + allowed-tools now emerges from the S3 SKILL.md projection intact. Co-Authored-By: Claude Opus 4.8 * chore(skills): backfill script for v1 skill bundles (skills v2 PR-3) Skills authored before PR-2 have neither the SKILL.md write-through projection nor the standard bundle layout — v1 stored resources content-addressed (skills/{id}/{sha256}) with no `kind`. Their S3 prefix is therefore not a valid agentskills.io bundle: it can't be handed to a managed Harness or exported as-is, which is the whole point of the projection. The script fixes both per skill: copies each legacy object to its standard path, rewrites the row's manifest to point there (adding `kind`), and writes the SKILL.md generated from the row. It imports `generate_skill_md` and the key helpers from the live write path, so a backfilled bundle is byte-identical to one the app writes today. Follows the backfill_session_static_sk conventions: dry-run by default, idempotent, throttled, scopeable to one skill. Copies are non-destructive — legacy objects survive unless --delete-legacy, and a manifest entry whose bytes are missing is left untouched rather than repointed at nothing. Applied to dev-ai/web_research: manifest now points at references/extraction_tips.md, SKILL.md written, read path verified at 200. Co-Authored-By: Claude Opus 4.8 * fix(skills): seed example skill as a v2 bundle (skills v2 PR-3) Backfilling dev's web_research row surfaced that the seeder itself still emits v1 shapes, so every fresh environment reproduces exactly the state the backfill just repaired: - resources landed at the content-addressed key (skills/{id}/{sha256}) with no `kind`, instead of skills/{id}/references/{filename} - no SKILL.md was ever written, so the seeded prefix was not a valid agentskills.io bundle and could not be handed to a managed Harness or exported as-is Both fixed. The slug and frontmatter rules are duplicated from apis/shared/skills/bundle.py rather than imported, because seed.sh runs this script standalone after infra deploy without the app package on the path — the existing content-hash logic was duplicated for the same reason. Tests now pin the standard layout and the projection, plus a guard that the seed prose never again names the retired v1 meta-tools (skill_executor / skill_dispatcher). That drift is what left dev's row instructing the model to call tools deleted in PR-1/PR-2. Note the seeders are skip-if-exists, so this repairs new environments only; existing ones need backfill_skill_bundles.py (dev-ai: applied). Co-Authored-By: Claude Opus 4.8 * feat(skills): selection surfaces + invoke-through access (skills v2 PR-4) Wires the chat opt-in picker end-to-end and lands the §6 invoke-through access predicate. Per spec docs/specs/skills-as-agent-primitive.md §8. The picker's markup shipped in PR-1 but nothing ever sent its selection, so the plain-chat skills path had never actually run. Turning it on surfaced three latent bugs: - Skill resolution was gated on agent_type == "skill", so the picker could never have taken effect. Skills are now driven by the selection on any turn; agent_type gates nothing ("skill" stays a ChatAgent alias only so stale SPA sessions don't 422). - The binding resolver gated on AppRoleService.can_access_skill, which has no ownership clause — an author was blocked on their own authored skill when invoking their own Agent — and whose "*" wildcard matched any id at all, including another user's private skill. Clauses 1+2 now route through resolve_accessible_skill_ids, which expands "*" over the catalog only. - Paused-turn resume and the construction snapshot both keyed skills off agent_type == "skill", which would have orphaned the paused agent of any plain-chat turn carrying skills. Both now key off the snapshot's own enabled_skills. Invoke-through (D7) is deliberately AGENT-scoped: it lives in the binding resolver, not in resolve_accessible_skill_ids, because widening the shared resolver would leak an Agent owner's private skills into every invoker's plain-chat picker and bindable palette. The owner-match clause blocks chain-sharing, and a system-owned Agent gets no invoke-through at all so RBAC stays the sole gate on catalog skills. D6 default flip: an absent or empty enabled_skills means no skills, on both the runtime filter and the picker's untouched-preference default. The two must agree or the UI would show skills as active that the turn never loads. It also keeps skills free for turns that don't want them — an absent selection short-circuits before any RBAC or skill-table read. read_skill_file needed no per-call predicate: its record set IS the turn's effective skill set, so there is no id the model can name to reach a skill the invoker cannot use. The Designer palette union needed no code — /agents/bindable already delegates to resolve_accessible_skill_ids, which PR-3 widened. Backend 4714 passed; SPA 1469 passed across 128 spec files. Co-Authored-By: Claude Opus 4.8 * chore(assets): add GitHub connector logo variants Light/dark Octocat marks alongside the existing google-* connector logos. Follows the repo's theme convention: -light is the black glyph (for light backgrounds), -dark the white one. Nothing references these yet — they're staged for a GitHub connector. Unrelated to the skills work in this branch; riding along rather than sitting untracked. Co-Authored-By: Claude Opus 4.8 * feat(skills): enable Skills v2 + admin-only capability gate (skills v2 PR-5) Flips SKILLS_ENABLED to default-ON with a kill switch and adds the infrastructure wiring it never had, closing out the Skills v2 epic (spec docs/specs/skills-as-agent-primitive.md §8). SKILLS_ENABLED had zero CDK/workflow plumbing, so "enable it per environment" was not previously expressible. Adds SkillsConfig to config.ts, threads SKILLS_ENABLED into both app-api and inference-api (they must stay in step — design-time refuses to bind a skill while the flag is off, so a mismatch would let an Agent be built with skills the runtime then blocks), and forwards CDK_SKILLS_ENABLED in platform.yml with the empty-string-safe ternary an unset GitHub variable requires. Feature existence and audience are two independent controls. The flag says the feature exists in an environment; the new `skills` RBAC capability says who sees the user-facing surfaces. system_admin holds it implicitly via its "*" tools grant, so the picker and My Skills stay admin-only during rollout; GA is one grant of `skills` to the `default` role, no redeploy. The gate raises 404, not 403. The SPA hides the My Skills nav entry by riding the list call, so a 404 hides the surface while a 403 surfaces an error toast — the failure mode that got the scheduled-runs capability gate reverted in prod. It also deliberately does not gate the runtime: an Agent shared to an ordinary user must still resolve its bound skills (invoke-through, §6/D7), and a capability check there would break exactly that path. Verified live end to end against a real agentskills.io bundle (Anthropic's docx) uploaded as a user skill and bound to an Agent: L1 8,075 -> L2 9,835 (skills tool, SKILL.md body) -> L3 10,737 (read_skill_file on references/LICENSE.txt). Invoke-through confirmed with a second non-admin account — the grant resolves through the shared Agent while the same skill stays absent from that user's own picker and /skills/mine. The "session auth, not Bearer" assertion now walks the transitive dependency tree rather than each route's direct dependencies, since the routes hang off the capability gate which in turn depends on the session. Pins the invariant that actually matters instead of the shape. Co-Authored-By: Claude Opus 4.8 * refactor(rbac): remove dead AppRoleService.can_access_skill Skills v2 moved skill authorization to apis/shared/skills/access.py (resolve_accessible_skill_ids = catalog ∪ own, and resolve_invocable_skill_ids which adds the Agent-owner invoke-through clause). AppRoleService.can_access_skill has had zero production callers since PR-4 and is wrong on two axes for anything user-tier: no ownership clause, and its "*" wildcard matches ANY skill id including another user's private authored skill. - Delete the method and its three tests. test_can_access_skill_with_wildcard asserted can_access_skill(user, "any_skill") is True — it enshrined the wildcard over-expansion bug as expected behavior. - Fix two stale docstring/comment references in agent_designer's binding_validation.py that still named it as the live run-time mechanism; since PR-4 that is resolve_invocable_skill_ids. - Reword the intentional "deliberately NOT can_access_skill" rationale in skills/access.py and agent_binding_resolver.py to past tense so they no longer imply the function still exists. Co-Authored-By: Claude Opus 4.8 * refactor(skills): delete dead SkillAccessService Skills v2 moved skill authorization to apis/shared/skills/access.py (resolve_accessible_skill_ids / resolve_invocable_skill_ids). SkillAccessService was left behind with zero live callers — not exported from admin/services/__init__.py, not DI-wired, and reached by no dynamic import. Its can_access_skill carried the same "*"-wildcard over-expansion flaw that got AppRoleService.can_access_skill deleted in #686. Also updates the stale comment in admin/skills/routes.py that named the service as the consumer of the all-skill-ids snapshot; that snapshot is still live, but its reader is now skills.access. Co-Authored-By: Claude Opus 4.8 * docs(agents): draft the Agent Directory spec A browse-and-discover surface for published Agents: a directory page and a detail page modeled on the ChatGPT/Claude plugin-detail layout, built as a read-view over the Agent record rather than a new primitive. The central decision (D1) is that we do NOT introduce a "Plugin" noun. Both vendors need a bundle layer because their capabilities install into a workspace separately from any persona; our Agent's `bindings[]` already IS that bundle, attached to the persona. Every field a vendor plugin-detail page renders already exists on our record. Grounding findings that shaped the design: - `VisibilityStatusIndex` (GSI2) is live and populated, and PUBLIC *access* still resolves to "viewer" — only the *listing* was switched off in ad4437e9 when email sharing superseded a public index. The read path is mostly built. - Listing is nonetheless a new sparse GSI5, not a re-enable: `VISIBILITY#PUBLIC` is one hot partition and can't be filtered by category. GSI5 is the next free slot (GSI4 is DueSyncIndex), and DueSyncIndex on the same table is the precedent — unlisted agents have no key, so the query physically can't see them. - `listed` is deliberately separate from `visibility` (D3). Deriving listing from PUBLIC would retroactively publish every existing PUBLIC agent to the whole institution with no author consent; backfill is listed=false. - Publishing amplifies Skills v2 invoke-through from a typed email list to everyone, so the publish dialog must enumerate which authored skills it exposes, and memory_space bindings block publication outright (D5). - The Designer's block-on-missing rule (D5) strains under open browsing, so the detail page previews per-invoker runnability up front — which resolves the "per-invoker capability preview" open question parked in agent-designer.md. Also notes a behavior change needing a call: GET /agents/{id} currently returns `instructions` to any PUBLIC viewer, which is a much larger exposure once agents are broadly listed than it was under link-sharing. Co-Authored-By: Claude Opus 4.8 * refactor(artifacts): collapse the two catalog rows into one Artifacts toggle Artifacts shipped as two independent `protocol: local` catalog rows — `create_artifact` and `update_artifact` — so the tool picker listed them as two unrelated entries. The picker only groups children under a parent for MCP protocols (driven by `serverTools`), so there was nothing to nest them under; the flat listing was a data-model fact, not a template gap. Adopt the Word-documents idiom already used one entry below in the seed list: a single catalog row whose id is the gate key, with the runtime injecting the full toolset. `create_artifact` is now that key and provisions both the create and update tools. `seed_default_tools` is create-only, so a seed run does nothing to an environment seeded before this change. Add a backfill script that retitles the surviving row, promotes `update_artifact` to `create_artifact` everywhere a grant can hide — role TOOL_GRANT# items *and* the grantedTools/effectivePermissions.tools arrays on DEFINITION, user toolPreferences, assistant bindings — then deletes the retired row. Promote-before-delete, so an aborted run degrades to "both granted", never "neither". Schedule snapshots are left alone: a stale id there is an inert no-op since the runtime only reads `create_artifact`. Two judgement calls worth recording: - User prefs are a sparse override map, so only an explicit *enable* of the retired id carries over. Someone who switched update off while leaving create at its default-on never asked to lose artifacts, so an explicit disable just drops the key. - A role granting `*` gains no concrete grant — the wildcard already covers the keeper and narrowing it would be a silent scope change. Behavior change: anyone with create enabled but update disabled now gains update. That is inherent to collapsing the toggle. Co-Authored-By: Claude Opus 4.8 * chore(sidenav): hide the My Skills nav entry The /my-skills route, page, service, and the whole /skills/mine backend surface stay fully functional — only the sidenav link is removed, until we decide how users should actually navigate to their skills. Drops the now-dead showMySkills computed, the MySkillService injection, and its loadSkills() accessibility probe (that call existed solely to decide whether to render the link). Co-Authored-By: Claude Opus 4.8 * docs(skills): draft the Skill Creator spec Adopts the authoring half of Anthropic's open-source skill-creator and scopes out the eval half, which assumes a filesystem, subagent spawning, and script execution — none of which exist here (scripts are inert by design). Evals hang off F1's headless lane instead. PR-1 ships the methodology as an admin-catalog skill with zero code, routing the handoff through the My Skills form's existing frontmatter parsing. PR-2..PR-4 add the missing primitive: agent tools that write the user's own skills, executing as the invoking user and re-checking ownership per call, mirroring memory_write. Depends on skill-bundle-import's nested paths and server-side SKILL.md parser rather than restating them. Co-Authored-By: Claude Opus 4.8 * feat(skills): drop the `skills` capability gate from user-facing routes The user-facing skills surfaces (`GET /skills/`, `PUT /skills/preferences`, and all of `/skills/mine/*`) hung off `require_skills_capability`, which 404'd anyone not holding the `skills` RBAC capability. Its job was to keep skills admin-only during the v2 rollout, with GA framed as "one grant of `skills` to the `default` role, no redeploy." That GA path does not work, for two independent reasons: 1. `default` is a *fallback* role. `resolve_user_permissions` consults it only when a user matches zero AppRoles (service.py "Step 3"); it is not merged alongside a matched role. Prod's `default` carries no JWT mappings at all, so granting there would reach only unmapped users — never the faculty/staff/student cohorts. 2. A capability id cannot be granted from the admin roles UI regardless. That form builds `grantedTools` from the tool catalog, and a capability is not a tool, so there is no way to select it and no free-text entry. Net effect: an admin who granted a catalog skill to a role would find it silently invisible to that role's users, with no in-product way to fix it. Remove the gate. Skills are governed by `SKILLS_ENABLED` per environment and by a role's `grantedSkills` per cohort — a complete model that the admin UI can actually operate. Both surfaces are already self-limiting: `GET /skills/` returns only what `resolve_accessible_skill_ids` grants (no grants means an empty list and no rendered picker), and every `/skills/mine/*` route is owner-scoped inside `UserSkillService`. The route-coverage control is kept rather than dropped, retargeted from "every route is capability-gated" to "every route requires a session" — the invariant that still matters now that the session dependency is the only thing between these routes and an anonymous caller. A second test pins the removal so the gate cannot creep back without also making capabilities grantable from the roles UI. Also corrects the "GA = grant to `default`" claim where it appeared in capabilities.py, infrastructure/lib/config.ts, and platform.yml, and notes that `SCHEDULED_RUNS_CAPABILITY` is itself unused (that gate was dropped after 403ing in prod), which leaves capabilities.py with no consumers. Co-Authored-By: Claude Opus 4.8 * refactor(rbac): delete the dead capabilities module `apis/shared/rbac/capabilities.py` has no consumers left. It defined two capability ids granted through the `grantedTools` axis: * `SCHEDULED_RUNS_CAPABILITY` was orphaned when the RBAC gate on `/schedules` and `/runs` was dropped after 403ing in prod. * `SKILLS_CAPABILITY` was the last live caller, removed in the preceding commit along with `require_skills_capability` and its 12 route deps. Delete it rather than keeping it as a reference. The mechanism it documented is not one we want reached for again: a capability id cannot be granted from the admin roles UI (that form builds `grantedTools` from the tool catalog and offers no free-text entry), so any gate built on it is operable only by hand-writing DynamoDB items. Both live gates were removed for that reason. Its docstring carried two findings that cost real effort to establish, so they move to `AppRoleService.resolve_user_permissions` — the code they actually describe — rather than dying with the file: 1. `default` is a *fallback*, not a universal role. Step 3 substitutes it only when a user matched zero AppRoles, and prod's `default` carries no `jwtRoleMappings`, so granting there reaches only unmapped users. The "GA = one grant to `default`, no redeploy" framing that appeared in several comments was wrong. 2. The roles-UI limitation above, recorded where someone would see it before routing a new grant through this axis. The algorithm list in that docstring is renumbered to match the code's own step comments, so the "Step 3" reference is unambiguous. Also corrects both `feature_flags.py` docstrings, which still described the now-deleted capability as the companion "who may use it" control: `skills_enabled` points at a role's `grantedSkills`; `scheduled_runs_enabled` notes the flag is now the only control and the routes are deliberately ungated. Co-Authored-By: Claude Opus 4.8 * Release/1.8.0 Feature release delivering Skills v2 — skills redesigned from tool-binding containers into pure, portable knowledge bundles that Agents load on demand, with a user-authored tier so any signed-in user can create their own. Skills ship enabled by default. Also completes the session-metadata static-sort-key migration (issue #175 Phases 2-3) and consolidates the two artifact tool-catalog rows into a single "Artifacts" toggle. - Skills v2 (#679-#685, #692): pure knowledge bundles in agentskills.io S3 format with a SKILL.md projection, progressive disclosure L1-L3 via the new read_skill_file tool, the Strands AgentSkills plugin replacing the bespoke SkillAgent, a user-authored tier at /skills/mine/*, a chat picker, and invoke-through so a shared Agent resolves its skills for the recipient. Skills no longer bind tools — allowedTools persists as advisory metadata and never grants. The `skills` capability gate was removed as inoperable: it could not be granted from the admin roles UI at all. - Session metadata (#677, #678): cold-tail backfill script plus a GSI-only read contraction gated on its completion marker, failing open to dual-read. - Artifacts (#689): two catalog rows collapsed to one, with a backfill that promotes grants before retiring the old id. Three backfill scripts must be run per environment; see RELEASE_NOTES.md. No new AWS resources and no dependency changes. Skills activate on the backend.yml deploy itself, since unset SKILLS_ENABLED now reads as enabled. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Colin Smith <7762103+colinmxs@users.noreply.github.com> Co-authored-by: derrickfink Co-authored-by: Derrick Fink Co-authored-by: Roman Meredith Co-authored-by: Roman meredith <48036775+ramenNoodles1998@users.noreply.github.com> --- .github/workflows/platform.yml | 9 + CHANGELOG.md | 35 ++ README.md | 4 +- RELEASE_NOTES.md | 101 ++++ VERSION | 2 +- backend/pyproject.toml | 2 +- .../scripts/backfill_artifact_tool_merge.py | 327 +++++++++++ backend/scripts/backfill_session_static_sk.py | 228 ++++++++ backend/scripts/backfill_skill_bundles.py | 292 ++++++++++ backend/scripts/seed_bootstrap_data.py | 112 +++- backend/src/.env.example | 31 +- backend/src/agents/main_agent/__init__.py | 2 - backend/src/agents/main_agent/agent_types.py | 8 +- backend/src/agents/main_agent/base_agent.py | 31 +- backend/src/agents/main_agent/chat_agent.py | 39 +- .../agents/main_agent/core/agent_factory.py | 7 +- .../integrations/external_mcp_client.py | 7 +- .../integrations/gateway_mcp_client.py | 6 - .../main_agent/integrations/mcp_apps.py | 8 - .../integrations/mcp_tool_folding.py | 106 ---- .../main_agent/session/hooks/oauth_consent.py | 42 +- .../main_agent/session/hooks/tool_approval.py | 53 +- backend/src/agents/main_agent/skill_agent.py | 356 ------------ .../src/agents/main_agent/skills/__init__.py | 46 +- .../agents/main_agent/skills/decorators.py | 46 -- .../definitions/canvas-morning-check/SKILL.md | 127 ----- .../skills/definitions/web-search/SKILL.md | 28 - .../agents/main_agent/skills/mcp_binding.py | 426 -------------- .../main_agent/skills/skill_registry.py | 478 ---------------- .../agents/main_agent/skills/skill_tools.py | 272 --------- .../main_agent/skills/strands_mapping.py | 281 ++++++++++ backend/src/apis/app_api/admin/routes.py | 8 - .../app_api/admin/services/skill_access.py | 160 ------ .../apis/app_api/admin/settings/__init__.py | 5 - .../src/apis/app_api/admin/settings/routes.py | 62 --- .../src/apis/app_api/admin/skills/routes.py | 23 +- .../services/bindable_catalog.py | 2 +- .../services/binding_validation.py | 4 +- backend/src/apis/app_api/skills/routes.py | 350 +++++++++++- backend/src/apis/app_api/skills/service.py | 225 ++++---- .../src/apis/app_api/skills/user_service.py | 338 +++++++++++ backend/src/apis/app_api/system/routes.py | 26 +- .../chat/agent_binding_resolver.py | 62 ++- backend/src/apis/inference_api/chat/models.py | 22 +- backend/src/apis/inference_api/chat/routes.py | 176 +++--- .../src/apis/inference_api/chat/service.py | 42 +- backend/src/apis/shared/feature_flags.py | 34 +- .../apis/shared/platform_settings/__init__.py | 21 - .../apis/shared/platform_settings/models.py | 100 ---- .../shared/platform_settings/repository.py | 101 ---- .../apis/shared/platform_settings/service.py | 82 --- backend/src/apis/shared/rbac/capabilities.py | 44 -- backend/src/apis/shared/rbac/service.py | 34 +- backend/src/apis/shared/sessions/metadata.py | 77 ++- backend/src/apis/shared/skills/__init__.py | 5 +- backend/src/apis/shared/skills/access.py | 126 ++++- backend/src/apis/shared/skills/bundle.py | 85 +++ backend/src/apis/shared/skills/freshness.py | 20 +- backend/src/apis/shared/skills/models.py | 47 +- backend/src/apis/shared/skills/repository.py | 70 ++- .../src/apis/shared/skills/resource_store.py | 114 ++-- .../src/apis/shared/user_settings/models.py | 11 +- .../apis/shared/user_settings/repository.py | 2 - .../artifacts/test_artifact_tools.py | 5 +- .../integrations/test_mcp_tool_folding.py | 69 --- .../session/test_oauth_consent_hook.py | 120 ---- .../main_agent/session/test_tool_approval.py | 113 ---- .../agents/main_agent/skills/__init__.py | 0 .../agents/main_agent/skills/conftest.py | 91 --- .../main_agent/skills/test_mcp_binding.py | 442 --------------- .../main_agent/skills/test_skill_agent_db.py | 282 ---------- .../main_agent/skills/test_skill_registry.py | 324 ----------- .../main_agent/skills/test_skill_tools.py | 289 ---------- .../main_agent/skills/test_strands_mapping.py | 181 ++++++ .../admin/services/test_skill_access.py | 158 ------ .../agent_designer/test_bindable_catalog.py | 4 +- backend/tests/apis/app_api/skills/conftest.py | 37 +- .../app_api/skills/test_admin_skill_routes.py | 32 -- .../app_api/skills/test_my_skill_routes.py | 184 ++++++ .../skills/test_skill_catalog_service.py | 159 ++---- .../skills/test_skill_resource_routes.py | 39 +- .../app_api/skills/test_user_skill_service.py | 376 +++++++++++++ .../apis/app_api/test_chat_settings_routes.py | 177 ------ .../apis/app_api/test_skills_feature_flag.py | 122 +++- .../apis/app_api/test_user_skills_routes.py | 21 +- .../test_agent_binding_resolver.py | 60 +- .../apis/inference_api/test_chat_service.py | 8 +- backend/tests/rbac/test_app_role_skills.py | 37 -- .../tests/routes/test_agent_mode_policy.py | 296 ---------- backend/tests/routes/test_inference.py | 147 ++++- .../tests/shared/test_platform_settings.py | 259 --------- .../tests/shared/test_sessions_metadata.py | 77 +++ backend/tests/shared/test_skills_access.py | 199 ++++++- backend/tests/shared/test_skills_bundle.py | 67 +++ backend/tests/shared/test_skills_models.py | 32 +- .../tests/shared/test_skills_repository.py | 58 +- .../shared/test_skills_resource_store.py | 91 +-- .../shared/test_user_settings_agent_mode.py | 29 - .../test_backfill_artifact_tool_merge.py | 333 +++++++++++ .../tests/test_backfill_session_static_sk.py | 190 +++++++ backend/tests/test_backfill_skill_bundles.py | 257 +++++++++ backend/tests/test_seed_system_admin_jwt.py | 63 ++- backend/uv.lock | 2 +- docs/specs/admin-skills-rbac-tool-binding.md | 6 + docs/specs/agent-directory.md | 316 +++++++++++ docs/specs/skill-creator.md | 165 ++++++ docs/specs/skills-as-agent-primitive.md | 391 +++++++++++++ docs/specs/skills-mode.md | 5 + docs/specs/skills-v2-pr2-spike-findings.md | 67 +++ frontend/ai.client/package-lock.json | 4 +- frontend/ai.client/package.json | 2 +- .../ai.client/public/logos/github-dark.svg | 3 + .../ai.client/public/logos/github-light.svg | 3 + .../ai.client/src/app/admin/admin.layout.ts | 16 +- .../tool-picker-dialog.component.ts | 498 ----------------- .../ai.client/src/app/admin/skills/index.ts | 1 - .../admin/skills/models/admin-skill.model.ts | 3 - .../skills/models/skill-import.util.spec.ts | 52 ++ .../admin/skills/models/skill-import.util.ts | 55 +- .../app/admin/skills/pages/skill-form.page.ts | 86 +-- .../app/admin/skills/pages/skill-list.page.ts | 35 +- .../agents/agent-form/agent-form.page.html | 2 +- .../agents/agent-form/agent-form.page.spec.ts | 154 +++++ .../app/agents/agent-form/agent-form.page.ts | 22 + frontend/ai.client/src/app/app.routes.ts | 15 + .../share-assistant-dialog.component.ts | 10 + .../model-settings/model-settings.html | 525 +++++++++++------- .../model-settings/model-settings.spec.ts | 22 +- .../model-settings/model-settings.ts | 15 +- .../src/app/components/sidenav/sidenav.html | 6 + .../app/my-skills/models/my-skill.model.ts | 111 ++++ .../src/app/my-skills/my-skill-form.page.html | 275 +++++++++ .../src/app/my-skills/my-skill-form.page.ts | 277 +++++++++ .../src/app/my-skills/my-skills.page.html | 142 +++++ .../src/app/my-skills/my-skills.page.ts | 86 +++ .../services/my-skill.service.spec.ts | 174 ++++++ .../my-skills/services/my-skill.service.ts | 171 ++++++ .../chat-mode/chat-mode.service.spec.ts | 162 ------ .../services/chat-mode/chat-mode.service.ts | 142 ----- .../app/services/skill/skill.service.spec.ts | 26 +- .../src/app/services/skill/skill.service.ts | 52 +- .../src/app/services/user-settings.service.ts | 2 - .../chat/chat-request.service.spec.ts | 66 ++- .../services/chat/chat-request.service.ts | 30 +- .../services/models/session-metadata.model.ts | 3 - .../services/session/session.service.ts | 1 - .../ai.client/src/app/session/session.page.ts | 16 +- infrastructure/lib/config.ts | 30 + .../constructs/app-api/app-api-environment.ts | 5 + .../inference-agentcore-construct.ts | 8 + infrastructure/package-lock.json | 4 +- infrastructure/package.json | 2 +- infrastructure/test/helpers/mock-config.ts | 3 + 153 files changed, 8336 insertions(+), 7351 deletions(-) create mode 100644 backend/scripts/backfill_artifact_tool_merge.py create mode 100644 backend/scripts/backfill_session_static_sk.py create mode 100644 backend/scripts/backfill_skill_bundles.py delete mode 100644 backend/src/agents/main_agent/integrations/mcp_tool_folding.py delete mode 100644 backend/src/agents/main_agent/skill_agent.py delete mode 100644 backend/src/agents/main_agent/skills/decorators.py delete mode 100644 backend/src/agents/main_agent/skills/definitions/canvas-morning-check/SKILL.md delete mode 100644 backend/src/agents/main_agent/skills/definitions/web-search/SKILL.md delete mode 100644 backend/src/agents/main_agent/skills/mcp_binding.py delete mode 100644 backend/src/agents/main_agent/skills/skill_registry.py delete mode 100644 backend/src/agents/main_agent/skills/skill_tools.py create mode 100644 backend/src/agents/main_agent/skills/strands_mapping.py delete mode 100644 backend/src/apis/app_api/admin/services/skill_access.py delete mode 100644 backend/src/apis/app_api/admin/settings/__init__.py delete mode 100644 backend/src/apis/app_api/admin/settings/routes.py create mode 100644 backend/src/apis/app_api/skills/user_service.py delete mode 100644 backend/src/apis/shared/platform_settings/__init__.py delete mode 100644 backend/src/apis/shared/platform_settings/models.py delete mode 100644 backend/src/apis/shared/platform_settings/repository.py delete mode 100644 backend/src/apis/shared/platform_settings/service.py delete mode 100644 backend/src/apis/shared/rbac/capabilities.py create mode 100644 backend/src/apis/shared/skills/bundle.py delete mode 100644 backend/tests/agents/main_agent/integrations/test_mcp_tool_folding.py delete mode 100644 backend/tests/agents/main_agent/skills/__init__.py delete mode 100644 backend/tests/agents/main_agent/skills/conftest.py delete mode 100644 backend/tests/agents/main_agent/skills/test_mcp_binding.py delete mode 100644 backend/tests/agents/main_agent/skills/test_skill_agent_db.py delete mode 100644 backend/tests/agents/main_agent/skills/test_skill_registry.py delete mode 100644 backend/tests/agents/main_agent/skills/test_skill_tools.py create mode 100644 backend/tests/agents/main_agent/skills/test_strands_mapping.py delete mode 100644 backend/tests/apis/app_api/admin/services/test_skill_access.py create mode 100644 backend/tests/apis/app_api/skills/test_my_skill_routes.py create mode 100644 backend/tests/apis/app_api/skills/test_user_skill_service.py delete mode 100644 backend/tests/apis/app_api/test_chat_settings_routes.py delete mode 100644 backend/tests/routes/test_agent_mode_policy.py delete mode 100644 backend/tests/shared/test_platform_settings.py create mode 100644 backend/tests/shared/test_skills_bundle.py delete mode 100644 backend/tests/shared/test_user_settings_agent_mode.py create mode 100644 backend/tests/test_backfill_artifact_tool_merge.py create mode 100644 backend/tests/test_backfill_session_static_sk.py create mode 100644 backend/tests/test_backfill_skill_bundles.py create mode 100644 docs/specs/agent-directory.md create mode 100644 docs/specs/skill-creator.md create mode 100644 docs/specs/skills-as-agent-primitive.md create mode 100644 docs/specs/skills-v2-pr2-spike-findings.md create mode 100644 frontend/ai.client/public/logos/github-dark.svg create mode 100644 frontend/ai.client/public/logos/github-light.svg delete mode 100644 frontend/ai.client/src/app/admin/skills/components/tool-picker-dialog.component.ts create mode 100644 frontend/ai.client/src/app/agents/agent-form/agent-form.page.spec.ts create mode 100644 frontend/ai.client/src/app/my-skills/models/my-skill.model.ts create mode 100644 frontend/ai.client/src/app/my-skills/my-skill-form.page.html create mode 100644 frontend/ai.client/src/app/my-skills/my-skill-form.page.ts create mode 100644 frontend/ai.client/src/app/my-skills/my-skills.page.html create mode 100644 frontend/ai.client/src/app/my-skills/my-skills.page.ts create mode 100644 frontend/ai.client/src/app/my-skills/services/my-skill.service.spec.ts create mode 100644 frontend/ai.client/src/app/my-skills/services/my-skill.service.ts delete mode 100644 frontend/ai.client/src/app/services/chat-mode/chat-mode.service.spec.ts delete mode 100644 frontend/ai.client/src/app/services/chat-mode/chat-mode.service.ts diff --git a/.github/workflows/platform.yml b/.github/workflows/platform.yml index 779344a98..c16325cbb 100644 --- a/.github/workflows/platform.yml +++ b/.github/workflows/platform.yml @@ -107,6 +107,15 @@ jobs: # config.ts treats as the default (on). Set the `CDK_MEMORY_SPACES_ENABLED` # variable to "false" in an environment only to dark-stop the feature there. CDK_MEMORY_SPACES_ENABLED: ${{ vars.CDK_MEMORY_SPACES_ENABLED }} + # Skills v2 (knowledge bundles: admin catalog, My Skills authoring, and + # skill resolution on the invocation path). Default ON with a kill switch: + # unset resolves to empty string, which config.ts treats as the default + # (on). Set the `CDK_SKILLS_ENABLED` variable to "false" in an environment + # only to dark-stop the feature there. NOTE this is the ONLY gate on the + # user-facing surfaces — with it on, any signed-in user can author skills + # and see the ones their role grants. Which skills a cohort gets is a + # role's `grantedSkills`, managed in the admin roles UI. + CDK_SKILLS_ENABLED: ${{ vars.CDK_SKILLS_ENABLED }} # Agent Designer /agents surface. Default OFF (opt-in) until the Phase-4 # Designer UI ships — a headless API helps no forker. Set the # `CDK_AGENTS_API_ENABLED` variable to "true" in an environment (e.g. dev) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2417379da..b2dd4383a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ All notable changes to this project are documented in this file. Format follows For narrative release notes written for operators and product owners, see [RELEASE_NOTES.md](RELEASE_NOTES.md). +## [1.8.0] - 2026-07-19 + +Feature release delivering **Skills v2** — skills are redesigned from tool-binding containers into pure, portable knowledge bundles that Agents load on demand, and for the first time **any signed-in user can author their own**. Skills ship enabled by default. Also completes the session-metadata static-sort-key migration (issue #175 Phases 2–3) with an operator backfill and a GSI-only read contraction, and consolidates the two artifact tool-catalog rows into a single "Artifacts" toggle. **Three backfill scripts must be run per environment** — see [RELEASE_NOTES.md](RELEASE_NOTES.md) deployment notes. No new AWS resources; a CDK deploy is needed only to set the `SKILLS_ENABLED` env var explicitly. + +### 🚀 Added + +- User-authored skills tier ("My Skills") — owner-scoped CRUD at `/skills/mine/*` over a new user tier in the app-roles table (GSI4-indexed), with bundle file upload, plus a `/my-skills` page and form in the SPA. Every route resolves ownership through `UserSkillService`; a skill you do not own is indistinguishable from one that does not exist (#683) +- Skills selection surfaces — a "Skills" section in the chat model-settings panel listing every skill a user can reach (RBAC-granted catalog skills **union** their own), each with an opt-in toggle, plus **invoke-through**: an Agent shared to an ordinary user resolves its bound skills for them even if they could not select those skills themselves (#684) +- `read_skill_file` progressive-disclosure tool and agentskills.io bundle support — skills are stored as portable S3 bundles with a `SKILL.md` write-through projection, so a skill prefix can be handed to a managed Harness or exported as-is (#681) + +### ✨ Improved + +- Session listing contracts to a single GSI query once the migration marker is set (issue #175 Phase 3) — the legacy base-table query is skipped after the Phase 2 backfill writes `PK=MIGRATION#session-sk`. Fails open: an absent marker or a GSI error keeps the dual-read path, so the deploy is order-independent and forks that never run the backfill are unaffected. The marker result is memoised only on success, so a container started before the backfill picks up the flip without a restart (#678) +- Static-SK backfill for the cold tail (issue #175 Phase 2) — `backend/scripts/backfill_session_static_sk.py` migrates legacy session rows and removes ghost stubs. Dry-run by default, idempotent, and `--set-marker` refuses to write the completion marker while any legacy rows remain (#677) + +### ⚠️ Changed + +- **Breaking (conceptual):** skills no longer bind tools. A skill is now instructions + reference files only; `allowedTools` persists as advisory metadata and **never grants a tool**. The skills mode toggle is gone, and the runtime moved to the Strands `AgentSkills` plugin with `agent_type="skill"` kept as a temporary ChatAgent alias. Existing skill rows keep working; their tool lists simply stop granting (#680, #681) +- Artifact tool catalog collapsed from two rows to one — "Create Artifact" and "Update Artifact" are now a single **"Artifacts"** toggle (`toolId` `create_artifact`), which injects both tools at runtime. **Requires `backend/scripts/backfill_artifact_tool_merge.py`** to promote existing role grants, user preferences, and assistant bindings before the retired row is deleted (#689) +- The `skills` RBAC capability gate was removed from the user-facing skills routes. It could not be granted from the admin roles UI — that form builds `grantedTools` from the tool catalog, and a capability id is not a tool — so an admin granting a catalog skill to a role would have found it silently invisible with no in-product fix. Access is now `SKILLS_ENABLED` per environment plus a role's `grantedSkills` per cohort (#692) +- The "My Skills" sidenav entry is hidden pending a navigation decision; the `/my-skills` route, page, and backend surface remain live and reachable by direct URL (#690) + +### 🐛 Fixed + +- Saving an Agent with an invalid form no longer looks like a click that did nothing — the first invalid control is scrolled into view and focused, with an explanatory toast. Also corrects stale copy on the agent and admin skill forms that still claimed skills carry their own bound tools (#682) + +### 🏗️ Infrastructure + +- `SKILLS_ENABLED` is threaded into both app-api and inference-api from `config.skills.enabled` (default ON with a `CDK_SKILLS_ENABLED=false` kill switch). No new AWS resources — the skill-resources S3 bucket and app-roles table already exist. Note that deployed containers set no `SKILLS_ENABLED` today, and unset now reads as enabled, so **skills activate on the `backend.yml` deploy, before any CDK deploy** (#685) + +### 📚 Docs + +- Skills v2 design spec — skills as a pure knowledge primitive bound on Agents (#679) +- Agent Directory spec (#688) and Skill Creator spec (#691) — design documents for unbuilt features + ## [1.7.1] - 2026-07-17 Patch release fixing a Word-document save failure in the AgentCore Runtime and advancing the **session-metadata static-sort-key migration** (issue #175) to its write side. Word tools now resolve the user-files bucket's real region instead of pinning to `AWS_REGION`, so `PutObject` no longer fails with `PermanentRedirect`. On the storage side, new sessions are now born with a static sort key and legacy rows self-migrate in place on their next write, structurally eliminating the ghost-row race behind the "Failed to parse session item" warnings and closing the first-turn duplicate-row race. No infra or data migration; ship through `backend.yml`. diff --git a/README.md b/README.md index 71bce3799..5b9906bb8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **An open-source, production-ready Generative AI platform for institutions** *Built by Boise State University, designed for everyone.* -[![Release](https://img.shields.io/badge/Release-v1.7.1-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) +[![Release](https://img.shields.io/badge/Release-v1.8.0-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) [![Nightly](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml/badge.svg)](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml) ![Python](https://img.shields.io/badge/Python-3.13+-3776AB?style=flat&logo=python&logoColor=white) @@ -296,7 +296,7 @@ agentcore-public-stack/ See [RELEASE_NOTES.md](RELEASE_NOTES.md) for the full changelog, including new features, bug fixes, platform upgrades, and deployment notes for each release. -**Current release:** v1.7.1 +**Current release:** v1.8.0 --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 8baae0c74..5c9db597f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,104 @@ +# Release Notes — v1.8.0 + +**Release Date:** July 19, 2026 +**Previous Release:** v1.7.1 (July 17, 2026) + +--- + +> ⚠️ **Three backfill scripts must be run per environment.** Skills activate on the `backend.yml` deploy itself — before any CDK deploy — because deployed containers set no `SKILLS_ENABLED` and unset now reads as enabled. No new AWS resources. See the Deployment notes at the end of this entry. + +--- + +## Highlights + +v1.8.0 delivers **Skills v2**, a ground-up redesign of what a skill *is*. Skills were containers that bound tools; they are now **pure, portable knowledge bundles** — instructions plus reference files — that an Agent loads on demand through progressive disclosure. For the first time, **any signed-in user can author their own skills**, not just admins, and a skill bound to a shared Agent resolves for whoever uses that Agent. Skills ship **enabled by default**. This release also completes the session-metadata static-sort-key migration (issue #175) through its backfill and read-contraction phases, and collapses the two artifact tool-catalog rows into a single "Artifacts" toggle. Operators must run three backfill scripts; there are no new AWS resources and no dependency changes. + +## Skills v2 — knowledge bundles as an Agent primitive + +A skill used to be a container that carried its own bound tools, which made it a second, parallel permission axis fighting the RBAC one. A skill is now **instructions + reference files only**. `allowedTools` still persists on the record but is advisory metadata that **never grants a tool** — tool access is RBAC's job, exclusively. The agent discovers skills by name and description (L1), reads `SKILL.md` when one looks relevant (L2), and pulls individual reference files through the new `read_skill_file` tool only as needed (L3), so a large skill library costs almost nothing in context until something is actually used. + +The storage format is now **agentskills.io-standard bundles** in S3, with a `SKILL.md` write-through projection generated from the DynamoDB row. That makes a skill prefix portable: it can be handed to a managed Harness as `{"s3": {"uri": ...}}` or exported as-is, which the previous content-addressed layout could not do. + +### Backend + +- `apis/app_api/skills/routes.py` — user-facing surfaces: the picker (`GET /skills/`, `PUT /skills/preferences`) and owner-scoped My Skills CRUD (`/skills/mine/*`), including bundle file upload. Ownership resolves through `UserSkillService`, so a skill you do not own is indistinguishable from one that does not exist. +- The runtime swapped the bespoke `SkillAgent` for the Strands **`AgentSkills` plugin**; `agent_type="skill"` remains a temporary ChatAgent alias for pre-existing snapshots and is scheduled for removal one release out. +- `read_skill_file` added for L3 progressive disclosure. `scripts/` files in a bundle are accept-and-inert by design — stored, listed, and readable, never executed. +- A new user tier in the app-roles table, GSI4-indexed for owner lookups. +- The `skills` RBAC capability gate was **removed**. It kept the surfaces admin-only during rollout, but it could not be granted from the admin roles UI at all — that form builds `grantedTools` from the tool catalog, and a capability id is not a tool. An admin granting a catalog skill to a role would have found it silently invisible to that role's users with no way to fix it in-product. Access is now `SKILLS_ENABLED` per environment plus a role's `grantedSkills` per cohort, which the roles UI *can* edit. + +### Frontend + +- A **Skills** section in the chat model-settings panel lists every skill the user can reach — RBAC-granted catalog skills **union** the ones they authored — each with an opt-in toggle. Skills are off by default; the section renders only when the user actually has one, so it stays invisible for users with no grants. +- When an Agent binds a fixed skill set, the picker shows only those skills, locked, with an explanatory note. +- A `/my-skills` page and authoring form for the user tier. **The sidenav entry is deliberately hidden** pending a decision on how users should reach the page — the route, page, and backend surface are fully live and reachable by direct URL. + +### Infrastructure + +- `SKILLS_ENABLED` threaded into app-api and inference-api from `config.skills.enabled`, default ON with a `CDK_SKILLS_ENABLED=false` per-environment kill switch. Design-time refuses to bind a skill while the flag is off on app-api, so the two services must stay in step — a mismatch would let an Agent be built with skills the runtime then blocks. +- **No new AWS resources.** The skill-resources S3 bucket and the app-roles table both predate this release. + +### Test Coverage + +~1,800 lines of new skills tests across the runtime plugin, the user tier's ownership boundaries, resource-store layout, and the SPA picker and My Skills services. + +## Session-metadata migration completed (issue #175, Phases 2–3) + +v1.7.0 landed the read side and v1.7.1 the write side; v1.8.0 finishes the job for rows that never get written again. **Phase 2** adds `backend/scripts/backfill_session_static_sk.py`, which migrates the cold tail of legacy rows and deletes ghost stubs. It is dry-run by default, idempotent, and throttled; the static put is guarded by `ConditionExpression=Attr("SK").not_exists()` so it can never clobber a row a live writer already migrated with fresher data. Its `--set-marker` flag re-scans and **refuses to write the completion marker while any legacy rows remain**, so multiple passes are expected on a large table. + +**Phase 3** then lets session listing skip the legacy base-table query entirely once that marker (`PK=MIGRATION#session-sk`) is present — one DynamoDB query per list call instead of two. It **fails open**: an absent marker, or a handled GSI error, keeps the dual-read path, so the deploy is order-independent and downstream forks that never run the backfill are unaffected. The marker result is memoised only on success, so a container that started before the backfill picks up the flip on a later list call with no restart. + +Users notice nothing — list contents, ordering, and cursor encoding are unchanged. + +## Artifacts consolidated to one catalog toggle + +Admins previously saw "Create Artifact" and "Update Artifact" as two unrelated rows in the tool catalog, to grant and toggle separately, even though updating an artifact is meaningless without creating one. They are now a single **"Artifacts"** entry (`toolId` `create_artifact`) that injects both tools at runtime. + +This requires `backend/scripts/backfill_artifact_tool_merge.py`, which promotes existing role grants, user tool preferences, and assistant bindings from the retired `update_artifact` id onto the kept one before deleting the retired row. Promotion happens **before** deletion, so an aborted run degrades to "both granted" rather than "neither." An explicit *enable* of the retired id carries over; an explicit *disable* deliberately does not. + +## 🐛 Bug fixes + +- **Saving an Agent with an invalid form looked like a click that did nothing.** The inline validation error was usually below the fold once the author had scrolled to the Model or Skills sections, so the save button appeared inert. The first invalid control is now scrolled into view and focused, with a "Fix the highlighted fields before saving" toast. The same change corrects stale copy on the agent and admin skill forms that still described skills as carrying their own bound tools. + +## ⚠️ Breaking changes + +- **Skills no longer bind tools.** This is a conceptual break, not a data break: existing skill rows keep working and keep their `allowedTools` values, but those values stop granting anything. Any workflow that relied on a skill to confer tool access must grant those tools through RBAC instead. The skills mode toggle is gone. +- **The `update_artifact` tool-catalog row is retired.** Run the artifact backfill below before or immediately after deploying, or roles and users that had been granted `update_artifact` alone will lose it. + +## 🏗️ Infrastructure + +- No new AWS resources, no new IAM, no dependency changes. +- The only CDK delta is the `SKILLS_ENABLED` environment variable on the app-api task definition and the inference-api Runtime. Because unset now reads as enabled, **a CDK deploy is not required to activate skills** — it only makes the setting explicit, and is required only to *disable* skills in a given environment. + +## 🚀 Deployment notes + +Standard order: `platform.yml` (optional this release — see above) → `backend.yml` → `frontend-deploy.yml`. + +**Skills go live the moment `backend.yml` completes.** With the surfaces ungated, any signed-in user can then author skills at `/my-skills` and use any skill their role grants. Nothing is *linked* — the sidenav entry is hidden and no catalog skills are granted by default — but the route is present in the SPA bundle. If an environment is not ready for that, set `CDK_SKILLS_ENABLED=false` for it and deploy `platform.yml` **before** `backend.yml`. + +Then run the three backfills, each **dry-run first** (all three are dry-run by default and idempotent), dev before prod: + +```bash +# 1. Artifact tool merge — required; the retired row is deleted at the end +python backend/scripts/backfill_artifact_tool_merge.py --table -app-roles +python backend/scripts/backfill_artifact_tool_merge.py --table -app-roles --apply + +# 2. Skill bundles — brings any pre-v2 skill up to the agentskills.io layout +python backend/scripts/backfill_skill_bundles.py \ + --table -app-roles --bucket -skill-resources +python backend/scripts/backfill_skill_bundles.py \ + --table -app-roles --bucket -skill-resources --apply + +# 3. Session static-SK cold tail — optional but unlocks the Phase 3 read path +python backend/scripts/backfill_session_static_sk.py --table -sessions-metadata +python backend/scripts/backfill_session_static_sk.py \ + --table -sessions-metadata --apply --set-marker +``` + +Notes on each: the artifact script promotes grants before deleting the retired row, so an interrupted run is safe. The skill-bundle script *copies* legacy objects and only removes them with an explicit `--delete-legacy` once the copy is verified. The session script deletes ghost stubs and migrated legacy rows permanently and has no rollback path — take the dry-run output seriously, and expect `--set-marker` to no-op until a pass reaches zero legacy rows. Session listing keeps working correctly whether or not you ever run script 3. + +--- + # Release Notes — v1.7.1 **Release Date:** July 17, 2026 diff --git a/VERSION b/VERSION index 943f9cbc4..27f9cd322 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.1 +1.8.0 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 86d099b35..57f66059b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agentcore-stack" -version = "1.7.1" +version = "1.8.0" requires-python = ">=3.10" description = "Multi-agent conversational AI system with AWS Bedrock AgentCore" readme = "README.md" diff --git a/backend/scripts/backfill_artifact_tool_merge.py b/backend/scripts/backfill_artifact_tool_merge.py new file mode 100644 index 000000000..f8d205463 --- /dev/null +++ b/backend/scripts/backfill_artifact_tool_merge.py @@ -0,0 +1,327 @@ +"""Backfill: collapse the two artifact catalog rows into one "Artifacts" toggle. + +Artifacts shipped as two independent ``protocol: local`` catalog rows — +``create_artifact`` and ``update_artifact`` — so the tool picker listed them as +two unrelated entries. They are now a single capability following the +Word-document idiom: one catalog row (``create_artifact``, displayName +"Artifacts") whose id is the gate key, and enabling it injects *both* the +create and update tools at runtime. See ``ARTIFACT_TOOL_IDS`` / +``_build_artifact_tools`` in ``apis/inference_api/chat/routes.py``. + +``seed_default_tools`` is create-only (it skips rows that already exist), so a +seed run does nothing to an environment that was seeded before this change. +This script performs the migration: + +1. **Retitle** ``TOOL#create_artifact`` to the merged displayName/description. +2. **Promote** every role that grants ``update_artifact`` so it also grants + ``create_artifact`` — a role holding *only* the retired id would otherwise + silently lose artifact access. Both representations are updated: the + ``TOOL_GRANT#`` mapping item (GSI2/ToolRoleMappingIndex) and the + ``grantedTools`` / ``effectivePermissions.tools`` arrays on ``DEFINITION``. +3. **Promote** user tool preferences the same way. ``toolPreferences`` is a + sparse override map: an explicit *enable* of the retired id carries over to + ``create_artifact`` (create wins when both keys are set), and the retired + key is dropped either way. An explicit *disable* does not carry over — + someone who switched update off while leaving create at its default-on never + asked to lose artifacts entirely. +4. **Promote** assistant tool bindings (``kind == "tool"``, ``ref == + "update_artifact"``) to ``create_artifact``, de-duplicating. +5. **Delete** the ``TOOL#update_artifact`` catalog row and every + ``TOOL_GRANT#update_artifact`` mapping item. + +Note the promote-before-delete ordering: nothing is removed until its +replacement is in place, so an aborted run degrades to "both ids granted", +never to "neither". + +OUT OF SCOPE +------------ +Schedule snapshots (``enabledTools`` on the sessions-metadata table) are left +alone. A stale ``update_artifact`` entry there is an inert no-op — the runtime +only reads ``create_artifact`` — and any snapshot taken while both rows were +seeded default-on already carries ``create_artifact`` alongside it. + +SAFETY +------ +* **Dry-run by default.** Pass ``--apply`` to actually write. +* **Idempotent + re-runnable.** A second run finds nothing to do. +* **Scoped.** ``--assistants-table`` is optional; omit it to skip step 4. + +Run against dev first, then prod:: + + AWS_PROFILE=dev-ai python backend/scripts/backfill_artifact_tool_merge.py \\ + --table dev-boisestateai-v2-app-roles \\ + --assistants-table dev-boisestateai-v2-assistants # dry-run + AWS_PROFILE=dev-ai python backend/scripts/backfill_artifact_tool_merge.py \\ + --table dev-boisestateai-v2-app-roles \\ + --assistants-table dev-boisestateai-v2-assistants --apply +""" + +from __future__ import annotations + +import argparse +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List + +import boto3 +from boto3.dynamodb.conditions import Attr, Key +from botocore.exceptions import ClientError + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger("backfill_artifact_tool_merge") + +KEEP_ID = "create_artifact" +RETIRED_ID = "update_artifact" + +MERGED_DISPLAY_NAME = "Artifacts" +MERGED_DESCRIPTION = ( + "Save standalone HTML or Markdown documents as versioned artifacts the " + "user can open and iterate on. Updates create a new immutable version." +) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def retitle_catalog_row(table: Any, apply: bool) -> int: + """Point TOOL#create_artifact at the merged name/description.""" + resp = table.get_item(Key={"PK": f"TOOL#{KEEP_ID}", "SK": "METADATA"}) + item = resp.get("Item") + if not item: + logger.warning(f"TOOL#{KEEP_ID} not found — nothing to retitle") + return 0 + if item.get("displayName") == MERGED_DISPLAY_NAME: + logger.info(f"TOOL#{KEEP_ID} already retitled — skipped") + return 0 + + logger.info( + f"retitle TOOL#{KEEP_ID}: {item.get('displayName')!r} -> {MERGED_DISPLAY_NAME!r}" + ) + if apply: + table.update_item( + Key={"PK": f"TOOL#{KEEP_ID}", "SK": "METADATA"}, + UpdateExpression=( + "SET displayName = :dn, description = :d, updatedAt = :u" + ), + ExpressionAttributeValues={ + ":dn": MERGED_DISPLAY_NAME, + ":d": MERGED_DESCRIPTION, + ":u": _now(), + }, + ) + return 1 + + +def _roles_granting(table: Any, tool_id: str) -> List[Dict[str, Any]]: + """Every TOOL_GRANT mapping item for a tool, via ToolRoleMappingIndex.""" + items: List[Dict[str, Any]] = [] + kwargs: Dict[str, Any] = { + "IndexName": "ToolRoleMappingIndex", + "KeyConditionExpression": Key("GSI2PK").eq(f"TOOL#{tool_id}"), + } + while True: + resp = table.query(**kwargs) + items.extend(resp.get("Items", [])) + if "LastEvaluatedKey" not in resp: + return items + kwargs["ExclusiveStartKey"] = resp["LastEvaluatedKey"] + + +def promote_role_grants(table: Any, apply: bool) -> int: + """Ensure every role granting the retired id also grants the keeper.""" + changed = 0 + for mapping in _roles_granting(table, RETIRED_ID): + role_pk = mapping["PK"] + role_id = mapping.get("roleId", role_pk) + + definition = table.get_item(Key={"PK": role_pk, "SK": "DEFINITION"}).get("Item") + if not definition: + logger.warning(f"{role_pk}: grant exists but DEFINITION missing — skipped") + continue + + granted: List[str] = list(definition.get("grantedTools") or []) + effective = definition.get("effectivePermissions") or {} + effective_tools: List[str] = list(effective.get("tools") or []) + + # A wildcard already covers the keeper; only the arrays need pruning. + needs_keeper = KEEP_ID not in granted and "*" not in granted + + if needs_keeper: + logger.info(f"{role_id}: grant {KEEP_ID} (held only {RETIRED_ID})") + if apply: + table.put_item( + Item={ + "PK": role_pk, + "SK": f"TOOL_GRANT#{KEEP_ID}", + "GSI2PK": f"TOOL#{KEEP_ID}", + "GSI2SK": role_pk, + "roleId": mapping.get("roleId"), + "displayName": mapping.get("displayName"), + "enabled": mapping.get("enabled", True), + } + ) + granted.append(KEEP_ID) + if "*" not in effective_tools: + effective_tools.append(KEEP_ID) + + new_granted = [t for t in granted if t != RETIRED_ID] + new_effective = [t for t in effective_tools if t != RETIRED_ID] + + if new_granted != (definition.get("grantedTools") or []) or new_effective != ( + effective.get("tools") or [] + ): + logger.info(f"{role_id}: grantedTools -> {new_granted}") + if apply: + effective["tools"] = new_effective + table.update_item( + Key={"PK": role_pk, "SK": "DEFINITION"}, + UpdateExpression=( + "SET grantedTools = :g, effectivePermissions = :e, updatedAt = :u" + ), + ExpressionAttributeValues={ + ":g": new_granted, + ":e": effective, + ":u": _now(), + }, + ) + changed += 1 + + logger.info(f"{role_id}: delete TOOL_GRANT#{RETIRED_ID}") + if apply: + table.delete_item(Key={"PK": role_pk, "SK": f"TOOL_GRANT#{RETIRED_ID}"}) + return changed + + +def _scan(table: Any, **kwargs: Any) -> List[Dict[str, Any]]: + items: List[Dict[str, Any]] = [] + while True: + resp = table.scan(**kwargs) + items.extend(resp.get("Items", [])) + if "LastEvaluatedKey" not in resp: + return items + kwargs["ExclusiveStartKey"] = resp["LastEvaluatedKey"] + + +def promote_user_preferences(table: Any, apply: bool) -> int: + """Carry an explicit opinion on the retired id over to the keeper.""" + changed = 0 + # `contains` matches strings/sets only, never Map keys — toolPreferences is + # a Map, so this has to be a nested-path existence check. + rows = _scan( + table, + FilterExpression=Attr("SK").eq("TOOL_PREFERENCES") + & Attr(f"toolPreferences.{RETIRED_ID}").exists(), + ) + for row in rows: + prefs: Dict[str, Any] = dict(row.get("toolPreferences") or {}) + if RETIRED_ID not in prefs: + continue + + retired_value = prefs.pop(RETIRED_ID) + # Create wins when the user has an opinion on both. Otherwise only a + # *True* carries over: someone who turned update off while leaving + # create at its default-on never asked to lose artifacts, so dropping + # the key (falling back to enabledByDefault) is the faithful read. + if KEEP_ID not in prefs and retired_value: + prefs[KEEP_ID] = True + + logger.info(f"{row['PK']}: toolPreferences -> {prefs}") + if apply: + table.update_item( + Key={"PK": row["PK"], "SK": "TOOL_PREFERENCES"}, + UpdateExpression="SET toolPreferences = :p, updatedAt = :u", + ExpressionAttributeValues={":p": prefs, ":u": _now()}, + ) + changed += 1 + return changed + + +def promote_assistant_bindings(table: Any, apply: bool) -> int: + """Rewrite tool bindings on the retired id to the keeper, de-duplicated.""" + changed = 0 + rows = _scan(table, FilterExpression=Attr("SK").eq("METADATA") & Attr("bindings").exists()) + for row in rows: + bindings: List[Dict[str, Any]] = list(row.get("bindings") or []) + if not any( + b.get("kind") == "tool" and b.get("ref") == RETIRED_ID for b in bindings + ): + continue + + has_keeper = any( + b.get("kind") == "tool" and b.get("ref") == KEEP_ID for b in bindings + ) + new_bindings: List[Dict[str, Any]] = [] + for b in bindings: + if b.get("kind") == "tool" and b.get("ref") == RETIRED_ID: + if has_keeper: + continue # keeper already bound — just drop the retired one + new_bindings.append({**b, "ref": KEEP_ID}) + has_keeper = True + continue + new_bindings.append(b) + + logger.info(f"{row['PK']}: bindings {RETIRED_ID} -> {KEEP_ID}") + if apply: + table.update_item( + Key={"PK": row["PK"], "SK": "METADATA"}, + UpdateExpression="SET bindings = :b, updatedAt = :u", + ExpressionAttributeValues={":b": new_bindings, ":u": _now()}, + ) + changed += 1 + return changed + + +def delete_retired_catalog_row(table: Any, apply: bool) -> int: + resp = table.get_item(Key={"PK": f"TOOL#{RETIRED_ID}", "SK": "METADATA"}) + if "Item" not in resp: + logger.info(f"TOOL#{RETIRED_ID} already absent — skipped") + return 0 + logger.info(f"delete TOOL#{RETIRED_ID}") + if apply: + table.delete_item(Key={"PK": f"TOOL#{RETIRED_ID}", "SK": "METADATA"}) + return 1 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--table", required=True, help="app-roles table name") + parser.add_argument( + "--assistants-table", + help="assistants table name; omit to skip the binding rewrite", + ) + parser.add_argument("--region", default="us-west-2") + parser.add_argument("--apply", action="store_true", help="actually write") + args = parser.parse_args() + + if not args.apply: + logger.info("DRY RUN — pass --apply to write") + + dynamodb = boto3.Session(region_name=args.region).resource("dynamodb") + table = dynamodb.Table(args.table) + + try: + retitled = retitle_catalog_row(table, args.apply) + roles = promote_role_grants(table, args.apply) + prefs = promote_user_preferences(table, args.apply) + bindings = 0 + if args.assistants_table: + bindings = promote_assistant_bindings( + dynamodb.Table(args.assistants_table), args.apply + ) + else: + logger.info("--assistants-table not given — skipping binding rewrite") + deleted = delete_retired_catalog_row(table, args.apply) + except ClientError as e: + logger.error(f"aborted: {e}") + return 1 + + logger.info( + f"done: retitled={retitled} roles={roles} prefs={prefs} " + f"bindings={bindings} deleted={deleted}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/backfill_session_static_sk.py b/backend/scripts/backfill_session_static_sk.py new file mode 100644 index 000000000..afd56b8b8 --- /dev/null +++ b/backend/scripts/backfill_session_static_sk.py @@ -0,0 +1,228 @@ +"""Backfill: migrate session-metadata rows to the static sort key (issue #175 Phase 2). + +Converts legacy session rows — whose sort key encodes ``lastMessageAt`` +(``S#ACTIVE#{lastMessageAt}#{id}`` / ``S#DELETED#{deletedAt}#{id}``) — to the +static ``S#{session_id}`` scheme, populating the sparse ``SessionRecencyIndex`` +(GSI4) for active sessions, and deletes the ghost/stub rows the old rotating-SK +writers produced. Once a full pass finds zero legacy rows remaining, it writes the +"migration complete" marker that lets Phase 3 collapse the dual-scheme read to +GSI-only. + +SAFETY +------ +* **Dry-run by default.** Pass ``--apply`` to actually write/delete. +* **Idempotent + re-runnable.** The static put uses ``attribute_not_exists(SK)`` + so it never clobbers a row a live writer already migrated (with fresher data); + the legacy delete is a harmless no-op if already gone. +* **Throttled.** ``--sleep`` between processed items (default 50ms) keeps read/write + pressure low on the live table. +* **Marker is gated.** ``--set-marker`` re-scans and only writes the marker if zero + legacy rows remain, so Phase 3 can never be unblocked while data is un-migrated. + +Run against dev first, then prod:: + + AWS_PROFILE=dev-ai python backend/scripts/backfill_session_static_sk.py \ + --table dev-boisestateai-v2-sessions-metadata # dry-run + AWS_PROFILE=dev-ai python backend/scripts/backfill_session_static_sk.py \ + --table dev-boisestateai-v2-sessions-metadata --apply --set-marker +""" + +from __future__ import annotations + +import argparse +import logging +import os +import time +from typing import Any, Dict, Optional, Tuple + +import boto3 +from boto3.dynamodb.conditions import Attr +from botocore.exceptions import ClientError + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger("backfill_session_static_sk") + +LEGACY_ACTIVE_PREFIX = "S#ACTIVE#" +LEGACY_DELETED_PREFIX = "S#DELETED#" +MARKER_PK = "MIGRATION#session-sk" +MARKER_SK = "STATE" + +# Every real session row (legacy or static) carries these; a ghost/stub upsert +# from the old rotating-SK race does not. +REQUIRED_FIELDS = ( + "sessionId", "userId", "title", "status", "createdAt", "lastMessageAt", "messageCount", +) + + +def _session_id_from_sk(sk: str) -> str: + """Legacy SK tail is the session id: S#ACTIVE#{ts}#{id} / S#DELETED#{ts}#{id}.""" + return sk.rsplit("#", 1)[-1] + + +def is_ghost(row: Dict[str, Any]) -> bool: + """A legacy-SK row that is not a valid session — the stub the migration cleans up. + + Real session rows always have ``GSI_SK == 'META'`` and the required fields; the + ghost upserts (REMOVE/SET on a rotated-away key) have neither. + """ + if row.get("GSI_SK") != "META": + return True + return any(field not in row for field in REQUIRED_FIELDS) + + +def build_static_item(row: Dict[str, Any], session_id: str) -> Dict[str, Any]: + """Rewrite a legacy row to the static-SK shape. + + Drops any stale GSI4 keys and re-derives them from status: active rows get the + sparse recency keys, deleted rows get none (so they leave the active listing). + """ + user_id = row["userId"] + is_deleted = row.get("SK", "").startswith(LEGACY_DELETED_PREFIX) or row.get("deleted") is True \ + or row.get("status") == "deleted" + + new = {k: v for k, v in row.items() if k not in ("SK", "GSI4_PK", "GSI4_SK")} + new["SK"] = f"S#{session_id}" + new["GSI_PK"] = f"SESSION#{session_id}" + new["GSI_SK"] = "META" + + if is_deleted: + new["status"] = "deleted" + new["deleted"] = True + else: + new["GSI4_PK"] = f"USER#{user_id}" + new["GSI4_SK"] = f"{row['lastMessageAt']}#{session_id}" + return new + + +def process_row(table, row: Dict[str, Any], apply: bool, stats: Dict[str, int]) -> None: + pk, sk = row["PK"], row["SK"] + session_id = _session_id_from_sk(sk) + + if is_ghost(row): + stats["ghosts"] += 1 + logger.info("%s ghost delete PK=%s SK=%s", "APPLY" if apply else "DRYRUN", pk, sk[:60]) + if apply: + table.delete_item(Key={"PK": pk, "SK": sk}) + return + + target_sk = f"S#{session_id}" + if sk == target_sk: + stats["already_static"] += 1 + return + + new_item = build_static_item(row, session_id) + stats["migrated"] += 1 + logger.info("%s migrate %s -> %s", "APPLY" if apply else "DRYRUN", sk[:50], target_sk) + if not apply: + return + + # Put the static row only if it doesn't already exist — a live writer may have + # migrated this session (with fresher data) between our scan and now; don't + # clobber it. Either way we then drop the legacy orphan. + try: + table.put_item(Item=new_item, ConditionExpression=Attr("SK").not_exists()) + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException": + stats["skipped_live_migrated"] += 1 + logger.info(" static row already present (live-migrated); dropping legacy orphan only") + else: + raise + table.delete_item(Key={"PK": pk, "SK": sk}) + + +def scan_legacy(table, limit: Optional[int]): + """Yield rows whose SK is a legacy session prefix, paginating the table.""" + kwargs: Dict[str, Any] = { + "FilterExpression": Attr("SK").begins_with(LEGACY_ACTIVE_PREFIX) + | Attr("SK").begins_with(LEGACY_DELETED_PREFIX), + } + yielded = 0 + while True: + resp = table.scan(**kwargs) + for item in resp.get("Items", []): + yield item + yielded += 1 + if limit and yielded >= limit: + return + lek = resp.get("LastEvaluatedKey") + if not lek: + return + kwargs["ExclusiveStartKey"] = lek + + +def count_remaining_legacy(table) -> int: + kwargs: Dict[str, Any] = { + "FilterExpression": Attr("SK").begins_with(LEGACY_ACTIVE_PREFIX) + | Attr("SK").begins_with(LEGACY_DELETED_PREFIX), + "Select": "COUNT", + } + total = 0 + while True: + resp = table.scan(**kwargs) + total += resp.get("Count", 0) + lek = resp.get("LastEvaluatedKey") + if not lek: + return total + kwargs["ExclusiveStartKey"] = lek + + +def maybe_set_marker(table, apply: bool) -> bool: + """Set the migration-complete marker iff zero legacy rows remain.""" + remaining = count_remaining_legacy(table) + if remaining > 0: + logger.warning( + "Marker NOT set: %d legacy rows still remain — re-run the backfill until zero.", + remaining, + ) + return False + logger.info("%s set migration-complete marker (%s / %s)", + "APPLY" if apply else "DRYRUN", MARKER_PK, MARKER_SK) + if apply: + table.put_item(Item={"PK": MARKER_PK, "SK": MARKER_SK, "complete": True}) + return True + + +def run(table_name: str, region: str, apply: bool, sleep: float, + limit: Optional[int], set_marker: bool) -> Dict[str, int]: + table = boto3.Session(region_name=region).resource("dynamodb").Table(table_name) + stats = {"migrated": 0, "ghosts": 0, "already_static": 0, "skipped_live_migrated": 0} + + logger.info("Backfill %s table=%s region=%s%s", + "APPLY" if apply else "DRY-RUN", table_name, region, + f" limit={limit}" if limit else "") + for row in scan_legacy(table, limit): + process_row(table, row, apply, stats) + if sleep: + time.sleep(sleep) + + logger.info("Done: migrated=%(migrated)d ghosts=%(ghosts)d " + "already_static=%(already_static)d skipped_live_migrated=%(skipped_live_migrated)d", + stats) + + if set_marker: + maybe_set_marker(table, apply) + return stats + + +def main() -> None: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--table", default=os.environ.get("DYNAMODB_SESSIONS_METADATA_TABLE_NAME"), + help="sessions-metadata table name (or DYNAMODB_SESSIONS_METADATA_TABLE_NAME)") + p.add_argument("--region", default=os.environ.get("AWS_REGION", "us-west-2")) + p.add_argument("--apply", action="store_true", help="actually write/delete (default: dry-run)") + p.add_argument("--sleep", type=float, default=0.05, help="seconds between items (throttle)") + p.add_argument("--limit", type=int, default=None, help="max rows to process (for testing)") + p.add_argument("--set-marker", action="store_true", + help="after the pass, set the migration-complete marker if zero legacy remain") + args = p.parse_args() + + if not args.table: + p.error("--table is required (or set DYNAMODB_SESSIONS_METADATA_TABLE_NAME)") + + run(args.table, args.region, args.apply, args.sleep, args.limit, args.set_marker) + if not args.apply: + logger.info("DRY-RUN only — no changes written. Re-run with --apply to execute.") + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/backfill_skill_bundles.py b/backend/scripts/backfill_skill_bundles.py new file mode 100644 index 000000000..ce3e73e3e --- /dev/null +++ b/backend/scripts/backfill_skill_bundles.py @@ -0,0 +1,292 @@ +"""Backfill: bring v1 skill rows up to the agentskills.io bundle layout (Skills v2). + +Skills authored before Skills v2 PR-2 predate two things: + +1. **The ``SKILL.md`` write-through projection.** Their S3 prefix holds only + resource bytes, so the prefix is not a valid, portable agentskills.io + bundle — it cannot be handed to a managed Harness (``{"s3": {"uri": ...}}``) + or exported as-is. +2. **The standard bundle layout.** v1 stored resources content-addressed + (``skills/{id}/{sha256}``) with no ``kind``; v2 stores them at + ``skills/{id}/{references|scripts|assets}/{filename}``. + +This script fixes both, per skill: it copies each legacy content-hash object to +its standard path, rewrites the row's ``resources`` manifest to point there +(adding ``kind``), and writes the ``SKILL.md`` projection generated from the +row. The DynamoDB row stays the source of truth throughout — nothing here +invents metadata. + +It does NOT touch ``instructions``, ``description`` or any other authored +field. A skill whose prose is stale is a content problem, not a layout one. + +SAFETY +------ +* **Dry-run by default.** Pass ``--apply`` to actually write. +* **Idempotent + re-runnable.** A skill already in the standard layout has its + projection rewritten (cheap, byte-identical) and its objects left alone. + Legacy objects are *copied*, never moved, and only deleted with + ``--delete-legacy`` once the copy is verified present. +* **Throttled.** ``--sleep`` between skills (default 50ms). +* **Scoped.** ``--skill`` limits the run to one skill id. + +Run against dev first, then prod:: + + AWS_PROFILE=dev-ai python backend/scripts/backfill_skill_bundles.py \\ + --table dev-boisestateai-v2-app-roles \\ + --bucket dev-boisestateai-v2-skill-resources # dry-run + AWS_PROFILE=dev-ai python backend/scripts/backfill_skill_bundles.py \\ + --table dev-boisestateai-v2-app-roles \\ + --bucket dev-boisestateai-v2-skill-resources --apply +""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys +import time +from typing import Any, Dict, List, Optional, Tuple + +import boto3 +from botocore.exceptions import ClientError + +# The generator and the layout rules are shared with the live write path, so a +# backfilled bundle is byte-identical to one the app would write today. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from apis.shared.skills.bundle import generate_skill_md # noqa: E402 +from apis.shared.skills.resource_store import ( # noqa: E402 + resource_key, + skill_md_key, +) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger("backfill_skill_bundles") + + +def is_legacy_key(s3_key: str, skill_id: str) -> bool: + """True if a manifest entry still uses the v1 content-addressed key. + + v1 wrote ``skills/{id}/{sha256}`` — a flat key with no bundle subdirectory. + v2 always writes ``skills/{id}/{references|scripts|assets}/{filename}``, so + the presence of a subdirectory is the discriminator. + """ + prefix = f"skills/{skill_id}/" + if not s3_key.startswith(prefix): + # Unrecognized shape — treat as legacy so it gets normalized. + return True + return "/" not in s3_key[len(prefix) :] + + +def scan_skills(table, skill_id: Optional[str]) -> List[Dict[str, Any]]: + """Return the skill rows to process (all, or just the one named).""" + if skill_id: + response = table.get_item(Key={"PK": f"SKILL#{skill_id}", "SK": "METADATA"}) + item = response.get("Item") + return [item] if item else [] + + filter_expr = "begins_with(PK, :pk_prefix) AND SK = :sk" + expr_values = {":pk_prefix": "SKILL#", ":sk": "METADATA"} + + response = table.scan( + FilterExpression=filter_expr, ExpressionAttributeValues=expr_values + ) + items = list(response.get("Items", [])) + while "LastEvaluatedKey" in response: + response = table.scan( + FilterExpression=filter_expr, + ExpressionAttributeValues=expr_values, + ExclusiveStartKey=response["LastEvaluatedKey"], + ) + items.extend(response.get("Items", [])) + return items + + +def object_exists(s3, bucket: str, key: str) -> bool: + try: + s3.head_object(Bucket=bucket, Key=key) + return True + except ClientError as e: + if e.response["Error"]["Code"] in ("404", "NoSuchKey", "NotFound"): + return False + raise + + +def migrate_resources( + s3, + bucket: str, + skill_id: str, + resources: List[Dict[str, Any]], + *, + apply: bool, + delete_legacy: bool, +) -> Tuple[List[Dict[str, Any]], int]: + """Copy legacy objects into the standard layout and rewrite the manifest. + + Returns the (possibly rewritten) manifest and the number of entries moved. + """ + migrated: List[Dict[str, Any]] = [] + moved = 0 + + for ref in resources: + entry = dict(ref) + filename = entry.get("filename") or "" + old_key = entry.get("s3Key") or "" + # v1 rows carry no `kind`; everything it could store was a reference doc. + kind = entry.get("kind") or "reference" + entry["kind"] = kind + + if not filename or not is_legacy_key(old_key, skill_id): + migrated.append(entry) + continue + + new_key = resource_key(skill_id, kind, filename) + entry["s3Key"] = new_key + moved += 1 + + if not apply: + logger.info(" [dry-run] copy %s -> %s", old_key, new_key) + migrated.append(entry) + continue + + if object_exists(s3, bucket, new_key): + logger.info(" %s already present, skipping copy", new_key) + elif not object_exists(s3, bucket, old_key): + # The manifest points at bytes that are gone. Rewriting the key + # would be a lie, so leave the entry untouched and shout. + logger.warning( + " MISSING legacy object %s for %s/%s — manifest left as-is", + old_key, + skill_id, + filename, + ) + entry["s3Key"] = old_key + moved -= 1 + migrated.append(entry) + continue + else: + s3.copy_object( + Bucket=bucket, + CopySource={"Bucket": bucket, "Key": old_key}, + Key=new_key, + ContentType=entry.get("contentType") or "application/octet-stream", + MetadataDirective="REPLACE", + ServerSideEncryption="AES256", + ) + logger.info(" copied %s -> %s", old_key, new_key) + + if delete_legacy and object_exists(s3, bucket, new_key): + s3.delete_object(Bucket=bucket, Key=old_key) + logger.info(" deleted legacy %s", old_key) + + migrated.append(entry) + + return migrated, moved + + +def write_projection( + s3, bucket: str, row: Dict[str, Any], *, apply: bool +) -> None: + """Write the row's ``SKILL.md`` projection into the bundle prefix.""" + skill_id = row["skillId"] + content = generate_skill_md( + skill_id=skill_id, + description=row.get("description") or "", + instructions=row.get("instructions") or "", + allowed_tools=[str(t) for t in (row.get("allowedTools") or [])], + skill_metadata=dict(row.get("skillMetadata") or {}), + ) + key = skill_md_key(skill_id) + + if not apply: + logger.info(" [dry-run] write %s (%d bytes)", key, len(content.encode())) + return + + s3.put_object( + Bucket=bucket, + Key=key, + Body=content.encode("utf-8"), + ContentType="text/markdown", + ServerSideEncryption="AES256", + ) + logger.info(" wrote %s", key) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--table", required=True, help="app-roles DynamoDB table") + parser.add_argument("--bucket", required=True, help="skill-resources S3 bucket") + parser.add_argument("--skill", help="Only process this skill id") + parser.add_argument( + "--apply", action="store_true", help="Actually write (default: dry-run)" + ) + parser.add_argument( + "--delete-legacy", + action="store_true", + help="Delete the old content-hash objects after a verified copy", + ) + parser.add_argument("--sleep", type=float, default=0.05) + args = parser.parse_args() + + dynamodb = boto3.resource("dynamodb") + table = dynamodb.Table(args.table) + s3 = boto3.client("s3") + + rows = scan_skills(table, args.skill) + if not rows: + logger.warning("No skills found (table=%s skill=%s)", args.table, args.skill) + return 1 + + mode = "APPLY" if args.apply else "DRY-RUN" + logger.info("%s: %d skill(s) to inspect", mode, len(rows)) + + projections = 0 + moves = 0 + + for row in rows: + skill_id = row.get("skillId") + if not skill_id: + logger.warning("Row without skillId, skipping: PK=%s", row.get("PK")) + continue + + logger.info("skill %s", skill_id) + resources = [dict(r) for r in (row.get("resources") or [])] + + migrated, moved = migrate_resources( + s3, + args.bucket, + skill_id, + resources, + apply=args.apply, + delete_legacy=args.delete_legacy, + ) + moves += moved + + if moved and args.apply: + table.update_item( + Key={"PK": f"SKILL#{skill_id}", "SK": "METADATA"}, + UpdateExpression="SET #r = :r", + ExpressionAttributeNames={"#r": "resources"}, + ExpressionAttributeValues={":r": migrated}, + ) + logger.info(" manifest rewritten (%d entr(y|ies))", moved) + + write_projection(s3, args.bucket, row, apply=args.apply) + projections += 1 + + time.sleep(args.sleep) + + logger.info( + "%s complete: %d projection(s), %d resource(s) relocated", + mode, + projections, + moves, + ) + if not args.apply: + logger.info("Re-run with --apply to write.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/seed_bootstrap_data.py b/backend/scripts/seed_bootstrap_data.py index 128bce6d8..41be7954b 100644 --- a/backend/scripts/seed_bootstrap_data.py +++ b/backend/scripts/seed_bootstrap_data.py @@ -24,6 +24,7 @@ import hashlib import logging import os +import re import sys import uuid from dataclasses import dataclass, field @@ -412,19 +413,14 @@ def seed_default_models( "forwardAuthToken": False, }, { + # Single catalog entry / toggle that provisions the whole artifact + # toolset. Enabling this one id injects create/update at runtime — see + # ARTIFACT_TOOL_IDS and _build_artifact_tools in + # apis/inference_api/chat/routes.py. Keep the toolId as + # "create_artifact": it is the gate key. "toolId": "create_artifact", - "displayName": "Create Artifact", - "description": "Save standalone HTML or Markdown documents as versioned artifacts the user can open and iterate on.", - "category": "document", - "protocol": "local", - "enabledByDefault": True, - "isPublic": True, - "forwardAuthToken": False, - }, - { - "toolId": "update_artifact", - "displayName": "Update Artifact", - "description": "Replace an existing artifact's content, creating a new immutable version.", + "displayName": "Artifacts", + "description": "Save standalone HTML or Markdown documents as versioned artifacts the user can open and iterate on. Updates create a new immutable version.", "category": "document", "protocol": "local", "enabledByDefault": True, @@ -781,11 +777,11 @@ def seed_default_tools( # ============================================================================= # # Seeds one demonstrable admin-managed Skill so the feature can be exercised -# end-to-end: SKILL.md-style instructions + a bound LOCAL catalog tool -# (fetch_url_content, seeded above) + a supporting reference file (uploaded to -# the skill-resources S3 bucket and referenced by the row's `resources` -# manifest). Mirrors the real `pdf`/`docx` bundle shape, where the instructions -# body names a reference file the agent reads on demand via skill_dispatcher. +# end-to-end: SKILL.md-style instructions + a supporting reference file +# (uploaded to the skill-resources S3 bucket and referenced by the row's +# `resources` manifest). Mirrors the real `pdf`/`docx` bundle shape, where the +# instructions body names a reference file the agent reads on demand. Skills +# are pure knowledge bundles (Skills v2) — they bind no tools. # # The skill is granted to the `default` role so any user can reach it once an # assistant opts into agent_type="skill". It is otherwise inert: the default @@ -825,23 +821,82 @@ def seed_default_tools( "accurate, citable notes.\n" "\n" "## Workflow\n" - "1. Use the bound `fetch_url_content` tool (via `skill_executor`) to pull\n" - " the page text for each URL the user provides.\n" + "1. Use a web-fetching tool (e.g. `fetch_url_content`, if the agent has it\n" + " enabled) to pull the page text for each URL the user provides.\n" "2. Extract the relevant facts. For handling tables, paywalls, and noisy\n" - " pages, read `extraction_tips.md` — call `skill_dispatcher` again with\n" - " `reference=\"extraction_tips.md\"`.\n" + " pages, read the `extraction_tips.md` reference file.\n" "3. Summarize with inline source attributions (page title + URL).\n" "\n" "Never invent details that are not present in the fetched content.\n" ) +def _skill_slug(skill_id: str) -> str: + """agentskills.io-valid skill name from a catalog id. + + Mirrors ``apis/shared/skills/bundle.py::slugify_skill_name``. Duplicated + rather than imported because this script is standalone — ``seed.sh`` runs it + straight after infra deploy, without the app package on the path. + """ + slug = skill_id.strip().lower().replace("_", "-") + slug = re.sub(r"[^a-z0-9-]+", "-", slug) + slug = re.sub(r"-{2,}", "-", slug).strip("-") + return slug[:64].strip("-") or "skill" + + +def _write_skill_md( + skill_id: str, description: str, instructions: str, region: str +) -> None: + """Write the skill's ``SKILL.md`` projection so the prefix is a real bundle. + + Mirrors ``apis/shared/skills/bundle.py::generate_skill_md`` for the subset + the seed uses (no advisory tools, no frontmatter passthrough). Without this + the seeded prefix holds only resource bytes and is not a valid + agentskills.io bundle — it could not be handed to a managed Harness or + exported as-is. Best-effort, like the reference upload. + """ + bucket = os.environ.get("S3_SKILL_RESOURCES_BUCKET_NAME", "") + if not bucket: + return + + content = ( + "---\n" + f"name: {_skill_slug(skill_id)}\n" + f"description: {description}\n" + "---\n" + "\n" + f"{instructions.strip()}\n" + ) + key = f"skills/{skill_id}/SKILL.md" + try: + s3 = boto3.Session(region_name=region).client("s3") + s3.put_object( + Bucket=bucket, + Key=key, + Body=content.encode("utf-8"), + ContentType="text/markdown", + ServerSideEncryption="AES256", + ) + except ClientError as e: + logger.warning( + "Could not write SKILL.md for skill '%s' to %s — seeding without " + "the projection: %s", + skill_id, + bucket, + e, + ) + return + + logger.info("Wrote SKILL.md for skill '%s' to s3://%s/%s", skill_id, bucket, key) + + def _upload_skill_reference( skill_id: str, filename: str, content: bytes, content_type: str, region: str ) -> Optional[dict[str, Any]]: """Upload a reference file's bytes to the skill-resources bucket. - Content-addressed (``skills/{skill_id}/{sha256}``), mirroring + Stored in the standard agentskills.io bundle layout + (``skills/{skill_id}/references/{filename}``), mirroring ``apis/shared/skills/resource_store.py``. Best-effort: returns the manifest entry on success, or ``None`` (with a warning) when the bucket is not configured or the put fails, so the skill still seeds without the bytes. @@ -857,7 +912,7 @@ def _upload_skill_reference( return None digest = hashlib.sha256(content).hexdigest() - key = f"skills/{skill_id}/{digest}" + key = f"skills/{skill_id}/references/{filename}" try: s3 = boto3.Session(region_name=region).client("s3") s3.put_object( @@ -885,6 +940,7 @@ def _upload_skill_reference( "size": len(content), "contentType": content_type, "s3Key": key, + "kind": "reference", } @@ -951,7 +1007,7 @@ def seed_example_skills( table_name: str, region: str, ) -> SeedResult: - """Seed one example bundled skill (instructions + bound tool + reference file).""" + """Seed one example knowledge skill (instructions + reference file).""" result = SeedResult(category="skill") session = boto3.Session(region_name=region) dynamodb = session.resource("dynamodb") @@ -992,6 +1048,8 @@ def seed_example_skills( now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + description = "Fetch web pages and turn them into accurate, citable notes." + item: dict[str, Any] = { "PK": pk, "SK": sk, @@ -1000,9 +1058,8 @@ def seed_example_skills( "GSI4SK": f"SKILL#{skill_id}", "skillId": skill_id, "displayName": "Web Research Assistant", - "description": "Fetch web pages and turn them into accurate, citable notes.", + "description": description, "instructions": EXAMPLE_SKILL_INSTRUCTIONS, - "boundToolIds": ["fetch_url_content"], "compose": [], "resources": resources, "status": "active", @@ -1029,6 +1086,9 @@ def seed_example_skills( result.details.append(msg) return result + # Projection last: the row is the source of truth, so it must exist first. + _write_skill_md(skill_id, description, EXAMPLE_SKILL_INSTRUCTIONS, region) + _grant_skill_to_default_role(table, skill_id) return result diff --git a/backend/src/.env.example b/backend/src/.env.example index 623ec1192..2cdbf72fb 100644 --- a/backend/src/.env.example +++ b/backend/src/.env.example @@ -871,20 +871,27 @@ S3_SKILL_RESOURCES_BUCKET_NAME= # ============================================================================= # SKILLS (OPTIONAL) # ============================================================================= -# The Skills feature (admin skills catalog, user-facing skills picker, and -# skills mode — routing turns through the SkillAgent) is gated by -# SKILLS_ENABLED. While off, app-api leaves the user + admin skills routes and -# the chat-mode policy route unmounted (404), GET /system/chat-settings reports -# tools/chat mode with toggling disabled, and inference-api forces every turn -# through the plain ChatAgent. All skills data and code remain intact, so the -# feature can be turned back on by flipping this flag. Set it on BOTH app-api -# and inference-api containers when enabling. +# The Skills feature (admin catalog, My Skills authoring, and skill resolution +# on the invocation path) is gated by SKILLS_ENABLED. Skills are pure knowledge +# bundles (Skills v2) — they bind no tools and are opt-in per turn, not a mode. +# While off, app-api leaves the user + admin skills routes unmounted (404) and +# inference-api resolves no skills. All skills data and code remain intact, so +# the feature can be turned back off by flipping this flag. Set it on BOTH the +# app-api and inference-api containers. +# +# TWO INDEPENDENT CONTROLS. This flag gates feature *existence* per environment. +# *Who* sees the user-facing surfaces is the `skills` RBAC capability +# (apis.shared.rbac.capabilities), granted through the tools axis: system_admin +# holds it implicitly via its "*" grant, so the surfaces are admin-only out of +# the box. GA = grant `skills` to the `default` role — one config change, no +# redeploy. The capability deliberately does NOT gate the runtime, so an Agent +# shared to an ordinary user still resolves its bound skills (invoke-through). # Enable the skills feature (OPTIONAL) -# Purpose: Feature gate. When "true", the skills surfaces are mounted and new -# conversations may route through the SkillAgent per the chat-mode policy. -# Default: false -SKILLS_ENABLED=false +# Purpose: Feature gate. Default ON with a kill switch — unset or empty resolves +# to enabled; only the literal "false" (case-insensitive) disables. +# Default: true +SKILLS_ENABLED=true # ============================================================================= # FINE-TUNING (OPTIONAL) diff --git a/backend/src/agents/main_agent/__init__.py b/backend/src/agents/main_agent/__init__.py index c491c2fa3..044a166cd 100644 --- a/backend/src/agents/main_agent/__init__.py +++ b/backend/src/agents/main_agent/__init__.py @@ -30,7 +30,6 @@ from .main_agent import MainAgent from .base_agent import BaseAgent from .chat_agent import ChatAgent -from .skill_agent import SkillAgent from .agent_types import create_agent, register_agent_type, get_available_types # VoiceAgent is optional (requires strands-agents[bidi]) @@ -64,7 +63,6 @@ "BaseAgent", "ChatAgent", "MainAgent", - "SkillAgent", "create_agent", "register_agent_type", "get_available_types", diff --git a/backend/src/agents/main_agent/agent_types.py b/backend/src/agents/main_agent/agent_types.py index b937b8c0f..3c095321f 100644 --- a/backend/src/agents/main_agent/agent_types.py +++ b/backend/src/agents/main_agent/agent_types.py @@ -56,9 +56,13 @@ def create_agent(agent_type: str = "chat", **kwargs) -> BaseAgent: # Register built-in agent types def _register_defaults(): from agents.main_agent.chat_agent import ChatAgent - from agents.main_agent.skill_agent import SkillAgent register_agent_type("chat", ChatAgent) - register_agent_type("skill", SkillAgent) + # Skills v2: the SkillAgent subclass is retired — skills disclosure is the + # vended Strands AgentSkills plugin, wired inside ChatAgent when the turn + # carries accessible_skill_ids. "skill" stays a registered alias so the + # existing agent_type/skills_hash cache-key + paused-turn resume path + # (which the spec keeps) resolve to a ChatAgent-with-plugin unchanged. + register_agent_type("skill", ChatAgent) # Voice agent is optional (requires strands-agents[bidi]) try: diff --git a/backend/src/agents/main_agent/base_agent.py b/backend/src/agents/main_agent/base_agent.py index 55a3fb04e..acdc7b133 100644 --- a/backend/src/agents/main_agent/base_agent.py +++ b/backend/src/agents/main_agent/base_agent.py @@ -8,7 +8,7 @@ import logging from abc import ABC, abstractmethod -from typing import Any, AsyncGenerator, Callable, Dict, List, Optional +from typing import Any, AsyncGenerator, Dict, List, Optional from agents.main_agent.core import ModelConfig, SystemPromptBuilder, AgentFactory from agents.main_agent.session import SessionFactory @@ -315,25 +315,10 @@ def approval_names_lookup(selected_tool: object) -> set[str]: return set() return integration.approval_names_for_client(selected_tool.mcp_client) - # Tools dispatched indirectly (SkillAgent's skill_executor meta-tool) - # never present as MCPAgentTool, so approval_names_lookup can't gate - # them. Subclasses that fold tools provide a tool_use-based second - # chance — mirrors the OAuth consent hook's fold-aware lookup. return MCPExternalApprovalHook( approval_names_lookup=approval_names_lookup, - tool_use_approval_lookup=self._build_tool_use_approval_lookup(), ) - def _build_tool_use_approval_lookup( - self, - ) -> Optional[Callable[[dict], Optional[Any]]]: - """Approval-target resolution from a raw `tool_use` dict, for agents - that dispatch tools indirectly (SkillAgent's meta-tools). The base - agent has no such indirection, so the approval hook gets None and - relies on `approval_names_lookup` alone. - """ - return None - def _build_oauth_consent_hook(self) -> OAuthConsentHook: """Construct the OAuth consent hook with closures over the MCP integration and provider repository so it stays decoupled from them. @@ -350,11 +335,6 @@ def provider_lookup(selected_tool: object) -> Optional[str]: return None return integration.provider_for_client(selected_tool.mcp_client) - # Tools dispatched indirectly (SkillAgent's skill_executor meta-tool) - # never present as MCPAgentTool, so provider_lookup can't gate them. - # Subclasses that fold tools provide a tool_use-based second chance. - tool_use_provider_lookup = self._build_tool_use_provider_lookup() - async def scopes_lookup(provider_id: str) -> List[str]: from apis.shared.oauth.provider_repository import get_provider_repository @@ -392,17 +372,8 @@ async def disconnected_lookup(provider_id: str) -> bool: scopes_lookup=scopes_lookup, custom_parameters_lookup=custom_parameters_lookup, disconnected_lookup=disconnected_lookup, - tool_use_provider_lookup=tool_use_provider_lookup, ) - def _build_tool_use_provider_lookup(self) -> Optional[Callable[[dict], Optional[str]]]: - """OAuth provider resolution from a raw `tool_use` dict, for agents - that dispatch tools indirectly (SkillAgent's meta-tools). The base - agent has no such indirection, so the consent hook gets None and - relies on `provider_lookup` alone. - """ - return None - def _expand_gateway_tool_ids(self, gateway_tool_ids: List[str]) -> List[str]: """Expand #419 catalog gateway tools into the gateway's runtime per-tool ids (see `expand_gateway_tool_ids`), bridging the async catalog lookup diff --git a/backend/src/agents/main_agent/chat_agent.py b/backend/src/agents/main_agent/chat_agent.py index 12baf9e49..48e097d14 100644 --- a/backend/src/agents/main_agent/chat_agent.py +++ b/backend/src/agents/main_agent/chat_agent.py @@ -10,6 +10,7 @@ from agents.main_agent.base_agent import BaseAgent from agents.main_agent.core import AgentFactory +from agents.main_agent.skills.strands_mapping import build_skills_runtime logger = logging.getLogger(__name__) @@ -22,20 +23,56 @@ class ChatAgent(BaseAgent): - Strands Agent creation with filtered tools and hooks - Text message streaming via StreamCoordinator - Multimodal prompt building (text + files) + - Skills disclosure (Skills v2): when ``accessible_skill_ids`` is provided, + the vended Strands ``AgentSkills`` plugin injects an ```` + catalog and a ``skills`` activation tool. Skills are pure knowledge + bundles — they bind no tools (the tool universe comes solely from the + Agent's bindings + RBAC-gated ``enabled_tools``). """ + def __init__( + self, + accessible_skill_ids: Optional[List[str]] = None, + **kwargs: Any, + ): + """ + Args: + accessible_skill_ids: Effective skill ids for this turn (already + RBAC-resolved and narrowed by the request's selection). When + non-empty an ``AgentSkills`` plugin is added; ``None``/empty + keeps plain chat behavior. Stored BEFORE ``super().__init__`` + because ``BaseAgent.__init__`` calls ``_create_agent`` at its tail. + **kwargs: All BaseAgent constructor args (session_id, user_id, ...). + """ + self._accessible_skill_ids = accessible_skill_ids + super().__init__(**kwargs) + def _create_agent(self) -> None: - """Create Strands Agent with filtered tools and session management.""" + """Create Strands Agent with filtered tools, hooks, and skills plugin.""" try: tools = self._build_filtered_tools() hooks = self._create_hooks() + # Skills disclosure: the AgentSkills plugin injects the catalog + + # activation tool; read_skill_file is the S3 adapter for reference + # files (added after tool filtering — it is infrastructure, not an + # RBAC-gated tool, and is implicitly scoped to the turn's skills). + plugin, read_skill_file = build_skills_runtime(self._accessible_skill_ids) + plugins = [plugin] if plugin else None + if plugin: + tools = list(tools) + [read_skill_file] + logger.info( + "ChatAgent: skills disclosure enabled (%d accessible skill ids)", + len(self._accessible_skill_ids or []), + ) + self.agent = AgentFactory.create_agent( model_config=self.model_config, system_prompt=self.system_prompt, tools=tools, session_manager=self.session_manager, hooks=hooks, + plugins=plugins, ) except Exception as e: diff --git a/backend/src/agents/main_agent/core/agent_factory.py b/backend/src/agents/main_agent/core/agent_factory.py index 1c5a9900e..6d9e99852 100644 --- a/backend/src/agents/main_agent/core/agent_factory.py +++ b/backend/src/agents/main_agent/core/agent_factory.py @@ -140,7 +140,8 @@ def create_agent( system_prompt: str, tools: List[Any], session_manager: Any, - hooks: Optional[List[Any]] = None + hooks: Optional[List[Any]] = None, + plugins: Optional[List[Any]] = None, ) -> Agent: """ Create a Strands Agent instance with the appropriate model provider @@ -151,6 +152,9 @@ def create_agent( tools: List of tools (local tools and/or MCP clients) session_manager: Session manager instance hooks: Optional list of agent hooks + plugins: Optional list of Strands plugins (e.g. AgentSkills). A + plugin auto-registers its hooks and tools with the agent, so + this is how skills disclosure is wired (Skills v2). Returns: Agent: Configured Strands Agent instance @@ -202,6 +206,7 @@ def create_agent( tool_executor=SequentialToolExecutor(), session_manager=session_manager, hooks=hooks if hooks else None, + plugins=plugins if plugins else None, retry_strategy=retry_strategy, ) diff --git a/backend/src/agents/main_agent/integrations/external_mcp_client.py b/backend/src/agents/main_agent/integrations/external_mcp_client.py index 5b363d96f..6cc762135 100644 --- a/backend/src/agents/main_agent/integrations/external_mcp_client.py +++ b/backend/src/agents/main_agent/integrations/external_mcp_client.py @@ -477,10 +477,9 @@ def get_client(self, tool_id: str, user_id: Optional[str] = None) -> Optional[MC ``collect_tool_name_filters`` folds every scoped id for one server into a single client per agent build, so the base uniquely identifies it. - Without this, a skill that bound a *subset* of an external MCP server - resolved to no client at fold time → the skill folded zero tools and - the model reported the server "not connected" (the OAuth consent gate - never fired because no FoldedMCPTool existed to trigger it). + Without this, a *subset*-scoped selection of an external MCP server + resolved to no client → the model reported the server "not connected" + and the OAuth consent gate never fired. """ base = base_tool_id(tool_id) # Exact keys win — a whole-server binding has no "|allow:" suffix. diff --git a/backend/src/agents/main_agent/integrations/gateway_mcp_client.py b/backend/src/agents/main_agent/integrations/gateway_mcp_client.py index c49ab6d5f..1e5754d79 100644 --- a/backend/src/agents/main_agent/integrations/gateway_mcp_client.py +++ b/backend/src/agents/main_agent/integrations/gateway_mcp_client.py @@ -16,7 +16,6 @@ ensure_ui_extension_session_patch, record_and_filter_ui_tools, ) -from agents.main_agent.integrations.mcp_tool_folding import drop_folded_tools logger = logging.getLogger(__name__) @@ -95,11 +94,6 @@ def list_tools_sync(self, *args, **kwargs): # issue `resources/read` against it. filtered_tools = record_and_filter_ui_tools(filtered_tools, client=self) - # Drop tools folded behind a skill's meta-tools (PR-6b). No-op unless a - # SkillAgent registered a fold set for this client. The tools stay - # executable via skill_executor → this client's call_tool_sync. - filtered_tools = drop_folded_tools(self, filtered_tools) - return PaginatedList(filtered_tools, token=paginated_result.pagination_token) diff --git a/backend/src/agents/main_agent/integrations/mcp_apps.py b/backend/src/agents/main_agent/integrations/mcp_apps.py index 0aed234ad..1d48a9a71 100644 --- a/backend/src/agents/main_agent/integrations/mcp_apps.py +++ b/backend/src/agents/main_agent/integrations/mcp_apps.py @@ -812,12 +812,4 @@ def start(self, *args: Any, **kwargs: Any) -> "UICapableMCPClient": def list_tools_sync(self, *args: Any, **kwargs: Any) -> PaginatedList: result = super().list_tools_sync(*args, **kwargs) filtered = record_and_filter_ui_tools(list(result), client=self) - # Drop tools folded behind a skill's meta-tools (PR-6b). No-op unless a - # SkillAgent registered a fold set for this client; imported lazily to - # avoid a module import cycle (mcp_tool_folding is integration-neutral). - from agents.main_agent.integrations.mcp_tool_folding import ( - drop_folded_tools, - ) - - filtered = drop_folded_tools(self, filtered) return PaginatedList(filtered, token=result.pagination_token) diff --git a/backend/src/agents/main_agent/integrations/mcp_tool_folding.py b/backend/src/agents/main_agent/integrations/mcp_tool_folding.py deleted file mode 100644 index 49a23a4b5..000000000 --- a/backend/src/agents/main_agent/integrations/mcp_tool_folding.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Hide specific MCP tools from a client's model-facing tool list (PR-6b). - -Gateway and external MCP servers are handed to the Strands ``Agent`` as -*client objects*, not as individual callables: Strands enumerates each -client's tools through its ``list_tools_sync`` and adds one ``MCPAgentTool`` -per server tool. That is exactly why PR-6a could fold only LOCAL tools — there -was no per-tool object to pull out of the top-level list. - -This module supplies the missing seam. A skill that binds a gateway/external -tool registers that tool's *agent-facing name* as **folded** on the owning -client; the client's ``list_tools_sync`` then drops it, so its schema never -rides in the model's context. The tool stays fully executable: the client -object remains in the agent's tool list (Strands keeps its session alive), and -``skill_executor`` invokes the folded tool through the client's -``call_tool_sync`` (see ``agents/main_agent/skills/mcp_binding.py``). - -Free functions (rather than a base class) so any ``MCPClient`` subclass opts in -with two lines in its ``list_tools_sync`` and no change to its hierarchy — and -so the mechanism works for clients constructed before a fold set is known. -""" - -from __future__ import annotations - -import logging -from typing import Any, Iterable, List - -logger = logging.getLogger(__name__) - -#: Attribute the fold set is stashed under on a client. Private/namespaced so -#: it never collides with Strands' own attributes. -_FOLD_ATTR = "_skill_folded_tool_names" - - -def set_folded_tool_names(client: Any, names: Iterable[str]) -> None: - """Mark ``names`` as folded on ``client`` (hidden from its model tool list). - - Idempotently *adds* to any existing fold set so binding two skills to the - same client accumulates rather than overwrites. Also invalidates the - client's cached ``_loaded_tools`` (set by a prior ``load_tools`` pre-flight) - so Strands re-lists through ``list_tools_sync`` with the fold applied — the - external pre-flight in ``BaseAgent._build_filtered_tools`` primes that cache - *before* folds are known, and without this reset the model would still see - the folded schemas. - """ - existing = getattr(client, _FOLD_ATTR, None) - folded = set(existing) if existing else set() - folded.update(names) - setattr(client, _FOLD_ATTR, folded) - - # Drop any cached tool list so the next load_tools() re-lists with the fold. - if getattr(client, "_loaded_tools", None) is not None: - try: - client._loaded_tools = None - except Exception: # pragma: no cover - defensive; attr is a plain field - logger.debug("could not reset _loaded_tools on %r", client, exc_info=True) - - -def reset_folded_tool_names(client: Any) -> None: - """Clear any fold set on ``client`` (and its cached tool list). - - The fold set is *per-agent-build* state, but clients are process-global and - reused across builds (the external / gateway integrations cache them), and - :func:`set_folded_tool_names` only ever *adds*. Each build must therefore - start from a clean fold and recompute it from its own skill bindings — - otherwise a prior build's fold persists and poisons this build's - enumeration: ``resolve_mcp_bindings`` lists an external server through the - same fold-filtered ``list_tools_sync``, so a stale fold makes a re-bind see - zero tools (the bound tool "works once, then disappears" on the next turn). - - Call this on every client a build is about to (re)bind, *before* resolving, - then let the build re-apply the fold via :func:`set_folded_tool_names`. - """ - if getattr(client, _FOLD_ATTR, None): - setattr(client, _FOLD_ATTR, set()) - if getattr(client, "_loaded_tools", None) is not None: - try: - client._loaded_tools = None - except Exception: # pragma: no cover - defensive; attr is a plain field - logger.debug("could not reset _loaded_tools on %r", client, exc_info=True) - - -def folded_tool_names(client: Any) -> set: - """Return the (copy of the) fold set on ``client``, or an empty set.""" - existing = getattr(client, _FOLD_ATTR, None) - return set(existing) if existing else set() - - -def drop_folded_tools(client: Any, tools: List[Any]) -> List[Any]: - """Filter folded tools out of a ``list_tools_sync`` result. - - Matches on the agent-facing ``tool_name`` (the name the model and - ``skill_executor`` use). A no-op when nothing is folded, so it is safe to - call unconditionally from any client's ``list_tools_sync``. - """ - folded = getattr(client, _FOLD_ATTR, None) - if not folded: - return tools - kept = [t for t in tools if getattr(t, "tool_name", None) not in folded] - if len(kept) != len(tools): - logger.info( - "Folded %d MCP tool(s) out of the model tool list (behind skill " - "meta-tools): %s", - len(tools) - len(kept), - sorted(folded), - ) - return kept diff --git a/backend/src/agents/main_agent/session/hooks/oauth_consent.py b/backend/src/agents/main_agent/session/hooks/oauth_consent.py index 8f221c19e..5783e86e1 100644 --- a/backend/src/agents/main_agent/session/hooks/oauth_consent.py +++ b/backend/src/agents/main_agent/session/hooks/oauth_consent.py @@ -6,12 +6,6 @@ don't, it calls `event.interrupt(...)` to pause the agent mid-turn and hand the authorization URL back to the caller. -Tools can be OAuth-gated through indirection too: in skills mode a bound -external MCP tool runs behind the `skill_executor` meta-tool, so the -selected tool alone doesn't identify the MCP server. The optional -`tool_use_provider_lookup` resolves the provider from the raw tool_use -(name + input) in that case — same gate, same interrupt, same resume. - When the user completes consent in the popup and the frontend resumes the turn, the hook fires a second time and `event.interrupt(...)` returns the user's response (instead of raising). At that point AgentCore Identity has @@ -111,14 +105,6 @@ def _looks_like_auth_failure(tool_result: Any) -> bool: # isn't OAuth-gated. Encapsulates the MCPClient -> provider mapping. ProviderLookup = Callable[[Any], Optional[str]] -# Second-chance provider resolution from the raw `tool_use` dict (name + -# input) for tools that dispatch indirectly — SkillAgent's `skill_executor` -# meta-tool runs folded external MCP tools, so `selected_tool` is the -# executor itself and `ProviderLookup` can't map it. Consulted only when -# `ProviderLookup` returns None; return None for anything that isn't an -# indirect OAuth-gated dispatch. -ToolUseProviderLookup = Callable[[dict], Optional[str]] - # Returns OAuth scopes for a provider_id. May be sync or async; the hook # awaits the result either way so we can read from an async repository # without forcing a sync wrapper. @@ -151,7 +137,6 @@ def __init__( scopes_lookup: ScopesLookup, custom_parameters_lookup: Optional[CustomParametersLookup] = None, disconnected_lookup: Optional[DisconnectedLookup] = None, - tool_use_provider_lookup: Optional[ToolUseProviderLookup] = None, ): """Initialize. @@ -170,17 +155,12 @@ def __init__( effectively assumes the user has not disconnected. Wire this to the durable disconnect repository in production so a /disconnect on one replica is visible from any other. - tool_use_provider_lookup: See `ToolUseProviderLookup`. Optional. - When omitted, only `provider_lookup` is consulted and - indirectly-dispatched tools (skill meta-tools) are not - OAuth-gated. """ self._user_id = user_id self._provider_lookup = provider_lookup self._scopes_lookup = scopes_lookup self._custom_parameters_lookup = custom_parameters_lookup self._disconnected_lookup = disconnected_lookup - self._tool_use_provider_lookup = tool_use_provider_lookup # Cache scopes per provider for the lifetime of this hook (one agent # invocation). Avoids repeated DB hits if the same provider is used # across multiple tool calls in a single turn. @@ -214,26 +194,8 @@ def _on_invocation_start(self, event: BeforeInvocationEvent) -> None: """ self._reauth_attempted_providers.clear() - def _resolve_provider_id( - self, selected_tool: Any, tool_use: Any - ) -> Optional[str]: - """Map a tool call to its OAuth provider, seeing through indirection. - - `provider_lookup` handles directly-selected MCP tools. When it can't - map the tool (e.g. SkillAgent's `skill_executor` meta-tool, whose - folded target only appears in the tool_use input), the optional - `tool_use_provider_lookup` gets a second chance with the raw - tool_use dict. - """ - provider_id = self._provider_lookup(selected_tool) - if provider_id: - return provider_id - if self._tool_use_provider_lookup is None or not isinstance(tool_use, dict): - return None - return self._tool_use_provider_lookup(tool_use) - async def _gate(self, event: BeforeToolCallEvent) -> None: - provider_id = self._resolve_provider_id(event.selected_tool, event.tool_use) + provider_id = self._provider_lookup(event.selected_tool) if not provider_id: return # Not an OAuth-gated tool @@ -365,7 +327,7 @@ async def _handle_auth_failure(self, event: AfterToolCallEvent) -> None: explicit user intent (the "Disconnect" button in the settings page). """ - provider_id = self._resolve_provider_id(event.selected_tool, event.tool_use) + provider_id = self._provider_lookup(event.selected_tool) if not provider_id: return diff --git a/backend/src/agents/main_agent/session/hooks/tool_approval.py b/backend/src/agents/main_agent/session/hooks/tool_approval.py index eb30d80ae..3d6e737dd 100644 --- a/backend/src/agents/main_agent/session/hooks/tool_approval.py +++ b/backend/src/agents/main_agent/session/hooks/tool_approval.py @@ -15,19 +15,12 @@ - The interrupt name is scoped by `toolUseId` so two parallel calls of the same tool in one turn produce distinct interrupts (and distinct SSE events the frontend can correlate per-prompt). -- Tools can be approval-gated through indirection too: in skills mode a - bound external MCP tool runs behind the `skill_executor` meta-tool, so - neither `selected_tool` nor `tool_use["name"]` identifies the flagged - tool. The optional `tool_use_approval_lookup` resolves the folded target - from the raw tool_use (name + input) in that case — same interrupt, same - resume; the prompt describes the inner tool, not the executor. """ from __future__ import annotations import json import logging -from dataclasses import dataclass from typing import Any, Callable, Optional, Set from strands.hooks import BeforeToolCallEvent, HookProvider, HookRegistry @@ -61,28 +54,6 @@ def _encode_tool_input(value: Any) -> Optional[str]: ApprovalNamesLookup = Callable[[Any], Set[str]] -@dataclass(frozen=True) -class FoldedToolApproval: - """An approval-flagged tool resolved from an indirect (folded) tool_use. - - `tool_name` / `tool_input` describe the inner tool the user is being - asked to approve (e.g. `gmail_search` and its args), not the meta-tool - that carries it — the frontend dialog renders them verbatim. - """ - - tool_name: str - tool_input: Any = None - - -# Second-chance resolution from the raw `tool_use` dict (name + input) for -# tools that dispatch indirectly — SkillAgent's `skill_executor` meta-tool -# runs folded external MCP tools, so `selected_tool` is the executor and -# `ApprovalNamesLookup` can't gate it. Returns the folded target only when -# that target is approval-flagged; None for everything else. Consulted only -# when the direct path didn't fire. -ToolUseApprovalLookup = Callable[[dict], Optional[FoldedToolApproval]] - - # Default user-facing message; admins can extend this later by wiring a # per-tool message field through the catalog if needed. _DEFAULT_APPROVAL_MESSAGE = ( @@ -97,19 +68,13 @@ class MCPExternalApprovalHook(HookProvider): def __init__( self, approval_names_lookup: ApprovalNamesLookup, - tool_use_approval_lookup: Optional[ToolUseApprovalLookup] = None, ): """Initialize. Args: approval_names_lookup: See `ApprovalNamesLookup`. - tool_use_approval_lookup: See `ToolUseApprovalLookup`. Optional. - When omitted, only directly-selected MCP tools are gated and - indirectly-dispatched tools (skill meta-tools) bypass the - approval prompt. """ self._approval_names_lookup = approval_names_lookup - self._tool_use_approval_lookup = tool_use_approval_lookup def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: registry.add_callback(BeforeToolCallEvent, self._gate) @@ -119,28 +84,14 @@ def _gate(self, event: BeforeToolCallEvent) -> None: tool_name = event.tool_use.get("name", "") if approval_names and tool_name in approval_names: self._require_approval(event, tool_name, event.tool_use.get("input")) - return - - # Second chance: the call may be a flagged tool hiding behind an - # indirect dispatcher (skill_executor). The lookup applies the - # approval check itself, so a non-None result means "gate this". - folded = self._resolve_folded_target(event.tool_use) - if folded is not None: - self._require_approval(event, folded.tool_name, folded.tool_input) - - def _resolve_folded_target(self, tool_use: Any) -> Optional[FoldedToolApproval]: - if self._tool_use_approval_lookup is None or not isinstance(tool_use, dict): - return None - return self._tool_use_approval_lookup(tool_use) def _require_approval( self, event: BeforeToolCallEvent, tool_name: str, tool_input: Any ) -> None: """Interrupt for user approval; cancel the call unless approved. - `tool_name` / `tool_input` describe the tool the user is approving — - for folded dispatch that's the inner tool, while the interrupt's - `toolUseId` stays the executor's so the frontend correlates it with + `tool_name` / `tool_input` describe the tool the user is approving, + while the interrupt's `toolUseId` lets the frontend correlate it with the actual streamed tool_use block. """ tool_use_id = event.tool_use.get("toolUseId", "") diff --git a/backend/src/agents/main_agent/skill_agent.py b/backend/src/agents/main_agent/skill_agent.py deleted file mode 100644 index daced772c..000000000 --- a/backend/src/agents/main_agent/skill_agent.py +++ /dev/null @@ -1,356 +0,0 @@ -""" -Skill Agent — ChatAgent with progressive skill disclosure. - -Replaces individual skill tools with skill_dispatcher + skill_executor, -injecting a lightweight skill catalog into the system prompt instead of -loading all tool schemas upfront. - -Two skill sources: - -- **DB / admin-managed** (PR-6): when ``accessible_skill_ids`` is provided - (the caller resolved them from the user's RBAC roles), the registry loads - those ACTIVE skills from the catalog repository. A granted skill's bound - catalog tools are added to the agent's tool universe so they materialize - (skill-as-grant). Local tools fold behind the two meta-tools by object - identity; gateway / external MCP bound tools fold via the MCP client (their - schemas are dropped from the model tool list and they execute through - ``call_tool_sync`` — see ``skills/mcp_binding.py``, PR-6b). -- **File / dev** (legacy): when ``accessible_skill_ids`` is None, the registry - scans ``definitions/*/SKILL.md`` and binds local ``@skill``-decorated tools - by their ``_skill_name`` stamp, exactly as before — unchanged behavior. - -When zero skills are available the agent degrades to plain ``ChatAgent``. -""" - -import logging -from typing import Any, List, Optional - -from agents.main_agent.chat_agent import ChatAgent -from agents.main_agent.core import AgentFactory -from agents.main_agent.skills import SkillRegistry, make_skill_tools - -logger = logging.getLogger(__name__) - - -def _is_active_status(status: Any) -> bool: - """True if a skill record's status is ACTIVE (handles enum or str).""" - return str(status).split(".")[-1].lower() == "active" - - -def _fetch_skill_records(skill_ids: List[str]) -> List[Any]: - """Fetch ACTIVE skill records for the given ids from the catalog repo. - - Bridges the async repository call into this sync agent-build path the same - way ``_register_external_mcp_tools`` / ``_expand_gateway_tool_ids`` do. - Returns an empty list on any failure (the agent then degrades to chat). - """ - if not skill_ids: - return [] - - import asyncio - - from apis.shared.skills.repository import get_skill_catalog_repository - - repo = get_skill_catalog_repository() - - async def _go() -> List[Any]: - records = await repo.batch_get_skills(list(skill_ids)) - return [r for r in records if _is_active_status(getattr(r, "status", "active"))] - - try: - try: - loop = asyncio.get_event_loop() - if loop.is_running(): - import concurrent.futures - - with concurrent.futures.ThreadPoolExecutor() as executor: - return executor.submit(asyncio.run, _go()).result() - return loop.run_until_complete(_go()) - except RuntimeError: - return asyncio.run(_go()) - except Exception as e: # noqa: BLE001 - degrade to chat on any error - logger.warning("Could not load skill records: %s", e) - return [] - - -class SkillAgent(ChatAgent): - """ - Chat agent with progressive skill disclosure. - - Overrides _create_agent() to: - 1. Discover skills (DB-backed when RBAC ids are supplied, else file scan) - 2. Bind tools to skills (by catalog id for DB skills, by _skill_name for file) - 3. Replace skill-bound tools with skill_dispatcher + skill_executor - 4. Inject the skill catalog into the system prompt - - The LLM sees a lightweight catalog and two meta-tools instead of all - individual tool schemas, reducing upfront token usage. - """ - - def __init__( - self, - skills_dir: Optional[str] = None, - accessible_skill_ids: Optional[List[str]] = None, - **kwargs, - ): - """ - Initialize skill agent. - - Args: - skills_dir: Optional path to file-based skill definitions (dev path). - accessible_skill_ids: When provided, load these admin/DB skills - (already RBAC-resolved for the user). When None, fall back to - the file scan. - **kwargs: All BaseAgent constructor args (session_id, user_id, ...). - """ - self._skills_dir = skills_dir - self._accessible_skill_ids = accessible_skill_ids - self._db_mode = accessible_skill_ids is not None - self._registry: SkillRegistry = SkillRegistry(skills_dir) - - # Discover skills BEFORE super().__init__ so we can augment the enabled - # tool set below — the materialization pipeline runs inside - # super().__init__ (which calls _create_agent at its tail). - if self._db_mode: - records = _fetch_skill_records(accessible_skill_ids or []) - self._registry.load_records(records) - else: - self._registry.discover_skills() - - # Skill-as-grant: a granted skill's bound catalog tools become available - # whenever this agent runs, independent of the user's per-tool - # enabled_tools. Fold them into the enabled set so they materialize. - original_enabled_tools = kwargs.get("enabled_tools") - bound_ids = self._registry.all_bound_tool_ids() - if bound_ids: - existing = list(original_enabled_tools or []) - kwargs["enabled_tools"] = list(dict.fromkeys(existing + bound_ids)) - - super().__init__(**kwargs) - - # Resume hashes the construction snapshot's enabled_tools into the - # cache key. The augmentation above is recomputed deterministically by - # this same constructor, so the snapshot must store the ORIGINAL - # (route-supplied) enabled_tools — not the augmented set — or a resume - # would land on a different cache slot and orphan the paused agent - # (same hazard the system_prompt snapshot avoids). The cache key on the - # live turn already uses the original enabled_tools + skills_hash. - self._construction_snapshot["enabled_tools"] = original_enabled_tools - - def _create_agent(self) -> None: - """Create the Strands Agent with skill disclosure instead of raw tool schemas.""" - try: - # Step 1: Materialize the (possibly augmented) tool universe. - all_tools = self._build_filtered_tools() - - # Step 2: Degrade to plain ChatAgent when there are no skills. - if self._registry.get_skill_count() == 0: - logger.info( - "No skills available — falling back to standard ChatAgent behavior" - ) - hooks = self._create_hooks() - self.agent = AgentFactory.create_agent( - model_config=self.model_config, - system_prompt=self.system_prompt, - tools=all_tools, - session_manager=self.session_manager, - hooks=hooks, - ) - return - - # Step 3: Bind tools to skills. - if self._db_mode: - self._bind_catalog_tools() # local tools (by catalog id) - self._bind_mcp_tools() # gateway / external MCP tools (folded) - else: - self._registry.bind_tools(all_tools) - - # Step 4: Fold skill-bound tools out of the top-level list (matched - # by object identity — the bound objects are the same instances the - # tool filter materialized). - skill_tool_ids = set() - for skill_name in self._registry.get_skill_names(): - for tool_obj in self._registry.get_tools(skill_name): - skill_tool_ids.add(id(tool_obj)) - non_skill_tools = [t for t in all_tools if id(t) not in skill_tool_ids] - - # Step 5: Build per-agent meta-tools bound to THIS registry (no - # process-global — safe for concurrent per-user skills). - dispatcher, executor = make_skill_tools(self._registry) - final_tools = non_skill_tools + [dispatcher, executor] - - # Step 6: Inject the skill catalog into the system prompt. - catalog = self._registry.get_catalog() - if catalog: - if isinstance(self.system_prompt, str): - self.system_prompt = self.system_prompt + "\n\n" + catalog - elif isinstance(self.system_prompt, list): - self.system_prompt.append({"text": "\n\n" + catalog}) - - logger.info( - "SkillAgent created: %d skills (%s), %d non-skill tools, " - "2 meta-tools (dispatcher + executor)", - self._registry.get_skill_count(), - "db" if self._db_mode else "file", - len(non_skill_tools), - ) - - # Step 7: Create the agent. - hooks = self._create_hooks() - self.agent = AgentFactory.create_agent( - model_config=self.model_config, - system_prompt=self.system_prompt, - tools=final_tools, - session_manager=self.session_manager, - hooks=hooks, - ) - - except Exception as e: - logger.error(f"Error creating skill agent: {e}") - raise - - def _bind_catalog_tools(self) -> None: - """Resolve each DB skill's bound LOCAL catalog tool ids to live objects. - - Local tools resolve via the agent's tool registry (the same instances - the tool filter materialized), so they fold cleanly behind the meta- - tools by object identity. Gateway / external MCP bound ids don't resolve - to individual objects here (they're live client objects) and are handled - by :meth:`_bind_mcp_tools`. - """ - catalog_map: dict = {} - for tid in self._registry.all_bound_tool_ids(): - if self.tool_registry.has_tool(tid): - catalog_map[tid] = self.tool_registry.get_tool(tid) - - self._registry.bind_catalog_tools(catalog_map) - - def _bind_mcp_tools(self) -> None: - """Fold a granted skill's gateway / external MCP bound tools (PR-6b). - - These materialize as *client objects* (one ``MCPClient`` per server, - exposing many tools), not individual callables — which is why PR-6a left - them visible. Here each bound non-local id is classified (gateway vs - external), resolved to its concrete MCP tool(s) + owning client, wrapped - as a ``FoldedMCPTool`` bound into the registry (so the meta-tools can - show its schema and run it), and its agent-facing name is folded off the - client's model tool list. The client object stays in the agent's tool - list, so Strands keeps its session alive for ``call_tool_sync``. - """ - non_local = [ - tid - for tid in self._registry.all_bound_tool_ids() - if not self.tool_registry.has_tool(tid) - ] - if not non_local: - return - - # Classify the non-local bound ids with the same filter that - # materialized the clients (its external set was populated from the - # augmented enabled_tools in __init__). - classified = self.tool_filter.filter_tools_extended(non_local) - gateway_ids = classified.gateway_tool_ids - external_ids = classified.external_mcp_tool_ids - if not gateway_ids and not external_ids: - return - - from agents.main_agent.integrations.external_mcp_client import ( - get_external_mcp_integration, - ) - from agents.main_agent.integrations.mcp_tool_folding import ( - reset_folded_tool_names, - set_folded_tool_names, - ) - from agents.main_agent.skills.mcp_binding import resolve_mcp_bindings - - external_integration = get_external_mcp_integration() - - # Clients are process-global and reused across agent builds; a prior - # build's fold persists on them (set_folded_tool_names only adds). - # resolve_mcp_bindings enumerates an external server through that same - # fold-filtered list_tools_sync, so a stale fold makes this re-bind see - # zero tools (the bound tool "works once, then disappears"). Reset each - # client this build will resolve so enumeration sees the full server; - # the fold is recomputed and re-applied from the bindings just below. - gateway_client = self.gateway_integration.client - if gateway_client is not None: - reset_folded_tool_names(gateway_client) - seen_clients: set = set() - for tid in external_ids: - client = external_integration.get_client(tid, self.user_id) - if client is not None and id(client) not in seen_clients: - seen_clients.add(id(client)) - reset_folded_tool_names(client) - - bindings = resolve_mcp_bindings( - gateway_ids=gateway_ids, - external_ids=external_ids, - gateway_client=gateway_client, - expand_gateway=self._expand_gateway_tool_ids, - external_client_lookup=lambda tid: external_integration.get_client( - tid, self.user_id - ), - ) - - if bindings.catalog_map: - self._registry.bind_catalog_tools(bindings.catalog_map) - for client, names in bindings.fold_by_client.items(): - set_folded_tool_names(client, names) - - folded_count = sum(len(v) for v in bindings.catalog_map.values()) - logger.info( - "SkillAgent folded %d gateway/external MCP tool(s) behind the meta-" - "tools (%d unresolved)", - folded_count, - len(bindings.unresolved), - ) - - def _build_tool_use_provider_lookup(self): - """See through the skill fold for the OAuth consent gate. - - Skill-bound external MCP tools execute via ``skill_executor``, so the - consent hook's ``provider_lookup`` (keyed on ``MCPAgentTool``) can't - map them. This resolver reads the executor's tool_use input and maps - the folded tool's owning client back to its OAuth provider, so an - unauthorized call pauses the turn with ``oauth_required`` exactly - like a directly-enabled tool would. - """ - from agents.main_agent.integrations.external_mcp_client import ( - get_external_mcp_integration, - ) - from agents.main_agent.skills.mcp_binding import ( - make_folded_tool_provider_lookup, - ) - - integration = get_external_mcp_integration() - return make_folded_tool_provider_lookup( - self._registry, integration.provider_for_client - ) - - def _build_tool_use_approval_lookup(self): - """See through the skill fold for the per-tool approval gate. - - Skill-bound external MCP tools execute via ``skill_executor``, so the - approval hook's ``approval_names_lookup`` (keyed on ``MCPAgentTool``) - can't gate them — an admin's ``needs_approval`` flag was silently - bypassed in skills mode. This resolver reads the executor's tool_use - input and checks the folded tool against its owning client's flagged - set, so the user sees the same approval prompt (describing the inner - tool, not the executor) as a directly-enabled tool would raise. - """ - from agents.main_agent.integrations.external_mcp_client import ( - get_external_mcp_integration, - ) - from agents.main_agent.skills.mcp_binding import ( - make_folded_tool_approval_lookup, - ) - - integration = get_external_mcp_integration() - return make_folded_tool_approval_lookup( - self._registry, integration.approval_names_for_client - ) - - @property - def registry(self) -> Optional[SkillRegistry]: - """Access the skill registry for inspection.""" - return self._registry diff --git a/backend/src/agents/main_agent/skills/__init__.py b/backend/src/agents/main_agent/skills/__init__.py index 3ebe10637..cf3c3cd68 100644 --- a/backend/src/agents/main_agent/skills/__init__.py +++ b/backend/src/agents/main_agent/skills/__init__.py @@ -1,30 +1,32 @@ -""" -Progressive Skill Disclosure System +"""Skills runtime (Skills v2). -Provides a three-level disclosure architecture for managing tool complexity: -- Level 1 (Catalog): Skill names + descriptions injected into system prompt -- Level 2 (Instructions): Full SKILL.md loaded on-demand via skill_dispatcher -- Level 3 (Execution): Tool invocation via skill_executor +Skills are pure knowledge bundles (agentskills.io format) disclosed at runtime +by the vended Strands ``AgentSkills`` plugin — metadata (name + description) is +injected into the system prompt, and full instructions load on demand via the +plugin's ``skills`` activation tool. This package holds the adapter that maps +our DynamoDB ``SkillDefinition`` rows into programmatic ``strands.Skill`` +instances and builds the plugin. -This approach is dramatically more token-efficient than loading all tool schemas -upfront, especially as the number of tools grows. +The homegrown progressive-disclosure engine (``SkillRegistry`` + +``skill_dispatcher``/``skill_executor`` meta-tools + ``SkillAgent``) and the +tool-binding machinery it served are removed — the plugin implements the same +standard natively and skills no longer bind tools. """ -from .decorators import skill, register_skill -from .skill_registry import SkillRegistry -from .skill_tools import ( - make_skill_tools, - skill_dispatcher, - skill_executor, - set_dispatcher_registry, +from .strands_mapping import ( + build_skills_plugin, + build_skills_runtime, + fetch_active_skill_records, + make_read_skill_file_tool, + record_to_strands_skill, + slugify_skill_name, ) __all__ = [ - "skill", - "register_skill", - "SkillRegistry", - "make_skill_tools", - "skill_dispatcher", - "skill_executor", - "set_dispatcher_registry", + "build_skills_plugin", + "build_skills_runtime", + "fetch_active_skill_records", + "make_read_skill_file_tool", + "record_to_strands_skill", + "slugify_skill_name", ] diff --git a/backend/src/agents/main_agent/skills/decorators.py b/backend/src/agents/main_agent/skills/decorators.py deleted file mode 100644 index 63504ca17..000000000 --- a/backend/src/agents/main_agent/skills/decorators.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Skill decorators for tagging tools with skill membership. - -Usage: - @skill("web-search") - @tool - def ddg_web_search(...): ... - - # Or batch registration: - register_skill("visualization", tools=[create_chart, create_graph]) -""" - -from typing import List, Any - - -def _apply_skill_metadata(tool_obj: Any, name: str) -> None: - """Attach skill name metadata to a tool object.""" - tool_obj._skill_name = name - - -def skill(name: str): - """ - Decorator that tags a tool function with a skill name. - - The SkillRegistry uses the _skill_name attribute to bind tools - to their parent skill during discovery. - - Args: - name: Skill identifier (must match a SKILL.md directory name) - """ - def decorator(func): - _apply_skill_metadata(func, name) - return func - return decorator - - -def register_skill(name: str, tools: List[Any]) -> None: - """ - Batch-register multiple tools under a single skill name. - - Args: - name: Skill identifier - tools: List of tool objects to tag - """ - for tool_obj in tools: - _apply_skill_metadata(tool_obj, name) diff --git a/backend/src/agents/main_agent/skills/definitions/canvas-morning-check/SKILL.md b/backend/src/agents/main_agent/skills/definitions/canvas-morning-check/SKILL.md deleted file mode 100644 index 93f62269d..000000000 --- a/backend/src/agents/main_agent/skills/definitions/canvas-morning-check/SKILL.md +++ /dev/null @@ -1,127 +0,0 @@ ---- -name: canvas-morning-check -description: Educator morning course health check for Canvas LMS. Shows submission rates, struggling students, grade distribution, and upcoming deadlines. Trigger phrases include "morning check", "course status", "how are my students", or any start-of-day teaching review. ---- - -# Canvas Morning Check - -A comprehensive course health check for educators using Canvas LMS. Run it at the start of a teaching day or week to surface submission gaps, students who need support, and upcoming deadlines -- then take action directly from the results. - -## Prerequisites - -- **Canvas MCP server** must be running and connected to the agent's MCP client. -- The authenticated user must have an **educator or instructor role** in the target Canvas course(s). -- **FERPA compliance**: Set `ENABLE_DATA_ANONYMIZATION=true` in the Canvas MCP server environment to anonymize student names in all output. When enabled, names render as `Student_xxxxxxxx` hashes. - -## Steps - -### 1. Identify Target Course(s) - -Ask the user which course(s) to check. Accept a course code, Canvas ID, or "all" to iterate through every active course. - -If the user does not specify, prompt: - -> Which course would you like to check? (Or say "all" for all active courses.) - -Use the `list_courses` MCP tool if you need to look up available courses. - -### 2. Collect Recent Submission Data - -For each target course: - -1. Call `list_assignments` to find assignments with a due date in the **past 7 days**. -2. For each recent assignment, call `get_assignment_analytics` to collect: - - Submission rate (submitted / enrolled) - - Average, high, and low scores - - Late submission count - -### 3. Identify Struggling Students - -Call `list_submissions` to retrieve student submission records, then flag students based on these thresholds: - -| Urgency | Criteria | -|---------|----------| -| **Critical** | Missing 3+ assignments in the past 2 weeks, or average grade below 60% | -| **Needs attention** | Missing 2 assignments, or average grade 60--70%, or 3+ late submissions | -| **On track** | All submissions current, grade above 70% | - -Use `get_student_analytics` for deeper per-student analysis when the user requests it. - -### 4. Check Upcoming Deadlines - -Call `list_assignments` filtered to the **next 7 days**. For each upcoming assignment, surface: - -- Assignment name -- Due date and time -- Point value -- Current submission count (if submissions have started) - -### 5. Generate the Status Report - -Present results in a structured format: - -``` -## Course Status: [Course Name] - -### Submission Overview -| Assignment | Due Date | Submitted | Rate | Avg Score | -|------------|----------|-----------|------|-----------| -| Quiz 3 | Feb 24 | 28/32 | 88% | 85.2 | -| Essay 2 | Feb 26 | 25/32 | 78% | -- | - -### Students Needing Support -**Critical (3+ missing):** -- Student_a8f7e23 (missing: Quiz 3, Essay 2, HW 5) - -**Needs Attention (2 missing):** -- Student_c9b21f8 (missing: Essay 2, HW 5) -- Student_d3e45f1 (missing: Quiz 3, Essay 2) - -### Upcoming This Week -- **Mar 3:** Final Project (100 pts) - 5 submitted so far -- **Mar 5:** Discussion 8 (20 pts) - -### Suggested Actions -1. Send reminder to 3 students with critical status -2. Review Essay 2 submissions (78% rate, below average) -3. Post announcement about Final Project deadline -``` - -### 6. Offer Follow-up Actions - -After presenting the report, offer actionable next steps: - -> Would you like me to: -> 1. Draft and send a message to struggling students (uses `send_conversation`) -> 2. Send reminders about upcoming deadlines (uses `send_peer_review_reminders` or `send_conversation`) -> 3. Get detailed analytics for a specific assignment (uses `get_assignment_analytics`) -> 4. Check another course - -If the user selects option 1, use the `send_conversation` MCP tool to message the identified students directly through Canvas. - -## MCP Tools Used - -| Tool | Purpose | -|------|---------| -| `list_courses` | Discover active courses | -| `list_assignments` | Find recent and upcoming assignments | -| `get_assignment_analytics` | Submission rates and score statistics | -| `list_submissions` | Per-student submission records | -| `get_student_analytics` | Detailed per-student performance data | -| `send_conversation` | Message students through Canvas inbox | - -## Example - -**User:** "Morning check for CS 101" - -**Agent:** Runs the workflow above, outputs the status report. - -**User:** "Send a reminder to students missing Quiz 3" - -**Agent:** Calls `send_conversation` to message the identified students with a reminder. - -## Notes - -- When anonymization is enabled, maintain a local mapping of anonymous IDs so follow-up actions (messaging, grading) still target the correct students. -- This skill works best as a weekly routine -- Monday mornings are ideal. -- Pairs well with the `canvas-week-plan` skill for student-facing planning. \ No newline at end of file diff --git a/backend/src/agents/main_agent/skills/definitions/web-search/SKILL.md b/backend/src/agents/main_agent/skills/definitions/web-search/SKILL.md deleted file mode 100644 index 5dd9cb382..000000000 --- a/backend/src/agents/main_agent/skills/definitions/web-search/SKILL.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: web-search -description: Search the web for current information -type: tool ---- - -# Web Search Skill - -Use this skill to search the web for up-to-date information that may not -be in your training data. - -## When to Use - -- User asks about current events or recent information -- User needs real-time data (weather, stock prices, news) -- User asks "what is the latest..." or "search for..." - -## Tools - -| Tool | Purpose | -|------|---------| -| `fetch_url_content` | Fetch and extract content from a URL | - -## Usage Pattern - -1. Use `fetch_url_content` to retrieve content from a specific URL -2. Summarize the relevant information for the user -3. Cite the source URL in your response diff --git a/backend/src/agents/main_agent/skills/mcp_binding.py b/backend/src/agents/main_agent/skills/mcp_binding.py deleted file mode 100644 index 028f618ae..000000000 --- a/backend/src/agents/main_agent/skills/mcp_binding.py +++ /dev/null @@ -1,426 +0,0 @@ -"""Resolve a skill's gateway / external MCP bound tools into foldable adapters. - -PR-6a folded only LOCAL callable tools behind ``skill_dispatcher`` / -``skill_executor``. Gateway and external MCP tools materialize as *client -objects* (one ``MCPClient`` exposing many tools), not individual callables, so -they could be made available but not hidden. This module closes that gap. - -For each non-local ``bound_tool_id`` a skill carries, ``resolve_mcp_bindings``: - -1. classifies it (gateway vs external) — reusing the agent's tool filter; -2. resolves it to the concrete MCP tool name(s) and the owning client — - gateway ids expand via ``expand_gateway_tool_ids`` (catalog ``gateway_`` - → runtime ``gateway____``; the gateway tool name is that with - the ``gateway_`` prefix stripped); external ids map to the per-server client - the external integration already built, whose tools are enumerated live - (its session is active after the build-time ``load_tools`` pre-flight); -3. wraps each as a :class:`FoldedMCPTool` — a lightweight adapter the registry - stores so ``skill_dispatcher`` can show its schema and ``skill_executor`` can - run it through the client — and records its agent-facing name to fold off - the client's model tool list. - -The adapter executes via ``MCPClient.call_tool_sync`` rather than the local -``tool_obj(**input)`` path, because an ``MCPAgentTool`` is not a plain callable. -""" - -from __future__ import annotations - -import json -import logging -import uuid -from typing import Any, Callable, Dict, List, Optional - -logger = logging.getLogger(__name__) - -_GATEWAY_PREFIX = "gateway_" - - -class FoldedMCPTool: - """Adapter exposing one folded gateway/external MCP tool to the registry. - - Duck-types the slice of the tool interface the skill registry + meta-tools - use: ``tool_name`` (matched by ``skill_executor`` and used in catalog/schema - output) and ``tool_spec`` (returned by ``skill_dispatcher`` so the model - learns the parameters). Execution is routed through the MCP client, marked - by ``is_mcp_folded`` so ``skill_executor`` calls :meth:`invoke` instead of - treating it as a plain callable. - """ - - is_mcp_folded = True - - def __init__( - self, - client: Any, - mcp_tool_name: str, - agent_tool_name: Optional[str] = None, - tool_spec: Optional[Dict[str, Any]] = None, - ) -> None: - """ - Args: - client: The ``MCPClient`` (gateway or external) that hosts the tool. - mcp_tool_name: Server-side tool name, passed to ``call_tool_sync``. - agent_tool_name: Agent-facing name (what the model calls). Defaults - to ``mcp_tool_name`` (true for gateway; external tools may - disambiguate, so it is captured explicitly there). - tool_spec: Captured tool spec, when known at build time (external). - When None (gateway, resolved without a live session), the spec - is fetched lazily from the client on first access. - """ - self._client = client - self._mcp_tool_name = mcp_tool_name - self._agent_tool_name = agent_tool_name or mcp_tool_name - self._tool_spec = tool_spec - - @property - def tool_name(self) -> str: - return self._agent_tool_name - - @property - def client(self) -> Any: - """The owning ``MCPClient`` — lets the OAuth consent gate map this - folded tool back to its provider via ``provider_for_client``.""" - return self._client - - @property - def tool_spec(self) -> Dict[str, Any]: - """The tool's parameter spec, resolved lazily for gateway tools. - - Lazy resolution lists the client (its session is live during the agent - loop, when ``skill_dispatcher`` runs) and caches the match. Falls back - to a name-only spec so the dispatcher always returns something usable. - """ - if self._tool_spec is not None: - return self._tool_spec - - spec: Dict[str, Any] = {"name": self._agent_tool_name} - try: - for t in self._client.list_tools_sync(): - if getattr(t, "tool_name", None) == self._agent_tool_name: - resolved = getattr(t, "tool_spec", None) - if resolved: - spec = resolved - break - except Exception: # noqa: BLE001 - never break dispatch on a list failure - logger.debug( - "Could not resolve tool_spec for folded MCP tool %s", - self._agent_tool_name, - exc_info=True, - ) - self._tool_spec = spec - return spec - - def invoke(self, tool_input: Optional[dict]) -> Any: - """Execute the tool through the MCP client. - - Synthesizes a ``tool_use_id`` (the executor path has none) and reduces - the structured ``MCPToolResult`` to text/JSON the model can read. - - Failures return a ToolResult-shaped dict (``status: error``) rather - than a plain string: ``skill_executor`` returns it verbatim and - Strands' ``@tool`` decorator passes status+content dicts through - unchanged, so the error status survives the fold. Without it every - folded failure surfaced as a *success*-status result and the OAuth - consent hook's 401-retry heuristic (gated on ``status == "error"``) - could never fire for skill-bound tools. - """ - try: - result = self._client.call_tool_sync( - tool_use_id=f"skill-{uuid.uuid4().hex}", - name=self._mcp_tool_name, - arguments=tool_input or {}, - ) - except Exception as e: # noqa: BLE001 - surface as a tool error, not a crash - logger.error( - "Folded MCP tool %s failed: %s", self._agent_tool_name, e, - exc_info=True, - ) - return _error_tool_result(json.dumps({"error": str(e)})) - - status = ( - result.get("status") - if isinstance(result, dict) - else getattr(result, "status", None) - ) - text = _stringify_mcp_result(result) - if status == "error": - return _error_tool_result(text) - return text - - -def _error_tool_result(text: str) -> dict: - """ToolResult-shaped error for the executor to return as-is.""" - return {"status": "error", "content": [{"text": text}]} - - -def _stringify_mcp_result(result: Any) -> str: - """Reduce an ``MCPToolResult`` to a string for the model. - - Joins text content blocks; falls back to a JSON dump of the content (or the - whole result) so non-text results still surface something. ``MCPToolResult`` - is a TypedDict (``{"content": [...], "status": ...}``); handle dict-shaped - and attribute-shaped results defensively. - """ - content = None - if isinstance(result, dict): - content = result.get("content") - else: - content = getattr(result, "content", None) - - if isinstance(content, list): - texts: List[str] = [] - for block in content: - text = block.get("text") if isinstance(block, dict) else getattr(block, "text", None) - if isinstance(text, str): - texts.append(text) - if texts: - return "\n".join(texts) - try: - return json.dumps(content, default=str) - except (TypeError, ValueError): - return str(content) - - try: - return json.dumps(result, default=str) - except (TypeError, ValueError): - return str(result) - - -def _resolve_folded_tool(registry: Any, tool_use: dict) -> Optional[FoldedMCPTool]: - """Resolve a ``skill_executor`` tool_use to the bound :class:`FoldedMCPTool`. - - Shared by the OAuth-consent and tool-approval second-chance lookups: - reads ``skill_name`` / ``tool_name`` from the executor's tool_use input - and finds the matching folded tool in the registry. Returns None for - anything that isn't an executor call over a known skill's folded MCP - tool (local callables, unknown skills, malformed input). - """ - from agents.main_agent.skills.skill_tools import SKILL_EXECUTOR_TOOL_NAME - - if (tool_use or {}).get("name") != SKILL_EXECUTOR_TOOL_NAME: - return None - tool_input = tool_use.get("input") or {} - if not isinstance(tool_input, dict): - return None - skill_name = tool_input.get("skill_name") - tool_name = tool_input.get("tool_name") - if not skill_name or not tool_name: - return None - try: - tools = registry.get_tools(skill_name) - except Exception: # noqa: BLE001 - unknown skill → nothing to gate - return None - for t in tools or []: - if not getattr(t, "is_mcp_folded", False): - continue - if getattr(t, "tool_name", None) != tool_name: - continue - return t - return None - - -def make_folded_tool_provider_lookup( - registry: Any, provider_for_client: Callable[[Any], Optional[str]] -) -> Callable[[dict], Optional[str]]: - """Build the OAuth consent gate's tool_use → provider resolver for skills. - - A skill-bound external MCP tool executes through the ``skill_executor`` - meta-tool, so the consent hook's ``provider_lookup`` (which keys off the - selected tool being an ``MCPAgentTool``) can't see it — the OAuth gate - silently never fired for skills mode, and an unauthorized tool ran - tokenless instead of pausing the turn with ``oauth_required``. This - resolver gives the hook a second chance: it finds the bound - :class:`FoldedMCPTool` and maps its owning client to a provider via - ``provider_for_client`` (gateway clients aren't in that map, so they - resolve to None — correct, they auth with SigV4, not user OAuth). - - Resolution is lazy (registry consulted per call), so building the lookup - before bindings exist is safe. - """ - - def lookup(tool_use: dict) -> Optional[str]: - folded = _resolve_folded_tool(registry, tool_use) - if folded is None: - return None - return provider_for_client(folded.client) - - return lookup - - -def make_folded_tool_approval_lookup( - registry: Any, approval_names_for_client: Callable[[Any], set] -) -> Callable[[dict], Optional[Any]]: - """Build the approval gate's tool_use → flagged-target resolver for skills. - - Mirrors :func:`make_folded_tool_provider_lookup` for the per-tool - approval hook: a skill-bound external MCP tool runs behind - ``skill_executor``, so the hook's ``approval_names_lookup`` (keyed on the - selected tool being an ``MCPAgentTool``) can't see the admin's - ``needs_approval`` flag and the call ran without the user prompt. This - resolver finds the bound :class:`FoldedMCPTool`, checks its agent-facing - name against the owning client's flagged set (same name the direct path - matches when the tool is enabled outside a skill), and returns the inner - tool's name + args so the approval dialog describes the real tool, not - the executor. Returns None when the folded target isn't flagged. - - Resolution is lazy (registry consulted per call), so building the lookup - before bindings exist is safe. - """ - - def lookup(tool_use: dict) -> Optional[Any]: - from agents.main_agent.session.hooks.tool_approval import FoldedToolApproval - - folded = _resolve_folded_tool(registry, tool_use) - if folded is None: - return None - if folded.tool_name not in approval_names_for_client(folded.client): - return None - return FoldedToolApproval( - tool_name=folded.tool_name, - tool_input=(tool_use.get("input") or {}).get("tool_input"), - ) - - return lookup - - -class MCPBindingResult: - """Outcome of :func:`resolve_mcp_bindings`. - - Attributes: - catalog_map: ``{catalog_tool_id -> [FoldedMCPTool, ...]}`` to hand to - ``SkillRegistry.bind_catalog_tools`` (a catalog id can expand to - several tools — a gateway target or external server with many). - fold_by_client: ``{client -> {agent_tool_name, ...}}`` to apply with - ``set_folded_tool_names`` so the bound tools drop off the model - tool list. - unresolved: catalog ids that could not be resolved (gateway disabled, - external client absent, …) — left visible/unfolded and logged. - """ - - def __init__(self) -> None: - self.catalog_map: Dict[str, List[FoldedMCPTool]] = {} - self.fold_by_client: Dict[Any, set] = {} - self.unresolved: List[str] = [] - - def _add(self, catalog_id: str, client: Any, tool: FoldedMCPTool) -> None: - self.catalog_map.setdefault(catalog_id, []).append(tool) - self.fold_by_client.setdefault(client, set()).add(tool.tool_name) - - -def resolve_mcp_bindings( - *, - gateway_ids: List[str], - external_ids: List[str], - gateway_client: Any, - expand_gateway: Callable[[List[str]], List[str]], - external_client_lookup: Callable[[str], Any], -) -> MCPBindingResult: - """Resolve gateway/external bound catalog ids to foldable MCP tools. - - Args: - gateway_ids: bound catalog ids classified as gateway. - external_ids: bound catalog ids classified as external MCP. - gateway_client: the single live gateway ``MCPClient`` (or None when - gateway is disabled / no gateway tools were materialized). - expand_gateway: maps catalog ``gateway_`` → runtime - ``gateway____`` ids (``BaseAgent._expand_gateway_tool_ids``). - external_client_lookup: maps an external catalog ``tool_id`` to its live - ``MCPClient`` (or None) — typically - ``lambda tid: integration.get_client(tid, user_id)``. - """ - result = MCPBindingResult() - - _resolve_gateway(result, gateway_ids, gateway_client, expand_gateway) - _resolve_external(result, external_ids, external_client_lookup) - - if result.unresolved: - logger.info( - "Skill-bound MCP tools could not be folded (kept visible): %s", - result.unresolved, - ) - return result - - -def _resolve_gateway( - result: MCPBindingResult, - gateway_ids: List[str], - gateway_client: Any, - expand_gateway: Callable[[List[str]], List[str]], -) -> None: - if not gateway_ids: - return - if gateway_client is None: - result.unresolved.extend(gateway_ids) - return - - for catalog_id in gateway_ids: - # A catalog gateway id can expand to several runtime per-tool ids; an - # already-expanded RBAC-direct id (`gateway____`) maps to - # itself. The gateway tool name is the runtime id minus the prefix. - try: - runtime_ids = expand_gateway([catalog_id]) - except Exception: # noqa: BLE001 - degrade to unresolved on lookup failure - logger.warning( - "Could not expand gateway id %s for skill binding", - catalog_id, - exc_info=True, - ) - result.unresolved.append(catalog_id) - continue - - resolved_any = False - for rid in runtime_ids: - tool_name = rid[len(_GATEWAY_PREFIX):] if rid.startswith(_GATEWAY_PREFIX) else rid - result._add( - catalog_id, - gateway_client, - FoldedMCPTool(gateway_client, mcp_tool_name=tool_name), - ) - resolved_any = True - if not resolved_any: - result.unresolved.append(catalog_id) - - -def _resolve_external( - result: MCPBindingResult, - external_ids: List[str], - external_client_lookup: Callable[[str], Any], -) -> None: - for catalog_id in external_ids: - client = external_client_lookup(catalog_id) - if client is None: - result.unresolved.append(catalog_id) - continue - - # Binding an external catalog id binds its whole MCP server; enumerate - # the server's tools (session is live post-load_tools) so each folds - # individually and carries its captured spec. - try: - tools = list(client.list_tools_sync()) - except Exception: # noqa: BLE001 - degrade to unresolved on list failure - logger.warning( - "Could not list external MCP tools for skill binding (%s)", - catalog_id, - exc_info=True, - ) - result.unresolved.append(catalog_id) - continue - - if not tools: - result.unresolved.append(catalog_id) - continue - - for t in tools: - agent_name = getattr(t, "tool_name", None) - if not agent_name: - continue - mcp_name = getattr(getattr(t, "mcp_tool", None), "name", None) or agent_name - result._add( - catalog_id, - client, - FoldedMCPTool( - client, - mcp_tool_name=mcp_name, - agent_tool_name=agent_name, - tool_spec=getattr(t, "tool_spec", None), - ), - ) diff --git a/backend/src/agents/main_agent/skills/skill_registry.py b/backend/src/agents/main_agent/skills/skill_registry.py deleted file mode 100644 index e8552cf46..000000000 --- a/backend/src/agents/main_agent/skills/skill_registry.py +++ /dev/null @@ -1,478 +0,0 @@ -""" -Skill Registry — single source of truth for skill discovery and access. - -Scans a skills definitions directory for SKILL.md files, parses their -frontmatter for metadata, and provides three levels of access: - -- Level 1: get_catalog() → lightweight listing for system prompt injection -- Level 2: load_instructions(name) → full SKILL.md body on demand -- Level 3: get_tools(name) → executable tool objects - -Based on the progressive disclosure pattern from: -https://github.com/aws-samples/sample-strands-agent-with-agentcore -""" - -import logging -import os -import re -from pathlib import Path -from typing import Any, Dict, List, Optional - -logger = logging.getLogger(__name__) - -# Regex for parsing YAML frontmatter (no PyYAML dependency) -_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL) -_YAML_LINE_RE = re.compile(r"^(\w+):\s*(.*)$") -_YAML_LIST_ITEM_RE = re.compile(r"^\s*-\s+(.+)$") - - -def _ref_attr(ref: Any, name: str) -> Any: - """Read a reference-manifest field, tolerating pydantic objects or dicts. - - Records loaded from the repository carry ``SkillResourceRef`` objects; - duck-typed test stand-ins may pass dicts (snake_case or camelCase). - """ - value = getattr(ref, name, None) - if value is None and isinstance(ref, dict): - value = ref.get(name) - return value - - -class SkillRegistry: - """ - Registry for discovering and managing agent skills. - - Skills are defined by SKILL.md files in a directory structure: - definitions/ - ├── web-search/ - │ └── SKILL.md - ├── visualization/ - │ └── SKILL.md - └── ... - - Each SKILL.md has YAML frontmatter: - --- - name: web-search - description: Search the web using DuckDuckGo - type: tool - --- - - """ - - def __init__(self, skills_dir: Optional[str] = None): - """ - Initialize the registry. - - Args: - skills_dir: Path to skills definitions directory. - Defaults to ./definitions/ relative to this module. - """ - if skills_dir is None: - skills_dir = os.path.join(os.path.dirname(__file__), "definitions") - self._skills_dir = skills_dir - self._skills: Dict[str, Dict[str, Any]] = {} - - def discover_skills(self) -> int: - """ - Scan the skills directory for SKILL.md files. - - Returns: - int: Number of skills discovered - """ - if not os.path.isdir(self._skills_dir): - logger.warning(f"Skills directory not found: {self._skills_dir}") - return 0 - - count = 0 - for entry in sorted(os.listdir(self._skills_dir)): - skill_dir = os.path.join(self._skills_dir, entry) - skill_md = os.path.join(skill_dir, "SKILL.md") - - if not os.path.isfile(skill_md): - continue - - try: - with open(skill_md, "r", encoding="utf-8") as f: - content = f.read() - - meta = self._parse_frontmatter(content) - name = meta.get("name", entry) - - self._skills[name] = { - "description": meta.get("description", ""), - "type": meta.get("type", "tool"), - "compose": meta.get("compose", []), - "tools": [], - "bound_tool_ids": [], - "resources": [], - # File mode: instructions live in the SKILL.md body, read - # on demand from md_path (kept None-instructions so - # load_instructions takes the file path). - "instructions": None, - "path": skill_dir, - "md_path": skill_md, - } - count += 1 - logger.info(f"Discovered skill: {name}") - - except Exception as e: - logger.error(f"Error parsing {skill_md}: {e}") - - logger.info(f"Discovered {count} skills from {self._skills_dir}") - return count - - def load_records(self, records: List[Any]) -> int: - """Populate the registry from DynamoDB-backed skill records. - - The admin-managed (DB) source of skills (PR-6). Each record is a - ``SkillDefinition``-shaped object (duck-typed via ``getattr`` so tests - can pass simple stand-ins): ``skill_id``, ``description``, - ``instructions``, ``compose``, ``bound_tool_ids``, ``resources``. - - Unlike ``discover_skills`` (file scan), instructions are carried inline - on the record (no file), and the skill keys on ``skill_id`` — the - stable id the model references in the catalog. Returns the number of - records loaded. - """ - count = 0 - for rec in records: - name = getattr(rec, "skill_id", None) - if not name: - continue - self._skills[name] = { - "description": getattr(rec, "description", "") or "", - "type": "tool", - "compose": list(getattr(rec, "compose", []) or []), - "tools": [], - "bound_tool_ids": list(getattr(rec, "bound_tool_ids", []) or []), - # Reference-file manifest — served on demand in PR-6b. - "resources": list(getattr(rec, "resources", []) or []), - # DB mode: instructions are inline (no md_path file). - "instructions": getattr(rec, "instructions", "") or "", - "path": None, - "md_path": None, - } - count += 1 - logger.info(f"Loaded skill record: {name}") - - logger.info(f"Loaded {count} skill records from repository") - return count - - def all_bound_tool_ids(self) -> List[str]: - """Return the de-duplicated union of every skill's bound catalog - tool ids (admin/DB skills). Used to augment the agent's tool universe - so a granted skill's bound tools materialize (skill-as-grant).""" - seen: Dict[str, None] = {} - for info in self._skills.values(): - for tid in info.get("bound_tool_ids", []) or []: - seen.setdefault(tid, None) - return list(seen.keys()) - - def bind_catalog_tools(self, catalog_map: Dict[str, Any]) -> int: - """Bind tools to skills by catalog ``tool_id`` (admin/DB skills). - - ``catalog_map`` maps a catalog ``tool_id`` to the live tool object the - agent materialized for it — or a *list* of objects, since one catalog - id can expand to several runtime tools (a gateway target or external - MCP server with many tools; see ``mcp_binding.resolve_mcp_bindings``). - For each skill, every ``bound_tool_id`` that resolves in the map is - attached. This is the cross-source-aware analog of ``bind_tools`` (which - matches the local-only ``_skill_name`` stamp): local tools and folded - gateway/external MCP tools both resolve here in PR-6b. - - Returns the number of (skill, tool) bindings made. - """ - bound = 0 - for info in self._skills.values(): - existing_ids = {id(t) for t in info["tools"]} - for tid in info.get("bound_tool_ids", []) or []: - value = catalog_map.get(tid) - if value is None: - continue - tool_objs = value if isinstance(value, list) else [value] - for tool_obj in tool_objs: - if id(tool_obj) not in existing_ids: - info["tools"].append(tool_obj) - existing_ids.add(id(tool_obj)) - bound += 1 - logger.info(f"Bound {bound} catalog tools across {len(self._skills)} skills") - return bound - - def bind_tools(self, tools: List[Any]) -> int: - """ - Attach tool objects to their parent skills. - - Tools are matched by the _skill_name attribute set by the @skill decorator - or register_skill() function. - - Args: - tools: List of tool objects (functions with _skill_name metadata) - - Returns: - int: Number of tools bound - """ - bound = 0 - for tool_obj in tools: - skill_name = getattr(tool_obj, "_skill_name", None) - if skill_name and skill_name in self._skills: - self._skills[skill_name]["tools"].append(tool_obj) - bound += 1 - logger.debug(f"Bound tool to skill '{skill_name}'") - - logger.info(f"Bound {bound} tools to {len(self._skills)} skills") - return bound - - def get_catalog(self) -> str: - """ - Generate Level 1 catalog for system prompt injection. - - Returns a lightweight listing of skill names and descriptions, - designed to be token-efficient while giving the LLM enough - information to decide which skill to activate. - - Returns: - str: Markdown-formatted skill catalog - """ - if not self._skills: - return "" - - lines = ["## Available Skills", ""] - lines.append("Use `skill_dispatcher` to activate a skill and get its instructions.") - lines.append("Use `skill_executor` to run a skill's tools.") - lines.append("") - - for name, info in sorted(self._skills.items()): - desc = info["description"] - tool_count = len(info["tools"]) - compose = info.get("compose", []) - - if compose: - lines.append(f"- **{name}**: {desc} _(combines: {', '.join(compose)})_") - elif tool_count > 0: - lines.append(f"- **{name}**: {desc} ({tool_count} tools)") - else: - lines.append(f"- **{name}**: {desc}") - - return "\n".join(lines) - - def load_instructions(self, skill_name: str) -> Optional[str]: - """ - Load Level 2 instructions (SKILL.md body without frontmatter). - - Args: - skill_name: Skill identifier - - Returns: - str: Markdown instructions, or None if skill not found - """ - skill = self._skills.get(skill_name) - if not skill: - return None - - # DB mode: instructions are carried inline on the record (no file). - if not skill.get("md_path"): - return skill.get("instructions") or "" - - try: - with open(skill["md_path"], "r", encoding="utf-8") as f: - content = f.read() - return self._strip_frontmatter(content) - except Exception as e: - logger.error(f"Error loading instructions for '{skill_name}': {e}") - return None - - def get_resource_names(self, skill_name: str) -> List[str]: - """List a skill's supporting reference filenames (Level-2.5). - - These are the deep progressive-disclosure files (e.g. ``forms.md``) - that the SKILL.md body refers to. ``skill_dispatcher`` surfaces this - list so the model knows which files it may read; the bytes live in S3 - (see :meth:`read_resource`). Empty for file/dev skills (no manifest). - """ - skill = self._skills.get(skill_name) - if not skill: - return [] - names: List[str] = [] - for ref in skill.get("resources", []) or []: - filename = _ref_attr(ref, "filename") - if filename: - names.append(filename) - return names - - def read_resource( - self, skill_name: str, filename: str, store: Optional[Any] = None - ) -> Optional[Dict[str, Any]]: - """Fetch one of a skill's reference files on demand (Level-3 disclosure). - - Resolves ``filename`` against the skill's ``resources`` manifest and - reads the bytes from the S3-backed ``SkillResourceStore`` (the runtime - was granted read access to the skill-resources bucket in PR-6a). Returns - a dict the dispatcher serializes: - - - ``{"filename", "content_type", "content"}`` for text; - - ``{"filename", "content_type", "size", "note"}`` for binary - (never dumps raw bytes into the model context); - - ``{"error": ...}`` if the file is missing or storage is unavailable. - - ``None`` only when the skill itself is unknown. ``store`` is injectable - for tests; defaults to the process-global store. - """ - skill = self._skills.get(skill_name) - if not skill: - return None - - ref = None - for candidate in skill.get("resources", []) or []: - if _ref_attr(candidate, "filename") == filename: - ref = candidate - break - if ref is None: - return {"error": f"Reference file '{filename}' not found in skill '{skill_name}'"} - - s3_key = _ref_attr(ref, "s3_key") or _ref_attr(ref, "s3Key") - content_type = _ref_attr(ref, "content_type") or _ref_attr(ref, "contentType") or "" - size = _ref_attr(ref, "size") - if not s3_key: - return {"error": f"Reference file '{filename}' has no storage key"} - - from apis.shared.skills.resource_store import ( - SkillResourceStoreError, - get_skill_resource_store, - ) - - store = store or get_skill_resource_store() - try: - data = store.get(s3_key) - except SkillResourceStoreError as e: - logger.warning("Could not read reference '%s' for skill '%s': %s", filename, skill_name, e) - return {"error": f"Could not read reference file '{filename}': {e}"} - - try: - text = data.decode("utf-8") - except (UnicodeDecodeError, AttributeError): - return { - "filename": filename, - "content_type": content_type or "application/octet-stream", - "size": size if size is not None else len(data), - "note": "Binary reference file — not rendered as text.", - } - return { - "filename": filename, - "content_type": content_type or "text/markdown", - "content": text, - } - - def get_tools(self, skill_name: str) -> List[Any]: - """ - Get Level 3 tool objects for a skill. - - For composite skills, aggregates tools from all composed skills. - - Args: - skill_name: Skill identifier - - Returns: - list: Tool objects, empty if skill not found - """ - skill = self._skills.get(skill_name) - if not skill: - return [] - - # For composite skills, aggregate tools from composed skills - compose = skill.get("compose", []) - if compose: - tools = [] - for child_name in compose: - tools.extend(self.get_tools(child_name)) - return tools - - return list(skill["tools"]) - - def get_tool_schemas(self, skill_name: str) -> List[Dict]: - """ - Get tool parameter schemas for a skill's tools. - - Extracts the tool_spec from each tool for inclusion in - skill_dispatcher responses, so the LLM knows what parameters - to pass to skill_executor. - - Args: - skill_name: Skill identifier - - Returns: - list: Tool specification dicts - """ - tools = self.get_tools(skill_name) - schemas = [] - for tool_obj in tools: - spec = getattr(tool_obj, "tool_spec", None) - if spec: - schemas.append(spec) - elif hasattr(tool_obj, "tool_name"): - schemas.append({"name": tool_obj.tool_name}) - return schemas - - def has_skill(self, skill_name: str) -> bool: - """Check if a skill is registered.""" - return skill_name in self._skills - - def get_skill_names(self) -> List[str]: - """Get all registered skill names.""" - return list(self._skills.keys()) - - def get_skill_count(self) -> int: - """Get total number of registered skills.""" - return len(self._skills) - - # --- Internal helpers --- - - @staticmethod - def _parse_frontmatter(content: str) -> Dict[str, Any]: - """Parse YAML frontmatter from a SKILL.md file (no PyYAML dependency).""" - match = _FRONTMATTER_RE.match(content) - if not match: - return {} - - result = {} - current_key = None - current_list = None - - for line in match.group(1).splitlines(): - # Check for key: value pair - kv_match = _YAML_LINE_RE.match(line) - if kv_match: - if current_key and current_list is not None: - result[current_key] = current_list - - key = kv_match.group(1) - value = kv_match.group(2).strip().strip('"').strip("'") - current_key = key - current_list = None - - if value: - result[key] = value - else: - # Empty value — next lines may be a list - result[key] = "" - continue - - # Check for list item - list_match = _YAML_LIST_ITEM_RE.match(line) - if list_match and current_key: - if current_list is None: - current_list = [] - result[current_key] = current_list - current_list.append(list_match.group(1).strip()) - - if current_key and current_list is not None: - result[current_key] = current_list - - return result - - @staticmethod - def _strip_frontmatter(content: str) -> str: - """Remove YAML frontmatter from content, returning the markdown body.""" - match = _FRONTMATTER_RE.match(content) - if match: - return content[match.end():].strip() - return content.strip() diff --git a/backend/src/agents/main_agent/skills/skill_tools.py b/backend/src/agents/main_agent/skills/skill_tools.py deleted file mode 100644 index fa8cf6abb..000000000 --- a/backend/src/agents/main_agent/skills/skill_tools.py +++ /dev/null @@ -1,272 +0,0 @@ -""" -Skill tools — LLM-callable tools for progressive skill disclosure. - -Two tools exposed to the agent: -- skill_dispatcher: Load a skill's instructions and tool schemas (L1 → L2) -- skill_executor: Execute a skill's tool with given input (L2 → L3) - -These tools are registered with the Strands Agent in place of the individual -skill tools, dramatically reducing the upfront token cost. - -Two ways to obtain the tools: - -- ``make_skill_tools(registry)`` returns a fresh ``(dispatcher, executor)`` - pair bound to one specific registry via closure. This is what ``SkillAgent`` - uses, so each agent's meta-tools resolve against **its own** registry. That - matters once skills are per-user (admin/DB-backed, RBAC-filtered): a process - serves many users concurrently, and a shared module-level registry would let - one user's invocation read another user's skills. -- The module-level ``skill_dispatcher`` / ``skill_executor`` + a process-global - registry set by ``set_dispatcher_registry`` remain for the file-based dev - path and existing callers. Safe there because every file registry is - identical; do NOT use this path for per-user skills. -""" - -import asyncio -import json -import logging -from typing import Any - -from strands import tool - -logger = logging.getLogger(__name__) - -# Agent-facing name of the executor meta-tool (both the module-level tool and -# the per-registry pairs from make_skill_tools use this function name). The -# OAuth consent gate matches tool_use dicts against it to see through the -# fold (see skills/mcp_binding.make_folded_tool_provider_lookup). -SKILL_EXECUTOR_TOOL_NAME = "skill_executor" - -# Module-level registry reference, set by set_dispatcher_registry(). Used only -# by the module-level skill_dispatcher/skill_executor (file/dev path). -_registry = None - - -def set_dispatcher_registry(registry: Any) -> None: - """ - Wire up the SkillRegistry for the module-level dispatcher and executor. - - Must be called before invoking the module-level skill_dispatcher or - skill_executor. NOTE: this is process-global; for per-user (admin/DB) - skills use ``make_skill_tools(registry)`` instead, which binds a registry - per agent and avoids cross-user bleed under concurrency. - - Args: - registry: SkillRegistry instance - """ - global _registry - _registry = registry - - -def _dispatch(registry: Any, skill_name: str, reference: str = "", source: str = "") -> str: - """Core skill_dispatcher logic against an explicit registry.""" - if not registry.has_skill(skill_name): - available = ", ".join(registry.get_skill_names()) - return json.dumps({ - "error": f"Unknown skill '{skill_name}'", - "available_skills": available, - }) - - # Deep progressive disclosure: when a reference filename is given, serve - # that supporting file's bytes from S3 instead of the instructions block. - if reference: - ref_result = registry.read_resource(skill_name, reference) - if ref_result is None or "error" in (ref_result or {}): - return json.dumps({ - "error": (ref_result or {}).get("error") - or f"Reference file '{reference}' not found in skill '{skill_name}'", - "available_references": registry.get_resource_names(skill_name), - }) - return json.dumps(ref_result, default=str) - - result = {} - - # Load Level 2 instructions - instructions = registry.load_instructions(skill_name) - if instructions: - result["instructions"] = instructions - - # Load tool schemas so the LLM knows what parameters to pass - schemas = registry.get_tool_schemas(skill_name) - if schemas: - result["tool_schemas"] = schemas - - # Surface the skill's supporting reference files so the model knows what it - # can read on demand (its instructions typically name them, e.g. "see - # forms.md"). Read one by calling skill_dispatcher again with `reference=`. - references = registry.get_resource_names(skill_name) - if references: - result["available_references"] = references - result["reference_hint"] = ( - "Call skill_dispatcher again with reference= to read one " - "of these supporting files." - ) - - if not result: - result["error"] = f"No instructions or tools found for skill '{skill_name}'" - - return json.dumps(result, default=str) - - -def _execute(registry: Any, skill_name: str, tool_name: str, tool_input: Any = None) -> Any: - """Core skill_executor logic against an explicit registry.""" - if not registry.has_skill(skill_name): - return json.dumps({"error": f"Unknown skill '{skill_name}'"}) - - tools = registry.get_tools(skill_name) - if not tools: - return json.dumps({"error": f"No tools found for skill '{skill_name}'"}) - - # Find the matching tool - target_tool = None - for t in tools: - name = getattr(t, "tool_name", getattr(t, "__name__", None)) - if name == tool_name: - target_tool = t - break - - if target_tool is None: - available = [getattr(t, "tool_name", getattr(t, "__name__", "?")) for t in tools] - return json.dumps({ - "error": f"Tool '{tool_name}' not found in skill '{skill_name}'", - "available_tools": available, - }) - - # Parse tool_input if it's a JSON string - if isinstance(tool_input, str): - try: - tool_input = json.loads(tool_input) - except (json.JSONDecodeError, TypeError): - # Not JSON — leave tool_input as its original value. - pass - - if tool_input is None: - tool_input = {} - - # Execute the tool. Folded gateway/external MCP tools (PR-6b) are not plain - # callables — they run through the MCP client via their own invoke(). - try: - if getattr(target_tool, "is_mcp_folded", False): - return target_tool.invoke(tool_input) - return _execute_tool(target_tool, tool_input) - except Exception as e: - logger.error(f"Error executing {skill_name}/{tool_name}: {e}", exc_info=True) - return json.dumps({"error": str(e)}) - - -def make_skill_tools(registry: Any): - """Return a ``(skill_dispatcher, skill_executor)`` pair bound to ``registry``. - - Each pair closes over its own registry, so concurrent agents (different - users / different accessible skills) never share skill state. This is the - per-agent replacement for the module-global ``set_dispatcher_registry``. - """ - - @tool - def skill_dispatcher(skill_name: str, reference: str = "", source: str = "") -> str: - """ - Load a skill's instructions and tool schemas. - - Call this to activate a skill from the Available Skills catalog and - learn how to use it. The response includes the skill's detailed - instructions and the parameter schemas for its tools. - - Args: - skill_name: Name of the skill to activate (from the catalog) - reference: Optional — name of a reference file to read - source: Optional — name of a tool function to view source for - - Returns: - JSON string with the skill's instructions and tool schemas - """ - return _dispatch(registry, skill_name, reference, source) - - @tool - def skill_executor(skill_name: str, tool_name: str, tool_input: Any = None) -> Any: - """ - Execute a tool within an activated skill. - - Call this after skill_dispatcher to run one of the skill's tools. - - Args: - skill_name: Name of the skill containing the tool - tool_name: Name of the specific tool to execute - tool_input: Input parameters for the tool (dict or JSON string) - - Returns: - The tool's execution result - """ - return _execute(registry, skill_name, tool_name, tool_input) - - return skill_dispatcher, skill_executor - - -@tool -def skill_dispatcher(skill_name: str, reference: str = "", source: str = "") -> str: - """ - Load a skill's instructions, tool schemas, and optional reference or source code. - - Call this tool when you want to activate a skill and learn how to use it. - The response includes the skill's detailed instructions (SKILL.md) and - the parameter schemas for its tools. - - Args: - skill_name: Name of the skill to activate (from the Available Skills catalog) - reference: Optional — name of a reference file to read - source: Optional — name of a tool function to view source code for - - Returns: - JSON string with skill instructions, tool schemas, and optional reference/source - """ - if _registry is None: - return json.dumps({"error": "Skill registry not initialized"}) - return _dispatch(_registry, skill_name, reference, source) - - -@tool -def skill_executor(skill_name: str, tool_name: str, tool_input: Any = None) -> Any: - """ - Execute a tool within an activated skill. - - Call this tool after using skill_dispatcher to learn which tools are available - and what parameters they accept. - - Args: - skill_name: Name of the skill containing the tool - tool_name: Name of the specific tool to execute - tool_input: Input parameters for the tool (dict or JSON string) - - Returns: - The tool's execution result - """ - if _registry is None: - return json.dumps({"error": "Skill registry not initialized"}) - return _execute(_registry, skill_name, tool_name, tool_input) - - -def _execute_tool(tool_obj: Any, tool_input: dict) -> Any: - """Execute a tool function, handling sync and async cases.""" - if isinstance(tool_input, dict): - result = tool_obj(**tool_input) - else: - result = tool_obj(tool_input) - - # Handle async results - if asyncio.iscoroutine(result): - result = _run_async(result) - - return result - - -def _run_async(coro): - """Run an async coroutine, handling cases where an event loop may already exist.""" - try: - loop = asyncio.get_event_loop() - if loop.is_running(): - import concurrent.futures - with concurrent.futures.ThreadPoolExecutor() as executor: - return executor.submit(asyncio.run, coro).result() - else: - return loop.run_until_complete(coro) - except RuntimeError: - return asyncio.run(coro) diff --git a/backend/src/agents/main_agent/skills/strands_mapping.py b/backend/src/agents/main_agent/skills/strands_mapping.py new file mode 100644 index 000000000..2f04c4cad --- /dev/null +++ b/backend/src/agents/main_agent/skills/strands_mapping.py @@ -0,0 +1,281 @@ +"""DB ``SkillDefinition`` → Strands ``Skill`` mapping (Skills v2 PR-2). + +The runtime disclosure engine is the vended Strands ``AgentSkills`` plugin. It +takes programmatic ``Skill`` instances (name/description/instructions + +advisory ``allowed_tools`` + ``metadata``), injects a ~100-token +```` block per skill into the system prompt, and exposes one +``skills`` activation tool that returns a skill's full instructions on demand. + +This module is the adapter between our catalog rows and those ``Skill`` +instances. It carries no disclosure logic of its own — that all lives in the +plugin now (the homegrown ``SkillRegistry``/``skill_tools`` dispatcher is +retired). +""" + +from __future__ import annotations + +import logging +from typing import Any, List, Optional + +from strands import AgentSkills, Skill, tool + +# Single source of truth for the slug rule (shared with the app-api SKILL.md +# write-through projection — the import-boundary forbids app_api importing here). +from apis.shared.skills.bundle import KIND_DIRS, slugify_skill_name +from apis.shared.skills.resource_store import ( + SkillResourceStoreError, + get_skill_resource_store, +) + +logger = logging.getLogger(__name__) + +__all__ = [ + "slugify_skill_name", + "record_to_strands_skill", + "fetch_active_skill_records", + "build_skills_plugin", + "build_skills_runtime", + "make_read_skill_file_tool", +] + +# Text MIME types whose bytes ``read_skill_file`` returns inline. Anything else +# (images, PDFs, archives) is described, never dumped as raw bytes. +_TEXT_PREFIXES = ("text/",) +_TEXT_EXACT = frozenset( + { + "application/json", + "application/xml", + "application/yaml", + "application/x-yaml", + "application/toml", + "application/javascript", + } +) +_INERT_SCRIPT_NOTICE = ( + "[This script is stored with the skill for reference only. It is NOT " + "executable on this platform — read it to understand the approach, then " + "reproduce the steps yourself.]" +) + + +def _manifest_path(ref: Any) -> str: + """Standard bundle path for a manifest entry, e.g. ``references/forms.md``.""" + subdir = KIND_DIRS.get(getattr(ref, "kind", "reference"), "references") + return f"{subdir}/{ref.filename}" + + +def _manifest_paths(record: Any) -> List[str]: + """All standard bundle paths a skill's reference files can be read at.""" + return [_manifest_path(r) for r in getattr(record, "resources", None) or []] + + +def _reference_files_section(record: Any) -> str: + """A generated 'Available reference files' block for a skill's instructions. + + Programmatic ``Skill`` instances carry no filesystem, so the plugin cannot + list resources on activation. We append this listing to the instructions so + the model knows what it can pull with ``read_skill_file`` (spec §5, L3). + """ + paths = _manifest_paths(record) + if not paths: + return "" + lines = "\n".join(f"- {p}" for p in paths) + return ( + "\n\n## Available reference files\n" + "Load any of these with the `read_skill_file` tool " + f"(skill_name `{slugify_skill_name(getattr(record, 'skill_id', ''))}`, " + "path = one of the entries below):\n" + f"{lines}" + ) + + +def record_to_strands_skill(record: Any) -> Skill: + """Map one ``SkillDefinition`` record to a Strands ``Skill``. + + ``metadata`` carries the true ``skill_id`` (the slug is lossy and is only an + activation key) plus the human ``display_name`` and any frontmatter + passthrough, so downstream code (access checks, ``read_skill_file``) can + recover the catalog id from an activated skill. The instructions gain a + generated reference-file listing (L3 disclosure) when the skill has any. + """ + skill_id = getattr(record, "skill_id", "") + metadata = { + "skill_id": skill_id, + "display_name": getattr(record, "display_name", "") or skill_id, + **(getattr(record, "skill_metadata", None) or {}), + } + allowed_tools = list(getattr(record, "allowed_tools", None) or []) + instructions = (getattr(record, "instructions", "") or "") + _reference_files_section( + record + ) + return Skill( + name=slugify_skill_name(skill_id), + description=getattr(record, "description", "") or "", + instructions=instructions, + allowed_tools=allowed_tools or None, + metadata=metadata, + ) + + +def _is_active_status(status: Any) -> bool: + """True if a skill record's status is ACTIVE (handles enum or str).""" + return str(status).split(".")[-1].lower() == "active" + + +def fetch_active_skill_records(skill_ids: List[str]) -> List[Any]: + """Fetch ACTIVE skill records for ``skill_ids`` from the catalog repo. + + Bridges the async repository call into the sync agent-build path the same + way the other tool-materialization helpers do. Returns an empty list on any + failure — the agent then simply runs without skills. + """ + if not skill_ids: + return [] + + import asyncio + + from apis.shared.skills.repository import get_skill_catalog_repository + + repo = get_skill_catalog_repository() + + async def _go() -> List[Any]: + records = await repo.batch_get_skills(list(skill_ids)) + return [r for r in records if _is_active_status(getattr(r, "status", "active"))] + + try: + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor() as executor: + return executor.submit(asyncio.run, _go()).result() + return loop.run_until_complete(_go()) + except RuntimeError: + return asyncio.run(_go()) + except Exception as e: # noqa: BLE001 - degrade to no-skills on any error + logger.warning("Could not load skill records: %s", e) + return [] + + +def _is_text_content(content_type: str) -> bool: + """True if the MIME type is one we return inline as text.""" + ct = (content_type or "").split(";", 1)[0].strip().lower() + return ct.startswith(_TEXT_PREFIXES) or ct in _TEXT_EXACT + + +def _resolve_resource(record: Any, path: str) -> Optional[Any]: + """Resolve ``path`` against a skill's manifest (no filesystem, no traversal). + + Accepts either the bare filename (``forms.md``) or the standard bundle path + (``references/forms.md``). Pure manifest lookup — the returned ref's stored + ``s3_key`` is the only thing ever read. + """ + wanted = (path or "").strip().lstrip("/") + for ref in getattr(record, "resources", None) or []: + if wanted == ref.filename or wanted == _manifest_path(ref): + return ref + return None + + +def make_read_skill_file_tool(records: List[Any]): + """Build the per-turn ``read_skill_file`` tool bound to ``records``. + + The tool is the S3-vs-filesystem adapter the spec calls for (§5): the + ``AgentSkills`` plugin discloses metadata + instructions but never reads + files for programmatic skills. + + **Access (§6) is enforced structurally, not re-checked per call.** ``records`` + is exactly the turn's effective skill set — already resolved against the + invoker (catalog ∪ own for plain chat; the invoke-through predicate for an + Agent's bindings) and narrowed by the opt-in selection. A skill the invoker + cannot use is simply absent from ``by_slug``, so there is no id the model can + name to reach one. That is strictly stronger than re-running the predicate on + an arbitrary caller-supplied id, and it keeps a single resolution point: + anything that widens access has to widen the effective set, where the tiering + rules actually live. + """ + by_slug = {slugify_skill_name(getattr(r, "skill_id", "")): r for r in records} + store = get_skill_resource_store() + + @tool + def read_skill_file(skill_name: str, path: str) -> str: + """Read one of an active skill's reference files. + + Use this to load a reference file listed in a skill's "Available + reference files" section (after you have activated the skill). + + Args: + skill_name: The skill's name (as shown in ````). + path: A path from the skill's reference-file listing, e.g. + ``references/forms.md``. + """ + record = by_slug.get(skill_name) + if record is None: + available = ", ".join(sorted(by_slug)) or "(none)" + return ( + f"Skill '{skill_name}' is not available in this conversation. " + f"Available skills: {available}" + ) + + ref = _resolve_resource(record, path) + if ref is None: + listing = ", ".join(_manifest_paths(record)) or "(no reference files)" + return ( + f"No reference file '{path}' in skill '{skill_name}'. " + f"Available paths: {listing}" + ) + + try: + raw = store.get(ref.s3_key) + except SkillResourceStoreError as e: + logger.warning("read_skill_file: %s", e) + return f"Could not read '{path}' from skill '{skill_name}'." + + kind = getattr(ref, "kind", "reference") + if kind == "script": + return f"{_INERT_SCRIPT_NOTICE}\n\n{raw.decode('utf-8', errors='replace')}" + if kind == "asset" or not _is_text_content(ref.content_type): + return ( + f"'{path}' is a binary {kind} ({ref.content_type or 'unknown type'}, " + f"{ref.size} bytes) and cannot be shown as text." + ) + return raw.decode("utf-8", errors="replace") + + return read_skill_file + + +def build_skills_runtime( + accessible_skill_ids: Optional[List[str]], +): + """Build the skills runtime for a turn: (AgentSkills plugin, read tool). + + Fetches the effective skill records once and returns both the disclosure + plugin and the ``read_skill_file`` tool bound to those records. Returns + ``(None, None)`` when there are no accessible/active skills — the caller + then wires neither and the agent behaves as a plain chat agent. + """ + if not accessible_skill_ids: + return None, None + + records = fetch_active_skill_records(list(accessible_skill_ids)) + if not records: + return None, None + + skills = [record_to_strands_skill(r) for r in records] + plugin = AgentSkills(skills=skills) + read_tool = make_read_skill_file_tool(records) + logger.info("Built skills runtime: %d skill(s) + read_skill_file", len(skills)) + return plugin, read_tool + + +def build_skills_plugin( + accessible_skill_ids: Optional[List[str]], +) -> Optional[AgentSkills]: + """Back-compat shim: return only the ``AgentSkills`` plugin. + + Prefer :func:`build_skills_runtime`, which also returns the + ``read_skill_file`` tool from the same single record fetch. + """ + plugin, _ = build_skills_runtime(accessible_skill_ids) + return plugin diff --git a/backend/src/apis/app_api/admin/routes.py b/backend/src/apis/app_api/admin/routes.py index 5b9244f88..5731a3f15 100644 --- a/backend/src/apis/app_api/admin/routes.py +++ b/backend/src/apis/app_api/admin/routes.py @@ -914,14 +914,6 @@ async def get_managed_model_roles( router.include_router(system_prompts_admin_router) -# ========== Include Platform Settings Admin Subrouter (conditional) ========== -# Currently only hosts the chat-mode (skills vs. tools) policy, which is part -# of the deferred skills feature. Mount it only when skills are enabled. -if skills_enabled(): - from .settings.routes import router as settings_admin_router - - router.include_router(settings_admin_router) - # ========== Include Fine-Tuning Admin Subrouter (conditional) ========== if os.environ.get("FINE_TUNING_ENABLED", "false").lower() == "true": from .fine_tuning.routes import router as fine_tuning_admin_router diff --git a/backend/src/apis/app_api/admin/services/skill_access.py b/backend/src/apis/app_api/admin/services/skill_access.py deleted file mode 100644 index 4cc742114..000000000 --- a/backend/src/apis/app_api/admin/services/skill_access.py +++ /dev/null @@ -1,160 +0,0 @@ -""" -Skill Access Service - -Handles skill authorization based on AppRoles. Mirrors ToolAccessService: -a user's accessible skills come from ``resolve_user_permissions(user).skills`` -(with ``"*"`` wildcard), and ``filter_allowed_skills`` intersects requested -skills against the DynamoDB-backed skill catalog. - -Unlike tools, skills have no dynamically-loaded ("gateway_*") runtime form — -every skill is an authored catalog entry — so there is no prefix passthrough -in the wildcard branch. -""" -import logging -from typing import List, Optional, Set - -from apis.shared.auth.models import User -from apis.shared.rbac.service import AppRoleService, get_app_role_service -from apis.shared.skills.freshness import get_all_skill_ids - -logger = logging.getLogger(__name__) - - -class SkillAccessService: - """Service for checking skill access based on AppRoles.""" - - def __init__(self, app_role_service: Optional[AppRoleService] = None): - """Initialize with optional AppRoleService.""" - self._app_role_service = app_role_service - - @property - def app_role_service(self) -> AppRoleService: - """Lazy-load AppRoleService.""" - if self._app_role_service is None: - self._app_role_service = get_app_role_service() - return self._app_role_service - - async def get_user_allowed_skills(self, user: User) -> Set[str]: - """ - Get the set of skill IDs the user is allowed to use. - - Returns: - Set of skill IDs. Contains "*" if user has wildcard access. - """ - permissions = await self.app_role_service.resolve_user_permissions(user) - return set(permissions.skills) - - async def can_access_skill(self, user: User, skill_id: str) -> bool: - """ - Check if a user can access a specific skill. - - Args: - user: The user to check - skill_id: Skill identifier - - Returns: - True if user has access to the skill - """ - allowed_skills = await self.get_user_allowed_skills(user) - - # Wildcard grants access to all skills - if "*" in allowed_skills: - return True - - # Check if specific skill is in allowed set - return skill_id in allowed_skills - - async def filter_allowed_skills( - self, - user: User, - requested_skills: Optional[List[str]] = None, - ) -> List[str]: - """ - Filter a list of requested skills to only those the user can access. - - If no skills are requested, returns all skills the user can access. - - The "universe of known skills" is sourced from the DynamoDB-backed - skill catalog (TTL-cached via ``freshness.get_all_skill_ids``). - - Args: - user: The user to check - requested_skills: Optional list of skill IDs the user wants to use. - If None, returns all allowed skills. - - Returns: - List of skill IDs the user is allowed to use from the requested set. - """ - allowed_skills = await self.get_user_allowed_skills(user) - has_wildcard = "*" in allowed_skills - - # Get all available skill IDs from the DynamoDB catalog - all_skill_ids = await get_all_skill_ids() - - if requested_skills is None: - # No specific skills requested - return all allowed - if has_wildcard: - return list(all_skill_ids) - else: - # Only return allowed skills that exist in the catalog - return list(allowed_skills & all_skill_ids) - - # Filter requested skills to only allowed ones - if has_wildcard: - # Wildcard: allow all requested skills that exist in the catalog - return [s for s in requested_skills if s in all_skill_ids] - else: - # Only return intersection of requested and allowed - return [s for s in requested_skills if s in allowed_skills] - - async def check_access_and_filter( - self, - user: User, - requested_skills: Optional[List[str]] = None, - strict: bool = False, - ) -> tuple[List[str], List[str]]: - """ - Check skill access and return both allowed and denied skills. - - Args: - user: The user to check - requested_skills: Optional list of skill IDs the user wants to use - strict: If True, raise ValueError if any requested skill is denied - - Returns: - Tuple of (allowed_skills, denied_skills) - - Raises: - ValueError: If strict=True and any skills are denied - """ - if requested_skills is None: - allowed = await self.filter_allowed_skills(user, None) - return allowed, [] - - allowed = await self.filter_allowed_skills(user, requested_skills) - allowed_set = set(allowed) - denied = [s for s in requested_skills if s not in allowed_set] - - if strict and denied: - raise ValueError( - f"User {user.email} is not authorized to use skills: {', '.join(denied)}" - ) - - if denied: - logger.warning( - f"User {user.email} requested unauthorized skills: {denied}" - ) - - return allowed, denied - - -# Singleton instance -_skill_access_service: Optional[SkillAccessService] = None - - -def get_skill_access_service() -> SkillAccessService: - """Get the singleton SkillAccessService instance.""" - global _skill_access_service - if _skill_access_service is None: - _skill_access_service = SkillAccessService() - return _skill_access_service diff --git a/backend/src/apis/app_api/admin/settings/__init__.py b/backend/src/apis/app_api/admin/settings/__init__.py deleted file mode 100644 index f3fb24a3d..000000000 --- a/backend/src/apis/app_api/admin/settings/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Admin platform-settings routes.""" - -from .routes import router - -__all__ = ["router"] diff --git a/backend/src/apis/app_api/admin/settings/routes.py b/backend/src/apis/app_api/admin/settings/routes.py deleted file mode 100644 index e3b1e03f4..000000000 --- a/backend/src/apis/app_api/admin/settings/routes.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Admin API routes for platform-wide settings. - -Currently hosts the chat-mode policy: which agent mode (skills vs. tools) -new conversations get by default, and whether users may switch between -modes. The user-facing read of the same policy lives at -``GET /system/chat-settings``. -""" - -import logging - -from fastapi import APIRouter, Depends, HTTPException, status - -from apis.shared.auth import User, require_admin -from apis.shared.platform_settings.models import ( - ChatModeSettingsResponse, - ChatModeSettingsUpdate, -) -from apis.shared.platform_settings.service import get_chat_mode_settings_service - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/settings", tags=["admin-settings"]) - - -@router.get( - "/chat", - response_model=ChatModeSettingsResponse, - summary="Get the chat-mode policy", -) -async def get_chat_mode_settings( - admin_user: User = Depends(require_admin), -) -> ChatModeSettingsResponse: - """Get the current chat-mode policy (defaults if never configured).""" - service = get_chat_mode_settings_service() - settings = await service.get_settings() - return ChatModeSettingsResponse.from_settings(settings) - - -@router.put( - "/chat", - response_model=ChatModeSettingsResponse, - summary="Update the chat-mode policy", -) -async def update_chat_mode_settings( - update: ChatModeSettingsUpdate, - admin_user: User = Depends(require_admin), -) -> ChatModeSettingsResponse: - """Set the default agent mode and whether users may toggle between modes.""" - service = get_chat_mode_settings_service() - try: - settings = await service.update_settings(update, updated_by=admin_user.email) - except Exception: - logger.exception("Failed to update chat-mode settings") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to update chat-mode settings", - ) - logger.info( - f"Admin {admin_user.email} updated chat-mode settings: " - f"default_mode={settings.default_mode}, allow_mode_toggle={settings.allow_mode_toggle}" - ) - return ChatModeSettingsResponse.from_settings(settings) diff --git a/backend/src/apis/app_api/admin/skills/routes.py b/backend/src/apis/app_api/admin/skills/routes.py index 44d667ff1..c3e2017ea 100644 --- a/backend/src/apis/app_api/admin/skills/routes.py +++ b/backend/src/apis/app_api/admin/skills/routes.py @@ -8,7 +8,7 @@ import logging from typing import Optional -from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile from fastapi.responses import Response from apis.shared.auth import User, require_admin @@ -75,9 +75,8 @@ async def admin_create_skill( ): """Create a new skill catalog entry. - Every bound_tool_id is validated against the tool catalog (must exist and - be ACTIVE). This only creates the catalog entry; use the role endpoints to - grant it to AppRoles. + This only creates the catalog entry; use the role endpoints to grant it to + AppRoles. """ logger.info("Admin creating skill") @@ -89,7 +88,6 @@ async def admin_create_skill( display_name=request.display_name, description=request.description, instructions=request.instructions, - bound_tool_ids=request.bound_tool_ids, compose=request.compose, status=request.status, category=request.category, @@ -99,7 +97,8 @@ async def admin_create_skill( raise HTTPException(status_code=400, detail=str(e)) # Drop the all-skill-ids snapshot so the new skill is recognized by - # SkillAccessService on the very next chat turn in this process. + # ``skills.access.resolve_accessible_skill_ids`` on the very next chat + # turn in this process. from apis.shared.skills.freshness import invalidate as invalidate_freshness invalidate_freshness(created.skill_id) @@ -264,13 +263,16 @@ async def list_skill_resources( async def upload_skill_resource( skill_id: str, file: UploadFile = File(...), + kind: str = Form("reference"), admin: User = Depends(require_admin), ): - """Upload (or replace) one supporting reference file for a skill. + """Upload (or replace) one supporting file for a skill. - Bytes are stored content-addressed in S3; the catalog row's manifest is - updated atomically. Re-uploading the same filename replaces it. Returns - the skill's updated manifest. + Bytes are stored in the standard agentskills.io bundle layout + (``references/`` | ``scripts/`` | ``assets/`` per ``kind``); the catalog + row's manifest is updated atomically. Re-uploading the same filename + replaces it. ``script`` files are stored inert (never executed). Returns the + skill's updated manifest. """ logger.info("Admin uploading skill reference file") @@ -283,6 +285,7 @@ async def upload_skill_resource( content=content, content_type=file.content_type or "", admin=admin, + kind=kind, ) except ValueError as e: raise _resource_value_error(e) diff --git a/backend/src/apis/app_api/agent_designer/services/bindable_catalog.py b/backend/src/apis/app_api/agent_designer/services/bindable_catalog.py index 651857082..8552e4401 100644 --- a/backend/src/apis/app_api/agent_designer/services/bindable_catalog.py +++ b/backend/src/apis/app_api/agent_designer/services/bindable_catalog.py @@ -154,7 +154,7 @@ async def _list_skills(user: User) -> List[BindableItem]: ref=s.skill_id, label=s.display_name, description=s.description or "", - meta={"boundToolIds": s.bound_tool_ids, "compose": s.compose}, + meta={"compose": s.compose}, ) for s in skills ] diff --git a/backend/src/apis/app_api/agent_designer/services/binding_validation.py b/backend/src/apis/app_api/agent_designer/services/binding_validation.py index 123eb1f01..88ae6e1fa 100644 --- a/backend/src/apis/app_api/agent_designer/services/binding_validation.py +++ b/backend/src/apis/app_api/agent_designer/services/binding_validation.py @@ -12,7 +12,7 @@ re-resolves each bound tool against the *invoker* (``AppRoleService.can_access_tool``, D5). - ``skill`` → feature-flagged; author must have the skill in the ``/agents/bindable`` palette (``resolve_accessible_skill_ids``, the SAME source the picker fetches). Run-time then - re-resolves each bound skill against the *invoker* (``AppRoleService.can_access_skill``, D5). + re-resolves each bound skill against the *invoker* (``resolve_invocable_skill_ids``, D5). - ``memory_space`` → feature-flagged; author needs viewer+ (read) / editor+ (readwrite). - ``knowledge_base`` → **managed implicitly** (the KB is welded to the agent and its index is not user-configurable), so it is NOT author-settable; the compat layer synthesizes it @@ -202,7 +202,7 @@ def _validate_skill(binding: AgentBinding, accessible_skill_ids: set) -> None: raise BindingValidationError("skill binding requires a non-empty 'ref'.", status_code=400) # The skill id must be in the author's palette (resolve_accessible_skill_ids) — the exact # source the Designer picker fetches. Run-time re-resolves against the invoker via - # AppRoleService.can_access_skill (D5). + # resolve_invocable_skill_ids (D5), which adds the Agent-owner invoke-through clause. if ref not in accessible_skill_ids: raise BindingValidationError( f"You do not have access to skill '{ref}'.", status_code=403 diff --git a/backend/src/apis/app_api/skills/routes.py b/backend/src/apis/app_api/skills/routes.py index d8f1733d3..736b3ec1f 100644 --- a/backend/src/apis/app_api/skills/routes.py +++ b/backend/src/apis/app_api/skills/routes.py @@ -1,26 +1,62 @@ -"""User-facing skills API: list accessible skills + per-skill preferences. +"""User-facing skills API: accessible skills, preferences, and My Skills CRUD. -The skills parallel of ``apis/app_api/tools/routes.py``. Returns only the -ACTIVE skills the user's RBAC roles grant — the same resolution -(``apis.shared.skills.access``) and the same ACTIVE filter the SkillAgent -applies at runtime, so what the user sees in the picker is exactly what the -agent can activate. Preferences are a global per-user map (skill_id -> -enabled); a skill absent from the map is enabled by default. +Two surfaces live here: -Admin skill management routes are in ``apis.app_api.admin.skills.routes``. +1. **Picker** (``GET /skills/``, ``PUT /skills/preferences``). Returns the + ACTIVE skills the user can reach — RBAC-granted catalog skills **union** + the skills they authored themselves — via the same resolution the runtime + uses (``apis.shared.skills.access``), so what the user sees in the picker + is exactly what the agent can activate. Preferences are a global per-user + map (skill_id -> enabled). + +2. **My Skills** (``/skills/mine/*``, Skills v2 PR-3). Owner-scoped CRUD over + the user-authored tier: create/edit/delete your own skills and upload the + supporting files of their agentskills.io bundle. Every route resolves + ownership through ``UserSkillService``; a skill you do not own is + indistinguishable from one that does not exist. + +Admin catalog management routes are in ``apis.app_api.admin.skills.routes``. + +**Access model.** These routes require only an authenticated session. There is +no per-user capability gate: ``SKILLS_ENABLED`` decides whether the feature +exists in this environment, and a role's ``grantedSkills`` decides *which* +catalog skills a user can reach. Both surfaces are already self-limiting — +``GET /skills/`` returns only what ``resolve_accessible_skill_ids`` grants +(so a user with no grants gets an empty list and the SPA renders no picker), +and every ``/skills/mine/*`` route is owner-scoped, so a user only ever sees +skills they authored. + +The ``skills`` RBAC capability that used to gate these routes was removed: it +kept the surfaces admin-only during the v2 rollout, but it could not be granted +from the admin roles UI (that form builds ``grantedTools`` from the tool +catalog, and a capability id is not a tool), so an admin who granted a catalog +skill to a role would find it silently invisible to that role's users with no +way to fix it in-product. """ import logging from typing import Dict, List, Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile from pydantic import BaseModel, Field from apis.shared.auth import User, get_current_user_from_session from apis.shared.skills.access import resolve_accessible_skill_ids -from apis.shared.skills.models import SkillStatus +from apis.shared.skills.models import ( + SkillDefinition, + SkillResourceRef, + SkillResourcesResponse, + SkillStatus, +) from apis.shared.skills.repository import get_skill_catalog_repository +from .user_service import ( + UserSkillError, + UserSkillLimitError, + UserSkillNotFoundError, + get_user_skill_service, +) + logger = logging.getLogger(__name__) router = APIRouter(prefix="/skills", tags=["skills"]) @@ -33,7 +69,6 @@ class UserSkillResponse(BaseModel): display_name: str = Field(..., alias="displayName") description: str category: Optional[str] = None - bound_tool_count: int = Field(..., alias="boundToolCount") user_enabled: Optional[bool] = Field(None, alias="userEnabled") is_enabled: bool = Field(..., alias="isEnabled") @@ -81,9 +116,13 @@ async def get_user_skills( display_name=record.display_name, description=record.description, category=record.category, - bound_tool_count=len(record.bound_tool_ids), user_enabled=preferences.get(record.skill_id), - is_enabled=preferences.get(record.skill_id, True), + # Skills v2 D6: opt-in. An untouched skill is OFF, unlike tools. + # This is the picker's half of the same default the runtime enforces + # in `_apply_enabled_skills_filter` (absent selection ⇒ no skills); + # the two must agree or the UI would show skills as active that the + # turn never loads. + is_enabled=preferences.get(record.skill_id, False), ) for record in records if record.status == SkillStatus.ACTIVE @@ -116,3 +155,288 @@ async def update_skill_preferences( repo = get_skill_catalog_repository() await repo.save_user_preferences(user.user_id, request.preferences) return {"message": "Preferences saved successfully"} + + +# ============================================================================= +# My Skills — the user-authored tier (Skills v2 PR-3) +# ============================================================================= + + +class MySkillResponse(BaseModel): + """One skill the caller authored, as shown on the My Skills page.""" + + skill_id: str = Field(..., alias="skillId") + display_name: str = Field(..., alias="displayName") + description: str + instructions: str = "" + allowed_tools: List[str] = Field(default_factory=list, alias="allowedTools") + skill_metadata: Dict = Field(default_factory=dict, alias="skillMetadata") + resources: List[SkillResourceRef] = Field(default_factory=list) + status: str = SkillStatus.ACTIVE.value + category: Optional[str] = None + created_at: Optional[str] = Field(None, alias="createdAt") + updated_at: Optional[str] = Field(None, alias="updatedAt") + + model_config = {"populate_by_name": True} + + @classmethod + def from_skill(cls, skill: SkillDefinition) -> "MySkillResponse": + return cls( + skill_id=skill.skill_id, + display_name=skill.display_name, + description=skill.description, + instructions=skill.instructions, + allowed_tools=list(skill.allowed_tools), + skill_metadata=dict(skill.skill_metadata), + resources=list(skill.resources), + status=str(skill.status.value if hasattr(skill.status, "value") else skill.status), + category=skill.category, + created_at=skill.created_at.isoformat() if skill.created_at else None, + updated_at=skill.updated_at.isoformat() if skill.updated_at else None, + ) + + +class MySkillListResponse(BaseModel): + """Response model for GET /skills/mine.""" + + skills: List[MySkillResponse] + total_count: int = Field(..., alias="totalCount") + + model_config = {"populate_by_name": True} + + +class CreateMySkillRequest(BaseModel): + """Request body for POST /skills/mine. + + No ``skillId``: ids are allocated server-side from the display name so a + user never has to invent one — or collide with a catalog skill they cannot + see. ``allowedTools`` is advisory metadata only and never grants a tool + (spec D1/D4). + """ + + display_name: str = Field(..., alias="displayName", max_length=200) + description: str = Field(..., max_length=2000) + instructions: str = "" + allowed_tools: List[str] = Field(default_factory=list, alias="allowedTools") + skill_metadata: Dict = Field(default_factory=dict, alias="skillMetadata") + category: Optional[str] = None + + model_config = {"populate_by_name": True} + + +class UpdateMySkillRequest(BaseModel): + """Request body for PUT /skills/mine/{skill_id}. + + Only authored fields are writable. ``ownerId``, ``visibility`` and the + audit fields are deliberately absent — a user cannot re-home their skill + into the admin catalog or onto another account. + """ + + display_name: Optional[str] = Field(None, alias="displayName", max_length=200) + description: Optional[str] = Field(None, max_length=2000) + instructions: Optional[str] = None + allowed_tools: Optional[List[str]] = Field(None, alias="allowedTools") + skill_metadata: Optional[Dict] = Field(None, alias="skillMetadata") + category: Optional[str] = None + status: Optional[SkillStatus] = None + + model_config = {"populate_by_name": True} + + +def _user_skill_error(e: UserSkillError) -> HTTPException: + """Map a user-tier service error to its HTTP status.""" + if isinstance(e, UserSkillNotFoundError): + return HTTPException(status_code=404, detail=str(e)) + if isinstance(e, UserSkillLimitError): + return HTTPException(status_code=409, detail=str(e)) + return HTTPException(status_code=400, detail=str(e)) + + +def _resource_value_error(e: ValueError) -> HTTPException: + """Map a resource-file validation error to its HTTP status.""" + status = 404 if "not found" in str(e).lower() else 400 + return HTTPException(status_code=status, detail=str(e)) + + +@router.get("/mine", response_model=MySkillListResponse) +async def list_my_skills( + user: User = Depends(get_current_user_from_session), +) -> MySkillListResponse: + """List every skill the current user authored (any status).""" + logger.info(f"User {user.name} listing authored skills") + + skills = await get_user_skill_service().list_my_skills(user) + return MySkillListResponse( + skills=[MySkillResponse.from_skill(s) for s in skills], + total_count=len(skills), + ) + + +@router.post("/mine", response_model=MySkillResponse) +async def create_my_skill( + request: CreateMySkillRequest, + user: User = Depends(get_current_user_from_session), +) -> MySkillResponse: + """Create a skill owned by the current user.""" + logger.info(f"User {user.name} creating an authored skill") + + try: + skill = await get_user_skill_service().create_my_skill( + user, + display_name=request.display_name, + description=request.description, + instructions=request.instructions, + allowed_tools=request.allowed_tools, + skill_metadata=request.skill_metadata, + category=request.category, + ) + except UserSkillError as e: + raise _user_skill_error(e) + + return MySkillResponse.from_skill(skill) + + +@router.get("/mine/{skill_id}", response_model=MySkillResponse) +async def get_my_skill( + skill_id: str, + user: User = Depends(get_current_user_from_session), +) -> MySkillResponse: + """Get one of the current user's authored skills.""" + try: + skill = await get_user_skill_service().get_my_skill(skill_id, user) + except UserSkillError as e: + raise _user_skill_error(e) + + return MySkillResponse.from_skill(skill) + + +@router.put("/mine/{skill_id}", response_model=MySkillResponse) +async def update_my_skill( + skill_id: str, + request: UpdateMySkillRequest, + user: User = Depends(get_current_user_from_session), +) -> MySkillResponse: + """Update one of the current user's authored skills.""" + logger.info(f"User {user.name} updating an authored skill") + + updates = request.model_dump(exclude_unset=True, exclude_none=True) + try: + skill = await get_user_skill_service().update_my_skill(skill_id, updates, user) + except UserSkillError as e: + raise _user_skill_error(e) + + return MySkillResponse.from_skill(skill) + + +@router.delete("/mine/{skill_id}") +async def delete_my_skill( + skill_id: str, + user: User = Depends(get_current_user_from_session), +): + """Delete one of the current user's authored skills and its bundle files.""" + logger.info(f"User {user.name} deleting an authored skill") + + try: + await get_user_skill_service().delete_my_skill(skill_id, user) + except UserSkillError as e: + raise _user_skill_error(e) + + return {"message": f"Skill '{skill_id}' deleted"} + + +# ----------------------------------------------------------------------------- +# My Skills — bundle files +# ----------------------------------------------------------------------------- + + +@router.get("/mine/{skill_id}/resources", response_model=SkillResourcesResponse) +async def list_my_skill_resources( + skill_id: str, + user: User = Depends(get_current_user_from_session), +): + """List an owned skill's supporting-file manifest (no bytes).""" + try: + resources = await get_user_skill_service().list_resources(skill_id, user) + except UserSkillError as e: + raise _user_skill_error(e) + + return SkillResourcesResponse(skill_id=skill_id, resources=resources) + + +@router.post("/mine/{skill_id}/resources", response_model=SkillResourcesResponse) +async def upload_my_skill_resource( + skill_id: str, + file: UploadFile = File(...), + kind: str = Form("reference"), + user: User = Depends(get_current_user_from_session), +): + """Upload (or replace) one supporting file on an owned skill. + + Bytes land in the standard agentskills.io bundle layout (``references/`` | + ``scripts/`` | ``assets/`` per ``kind``). ``script`` files are stored inert + — listed and readable, never executed (spec D5). + """ + logger.info(f"User {user.name} uploading a skill bundle file") + + content = await file.read() + try: + resources = await get_user_skill_service().add_resource( + skill_id, + filename=file.filename or "", + content=content, + content_type=file.content_type or "", + user=user, + kind=kind, + ) + except UserSkillError as e: + raise _user_skill_error(e) + except ValueError as e: + raise _resource_value_error(e) + + return SkillResourcesResponse(skill_id=skill_id, resources=resources) + + +@router.get("/mine/{skill_id}/resources/{filename}") +async def read_my_skill_resource( + skill_id: str, + filename: str, + user: User = Depends(get_current_user_from_session), +): + """Return the raw bytes of one of an owned skill's supporting files.""" + try: + ref, content = await get_user_skill_service().read_resource( + skill_id, filename, user + ) + except UserSkillError as e: + raise _user_skill_error(e) + except ValueError as e: + raise _resource_value_error(e) + + return Response( + content=content, + media_type=ref.content_type or "application/octet-stream", + headers={"Content-Disposition": f'inline; filename="{ref.filename}"'}, + ) + + +@router.delete( + "/mine/{skill_id}/resources/{filename}", response_model=SkillResourcesResponse +) +async def delete_my_skill_resource( + skill_id: str, + filename: str, + user: User = Depends(get_current_user_from_session), +): + """Delete one supporting file from an owned skill. Returns the manifest.""" + logger.info(f"User {user.name} deleting a skill bundle file") + + try: + resources = await get_user_skill_service().delete_resource( + skill_id, filename, user + ) + except UserSkillError as e: + raise _user_skill_error(e) + except ValueError as e: + raise _resource_value_error(e) + + return SkillResourcesResponse(skill_id=skill_id, resources=resources) diff --git a/backend/src/apis/app_api/skills/service.py b/backend/src/apis/app_api/skills/service.py index 28c3ca129..01ab2d6d2 100644 --- a/backend/src/apis/app_api/skills/service.py +++ b/backend/src/apis/app_api/skills/service.py @@ -5,9 +5,8 @@ ``ToolCatalogService``: CRUD over skill metadata, bidirectional role sync (updating ``granted_skills`` on AppRoles), and ``allowedAppRoles`` hydration. -Skill-specific: ``create_skill``/``update_skill`` validate that every -``bound_tool_id`` exists in the tool catalog and is ACTIVE (spec §6), since a -skill folds those catalog tools behind the meta-tools at runtime. +Skills are pure knowledge bundles (Skills v2): they carry no bound tools, so +there is no tool-catalog validation here. """ import logging @@ -21,6 +20,7 @@ ) from apis.shared.rbac.service import AppRoleService, get_app_role_service from apis.shared.skills.models import ( + SYSTEM_OWNER_ID, SkillDefinition, SkillResourceRef, SkillRoleAssignment, @@ -29,21 +29,15 @@ SkillCatalogRepository, get_skill_catalog_repository, ) +from apis.shared.skills.bundle import generate_skill_md from apis.shared.skills.resource_store import ( SkillResourceStore, + SkillResourceStoreError, compute_content_hash, get_skill_resource_store, ) -from apis.shared.tools.models import ToolStatus -from apis.shared.tools.scoped_ids import ( - base_tool_id, - base_tool_ids, - parse_scoped_tool_id, -) -from apis.shared.tools.repository import ( - ToolCatalogRepository, - get_tool_catalog_repository, -) + +VALID_RESOURCE_KINDS = ("reference", "script", "asset") logger = logging.getLogger(__name__) @@ -63,21 +57,18 @@ class SkillCatalogService: Service for skill catalog operations. Skill access is determined by AppRoles (granted_skills). This service - provides catalog CRUD, bound-tool validation against the tool catalog, and - bidirectional sync between skills and AppRoles. + provides catalog CRUD and bidirectional sync between skills and AppRoles. """ def __init__( self, repository: Optional[SkillCatalogRepository] = None, - tool_repository: Optional[ToolCatalogRepository] = None, app_role_service: Optional[AppRoleService] = None, app_role_admin_service: Optional[AppRoleAdminService] = None, resource_store: Optional[SkillResourceStore] = None, ): """Initialize with dependencies.""" self.repository = repository or get_skill_catalog_repository() - self.tool_repository = tool_repository or get_tool_catalog_repository() self.app_role_service = app_role_service or get_app_role_service() self.app_role_admin_service = ( app_role_admin_service or get_app_role_admin_service() @@ -92,7 +83,12 @@ async def get_all_skills( self, status: Optional[str] = None, include_roles: bool = True ) -> List[SkillDefinition]: """ - Get all skills in the catalog. + Get all skills in the admin catalog. + + Scoped to ``owner_id == "system"``: user-authored skills (Skills v2 + PR-3) live in the same table but are governed by ownership, not by RBAC + role grants, so they are not part of the catalog an admin curates and + must not appear on the admin skills page. Args: status: Optional status filter @@ -101,7 +97,9 @@ async def get_all_skills( Returns: List of SkillDefinition objects """ - skills = await self.repository.list_skills(status=status) + skills = await self.repository.list_skills( + status=status, owner_id=SYSTEM_OWNER_ID + ) if include_roles: for skill in skills: @@ -116,76 +114,6 @@ async def get_skill(self, skill_id: str) -> Optional[SkillDefinition]: """Get a specific skill by ID.""" return await self.repository.get_skill(skill_id) - async def _validate_bound_tools(self, bound_tool_ids: List[str]) -> None: - """ - Validate that every bound tool exists in the catalog and is ACTIVE. - - A skill folds its bound catalog tools behind the meta-tools at runtime, - so binding an unknown or disabled tool would silently drop it. Reject - such bindings up front (spec §6). - - A scoped id (``tool_id::mcp_tool_name``) binds a single tool of an MCP - server. Its base catalog tool must exist + be active, and — when the - server has a curated tool list — the named tool must be one it exposes. - Servers whose tools are discovered live have no curated list, so the - name can't be validated statically and is accepted. - - Raises: - ValueError: If any bound tool is unknown, non-active, scoped onto a - tool the server doesn't expose, or scoped onto a non-MCP tool. - """ - if not bound_tool_ids: - return - - # Dedupe while preserving the admin's set for clear error messages. - requested = list(dict.fromkeys(bound_tool_ids)) - base_ids = base_tool_ids(requested) - found = await self.tool_repository.batch_get_tools(base_ids) - by_id = {t.tool_id: t for t in found} - - unknown = [tid for tid in requested if base_tool_id(tid) not in by_id] - disabled = [ - tid - for tid in requested - if base_tool_id(tid) in by_id - and by_id[base_tool_id(tid)].status != ToolStatus.ACTIVE.value - ] - - # Per-tool bindings: the named tool must belong to the server (when the - # server exposes a curated list) and the base must be an MCP server. - unexposed: List[str] = [] - not_mcp: List[str] = [] - for tid in requested: - base, tool_name = parse_scoped_tool_id(tid) - if tool_name is None or base not in by_id or tid in disabled: - continue - tool = by_id[base] - if tool.protocol not in ("mcp", "mcp_external"): - not_mcp.append(tid) - continue - names = tool.curated_tool_names() - if names is not None and tool_name not in names: - unexposed.append(tid) - - problems = [] - if unknown: - problems.append(f"unknown tool(s): {', '.join(sorted(unknown))}") - if disabled: - problems.append(f"non-active tool(s): {', '.join(sorted(disabled))}") - if not_mcp: - problems.append( - f"per-tool binding on non-MCP tool(s): {', '.join(sorted(not_mcp))}" - ) - if unexposed: - problems.append( - f"tool(s) not exposed by their server: {', '.join(sorted(unexposed))}" - ) - if problems: - raise ValueError( - "Cannot bind " + "; ".join(problems) + ". " - "Bound tools must exist in the catalog and be active." - ) - async def create_skill( self, skill: SkillDefinition, admin: User ) -> SkillDefinition: @@ -200,14 +128,13 @@ async def create_skill( Created SkillDefinition Raises: - ValueError: If a bound tool is unknown/disabled, or the skill exists + ValueError: If the skill already exists """ - await self._validate_bound_tools(skill.bound_tool_ids) - skill.created_by = admin.user_id skill.updated_by = admin.user_id created = await self.repository.create_skill(skill) + self._write_skill_md(created) logger.info( f"Admin {admin.email} created skill: {skill.skill_id}", @@ -234,18 +161,16 @@ async def update_skill( Returns: Updated SkillDefinition or None if not found - - Raises: - ValueError: If the new bound tools are unknown/disabled """ - if "bound_tool_ids" in updates and updates["bound_tool_ids"] is not None: - await self._validate_bound_tools(updates["bound_tool_ids"]) - updated = await self.repository.update_skill( skill_id, updates, admin_user_id=admin.user_id ) if updated: + # Keep the SKILL.md projection in sync with the row (best-effort). + # A pure resource-manifest update also lands here, but regenerating + # is cheap and idempotent. + self._write_skill_md(updated) logger.info( f"Admin {admin.email} updated skill: {skill_id}", extra={ @@ -322,20 +247,30 @@ async def add_resource( content: bytes, content_type: str, admin: User, + kind: str = "reference", ) -> List[SkillResourceRef]: - """Upload (or replace) one reference file and update the manifest. + """Upload (or replace) one supporting file and update the manifest. - Bytes are stored content-addressed in S3 (dedupe); the manifest on + Bytes are stored in the standard agentskills.io bundle layout + (``skills/{id}/{references|scripts|assets}/{filename}``); the manifest on the catalog row is updated atomically (single row write). Re-uploading - the same filename replaces its manifest entry; any S3 object that is - no longer referenced afterward is garbage-collected. + the same filename replaces its manifest entry (and overwrites its object + in place); a file whose ``(kind, filename)`` key changed is + garbage-collected. ``script`` files are accept-and-inert (stored, + listed, never executed — D5). Returns the skill's updated manifest. Raises: - ValueError: If the skill is missing, the filename is invalid, the - file is too large, or the per-skill file cap is exceeded. + ValueError: If the skill is missing, the kind or filename is invalid, + the file is too large, or the per-skill file cap is exceeded. """ + if kind not in VALID_RESOURCE_KINDS: + raise ValueError( + f"Invalid resource kind '{kind}'. Expected one of " + f"{', '.join(VALID_RESOURCE_KINDS)}." + ) + skill = await self.repository.get_skill(skill_id) if skill is None: raise ValueError(f"Skill '{skill_id}' not found") @@ -362,7 +297,11 @@ async def add_resource( resolved_type = content_type or "application/octet-stream" digest = compute_content_hash(content) s3_key = self.resource_store.put( - skill_id=skill_id, content=content, content_type=resolved_type + skill_id=skill_id, + filename=filename, + content=content, + content_type=resolved_type, + kind=kind, ) new_ref = SkillResourceRef( filename=filename, @@ -370,6 +309,7 @@ async def add_resource( size=len(content), content_type=resolved_type, s3_key=s3_key, + kind=kind, ) new_resources = [r for r in existing if r.filename != filename] @@ -466,11 +406,51 @@ async def _persist_resources( resources: List[SkillResourceRef], admin: User, ) -> None: - """Write the manifest to the catalog row (single atomic item write).""" + """Write the manifest to the catalog row (single atomic item write). + + The SKILL.md projection is NOT rewritten here: its frontmatter does not + list resources, so a manifest change never alters it. + """ await self.repository.update_skill( skill_id, {"resources": resources}, admin_user_id=admin.user_id ) + def write_skill_md(self, skill: SkillDefinition) -> None: + """Write the skill's SKILL.md projection to S3 (best-effort). + + Public entry point for the user-authored tier (``UserSkillService``), + which reuses this bundle machinery so both tiers emit identical + agentskills.io layouts. + """ + self._write_skill_md(skill) + + def _write_skill_md(self, skill: SkillDefinition) -> None: + """Write the skill's SKILL.md projection to S3 (best-effort). + + The DynamoDB row is the source of truth; this projection exists only so + the S3 prefix is a valid, portable agentskills.io bundle. When storage + is unconfigured (local dev) or the write fails, log and continue — never + fail the catalog write on the projection. + """ + if not self.resource_store.enabled: + return + try: + content = generate_skill_md( + skill_id=skill.skill_id, + description=skill.description, + instructions=skill.instructions, + allowed_tools=skill.allowed_tools, + skill_metadata=skill.skill_metadata, + ) + self.resource_store.put_skill_md(skill_id=skill.skill_id, content=content) + except SkillResourceStoreError: + logger.warning( + "skill-resources: SKILL.md projection failed for skill=%s " + "(row is source of truth; continuing)", + skill.skill_id, + exc_info=True, + ) + def _gc_orphaned( self, old_resources: List[SkillResourceRef], @@ -478,11 +458,9 @@ def _gc_orphaned( ) -> None: """Delete S3 objects no longer referenced by the new manifest. - Objects are content-addressed, so a key still referenced by another - manifest entry (identical content under a different filename) is - retained. Best-effort: a failed cleanup never fails the write — the - manifest is already consistent; an orphaned object is only wasted - storage. + Keys are now path-based (``.../{kind}/{filename}``), so a removed file — + or one whose kind changed — orphans its old key. Best-effort: a failed + cleanup never fails the write; an orphaned object is only wasted storage. """ old_keys = {r.s3_key for r in old_resources} new_keys = {r.s3_key for r in new_resources} @@ -556,9 +534,7 @@ async def set_roles_for_skill( app_role_ids: AppRole IDs that should grant this skill admin: Admin user performing the action """ - skill = await self.get_skill(skill_id) - if not skill: - raise ValueError(f"Skill '{skill_id}' not found") + await self._require_catalog_skill(skill_id) current_roles = await self.get_roles_for_skill(skill_id) current_role_ids = { @@ -589,6 +565,7 @@ async def add_roles_to_skill( self, skill_id: str, app_role_ids: List[str], admin: User ) -> None: """Add AppRoles to skill access (preserves existing).""" + await self._require_catalog_skill(skill_id) for role_id in app_role_ids: await self._add_skill_to_role(role_id, skill_id, admin) @@ -596,9 +573,29 @@ async def remove_roles_from_skill( self, skill_id: str, app_role_ids: List[str], admin: User ) -> None: """Remove AppRoles from skill access.""" + await self._require_catalog_skill(skill_id) for role_id in app_role_ids: await self._remove_skill_from_role(role_id, skill_id, admin) + async def _require_catalog_skill(self, skill_id: str) -> SkillDefinition: + """Assert a skill exists AND belongs to the admin catalog. + + Role grants are the catalog's governance mechanism. A user-authored + skill (Skills v2 PR-3) is reached through ownership — and, once PR-4 + lands, through invoke-through on a shared Agent. Granting one to an + AppRole would hand a private, user-owned document to a whole role, so + the role endpoints refuse to touch anything outside the catalog. + """ + skill = await self.get_skill(skill_id) + if not skill: + raise ValueError(f"Skill '{skill_id}' not found") + if skill.owner_id != SYSTEM_OWNER_ID: + raise ValueError( + f"Skill '{skill_id}' is user-authored and cannot be granted to " + "AppRoles. Only admin catalog skills carry role grants." + ) + return skill + async def _add_skill_to_role( self, role_id: str, skill_id: str, admin: User ) -> None: diff --git a/backend/src/apis/app_api/skills/user_service.py b/backend/src/apis/app_api/skills/user_service.py new file mode 100644 index 000000000..83a58f880 --- /dev/null +++ b/backend/src/apis/app_api/skills/user_service.py @@ -0,0 +1,338 @@ +"""User-authored skills service (Skills v2 PR-3). + +The owner-scoped half of the skill catalog. Admin catalog skills +(``owner_id == "system"``, governed by RBAC ``granted_skills``) and +user-authored skills (``owner_id == ``, governed by ownership) are +the same record type in the same table — two authorship tiers, one store +(spec D8). This service is the ownership-governed entry point; every method +resolves the record and refuses to touch one the caller does not own. + +Resource-file handling (caps, manifest, S3 bundle layout, orphan GC) is +*not* reimplemented here — it is delegated to :class:`SkillCatalogService` +after the ownership check, so both tiers write identical agentskills.io +bundles through one code path. +""" + +import logging +import re +from typing import Dict, List, Optional, Tuple + +from apis.shared.auth.models import User +from apis.shared.skills.models import ( + SKILL_ID_PATTERN, + SkillDefinition, + SkillResourceRef, + SkillStatus, + SkillVisibility, +) +from apis.shared.skills.repository import ( + SkillCatalogRepository, + get_skill_catalog_repository, +) + +from .service import SkillCatalogService, get_skill_catalog_service + +logger = logging.getLogger(__name__) + +# A user may author this many skills. Generous enough that nobody legitimate +# hits it; low enough that one account cannot inflate the shared catalog table +# (every admin catalog list still scans over these rows). +MAX_SKILLS_PER_USER = 50 + +# Fallback stem when a display name slugifies to nothing usable (e.g. a name +# written entirely in a non-Latin script). +_FALLBACK_STEM = "skill" +_SKILL_ID_RE = re.compile(SKILL_ID_PATTERN) + + +class UserSkillError(Exception): + """Base class for user-tier skill failures (400).""" + + +class UserSkillNotFoundError(UserSkillError): + """The skill does not exist, or is not visible to this caller (404).""" + + +class UserSkillLimitError(UserSkillError): + """The caller is at their authored-skill cap (409).""" + + +def slugify_skill_id(display_name: str) -> str: + """Derive a ``skill_id`` stem from a human display name. + + Produces the ``SKILL_ID_PATTERN`` shape: lowercase, underscore-separated, + leading letter, 3-50 chars. This is the *stem* only — uniqueness is + settled by :meth:`UserSkillService._allocate_skill_id`. + """ + stem = re.sub(r"[^a-z0-9]+", "_", (display_name or "").lower()).strip("_") + # The pattern demands a leading letter, so a name like "3d modeling" needs + # a prefix rather than a truncation that would change its meaning. + if not stem or not stem[0].isalpha(): + stem = f"{_FALLBACK_STEM}_{stem}".strip("_") if stem else _FALLBACK_STEM + # Leave room for a disambiguating suffix (see _allocate_skill_id). + stem = stem[:45].rstrip("_") + while len(stem) < 3: + stem = f"{stem}_x" if stem else _FALLBACK_STEM + return stem + + +class UserSkillService: + """Owner-scoped CRUD over the user-authored skill tier.""" + + def __init__( + self, + repository: Optional[SkillCatalogRepository] = None, + catalog_service: Optional[SkillCatalogService] = None, + ): + self.repository = repository or get_skill_catalog_repository() + # Reused purely for its resource-file machinery (caps, manifest, + # SKILL.md projection, orphan GC) — never for its role-sync methods, + # which are admin-only by construction. + self.catalog_service = catalog_service or get_skill_catalog_service() + + # ========================================================================= + # Ownership + # ========================================================================= + + async def require_owned(self, skill_id: str, user: User) -> SkillDefinition: + """Load a skill and assert the caller authored it. + + Raises: + UserSkillNotFoundError: No such skill, or it belongs to the admin + catalog / another user. Both collapse to "not found" so this + surface never confirms the existence of someone else's skill. + """ + skill = await self.repository.get_skill(skill_id) + if skill is None or skill.owner_id != user.user_id: + raise UserSkillNotFoundError(f"Skill '{skill_id}' not found") + return skill + + # ========================================================================= + # CRUD + # ========================================================================= + + async def list_my_skills(self, user: User) -> List[SkillDefinition]: + """Every skill the caller authored, any status (GSI4 partition query).""" + return await self.repository.list_skills_by_owner(user.user_id) + + async def get_my_skill(self, skill_id: str, user: User) -> SkillDefinition: + """One owned skill, including its resource manifest.""" + return await self.require_owned(skill_id, user) + + async def create_my_skill( + self, + user: User, + *, + display_name: str, + description: str, + instructions: str = "", + allowed_tools: Optional[List[str]] = None, + skill_metadata: Optional[Dict] = None, + category: Optional[str] = None, + ) -> SkillDefinition: + """Create a skill owned by ``user``. + + The ``skill_id`` is allocated server-side from the display name — users + never type one. Ids are globally unique across both tiers on purpose: + the runtime activation key is the (slugified) id, so two same-named + skills in one turn would be ambiguous to the model. + + Raises: + UserSkillLimitError: Caller is at ``MAX_SKILLS_PER_USER``. + UserSkillError: Display name or description is blank. + """ + display_name = (display_name or "").strip() + description = (description or "").strip() + if not display_name: + raise UserSkillError("A skill needs a name.") + if not description: + raise UserSkillError( + "A skill needs a description — it is the one line the agent " + "reads when deciding whether to use the skill." + ) + + existing = await self.repository.list_skills_by_owner(user.user_id) + if len(existing) >= MAX_SKILLS_PER_USER: + raise UserSkillLimitError( + f"You already have the maximum of {MAX_SKILLS_PER_USER} skills. " + "Delete one before creating another." + ) + + skill_id = await self._allocate_skill_id(display_name) + skill = SkillDefinition( + skill_id=skill_id, + display_name=display_name, + description=description, + instructions=instructions or "", + allowed_tools=list(allowed_tools or []), + skill_metadata=dict(skill_metadata or {}), + category=category, + status=SkillStatus.ACTIVE, + owner_id=user.user_id, + visibility=SkillVisibility.PRIVATE, + created_by=user.user_id, + updated_by=user.user_id, + ) + + created = await self.repository.create_skill(skill) + self.catalog_service.write_skill_md(created) + self._invalidate(skill_id) + + logger.info( + f"User {user.email} created skill: {skill_id}", + extra={ + "event": "user_skill_created", + "skill_id": skill_id, + "owner_user_id": user.user_id, + }, + ) + return created + + async def update_my_skill( + self, skill_id: str, updates: Dict, user: User + ) -> SkillDefinition: + """Update an owned skill's authored fields. + + ``updates`` is already narrowed to the caller-writable fields by the + route DTO — ``owner_id``, ``visibility`` and audit fields are never + routed through here. + """ + await self.require_owned(skill_id, user) + + updated = await self.repository.update_skill( + skill_id, updates, admin_user_id=user.user_id + ) + if updated is None: + raise UserSkillNotFoundError(f"Skill '{skill_id}' not found") + + self.catalog_service.write_skill_md(updated) + self._invalidate(skill_id) + + logger.info( + f"User {user.email} updated skill: {skill_id}", + extra={ + "event": "user_skill_updated", + "skill_id": skill_id, + "owner_user_id": user.user_id, + "changes": list(updates.keys()), + }, + ) + return updated + + async def delete_my_skill(self, skill_id: str, user: User) -> None: + """Hard-delete an owned skill and purge its bundle objects. + + Deliberately a *hard* delete, unlike the admin catalog's default soft + delete: an admin skill may be referenced by role grants worth auditing, + whereas a user deleting their own draft expects it gone. Bundle objects + are removed first so a failed row delete cannot strand a live skill + pointing at missing files. + """ + skill = await self.require_owned(skill_id, user) + + for ref in skill.resources: + self.catalog_service.resource_store.delete(ref.s3_key) + self._delete_skill_md(skill_id) + + await self.repository.delete_skill(skill_id) + self._invalidate(skill_id) + + logger.info( + f"User {user.email} deleted skill: {skill_id}", + extra={ + "event": "user_skill_deleted", + "skill_id": skill_id, + "owner_user_id": user.user_id, + }, + ) + + # ========================================================================= + # Bundle files — delegated to the shared catalog service post-ownership + # ========================================================================= + + async def list_resources( + self, skill_id: str, user: User + ) -> List[SkillResourceRef]: + skill = await self.require_owned(skill_id, user) + return list(skill.resources) + + async def add_resource( + self, + skill_id: str, + filename: str, + content: bytes, + content_type: str, + user: User, + kind: str = "reference", + ) -> List[SkillResourceRef]: + await self.require_owned(skill_id, user) + refs = await self.catalog_service.add_resource( + skill_id, filename, content, content_type, user, kind=kind + ) + self._invalidate(skill_id) + return refs + + async def read_resource( + self, skill_id: str, filename: str, user: User + ) -> Tuple[SkillResourceRef, bytes]: + await self.require_owned(skill_id, user) + return await self.catalog_service.read_resource(skill_id, filename) + + async def delete_resource( + self, skill_id: str, filename: str, user: User + ) -> List[SkillResourceRef]: + await self.require_owned(skill_id, user) + refs = await self.catalog_service.delete_resource(skill_id, filename, user) + self._invalidate(skill_id) + return refs + + # ========================================================================= + # Internals + # ========================================================================= + + async def _allocate_skill_id(self, display_name: str) -> str: + """Pick a globally-unique ``skill_id`` from a display-name stem. + + Collisions are resolved by suffixing rather than rejected, so a user + naming their skill "docx" when the catalog already has one succeeds + (as ``docx_2``) instead of hitting a 409 that would also disclose the + existence of a skill they cannot see. + """ + stem = slugify_skill_id(display_name) + candidate = stem + for suffix in range(2, 100): + if not await self.repository.skill_exists(candidate): + return candidate + candidate = f"{stem}_{suffix}" + + # Astronomically unlikely; fall back to a random tail rather than loop. + import uuid + + candidate = f"{stem}_{uuid.uuid4().hex[:6]}" + if not _SKILL_ID_RE.match(candidate): + candidate = f"{_FALLBACK_STEM}_{uuid.uuid4().hex[:8]}" + return candidate + + def _delete_skill_md(self, skill_id: str) -> None: + """Best-effort removal of the SKILL.md projection for a deleted skill.""" + from apis.shared.skills.resource_store import skill_md_key + + self.catalog_service.resource_store.delete(skill_md_key(skill_id)) + + @staticmethod + def _invalidate(skill_id: str) -> None: + """Drop the freshness caches so the change is visible on the next turn.""" + from apis.shared.skills.freshness import invalidate + + invalidate(skill_id) + + +_user_service_instance: Optional[UserSkillService] = None + + +def get_user_skill_service() -> UserSkillService: + """Get or create the global UserSkillService instance.""" + global _user_service_instance + if _user_service_instance is None: + _user_service_instance = UserSkillService() + return _user_service_instance diff --git a/backend/src/apis/app_api/system/routes.py b/backend/src/apis/app_api/system/routes.py index e737e9058..e071632fb 100644 --- a/backend/src/apis/app_api/system/routes.py +++ b/backend/src/apis/app_api/system/routes.py @@ -4,13 +4,8 @@ from datetime import datetime, timezone from botocore.exceptions import ClientError -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, HTTPException -from apis.shared.auth.dependencies import get_current_user_from_session -from apis.shared.auth.models import User -from apis.shared.feature_flags import skills_enabled -from apis.shared.platform_settings.models import ChatSettingsPublicResponse -from apis.shared.platform_settings.service import get_chat_mode_settings_service from apis.shared.users.models import UserProfile, UserStatus from apis.shared.users.repository import UserRepository @@ -37,25 +32,6 @@ async def get_system_status() -> SystemStatusResponse: return SystemStatusResponse(first_boot_completed=False) -@router.get("/chat-settings", response_model=ChatSettingsPublicResponse) -async def get_chat_settings( - current_user: User = Depends(get_current_user_from_session), -) -> ChatSettingsPublicResponse: - """Chat-mode policy for the SPA: default agent mode + whether the user may toggle. - - When the skills feature is disabled for this environment, ignore any stored - policy and report tools/chat mode with toggling off, so the SPA hides the - mode toggle, the skills picker, and the admin skills nav entry. - """ - if not skills_enabled(): - return ChatSettingsPublicResponse( - default_mode="chat", allow_mode_toggle=False, skills_enabled=False - ) - service = get_chat_mode_settings_service() - settings = await service.get_settings() - return ChatSettingsPublicResponse.from_settings(settings, skills_enabled=True) - - @router.post("/first-boot", status_code=200) async def first_boot(request: FirstBootRequest) -> FirstBootResponse: """ diff --git a/backend/src/apis/inference_api/chat/agent_binding_resolver.py b/backend/src/apis/inference_api/chat/agent_binding_resolver.py index 934b8fd34..d02fd0afd 100644 --- a/backend/src/apis/inference_api/chat/agent_binding_resolver.py +++ b/backend/src/apis/inference_api/chat/agent_binding_resolver.py @@ -21,11 +21,12 @@ the same ``AppRoleService.can_access_tool`` gate; a bound tool the invoker lacks blocks the turn (D5). Absent tool bindings ⇒ the request's ``enabled_tools`` drive the turn as today. - ``skill`` bindings → the effective skill set (**replace**, same shape as tools): when an - Agent binds skills they *are* the turn's skills, re-resolved per invoker via - ``AppRoleService.can_access_skill``; a bound skill the invoker lacks — or the skills feature - being disabled in this environment — blocks the turn (D5). The caller then forces skill-mode - (``agent_type="skill"``) for the turn so the SkillAgent actually discloses them. Absent skill - bindings ⇒ the request's ``agent_type``/``enabled_skills`` drive the turn as today. + Agent binds skills they *are* the turn's skills, re-resolved per invoker via the + invoke-through predicate (§6/D7, ``resolve_invocable_skill_ids``); a bound skill the + invoker lacks — or the skills feature being disabled in this environment — blocks the + turn (D5). The caller hands the result to ChatAgent as ``accessible_skill_ids``, which + is what mounts the ``AgentSkills`` disclosure plugin. Absent skill bindings ⇒ the + request's ``enabled_skills`` drive the turn as today. - ``knowledge_base`` stays with the existing RAG path. """ @@ -40,6 +41,7 @@ from apis.shared.feature_flags import memory_spaces_enabled, skills_enabled from apis.shared.memory.service import MemorySpaceService from apis.shared.rbac.service import get_app_role_service +from apis.shared.skills.access import resolve_invocable_skill_ids _ROLE_RANK = {"viewer": 1, "editor": 2, "owner": 3} @@ -101,12 +103,12 @@ class ResolvedTools: class ResolvedSkills: """The Agent's ``skill`` bindings resolved to an effective skill set for the invoker. - ``skill_ids`` **replaces** the request's ``enabled_skills`` for this turn and the caller - forces ``agent_type="skill"`` so the SkillAgent discloses exactly these (an Agent that - binds skills owns its skills, like tools own the toolset). Every id has already passed the - invoker's ``AppRoleService.can_access_skill`` gate; a bound skill the invoker could not - access blocks the turn before this is constructed (D5). Always non-empty — the resolver - returns ``None`` (no skill binding) rather than an empty ``ResolvedSkills``. + ``skill_ids`` **replaces** the request's ``enabled_skills`` for this turn so ChatAgent's + AgentSkills plugin discloses exactly these (an Agent that binds skills owns its skills, + like tools own the toolset). Every id has already passed the invoker's invoke-through + predicate (§6); a bound skill the invoker could not access blocks the turn before this + is constructed (D5). Always non-empty — the resolver returns ``None`` (no skill + binding) rather than an empty ``ResolvedSkills``. """ skill_ids: List[str] @@ -162,13 +164,28 @@ async def resolve_agent_invocation(assistant: Assistant, invoker: User) -> Agent async def _resolve_skills(assistant: Assistant, invoker: User) -> Optional[ResolvedSkills]: """Resolve the Agent's ``skill`` bindings to an effective skill set for ``invoker`` (D5). - Each bound skill is re-checked against the invoker with ``AppRoleService.can_access_skill`` - (the same AppRole layer the harness's model/tool gates use); a single missing skill blocks - the turn (block-with-message, no silent drop — D5). The skills feature being disabled in + Each bound skill is re-checked against the invoker with the **invoke-through** + predicate (§6/D7, ``resolve_invocable_skill_ids``): catalog grant ∪ ownership ∪ + "the Agent's owner authored it". A single missing skill blocks the turn + (block-with-message, no silent drop — D5). The skills feature being disabled in this environment also blocks — design-time refuses to create these while the flag is off, so reaching here with the flag off is environment drift (mirror ``_resolve_memory``). Returns ``None`` when the Agent binds no skills, leaving the request's ``agent_type`` / ``enabled_skills`` in force. + + This is the **only** place invoke-through applies. It is AGENT-scoped, not + user-scoped: widening the shared ``resolve_accessible_skill_ids`` instead would + leak an Agent owner's private skills into every invoker's plain-chat picker. + + Clause 3 of the predicate also requires the invoker to have share-access to the + Agent. That half is already satisfied here by construction — the caller reaches + this resolver only after ``get_assistant_with_access_check`` has admitted the + invoker — so the predicate only re-tests the owner-match half. + + Replaced the previous ``AppRoleService.can_access_skill`` check (since removed), + which was wrong on two axes: it had no ownership clause (an author binding their + *own* authored skill was blocked on their own invocation), and its ``"*"`` wildcard + matched any id at all, including another user's private authored skill. """ skill_bindings = [b for b in (assistant.bindings or []) if b.kind == "skill"] if not skill_bindings: @@ -179,19 +196,22 @@ async def _resolve_skills(assistant: Assistant, invoker: User) -> Optional[Resol "This agent uses Skills, which aren't enabled in this environment." ) - app_role_service = get_app_role_service() - resolved: List[str] = [] + refs: List[str] = [] for binding in skill_bindings: - ref = binding.ref - if not await app_role_service.can_access_skill(invoker, ref): + if binding.ref not in refs: + refs.append(binding.ref) + + invocable = await resolve_invocable_skill_ids( + invoker, refs, getattr(assistant, "owner_id", None) + ) + for ref in refs: + if ref not in invocable: raise AgentBindingBlockedError( f"This agent uses the skill **{ref}**, which isn't available to your account. " "Ask an administrator for access, or use a different agent." ) - if ref not in resolved: - resolved.append(ref) - return ResolvedSkills(skill_ids=resolved) + return ResolvedSkills(skill_ids=refs) async def _resolve_tools(assistant: Assistant, invoker: User) -> Optional[ResolvedTools]: diff --git a/backend/src/apis/inference_api/chat/models.py b/backend/src/apis/inference_api/chat/models.py index 495eab24b..6592015f6 100644 --- a/backend/src/apis/inference_api/chat/models.py +++ b/backend/src/apis/inference_api/chat/models.py @@ -112,19 +112,17 @@ class InvocationRequest(BaseModel): # continues the truncated assistant message already in restored history # (assistant-prefill). Bypasses quota / RAG / file resolution like resume. continue_truncated: Optional[bool] = None - # Selects which agent factory variant builds the turn. When omitted, the - # server applies its default (PR-7: "skill" — admin-configurable via the - # chat-mode policy, see routes.py), routing through SkillAgent's progressive - # disclosure; a user with no granted skills degrades to plain ChatAgent - # behavior. Pass "chat" to opt out of the skill path for a turn — honored - # only while the admin policy allows mode toggling. + # Legacy cache/marker dimension. Skills v2 retired the SkillAgent subclass: + # both "chat" and "skill" build a ChatAgent. When the turn carries + # accessible_skill_ids, ChatAgent adds the AgentSkills disclosure plugin. The + # value is kept as a cache-key/resume-snapshot dimension; a turn with no + # skills behaves as plain chat regardless. agent_type: Optional[str] = None - # Per-turn selection of which accessible skills are active (skill agent - # path only). None/absent = all RBAC-accessible skills (back-compat with - # clients that predate the skills picker). A list is intersected - # server-side with the accessible set — client input can narrow the set, - # never grant. An empty (or fully inaccessible) list yields zero skills, - # so the SkillAgent degrades to plain chat behavior for the turn. + # Per-turn selection of which accessible skills are active. None/absent = + # all RBAC-accessible skills (back-compat with clients that predate the + # skills picker). A list is intersected server-side with the accessible set + # — client input can narrow the set, never grant. An empty (or fully + # inaccessible) list yields zero skills, so the turn is plain chat. enabled_skills: Optional[List[str]] = None # User-selected custom system prompt ("conversation mode") for this # turn. The frontend forwards the active selection on every submit so diff --git a/backend/src/apis/inference_api/chat/routes.py b/backend/src/apis/inference_api/chat/routes.py index a6538038c..1e37af085 100644 --- a/backend/src/apis/inference_api/chat/routes.py +++ b/backend/src/apis/inference_api/chat/routes.py @@ -30,8 +30,6 @@ from apis.shared.feature_flags import agents_enabled, skills_enabled from apis.shared.files.file_resolver import get_file_resolver from apis.shared.models.managed_models import list_managed_models -from apis.shared.platform_settings.models import DEFAULT_CHAT_MODE, ChatModeSettings -from apis.shared.platform_settings.service import get_chat_mode_settings_service from apis.shared.quota import ( QuotaExceededEvent, build_no_quota_configured_event, @@ -77,22 +75,11 @@ # Preview session prefix - sessions with this prefix skip persistence PREVIEW_SESSION_PREFIX = "preview-" -# Default agent factory variant for a user turn when the client doesn't pin one -# (PR-7). Flipped from "chat" to "skill": every turn now routes through the -# SkillAgent's progressive disclosure, which folds a granted skill's bound tools -# behind the two meta-tools. Safe by construction — a user with zero accessible -# skills gets a SkillAgent that degrades to plain ChatAgent behavior -# (skill_agent.py), so this is a no-op for them. Clients can still opt out per -# turn by sending agent_type="chat". (The lower-level service.get_agent keeps a -# conservative "chat" fallback for direct/programmatic callers that don't -# resolve skills; this request-policy default lives here.) -# -# Since the skills-mode work (docs/specs/skills-mode.md) the *runtime* default -# comes from the admin-managed chat-mode policy (apis.shared.platform_settings), -# which also decides whether a client agent_type override is honored at all — -# see _resolve_effective_agent_type. This constant is the compiled-in fallback -# the policy model itself defaults to, kept aliased so they can never drift. -DEFAULT_AGENT_TYPE = DEFAULT_CHAT_MODE +# Default agent factory variant for a user turn when the client doesn't pin one. +# Skills v2: plain chat is the default; skills are opt-in (selected per-turn or +# bound on an Agent). A client can still pin agent_type explicitly (e.g. an +# Agent that binds skills resolves to "skill" via the agent-binding resolver). +DEFAULT_AGENT_TYPE = "chat" def _mark_session_cancelled(agent) -> None: @@ -428,7 +415,7 @@ def _build_spreadsheet_tools( # Artifact Authoring Tool Injection # ============================================================ -ARTIFACT_TOOL_IDS = {"create_artifact", "update_artifact"} +ARTIFACT_TOOL_IDS = {"create_artifact"} def _build_artifact_tools( @@ -437,23 +424,23 @@ def _build_artifact_tools( user_id: str, ) -> list: """Create context-bound artifact authoring tools if enabled by the user.""" - if not enabled_tools: - return [] - - requested = ARTIFACT_TOOL_IDS.intersection(enabled_tools) - if not requested: + if not enabled_tools or not ARTIFACT_TOOL_IDS.intersection(enabled_tools): return [] + # Artifacts are a single toggle: enabling create_artifact provisions the + # full authoring toolset (create + update) so the model can iterate on a + # document without a second admin catalog entry. The legacy + # "update_artifact" catalog row is retired — see + # backend/scripts/backfill_artifact_tool_merge.py. from agents.builtin_tools.artifacts import ( make_create_artifact_tool, make_update_artifact_tool, ) - tools = [] - if "create_artifact" in requested: - tools.append(make_create_artifact_tool(session_id, user_id)) - if "update_artifact" in requested: - tools.append(make_update_artifact_tool(session_id, user_id)) + tools = [ + make_create_artifact_tool(session_id, user_id), + make_update_artifact_tool(session_id, user_id), + ] logger.info(f"Created {len(tools)} artifact authoring tools") return tools @@ -856,57 +843,35 @@ async def ping(): async def _resolve_accessible_skill_ids(current_user: User) -> list[str]: - """Resolve the skills a user's RBAC roles grant (admin/DB-backed). + """Resolve every skill a user can reach: RBAC-granted catalog ∪ own. Thin delegate to the shared resolver (``apis.shared.skills.access``) used by both this path and the user-facing skills API, so the picker and the runtime can never drift. Kept as a module-level seam for tests. Never - raises — on any failure the user simply gets no skills (the SkillAgent - degrades to chat). + raises — on any failure the user simply gets no skills and the turn runs + without the disclosure plugin. """ from apis.shared.skills.access import resolve_accessible_skill_ids return await resolve_accessible_skill_ids(current_user) -def _resolve_effective_agent_type( - requested: Optional[str], settings: ChatModeSettings -) -> str: - """Apply the admin chat-mode policy to a client's requested agent type. - - The client's choice between "skill" and "chat" is honored only while the - policy allows mode toggling; otherwise the admin default wins (UI gating - alone is not enforcement — the SPA hides the toggle, but any client can - craft a request). Other values ("voice", future internal types) pass - through untouched: the policy governs the user-facing mode pair only. - """ - if ( - requested in ("skill", "chat") - and requested != settings.default_mode - and not settings.allow_mode_toggle - ): - logger.info( - "Client agent_type=%s overridden to %s (mode toggling disabled by admin)", - requested, - settings.default_mode, - ) - requested = None - return requested or settings.default_mode - - def _apply_enabled_skills_filter( accessible_skill_ids: list[str], enabled_skills: Optional[list[str]] ) -> list[str]: - """Narrow the RBAC-accessible skill set by the client's per-turn selection. + """Narrow the accessible skill set by the client's per-turn selection. - ``None`` means the client predates (or isn't using) the skills picker — - all accessible skills stay active, the pre-picker behavior. A list is an - intersection: client input can narrow the set, never grant. The result - stays a list (possibly empty) because SkillAgent distinguishes [] (DB - mode, zero skills → degrade to chat) from None (file-scan fallback). + Intersection only: client input can narrow the set, never grant. + + ``None`` (or an empty list) means **no skills** — Skills v2 D6 flips plain + chat to opt-in, unlike tools. This is the reverse of v1, where absent meant + "every accessible skill". Opt-in is what keeps prompt bloat and instruction + conflicts bounded as the catalog grows across two authorship tiers; a client + that predates the picker now simply gets a plain chat turn, which is the + safe direction to fail. """ - if enabled_skills is None: - return accessible_skill_ids + if not enabled_skills: + return [] requested = set(enabled_skills) return [sid for sid in accessible_skill_ids if sid in requested] @@ -932,31 +897,32 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g # they bypass quota, file resolution, and RAG augmentation because those # already ran on the original turn that got paused. is_resume = bool(input_data.interrupt_responses) - # Resolve the effective agent type once: the client's explicit choice - # (honored only while the admin chat-mode policy allows toggling), else - # the policy's default mode. Used for the skill resolution below and the - # non-resume get_agent calls (resume reuses the snapshot's type). The - # settings read is TTL-cached in-process (~60s) and degrades to compiled-in - # defaults, so this adds no per-turn Dynamo cost and can't fail the turn. - chat_mode_settings = await get_chat_mode_settings_service().get_settings() - effective_agent_type = _resolve_effective_agent_type( - input_data.agent_type, chat_mode_settings - ) - # Skills feature deferred for this environment: never route a turn through - # the SkillAgent, regardless of the client's request or any stored policy. - # Only the skill mode is neutralized — voice and other agent types pass - # through untouched. + # Resolve the effective agent type: the client's explicit choice, else the + # compiled-in default ("chat"). Used for the skill resolution below and the + # non-resume get_agent calls (resume reuses the snapshot's type). An Agent + # that binds skills is coerced to "skill" later by the agent-binding + # resolver. + effective_agent_type = input_data.agent_type or DEFAULT_AGENT_TYPE + # Skills feature deferred for this environment: neutralize the legacy + # "skill" agent type, which is a ChatAgent alias since v2 PR-2. Voice and + # other agent types pass through untouched. if not skills_enabled() and effective_agent_type == "skill": effective_agent_type = "chat" - # Resolve the user's *effective* skills once for the whole request — only - # for the skill agent path: the RBAC-accessible set (admin/DB-backed), - # narrowed by the client's per-turn enabled_skills selection. Threaded into - # every get_agent call below so they share one skills_hash cache key - # (otherwise the app-tool-call / resume paths would miss the main turn's - # cached SkillAgent). An explicit agent_type="chat" opts out and stays free - # of the extra reads. + # Resolve the user's *effective* skills once for the whole request: the + # accessible set (catalog ∪ own), narrowed by the client's per-turn + # enabled_skills selection. Threaded into every get_agent call below so they + # share one skills_hash cache key (otherwise the app-tool-call / resume paths + # would miss the main turn's cached agent). + # + # Skills v2: this is no longer gated on agent_type == "skill". Skills are a + # plain-chat capability now — the picker in model settings sends + # enabled_skills on an ordinary turn and ChatAgent mounts the AgentSkills + # plugin. The opt-in default (D6) is what keeps this cheap: an absent or + # empty selection short-circuits to [] without touching RBAC or the skill + # table, so every turn that doesn't ask for skills costs exactly what it did + # before. An Agent's skill bindings override this further down. effective_skill_ids = None - if effective_agent_type == "skill": + if skills_enabled() and input_data.enabled_skills: effective_skill_ids = _apply_enabled_skills_filter( await _resolve_accessible_skill_ids(current_user), input_data.enabled_skills, @@ -1685,22 +1651,24 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g agent_type=snapshot.agent_type, is_resume=True, # Resume must rebuild the SAME cache key the original turn used, - # or the paused agent is orphaned. The original turn's - # skills_hash was derived from its *effective* skill set only - # when it was a skill turn; mirror that off the snapshot's type - # (a turn explicitly built as "chat" carried no skills → empty - # skills_hash). New snapshots carry that exact set in - # enabled_skills; legacy snapshots (written before the field - # existed) fall back to this request's resolution, the - # pre-skills-picker behavior. + # or the paused agent is orphaned. New snapshots carry the + # original turn's exact effective set in enabled_skills, so + # replay it verbatim — including [] (a turn that deliberately + # carried no skills → empty skills_hash). + # + # Skills v2: this must NOT be gated on snapshot.agent_type == + # "skill" any more. A plain-chat turn now carries skills too, so + # that gate would resolve a skills-bearing "chat" snapshot back + # to None and orphan its paused agent. The snapshot's own field + # is the authority. + # + # Legacy snapshots (written before enabled_skills existed) still + # fall back to this request's resolution, and only for a "skill" + # turn — that is exactly what those turns were built with. accessible_skill_ids=( - ( - snapshot.enabled_skills - if snapshot.enabled_skills is not None - else effective_skill_ids - ) - if snapshot.agent_type == "skill" - else None + snapshot.enabled_skills + if snapshot.enabled_skills is not None + else (effective_skill_ids if snapshot.agent_type == "skill" else None) ), ) # The `stream_with_quota_warning` closure below references @@ -1802,8 +1770,8 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g else input_data.enabled_tools ) - # An Agent's skill bindings replace the request's skills for this turn and - # force skill-mode so the SkillAgent discloses exactly the bound set (D5, + # An Agent's skill bindings replace the request's skills for this turn so + # ChatAgent's AgentSkills plugin discloses exactly the bound set (D5, # resolved per invoker above). Reassigning these function-scope locals here # (before the main-turn get_agent below) makes them flow into construction — # and thus the paused-turn snapshot — so a bound-skill agent resumes on the diff --git a/backend/src/apis/inference_api/chat/service.py b/backend/src/apis/inference_api/chat/service.py index 57f707346..467d92418 100644 --- a/backend/src/apis/inference_api/chat/service.py +++ b/backend/src/apis/inference_api/chat/service.py @@ -72,10 +72,10 @@ def _create_cache_key( `skills_hash` is the skills analog: a digest of the user's resolved accessible skill ids AND their `updated_at` values (skills/freshness). - A SkillAgent's tool universe is derived from the user's granted skills, - which `enabled_tools` does not capture — so without this an edit to a - granted skill (or a role grant change) would serve a stale agent. Empty - for chat agents (no skills), so the default path is unaffected. + A skill turn's `` disclosure is derived from the user's + granted skills, which `enabled_tools` does not capture — so without this an + edit to a granted skill (or a role grant change) would serve a stale agent. + Empty for chat turns with no skills, so the default path is unaffected. """ tools_hash = _hash_tools(enabled_tools) @@ -175,10 +175,11 @@ async def get_agent( freshness_hash = await get_freshness_hash(enabled_tools or []) - # Skills dimension of the cache key (skill agents only). Digest of the - # user's accessible skill ids + their updated_at, so an edit to a granted - # skill or a role-grant change invalidates the cached SkillAgent. Empty - # string for the chat path (no accessible_skill_ids) — key unchanged. + # Skills dimension of the cache key. Digest of the turn's effective skill + # ids + their updated_at, so an edit to a granted skill or a role-grant + # change invalidates the cached agent. Empty string when the turn carries no + # skills — which, under the D6 opt-in default, is the common case — so those + # keys are byte-identical to the pre-skills ones. skills_hash = "" if accessible_skill_ids: from apis.shared.skills.freshness import ( @@ -224,9 +225,9 @@ async def get_agent( # Cache miss - create new agent logger.debug("⚠️ Agent cache miss - creating new instance") - # Create agent via the type registry. Default "chat" preserves the - # existing MainAgent (= ChatAgent) behavior; "skill" routes through - # SkillAgent's progressive skill disclosure. + # Create agent via the type registry. Both "chat" and "skill" resolve to + # ChatAgent (Skills v2 retired the SkillAgent subclass); a "skill" turn just + # carries accessible_skill_ids, which adds the AgentSkills disclosure plugin. resolved_agent_type = agent_type or "chat" create_kwargs: Dict[str, Any] = dict( agent_type=resolved_agent_type, @@ -244,19 +245,26 @@ async def get_agent( mantle_api_mode=mantle_api_mode, mantle_region=mantle_region, ) - # Only the SkillAgent accepts accessible_skill_ids; ChatAgent's constructor - # would reject the unknown kwarg, so gate it on the skill type. - if resolved_agent_type == "skill": + # Skills v2: ChatAgent (now the target of both "chat" and "skill" types) + # accepts accessible_skill_ids and conditionally adds the AgentSkills + # plugin. Pass it through whenever resolved — VoiceAgent does not take the + # kwarg, so keep it off that path. + if resolved_agent_type != "voice": create_kwargs["accessible_skill_ids"] = accessible_skill_ids agent = create_agent(**create_kwargs) # Stamp the type onto the construction snapshot so a paused turn can - # resume on the same factory variant after cache eviction. Skill turns - # also stamp their effective skill set: resume must rebuild the same + # resume on the same factory variant after cache eviction. A turn carrying + # skills also stamps its effective set: resume must rebuild the same # skills_hash cache key even if the user toggles skills mid-pause. + # + # Skills v2: stamped for any non-voice type, not just "skill" — a plain + # "chat" turn now carries skills via the opt-in picker, and gating this on + # the type would leave those snapshots blank and orphan the paused agent on + # resume. if hasattr(agent, "_construction_snapshot"): agent._construction_snapshot["agent_type"] = resolved_agent_type - if resolved_agent_type == "skill" and accessible_skill_ids is not None: + if resolved_agent_type != "voice" and accessible_skill_ids is not None: agent._construction_snapshot["enabled_skills"] = list(accessible_skill_ids) # Don't cache agents with context-bound extra_tools diff --git a/backend/src/apis/shared/feature_flags.py b/backend/src/apis/shared/feature_flags.py index b6cffd0cf..2392c0f73 100644 --- a/backend/src/apis/shared/feature_flags.py +++ b/backend/src/apis/shared/feature_flags.py @@ -16,15 +16,27 @@ def skills_enabled() -> bool: - """Whether the Skills feature is enabled for this environment. - - Covers the admin skills catalog, the user-facing skills picker, and - skills mode (routing turns through the ``SkillAgent``). Defaults off; - set ``SKILLS_ENABLED=true`` to turn it on. While off, new turns are - forced through the plain ``ChatAgent`` and the skills surfaces are - unmounted / hidden, but all skills data and code remain intact. + """Whether the Skills feature exists in this environment. + + Covers the admin skills catalog, the user-facing skills picker, My Skills + authoring, and skill resolution on the runtime path. **Default ON with a + kill switch** (house style, mirroring ``SCHEDULED_RUNS_ENABLED``): unset or + empty resolves to enabled; only the literal ``"false"`` (case-insensitive) + disables. The CDK side threads ``config.skills.enabled`` into this env var + with the same empty-string-safe ternary, so an unset GitHub Actions + variable can never silently turn the feature off. + + Flipped from default-off in Skills v2 PR-5, once the epic was complete and + dogfooded end to end (a real agentskills.io bundle uploaded as a user skill, + bound to an Agent, exercised L1→L2→L3 including ``read_skill_file``). + + Note this flag gates *feature existence* per environment; *who* may use it + is a role's ``grantedSkills`` — two independent controls. (A ``skills`` RBAC + *capability* briefly gated the user-facing surfaces on top of this; it was + removed because a capability id cannot be granted from the admin roles UI. + See ``AppRoleService.resolve_user_permissions``.) """ - return os.environ.get("SKILLS_ENABLED", "false").lower() == "true" + return os.environ.get("SKILLS_ENABLED", "").strip().lower() != "false" def scheduled_runs_enabled() -> bool: @@ -39,9 +51,9 @@ def scheduled_runs_enabled() -> bool: empty-string-safe ternary, so an unset GitHub Actions variable can never silently turn the feature off. - Note this flag gates *feature existence* per environment; *who* can use - it is the ``scheduled-runs`` RBAC capability - (``apis.shared.rbac.capabilities``) — two independent controls. + Note this flag is the *only* control on this surface. A ``scheduled-runs`` + RBAC capability once gated *who* could use it, but that gate 403'd in prod + and was dropped; the routes are deliberately ungated now. """ return os.environ.get("SCHEDULED_RUNS_ENABLED", "").strip().lower() != "false" diff --git a/backend/src/apis/shared/platform_settings/__init__.py b/backend/src/apis/shared/platform_settings/__init__.py deleted file mode 100644 index 4b1a29bda..000000000 --- a/backend/src/apis/shared/platform_settings/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Platform-wide admin-managed settings shared by app-api and inference-api.""" - -from .models import ( - DEFAULT_CHAT_MODE, - ChatMode, - ChatModeSettings, - ChatModeSettingsUpdate, -) -from .repository import PlatformSettingsRepository, get_platform_settings_repository -from .service import ChatModeSettingsService, get_chat_mode_settings_service - -__all__ = [ - "DEFAULT_CHAT_MODE", - "ChatMode", - "ChatModeSettings", - "ChatModeSettingsUpdate", - "PlatformSettingsRepository", - "get_platform_settings_repository", - "ChatModeSettingsService", - "get_chat_mode_settings_service", -] diff --git a/backend/src/apis/shared/platform_settings/models.py b/backend/src/apis/shared/platform_settings/models.py deleted file mode 100644 index e965fcd1c..000000000 --- a/backend/src/apis/shared/platform_settings/models.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Models for platform-wide chat-mode settings. - -Chat-mode settings control which agent mode new conversations get by -default (``skill`` routes through the SkillAgent, ``chat`` through the -plain ChatAgent) and whether users may switch between the two modes. - -The defaults here must reproduce the server behavior that existed before -these settings were introduced (``DEFAULT_AGENT_TYPE = "skill"``, client -``agent_type`` overrides honored), so an environment without a stored -settings item sees no behavior change. -""" - -from datetime import datetime -from typing import Any, Dict, Literal, Optional - -from pydantic import BaseModel, ConfigDict, Field - -ChatMode = Literal["skill", "chat"] - -DEFAULT_CHAT_MODE: ChatMode = "skill" - - -class ChatModeSettings(BaseModel): - """The stored chat-mode policy.""" - - default_mode: ChatMode = DEFAULT_CHAT_MODE - allow_mode_toggle: bool = True - updated_at: Optional[datetime] = None - updated_by: Optional[str] = None - - def to_dynamo_item(self) -> Dict[str, Any]: - item: Dict[str, Any] = { - "defaultMode": self.default_mode, - "allowModeToggle": self.allow_mode_toggle, - } - if self.updated_at is not None: - item["updatedAt"] = self.updated_at.isoformat() - if self.updated_by is not None: - item["updatedBy"] = self.updated_by - return item - - @classmethod - def from_dynamo_item(cls, item: Dict[str, Any]) -> "ChatModeSettings": - updated_at = item.get("updatedAt") - return cls( - default_mode=item.get("defaultMode", DEFAULT_CHAT_MODE), - allow_mode_toggle=item.get("allowModeToggle", True), - updated_at=datetime.fromisoformat(updated_at) if updated_at else None, - updated_by=item.get("updatedBy"), - ) - - -class ChatModeSettingsUpdate(BaseModel): - """Admin PUT body. Accepts camelCase from the SPA.""" - - model_config = ConfigDict(populate_by_name=True) - - default_mode: ChatMode = Field(alias="defaultMode") - allow_mode_toggle: bool = Field(alias="allowModeToggle") - - -class ChatModeSettingsResponse(BaseModel): - """Admin-facing view, including audit fields.""" - - model_config = ConfigDict(populate_by_name=True) - - default_mode: ChatMode = Field(alias="defaultMode") - allow_mode_toggle: bool = Field(alias="allowModeToggle") - updated_at: Optional[datetime] = Field(None, alias="updatedAt") - updated_by: Optional[str] = Field(None, alias="updatedBy") - - @classmethod - def from_settings(cls, settings: ChatModeSettings) -> "ChatModeSettingsResponse": - return cls( - default_mode=settings.default_mode, - allow_mode_toggle=settings.allow_mode_toggle, - updated_at=settings.updated_at, - updated_by=settings.updated_by, - ) - - -class ChatSettingsPublicResponse(BaseModel): - """User-facing view for the SPA — the policy flags plus whether the - skills feature is enabled at all (drives the admin nav + mode toggle).""" - - model_config = ConfigDict(populate_by_name=True) - - default_mode: ChatMode = Field(alias="defaultMode") - allow_mode_toggle: bool = Field(alias="allowModeToggle") - skills_enabled: bool = Field(default=True, alias="skillsEnabled") - - @classmethod - def from_settings( - cls, settings: ChatModeSettings, *, skills_enabled: bool = True - ) -> "ChatSettingsPublicResponse": - return cls( - default_mode=settings.default_mode, - allow_mode_toggle=settings.allow_mode_toggle, - skills_enabled=skills_enabled, - ) diff --git a/backend/src/apis/shared/platform_settings/repository.py b/backend/src/apis/shared/platform_settings/repository.py deleted file mode 100644 index 070c6a55a..000000000 --- a/backend/src/apis/shared/platform_settings/repository.py +++ /dev/null @@ -1,101 +0,0 @@ -"""DynamoDB repository for platform-wide chat-mode settings. - -Stores a single sentinel item in the auth-providers table, following the -``SYSTEM_SETTINGS#first-boot`` convention (`app_api/system/repository.py`). -Both app-api and the inference-api runtime already have this table's name -in their environment (``DYNAMODB_AUTH_PROVIDERS_TABLE_NAME``) and IAM read -access, so no new infrastructure is required. -""" - -import logging -import os -from typing import Optional - -import boto3 -from botocore.exceptions import ClientError - -from .models import ChatModeSettings - -logger = logging.getLogger(__name__) - -CHAT_MODE_PK = "SYSTEM_SETTINGS#chat-mode" -CHAT_MODE_SK = "SYSTEM_SETTINGS#chat-mode" - - -class PlatformSettingsRepository: - """Repository for platform-settings sentinel items.""" - - def __init__( - self, - table_name: Optional[str] = None, - region: Optional[str] = None, - ): - self._table_name = table_name or os.getenv("DYNAMODB_AUTH_PROVIDERS_TABLE_NAME") - self._region = region or os.getenv("AWS_REGION", "us-west-2") - self._enabled = bool(self._table_name) - - if not self._enabled: - logger.warning( - "DYNAMODB_AUTH_PROVIDERS_TABLE_NAME not set. " - "Platform settings repository is disabled." - ) - return - - profile = os.getenv("AWS_PROFILE") - if profile: - session = boto3.Session(profile_name=profile) - self._dynamodb = session.resource("dynamodb", region_name=self._region) - else: - self._dynamodb = boto3.resource("dynamodb", region_name=self._region) - - self._table = self._dynamodb.Table(self._table_name) - logger.info(f"Initialized platform settings repository: table={self._table_name}") - - @property - def enabled(self) -> bool: - return self._enabled - - async def get_chat_mode_settings(self) -> Optional[ChatModeSettings]: - """Read the stored chat-mode settings, or None if never written.""" - if not self._enabled: - return None - - try: - response = self._table.get_item( - Key={"PK": CHAT_MODE_PK, "SK": CHAT_MODE_SK} - ) - item = response.get("Item") - if item is None: - return None - return ChatModeSettings.from_dynamo_item(item) - except ClientError as e: - logger.error(f"Error reading chat-mode settings: {e}") - raise - - async def put_chat_mode_settings(self, settings: ChatModeSettings) -> None: - """Write the chat-mode settings item (full replace).""" - if not self._enabled: - raise RuntimeError("Platform settings repository is not enabled") - - item = { - "PK": CHAT_MODE_PK, - "SK": CHAT_MODE_SK, - **settings.to_dynamo_item(), - } - self._table.put_item(Item=item) - logger.info( - f"Chat-mode settings updated: default_mode={settings.default_mode}, " - f"allow_mode_toggle={settings.allow_mode_toggle}" - ) - - -# Singleton instance -_repository: Optional[PlatformSettingsRepository] = None - - -def get_platform_settings_repository() -> PlatformSettingsRepository: - """Get the platform settings repository singleton.""" - global _repository - if _repository is None: - _repository = PlatformSettingsRepository() - return _repository diff --git a/backend/src/apis/shared/platform_settings/service.py b/backend/src/apis/shared/platform_settings/service.py deleted file mode 100644 index ea261f352..000000000 --- a/backend/src/apis/shared/platform_settings/service.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Chat-mode settings service with a short in-process TTL cache. - -The inference path reads the policy on every turn; the TTL cache keeps -that to one DynamoDB read per container per minute. Admin updates bust -the local cache immediately — other containers converge within the TTL. -Reads degrade to compiled-in defaults (current server behavior) when the -table is unconfigured, the item is absent, or the read fails. -""" - -import logging -import time -from datetime import datetime, timezone -from typing import Callable, Optional - -from .models import ChatModeSettings, ChatModeSettingsUpdate -from .repository import PlatformSettingsRepository, get_platform_settings_repository - -logger = logging.getLogger(__name__) - -CACHE_TTL_SECONDS = 60.0 - - -class ChatModeSettingsService: - """Cached read / write-through update of the chat-mode policy.""" - - def __init__( - self, - repository: Optional[PlatformSettingsRepository] = None, - cache_ttl_seconds: float = CACHE_TTL_SECONDS, - clock: Callable[[], float] = time.monotonic, - ): - self._repository = repository or get_platform_settings_repository() - self._cache_ttl_seconds = cache_ttl_seconds - self._clock = clock - self._cached: Optional[ChatModeSettings] = None - self._cached_at: float = 0.0 - - async def get_settings(self) -> ChatModeSettings: - """Get the chat-mode policy, falling back to defaults on any failure.""" - now = self._clock() - if self._cached is not None and (now - self._cached_at) < self._cache_ttl_seconds: - return self._cached - - try: - settings = await self._repository.get_chat_mode_settings() - except Exception: - logger.exception("Failed to read chat-mode settings — using defaults") - settings = None - - resolved = settings or ChatModeSettings() - self._cached = resolved - self._cached_at = now - return resolved - - async def update_settings( - self, - update: ChatModeSettingsUpdate, - updated_by: str, - ) -> ChatModeSettings: - """Persist a new chat-mode policy and bust the local cache.""" - settings = ChatModeSettings( - default_mode=update.default_mode, - allow_mode_toggle=update.allow_mode_toggle, - updated_at=datetime.now(timezone.utc), - updated_by=updated_by, - ) - await self._repository.put_chat_mode_settings(settings) - self._cached = settings - self._cached_at = self._clock() - return settings - - -# Singleton instance -_service: Optional[ChatModeSettingsService] = None - - -def get_chat_mode_settings_service() -> ChatModeSettingsService: - """Get the chat-mode settings service singleton.""" - global _service - if _service is None: - _service = ChatModeSettingsService() - return _service diff --git a/backend/src/apis/shared/rbac/capabilities.py b/backend/src/apis/shared/rbac/capabilities.py deleted file mode 100644 index 328eed0cf..000000000 --- a/backend/src/apis/shared/rbac/capabilities.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Feature-capability checks riding the AppRole grant system. - -A *capability* is a feature-level permission ("may this user use scheduled -runs?") rather than a resource-level one ("may this user call this tool?"). -Rather than adding a net-new allowlist table or a new grant axis for the -first capability, capability ids are granted through the mature **tools -grant axis**: an admin adds the capability id to a role's ``grantedTools`` -(e.g. a ``scheduled_runs_beta`` role granting ``scheduled-runs``), and this -module checks it via the same cached RBAC resolution path every tool check -uses (scheduled-agent-runs.md §6 — "reuses the mature RBAC resolution path, -no net-new allowlist table"). - -Consequences to be aware of: - -* A wildcard tools grant (``*`` — the seeded ``system_admin`` role) holds - every capability implicitly. Admins are in every beta by construction. -* Capability ids share a namespace with tool ids. They never collide with - a real tool in practice (no tool in the catalog is named like a feature), - and a granted capability id simply matches no tool at agent-build time — - but pick ids that read as features, not tools. -* GA for a capability = grant its id to the ``default`` role. One config - change, no redeploy (scheduled-agent-runs.md §6, "GA path"). - -If capabilities outgrow this (per-capability metadata, UI surfacing), the -RBAC gap ledger already names the real fix: extend the grant vocabulary -with a first-class axis (agentic-platform-primitives.md §1, RBAC row). -""" - -from __future__ import annotations - -from apis.shared.auth.models import User -from apis.shared.rbac.service import get_app_role_service - -#: Gates the headless-runs surface ("Run now" today; schedule CRUD in -#: Phase B). Granted to the beta cohort's role; GA = grant to ``default``. -SCHEDULED_RUNS_CAPABILITY = "scheduled-runs" - - -async def user_has_capability(user: User, capability_id: str) -> bool: - """True iff ``user`` resolves the capability through their AppRoles. - - Wildcard tool grants satisfy every capability (see module docstring). - """ - return await get_app_role_service().can_access_tool(user, capability_id) diff --git a/backend/src/apis/shared/rbac/service.py b/backend/src/apis/shared/rbac/service.py index 8e1899dbc..4c1f93e80 100644 --- a/backend/src/apis/shared/rbac/service.py +++ b/backend/src/apis/shared/rbac/service.py @@ -41,8 +41,28 @@ async def resolve_user_permissions( Algorithm: 1. Check user cache 2. For each JWT role, find matching AppRoles - 3. Merge permissions (union for tools/models, highest priority for quota) - 4. Cache and return + 3. If *nothing* matched, fall back to the ``default`` role + 4. Merge permissions (union for tools/models, highest priority for quota) + 5. Cache and return + + **``default`` is a fallback, not a universal role.** Step 3 + consults it only when the user matched *zero* AppRoles; it is never + merged alongside a matched role. Prod's ``default`` additionally + carries no ``jwtRoleMappings`` at all, so granting something there + reaches only users who match nothing else — never the cohort roles + (prod: ``faculty``/``staff``/``student``/``demo_day``). Comments + across this repo previously framed feature GA as "one grant to + ``default``, no redeploy"; that was wrong and has been corrected. + Reaching everyone means granting to each cohort role, or changing + ``default`` to merge as a baseline rather than substitute. + + Related: the admin roles UI builds its ``grantedTools`` control from + the tool catalog (``admin/roles/pages/role-form.page.ts``, + ``availableTools()``) with no free-text entry. Anything that is not a + catalog tool — a feature-capability id, say — therefore cannot be + granted from the UI at all, only by hand-writing DynamoDB items. That + is what made the short-lived ``skills`` capability gate inoperable and + got it removed; weigh it before routing a new grant through this axis. Args: user: Authenticated user with JWT roles @@ -199,16 +219,6 @@ async def can_access_model(self, user: User, model_id: str) -> bool: return model_id in permissions.models - async def can_access_skill(self, user: User, skill_id: str) -> bool: - """Check if user can access a specific skill.""" - permissions = await self.resolve_user_permissions(user) - - # Wildcard grants access to all - if "*" in permissions.skills: - return True - - return skill_id in permissions.skills - async def get_accessible_tools(self, user: User) -> List[str]: """Get list of tool IDs user can access.""" permissions = await self.resolve_user_permissions(user) diff --git a/backend/src/apis/shared/sessions/metadata.py b/backend/src/apis/shared/sessions/metadata.py index ea6d6b7db..bc75d0c9e 100644 --- a/backend/src/apis/shared/sessions/metadata.py +++ b/backend/src/apis/shared/sessions/metadata.py @@ -1840,6 +1840,37 @@ def _encode_list_cursor(session: SessionMetadata) -> str: return base64.b64encode(json.dumps(payload).encode('utf-8')).decode('utf-8') +# --- Issue #175 Phase 3: contract the dual-scheme read once migration completes --- +_MIGRATION_MARKER_PK = "MIGRATION#session-sk" +_MIGRATION_MARKER_SK = "STATE" +# Memoised once observed True. The marker only ever goes unset -> set (the Phase 2 +# backfill sets it after confirming zero legacy rows), never back, so caching a True +# result per-process is safe. A False/absent result is NOT cached, so a container +# that started before the backfill picks up the flip on a later list call. +_migration_complete_cache = False + + +def _is_session_migration_complete(table) -> bool: + """True once the Phase 2 backfill has set the migration-complete marker. + + Gates ``list_user_sessions`` from the dual-scheme union down to a GSI-only read. + Fails open (returns False -> keep dual-read) on any error, and stays in dual-read + for downstream/forked deployments that haven't run the backfill — so removing the + legacy branch here can never blank the sidebar on un-migrated data. + """ + global _migration_complete_cache + if _migration_complete_cache: + return True + try: + resp = table.get_item(Key={"PK": _MIGRATION_MARKER_PK, "SK": _MIGRATION_MARKER_SK}) + if (resp.get("Item") or {}).get("complete"): + _migration_complete_cache = True + return True + except Exception as e: + logger.debug("migration marker check failed (staying in dual-read): %s", e) + return False + + async def _list_user_sessions_cloud( user_id: str, table_name: str, @@ -1885,25 +1916,13 @@ async def _list_user_sessions_cloud( want = (limit + 1) if limit else None pk = f'USER#{user_id}' - # Legacy source: base table, S#ACTIVE# prefix. Resuming after a cursor uses - # between('S#ACTIVE#', 'S#ACTIVE#{la}#{sid}') — a single range condition that - # keeps the prefix filter while bounding the upper end (inclusive; the exact - # cursor row is dropped by the strict-less filter below). - if cursor: - la, sid = cursor - legacy_cond = Key('PK').eq(pk) & Key('SK').between('S#ACTIVE#', f'S#ACTIVE#{la}#{sid}') - else: - legacy_cond = Key('PK').eq(pk) & Key('SK').begins_with('S#ACTIVE#') - legacy_params: Dict[str, Any] = { - 'KeyConditionExpression': legacy_cond, - 'ScanIndexForward': False, - } - if want: - legacy_params['Limit'] = want - legacy_sessions = _collect_valid_sessions(table, legacy_params, want) + # GSI-only once the migration is complete (marker set); otherwise dual-read + # the union so un-migrated legacy rows stay visible — for downstream/forked + # deployments that haven't run the Phase 2 backfill yet. + gsi_only = _is_session_migration_complete(table) # Migrated source: SessionRecencyIndex GSI. GSI4_SK < '{la}#{sid}' is a clean - # strict-less resume. Degrade to legacy-only if the index isn't there yet. + # strict-less resume. if cursor: la, sid = cursor gsi_cond = Key('GSI4_PK').eq(pk) & Key('GSI4_SK').lt(f'{la}#{sid}') @@ -1916,6 +1935,7 @@ async def _list_user_sessions_cloud( } if want: gsi_params['Limit'] = want + gsi_failed = False try: gsi_sessions = _collect_valid_sessions(table, gsi_params, want) except ClientError as e: @@ -1936,9 +1956,32 @@ async def _list_user_sessions_cloud( "session listing", code ) gsi_sessions = [] + gsi_failed = True else: raise + # Legacy source: base table, S#ACTIVE# prefix. Skipped once migration is + # complete (GSI-only), UNLESS the GSI query failed — then fall back to legacy + # regardless so a transient index error never blanks the list. Resuming after + # a cursor uses between('S#ACTIVE#', 'S#ACTIVE#{la}#{sid}') — a single range + # condition that keeps the prefix filter while bounding the upper end + # (inclusive; the exact cursor row is dropped by the strict-less filter below). + if gsi_only and not gsi_failed: + legacy_sessions: list[SessionMetadata] = [] + else: + if cursor: + la, sid = cursor + legacy_cond = Key('PK').eq(pk) & Key('SK').between('S#ACTIVE#', f'S#ACTIVE#{la}#{sid}') + else: + legacy_cond = Key('PK').eq(pk) & Key('SK').begins_with('S#ACTIVE#') + legacy_params: Dict[str, Any] = { + 'KeyConditionExpression': legacy_cond, + 'ScanIndexForward': False, + } + if want: + legacy_params['Limit'] = want + legacy_sessions = _collect_valid_sessions(table, legacy_params, want) + # Merge: sort by (lastMessageAt, sessionId) descending, drop anything not # strictly older than the cursor (removes the inclusive legacy cursor row), # dedupe by session_id. diff --git a/backend/src/apis/shared/skills/__init__.py b/backend/src/apis/shared/skills/__init__.py index 5be477c82..c6d85ea28 100644 --- a/backend/src/apis/shared/skills/__init__.py +++ b/backend/src/apis/shared/skills/__init__.py @@ -14,6 +14,7 @@ ) from .models import ( SKILL_ID_PATTERN, + SYSTEM_OWNER_ID, AddRemoveSkillRolesRequest, AdminSkillListResponse, AdminSkillResponse, @@ -28,7 +29,7 @@ SkillUpdateRequest, SkillVisibility, ) -from .access import resolve_accessible_skill_ids +from .access import resolve_accessible_skill_ids, resolve_owned_skill_ids from .models import UserSkillPreference from .repository import SkillCatalogRepository, get_skill_catalog_repository from .resource_store import ( @@ -39,6 +40,7 @@ __all__ = [ "SKILL_ID_PATTERN", + "SYSTEM_OWNER_ID", "SkillDefinition", "SkillResourceRef", "SkillResourcesResponse", @@ -62,5 +64,6 @@ "get_skill_updated_at", "invalidate", "resolve_accessible_skill_ids", + "resolve_owned_skill_ids", "UserSkillPreference", ] diff --git a/backend/src/apis/shared/skills/access.py b/backend/src/apis/shared/skills/access.py index b3b96f783..1998af9bd 100644 --- a/backend/src/apis/shared/skills/access.py +++ b/backend/src/apis/shared/skills/access.py @@ -1,34 +1,140 @@ """Per-user skill access resolution shared by app_api and the runtime. -Single source of truth for "which skills do this user's RBAC roles grant", -so the user-facing skills API (``app_api/skills``) and the inference path +Single source of truth for "which skills can this user reach", so the +user-facing skills API (``app_api/skills``) and the inference path (``inference_api/chat``) can never drift. Lives in ``apis.shared`` per the import-boundary rule (both consume it; neither may import the other). + +Two tiers feed the result (Skills v2 §5): + +- **catalog** — admin-authored skills the user's RBAC roles grant + (``granted_skills``, ``"*"`` honored over the catalog only). +- **own** — skills the user authored themselves (``owner_id == user_id``, + resolved through the ``SkillOwnerIndex`` GSI). Ownership is its own grant; + it needs no role. + +Selection (``enabled_skills``) narrows this set per turn; it never widens it. + +A third tier exists only *through an Agent* — **invoke-through** (§6/D7): +skills the Agent's owner authored resolve for anyone the Agent is shared with. +It deliberately does NOT live in ``resolve_accessible_skill_ids``, because that +function feeds the plain-chat picker and the design-time bindable palette; a +skill reachable only by invoking someone's Agent must never appear there. See +:func:`resolve_invocable_skill_ids`. """ import logging -from typing import List +from typing import List, Optional, Set from apis.shared.auth.models import User logger = logging.getLogger(__name__) +async def resolve_owned_skill_ids(user: User) -> List[str]: + """Resolve the ACTIVE skills a user authored (the user-authored tier). + + Ownership is the grant — a user always reaches their own skills without + any RBAC role. Never raises; on failure the user simply sees none. + """ + if not getattr(user, "user_id", None): + return [] + try: + from apis.shared.skills.models import SkillStatus + from apis.shared.skills.repository import get_skill_catalog_repository + + skills = await get_skill_catalog_repository().list_skills_by_owner( + user.user_id, status=SkillStatus.ACTIVE.value + ) + return [s.skill_id for s in skills] + except Exception: + logger.warning("Failed to resolve owned skills", exc_info=True) + return [] + + async def resolve_accessible_skill_ids(user: User) -> List[str]: - """Resolve the skills a user's RBAC roles grant (admin/DB-backed). + """Resolve every skill a user can reach: RBAC-granted catalog ∪ own. - A ``"*"`` wildcard grant expands to every known skill id. Never raises — - on any failure the user simply gets no skills (the SkillAgent degrades - to chat, the skills list renders empty). + A ``"*"`` wildcard grant expands to every *catalog* skill id — never to + another user's authored skills (see ``freshness.get_all_skill_ids``). + Never raises — on any failure the user simply gets no skills (the agent + runs without the disclosure plugin, the skills list renders empty). """ + catalog: List[str] = [] try: from apis.shared.rbac.service import get_app_role_service from apis.shared.skills.freshness import get_all_skill_ids skills = await get_app_role_service().get_accessible_skills(user) if "*" in skills: - return sorted(await get_all_skill_ids()) - return list(skills) + catalog = sorted(await get_all_skill_ids()) + else: + catalog = list(skills) except Exception: logger.warning("Failed to resolve accessible skills", exc_info=True) - return [] + + owned = await resolve_owned_skill_ids(user) + + # Preserve catalog order, then append owned ids the catalog didn't already + # grant (a user may hold a role that grants a skill they also authored). + seen = set(catalog) + return catalog + [sid for sid in owned if sid not in seen] + + +async def resolve_invocable_skill_ids( + invoker: User, requested_ids: List[str], agent_owner_id: Optional[str] +) -> Set[str]: + """Which of ``requested_ids`` may resolve for ``invoker`` through this Agent (§6). + + The **invoke-through** predicate. A skill bound on an Agent resolves when any + of three clauses holds: + + 1. **Catalog grant** — the invoker's RBAC roles reach the skill; or + 2. **Ownership** — the invoker authored it; or + 3. **Invoke-through** — the *Agent's owner* authored it, and the invoker has + share-access to the Agent. + + Clauses 1+2 are exactly ``resolve_accessible_skill_ids``. Note this is + deliberately NOT a bare RBAC permission check (the removed + ``AppRoleService.can_access_skill``): that returned ``True`` for a ``"*"`` + wildcard role against *any* id, including another user's private authored + skill, and it had no ownership clause at all (so an author binding their own + skill was blocked on their own invocation). Routing through the shared + resolver expands ``"*"`` over the catalog only. + + Clause 3's **share-access half is the caller's precondition**: this is + reached only after ``get_assistant_with_access_check`` has already admitted + the invoker to the Agent, so owner-match is the only part left to test here. + That owner-match is what blocks **chain-sharing** — a skill merely shared + *to* the Agent's owner cannot be laundered to a wider audience by binding it + and re-sharing the Agent. Invoke-through extends the owner's *own* skills + and nothing else. + + A ``system``-owned Agent gets no clause 3: catalog skills are governed by + RBAC alone, and an owner-match on ``"system"`` would hand the whole catalog + to anyone who could invoke one. + + Returns the allowed subset as a set; the caller decides what a miss means + (the binding resolver blocks the turn with the offending id, D5). Never + raises — clause 3 degrades to "not granted" on any lookup failure. + """ + allowed = set(await resolve_accessible_skill_ids(invoker)) + + missing = [sid for sid in requested_ids if sid not in allowed] + if not missing or not agent_owner_id: + return allowed + + from apis.shared.skills.models import SYSTEM_OWNER_ID + + if agent_owner_id == SYSTEM_OWNER_ID: + return allowed + + try: + from apis.shared.skills.repository import get_skill_catalog_repository + + records = await get_skill_catalog_repository().batch_get_skills(missing) + allowed |= {r.skill_id for r in records if r.owner_id == agent_owner_id} + except Exception: + logger.warning("Failed to resolve invoke-through skills", exc_info=True) + + return allowed diff --git a/backend/src/apis/shared/skills/bundle.py b/backend/src/apis/shared/skills/bundle.py new file mode 100644 index 000000000..42c8a954b --- /dev/null +++ b/backend/src/apis/shared/skills/bundle.py @@ -0,0 +1,85 @@ +"""agentskills.io bundle helpers (Skills v2). + +A skill is stored so that its S3 prefix is a valid agentskills.io bundle: + + skills/{skill_id}/SKILL.md + skills/{skill_id}/references/{file} + skills/{skill_id}/scripts/{file} # inert (accept-and-inert) + skills/{skill_id}/assets/{file} + +DynamoDB remains the source of truth for metadata + instructions; the S3 +``SKILL.md`` is a write-through projection generated from the row, so the prefix +is always attachable elsewhere (the managed-Harness lane, ``{"s3": {"uri": ...}}``) +and exportable as-is. + +This module is under ``apis/shared`` because both the admin write path (app-api, +which regenerates ``SKILL.md``) and the runtime mapping (agents, which slugifies +the skill name for the Strands ``Skill``) need the same slug rule — neither may +import the other (import-boundary). +""" + +from __future__ import annotations + +import re +from typing import Any + +import yaml + +_SLUG_STRIP = re.compile(r"[^a-z0-9-]+") +_SLUG_DEDUPE_HYPHENS = re.compile(r"-{2,}") +_MAX_SLUG_LEN = 64 + +# agentskills.io frontmatter keys we project explicitly; everything else in +# ``skill_metadata`` is passed through verbatim (round-trip-faithful, D2). +_RESERVED_METADATA_KEYS = frozenset({"name", "description", "allowed-tools", "instructions"}) + +# Resource ``kind`` → bundle subdirectory. +KIND_DIRS: dict[str, str] = {"reference": "references", "script": "scripts", "asset": "assets"} + + +def slugify_skill_name(skill_id: str) -> str: + """Convert a catalog ``skill_id`` into an agentskills.io-valid skill name. + + The runtime plugin uses this as both the injected ``Skill.name`` label and + the ``skills`` tool activation key, and it is the ``name:`` written into the + projected ``SKILL.md`` frontmatter. Must match + ``^[a-z0-9]([a-z0-9-]*[a-z0-9])?$``: lowercase, ``_``→``-``, drop other + invalid characters, collapse and trim hyphens, cap at 64 chars. Falls back + to ``"skill"`` if normalization empties the string (defensive — ``skill_id`` + is pattern-validated on write). + """ + slug = skill_id.strip().lower().replace("_", "-") + slug = _SLUG_STRIP.sub("-", slug) + slug = _SLUG_DEDUPE_HYPHENS.sub("-", slug).strip("-") + slug = slug[:_MAX_SLUG_LEN].strip("-") + return slug or "skill" + + +def generate_skill_md( + *, + skill_id: str, + description: str, + instructions: str, + allowed_tools: list[str] | None = None, + skill_metadata: dict[str, Any] | None = None, +) -> str: + """Render a SKILL.md (YAML frontmatter + markdown body) from a catalog row. + + ``name`` is the slug (so a strict agentskills.io loader accepts it); + ``allowed-tools`` is emitted only when non-empty (advisory, D4); any extra + ``skill_metadata`` keys (license, compatibility, ...) pass through except the + reserved ones the projection owns. + """ + frontmatter: dict[str, Any] = { + "name": slugify_skill_name(skill_id), + "description": description or "", + } + if allowed_tools: + frontmatter["allowed-tools"] = list(allowed_tools) + for key, value in (skill_metadata or {}).items(): + if key not in _RESERVED_METADATA_KEYS and key not in frontmatter: + frontmatter[key] = value + + fm = yaml.safe_dump(frontmatter, sort_keys=False, allow_unicode=True).strip() + body = (instructions or "").strip() + return f"---\n{fm}\n---\n\n{body}\n" diff --git a/backend/src/apis/shared/skills/freshness.py b/backend/src/apis/shared/skills/freshness.py index 2311affcb..f2b56551b 100644 --- a/backend/src/apis/shared/skills/freshness.py +++ b/backend/src/apis/shared/skills/freshness.py @@ -9,9 +9,11 @@ the freshness hash in a cache key causes the next build to miss and rebuild with the fresh config (the runtime ``skills_hash`` in spec §8.3). -2. **All-known-skill-ids snapshot** (``get_all_skill_ids``). The set of - skill IDs known to the catalog — the source of truth that RBAC's - wildcard (``"*"``) skill access needs to enumerate. +2. **Catalog-skill-ids snapshot** (``get_all_skill_ids``). The set of + ADMIN-CATALOG skill IDs — the source of truth that RBAC's wildcard + (``"*"``) skill access needs to enumerate. User-authored skills are + deliberately excluded: a ``"*"`` grant is a grant over the catalog an + admin curates, never over other users' private skills (Skills v2 PR-3). Reads are TTL-cached so the per-turn overhead is bounded to at most one DynamoDB read per cache key per TTL window, per process. Admin routes call @@ -98,13 +100,18 @@ async def get_freshness_hash(skill_ids: List[str]) -> str: async def get_all_skill_ids() -> FrozenSet[str]: - """Return the set of all known skill IDs, TTL-cached per process. + """Return the set of ADMIN-CATALOG skill IDs, TTL-cached per process. Listed once per TTL window via `repository.list_skills()` and reused across that window. Used by RBAC skill access to enumerate "every skill - the system knows about" (e.g. expanding a `"*"` wildcard grant) without + the catalog offers" (e.g. expanding a `"*"` wildcard grant) without scanning DynamoDB on every chat turn. + User-authored skills (``owner_id != "system"``) are excluded: a wildcard + RBAC grant must never sweep in another user's private skills. Owned + skills reach their author through ``access.resolve_accessible_skill_ids`` + instead, via the ownership union. + On a repository error, returns the last-known set if available, else an empty frozenset — never raises (auth must not break on a transient DB blip). @@ -114,11 +121,12 @@ async def get_all_skill_ids() -> FrozenSet[str]: if cached is not None and now - cached[1] < _TTL_SECONDS: return cached[0] + from apis.shared.skills.models import SYSTEM_OWNER_ID from apis.shared.skills.repository import get_skill_catalog_repository try: repo = get_skill_catalog_repository() - skills = await repo.list_skills() + skills = await repo.list_skills(owner_id=SYSTEM_OWNER_ID) ids = frozenset(s.skill_id for s in skills) except Exception: logger.exception("Failed to list skill IDs for catalog snapshot") diff --git a/backend/src/apis/shared/skills/models.py b/backend/src/apis/shared/skills/models.py index e7f93bdc7..a83a76c15 100644 --- a/backend/src/apis/shared/skills/models.py +++ b/backend/src/apis/shared/skills/models.py @@ -25,6 +25,10 @@ # Regex for a skill_id — identical shape to tool_id (see ToolCreateRequest). SKILL_ID_PATTERN = r"^[a-z][a-z0-9_]{2,49}$" +# ``owner_id`` of an admin-catalog skill. Anything else is a user-authored +# skill (Skills v2 PR-3): governed by ownership, not by RBAC ``granted_skills``. +SYSTEM_OWNER_ID = "system" + class SkillStatus(str, Enum): """Availability status of a skill (mirrors ToolStatus).""" @@ -81,8 +85,15 @@ class SkillResourceRef(BaseModel): s3_key: str = Field( ..., alias="s3Key", - description="Object key in the skill-resources bucket " - "(skills/{skill_id}/{content_hash})", + description="Object key in the skill-resources bucket, in the standard " + "agentskills.io layout (skills/{skill_id}/{references|scripts|assets}/{filename})", + ) + # Skills v2: which agentskills.io bundle directory the file belongs to. + # ``script`` files are accept-and-inert (stored + listed, never executed). + # Old manifest rows (v1, content-hash keyed) default to ``reference``. + kind: str = Field( + default="reference", + description="Bundle directory: reference | script | asset", ) model_config = {"populate_by_name": True} @@ -118,16 +129,25 @@ class SkillDefinition(BaseModel): ..., description="Level-2 SKILL.md body, loaded on dispatch" ) - # Bound capabilities - bound_tool_ids: List[str] = Field( - default_factory=list, - description="Catalog tool_ids bound to this skill (span all protocols)", - ) compose: List[str] = Field( default_factory=list, description="skill_ids composed into this skill (composite skills)", ) + # Skills v2 (agentskills.io frontmatter passthrough). ``allowed_tools`` is + # ADVISORY ONLY — Strands parses it and marks it "Experimental: not yet + # enforced"; the platform never grants, mounts, or folds a tool because a + # skill names it (spec D1/D4). ``skill_metadata`` carries the remaining + # frontmatter (license, compatibility, arbitrary keys) round-trip-faithfully. + allowed_tools: List[str] = Field( + default_factory=list, + description="Advisory tool names from SKILL.md frontmatter; never enforced (D4)", + ) + skill_metadata: dict = Field( + default_factory=dict, + description="agentskills.io frontmatter passthrough (license, compatibility, ...)", + ) + # Supporting reference files (rev 2026-06-09 §0.2; PR-4). Lightweight # manifest only — the file BYTES live in the skill-resources S3 bucket # (the 400 KB DynamoDB item limit rules out inlining reference docs). @@ -187,8 +207,9 @@ def to_dynamo_item(self) -> dict: "displayName": self.display_name, "description": self.description, "instructions": self.instructions, - "boundToolIds": list(self.bound_tool_ids), "compose": list(self.compose), + "allowedTools": list(self.allowed_tools), + "skillMetadata": dict(self.skill_metadata), # Reference-file manifest (camelCase maps, mirroring the row's # convention). The bytes live in S3; this is just pointers. "resources": [ @@ -198,6 +219,7 @@ def to_dynamo_item(self) -> dict: "size": r.size, "contentType": r.content_type, "s3Key": r.s3_key, + "kind": r.kind, } for r in self.resources ], @@ -225,8 +247,9 @@ def from_dynamo_item(cls, item: dict) -> "SkillDefinition": display_name=item.get("displayName", ""), description=item.get("description", ""), instructions=item.get("instructions", ""), - bound_tool_ids=list(item.get("boundToolIds") or []), compose=list(item.get("compose") or []), + allowed_tools=list(item.get("allowedTools") or []), + skill_metadata=dict(item.get("skillMetadata") or {}), resources=[ SkillResourceRef( filename=r.get("filename", ""), @@ -235,6 +258,8 @@ def from_dynamo_item(cls, item: dict) -> "SkillDefinition": size=int(r.get("size", 0)), content_type=r.get("contentType", ""), s3_key=r.get("s3Key", ""), + # Old rows predate kind → default to reference. + kind=r.get("kind", "reference"), ) for r in (item.get("resources") or []) ], @@ -269,7 +294,6 @@ class SkillCreateRequest(BaseModel): # SKILL.md body — uncapped (instructions can be long); empty is allowed so # an admin can save a draft and fill it in later. instructions: str = Field(default="") - bound_tool_ids: List[str] = Field(default_factory=list, alias="boundToolIds") compose: List[str] = Field(default_factory=list) status: SkillStatus = Field(default=SkillStatus.ACTIVE) category: Optional[str] = None @@ -285,7 +309,6 @@ class SkillUpdateRequest(BaseModel): ) description: Optional[str] = Field(None, max_length=500) instructions: Optional[str] = None - bound_tool_ids: Optional[List[str]] = Field(None, alias="boundToolIds") compose: Optional[List[str]] = None status: Optional[SkillStatus] = None category: Optional[str] = None @@ -344,7 +367,6 @@ class AdminSkillResponse(BaseModel): display_name: str = Field(..., alias="displayName") description: str instructions: str - bound_tool_ids: List[str] = Field(default_factory=list, alias="boundToolIds") compose: List[str] = Field(default_factory=list) resources: List[SkillResourceRef] = Field(default_factory=list) status: SkillStatus @@ -371,7 +393,6 @@ def from_skill_definition( display_name=skill.display_name, description=skill.description, instructions=skill.instructions, - bound_tool_ids=list(skill.bound_tool_ids), compose=list(skill.compose), resources=list(skill.resources), status=skill.status, diff --git a/backend/src/apis/shared/skills/repository.py b/backend/src/apis/shared/skills/repository.py index b48e1d2be..6a83bb3f7 100644 --- a/backend/src/apis/shared/skills/repository.py +++ b/backend/src/apis/shared/skills/repository.py @@ -8,9 +8,10 @@ - Skill: PK=SKILL#{skill_id}, SK=METADATA ``SkillOwnerIndex`` (GSI4: GSI4PK=OWNER#{owner_id}, GSI4SK=SKILL#{skill_id}) -is provisioned for a Phase-2 "list my skills" query; v1 admin lists scan by -``begins_with(PK, "SKILL#")``. See -``docs/specs/admin-skills-rbac-tool-binding.md`` (§5). +backs the user-authored tier's "list my skills" query +(``list_skills_by_owner``). Admin catalog lists still scan by +``begins_with(PK, "SKILL#")`` and narrow to ``owner_id == "system"``. See +``docs/specs/skills-as-agent-primitive.md`` (§4, PR-3). """ import logging @@ -19,12 +20,16 @@ from typing import Any, Dict, List, Optional import boto3 +from boto3.dynamodb.conditions import Key from botocore.exceptions import ClientError -from .models import SkillDefinition, SkillStatus, UserSkillPreference +from .models import SYSTEM_OWNER_ID, SkillDefinition, SkillStatus, UserSkillPreference logger = logging.getLogger(__name__) +# GSI4 — see module docstring. Provisioned in the CDK auth-tables construct. +SKILL_OWNER_INDEX = "SkillOwnerIndex" + class SkillCatalogRepository: """ @@ -69,13 +74,17 @@ async def get_skill(self, skill_id: str) -> Optional[SkillDefinition]: raise async def list_skills( - self, status: Optional[str] = None + self, status: Optional[str] = None, owner_id: Optional[str] = None ) -> List[SkillDefinition]: """ - List all skills, optionally filtered by status. + List all skills, optionally filtered by status and/or owner. Args: status: Optional status filter (active, draft, disabled) + owner_id: Optional owner filter. Pass ``"system"`` to get only the + admin catalog (excluding every user-authored skill); pass a user + id to get that user's skills — though ``list_skills_by_owner`` + is the cheap GSI-backed path for the latter. Returns: List of SkillDefinition objects @@ -105,6 +114,9 @@ async def list_skills( if status: skills = [s for s in skills if s.status == status] + if owner_id is not None: + skills = [s for s in skills if s.owner_id == owner_id] + # Sort by category then display_name skills.sort(key=lambda s: (s.category or "", s.display_name)) @@ -114,6 +126,52 @@ async def list_skills( logger.error(f"Error listing skills: {e}") raise + async def list_skills_by_owner( + self, owner_id: str, status: Optional[str] = None + ) -> List[SkillDefinition]: + """ + List the skills authored by one owner, via the ``SkillOwnerIndex`` GSI. + + This is the "list my skills" query for the user-authored tier — a + partition query, not a table scan, so it stays cheap as the catalog + grows. Passing ``"system"`` returns the admin catalog. + + Args: + owner_id: Author identity (a user id, or ``"system"``) + status: Optional status filter (active, draft, disabled) + + Returns: + List of SkillDefinition objects, sorted by display name + """ + try: + key_condition = Key("GSI4PK").eq(f"OWNER#{owner_id}") + + response = self._table.query( + IndexName=SKILL_OWNER_INDEX, + KeyConditionExpression=key_condition, + ) + items = response.get("Items", []) + + while "LastEvaluatedKey" in response: + response = self._table.query( + IndexName=SKILL_OWNER_INDEX, + KeyConditionExpression=key_condition, + ExclusiveStartKey=response["LastEvaluatedKey"], + ) + items.extend(response.get("Items", [])) + + skills = [SkillDefinition.from_dynamo_item(item) for item in items] + + if status: + skills = [s for s in skills if s.status == status] + + skills.sort(key=lambda s: s.display_name.lower()) + return skills + + except ClientError as e: + logger.error(f"Error listing skills for owner {owner_id}: {e}") + raise + async def create_skill(self, skill: SkillDefinition) -> SkillDefinition: """ Create a new skill catalog entry. diff --git a/backend/src/apis/shared/skills/resource_store.py b/backend/src/apis/shared/skills/resource_store.py index ea1dc85e1..6008061dd 100644 --- a/backend/src/apis/shared/skills/resource_store.py +++ b/backend/src/apis/shared/skills/resource_store.py @@ -6,17 +6,18 @@ ``skill-resources`` S3 bucket and the row carries only a lightweight ``SkillResourceRef`` manifest. -This store mirrors the content-hash + dedupe shape used by the MCP-Apps -UI-resource persistence and the artifacts bucket: - - - Objects are **content-addressed**: the key is - ``skills/{skill_id}/{content_hash}`` where ``content_hash`` is the - sha256 hex of the bytes. Two reference files with identical content - within a skill therefore resolve to the same object (dedupe) — a - re-upload of unchanged bytes is a no-op ``head_object`` instead of a - re-``put``. - - The manifest on the catalog row references objects by key; the bytes - never travel through DynamoDB. +Skills v2 stores objects in the **standard agentskills.io bundle layout** so a +skill's S3 prefix is a valid bundle (attachable to the managed-Harness lane, +exportable as-is): + + - ``skills/{skill_id}/SKILL.md`` — write-through projection of the row. + - ``skills/{skill_id}/{references|scripts|assets}/{filename}`` — supporting + files, keyed by their bundle directory + filename (NOT content-addressed; + dedupe is dropped in favor of the readable standard layout — bundles are + small). Re-uploading the same filename overwrites its object. + - The manifest on the catalog row references objects by key; the bytes never + travel through DynamoDB. The manifest still records a ``content_hash`` per + file for change detection/display. Boundary: this module lives under ``apis/shared/skills/`` and is import- clean (it never imports ``app_api``/``inference_api``). The admin write @@ -61,13 +62,28 @@ class SkillResourceStoreError(RuntimeError): """ -def content_key(skill_id: str, content_hash: str) -> str: - """Return the content-addressed object key for a skill's file.""" - return f"skills/{skill_id}/{content_hash}" +# Resource ``kind`` → bundle subdirectory (agentskills.io standard). +KIND_DIRS: dict = {"reference": "references", "script": "scripts", "asset": "assets"} + + +def resource_key(skill_id: str, kind: str, filename: str) -> str: + """Return the standard bundle object key for one of a skill's files. + + ``skills/{skill_id}/{references|scripts|assets}/{filename}``. An unknown + ``kind`` falls back to ``references`` (defensive; callers pass a validated + kind). + """ + subdir = KIND_DIRS.get(kind, "references") + return f"skills/{skill_id}/{subdir}/{filename}" + + +def skill_md_key(skill_id: str) -> str: + """Return the object key for a skill's projected ``SKILL.md``.""" + return f"skills/{skill_id}/SKILL.md" def compute_content_hash(content: bytes) -> str: - """Return the sha256 hex digest used as the content address.""" + """Return the sha256 hex digest recorded in the manifest for change detection.""" return hashlib.sha256(content).hexdigest() @@ -109,29 +125,46 @@ def _require_enabled(self) -> None: ) def put( - self, *, skill_id: str, content: bytes, content_type: str + self, + *, + skill_id: str, + filename: str, + content: bytes, + content_type: str, + kind: str = "reference", ) -> str: - """Persist file bytes content-addressed; return the object key. + """Persist a supporting file into the standard bundle layout; return the key. - Computes the sha256 of ``content``, derives the - ``skills/{skill_id}/{content_hash}`` key, and uploads. If an object - already exists at that key (same content), the upload is skipped - (dedupe) — the key is returned either way. + The key is ``skills/{skill_id}/{references|scripts|assets}/{filename}`` + (not content-addressed). Re-uploading the same ``(kind, filename)`` + overwrites the object in place — the manifest, keyed by filename, stays + a single entry. """ - self._require_enabled() - digest = compute_content_hash(content) - key = content_key(skill_id, digest) - client = self._client() + key = resource_key(skill_id, kind, filename) + return self._put_bytes( + skill_id=skill_id, key=key, content=content, content_type=content_type + ) - if self._object_exists(key): - logger.info( - "skill-resources: dedupe hit for skill=%s key=%s (%d bytes)", - skill_id, - key, - len(content), - ) - return key + def put_skill_md(self, *, skill_id: str, content: str) -> str: + """Write the projected ``SKILL.md`` for a skill; return the key. + + Text is UTF-8 encoded. Overwrites in place (the row is the source of + truth, so the projection is always fully rewritten). + """ + key = skill_md_key(skill_id) + return self._put_bytes( + skill_id=skill_id, + key=key, + content=content.encode("utf-8"), + content_type="text/markdown", + ) + def _put_bytes( + self, *, skill_id: str, key: str, content: bytes, content_type: str + ) -> str: + """Upload bytes to ``key`` (overwrite); return the key.""" + self._require_enabled() + client = self._client() try: client.put_object( Bucket=self.bucket_name, @@ -148,7 +181,7 @@ def put( e, ) raise SkillResourceStoreError( - f"failed to store reference file for skill '{skill_id}'" + f"failed to store object for skill '{skill_id}'" ) from e logger.info( @@ -190,19 +223,6 @@ def delete(self, s3_key: str) -> None: "skill-resources: delete failed for key=%s", s3_key, exc_info=True ) - def _object_exists(self, key: str) -> bool: - client = self._client() - try: - client.head_object(Bucket=self.bucket_name, Key=key) - return True - except ClientError as e: - code = e.response.get("Error", {}).get("Code", "") - if code in ("404", "NoSuchKey", "NotFound"): - return False - # Any other error (permissions, throttling) is real — surface it - # rather than masquerading as "absent" and double-uploading. - raise - _store: Optional[SkillResourceStore] = None diff --git a/backend/src/apis/shared/user_settings/models.py b/backend/src/apis/shared/user_settings/models.py index e6460fdbe..8f3743542 100644 --- a/backend/src/apis/shared/user_settings/models.py +++ b/backend/src/apis/shared/user_settings/models.py @@ -1,7 +1,7 @@ """Domain models for user settings.""" from pydantic import BaseModel, ConfigDict -from typing import Annotated, Literal, Optional +from typing import Annotated, Optional from pydantic import Field @@ -10,12 +10,6 @@ class UserSettings(BaseModel): model_config = ConfigDict(populate_by_name=True) default_model_id: Annotated[Optional[str], Field(alias="defaultModelId")] = None - # User-level default for the skills/tools mode toggle. New conversations - # start in this mode (when the admin policy allows toggling); per-session - # choices live in SessionPreferences.agent_type. - preferred_agent_mode: Annotated[ - Optional[Literal["skill", "chat"]], Field(alias="preferredAgentMode") - ] = None class UserSettingsUpdate(BaseModel): @@ -23,6 +17,3 @@ class UserSettingsUpdate(BaseModel): model_config = ConfigDict(populate_by_name=True) default_model_id: Annotated[Optional[str], Field(alias="defaultModelId")] = None - preferred_agent_mode: Annotated[ - Optional[Literal["skill", "chat"]], Field(alias="preferredAgentMode") - ] = None diff --git a/backend/src/apis/shared/user_settings/repository.py b/backend/src/apis/shared/user_settings/repository.py index 4a7ade8d1..7b59338a7 100644 --- a/backend/src/apis/shared/user_settings/repository.py +++ b/backend/src/apis/shared/user_settings/repository.py @@ -11,7 +11,6 @@ # Default settings returned when no record exists DEFAULT_SETTINGS = { "defaultModelId": None, - "preferredAgentMode": None, } @@ -64,7 +63,6 @@ async def get_settings(self, user_id: str) -> dict: item = response["Item"] return { "defaultModelId": item.get("defaultModelId"), - "preferredAgentMode": item.get("preferredAgentMode"), } except ClientError as e: logger.error(f"Error getting settings for user {user_id}: {e}") diff --git a/backend/tests/agents/builtin_tools/artifacts/test_artifact_tools.py b/backend/tests/agents/builtin_tools/artifacts/test_artifact_tools.py index 23841c852..02d6cceb4 100644 --- a/backend/tests/agents/builtin_tools/artifacts/test_artifact_tools.py +++ b/backend/tests/agents/builtin_tools/artifacts/test_artifact_tools.py @@ -231,7 +231,10 @@ def test_record_satisfies_minter(aws) -> None: @pytest.mark.parametrize( "enabled,expected", - [(None, 0), ([], 0), (["other"], 0), (["create_artifact"], 1), + # create_artifact is the single gate key: it provisions create + update. + # A stale "update_artifact" preference on its own grants nothing. + [(None, 0), ([], 0), (["other"], 0), (["update_artifact"], 0), + (["create_artifact"], 2), (["create_artifact", "update_artifact"], 2)], ) def test_routes_gating(enabled, expected) -> None: diff --git a/backend/tests/agents/main_agent/integrations/test_mcp_tool_folding.py b/backend/tests/agents/main_agent/integrations/test_mcp_tool_folding.py deleted file mode 100644 index 7b5748035..000000000 --- a/backend/tests/agents/main_agent/integrations/test_mcp_tool_folding.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Tests for the per-client MCP tool-fold mechanism (PR-6b).""" - -from types import SimpleNamespace - -from agents.main_agent.integrations.mcp_tool_folding import ( - drop_folded_tools, - folded_tool_names, - set_folded_tool_names, -) - - -def _tool(name): - return SimpleNamespace(tool_name=name) - - -class _Client: - """Minimal stand-in for an MCPClient (just the fields the helpers touch).""" - - def __init__(self, loaded=None): - self._loaded_tools = loaded - - -class TestSetFolded: - def test_records_names(self): - c = _Client() - set_folded_tool_names(c, ["a", "b"]) - assert folded_tool_names(c) == {"a", "b"} - - def test_accumulates_across_calls(self): - c = _Client() - set_folded_tool_names(c, ["a"]) - set_folded_tool_names(c, ["b", "c"]) - assert folded_tool_names(c) == {"a", "b", "c"} - - def test_invalidates_loaded_tools_cache(self): - # The external pre-flight primes _loaded_tools before folds are known; - # setting a fold must reset it so Strands re-lists with the fold. - c = _Client(loaded=[_tool("x")]) - set_folded_tool_names(c, ["x"]) - assert c._loaded_tools is None - - def test_no_cache_to_invalidate_is_fine(self): - c = _Client(loaded=None) - set_folded_tool_names(c, ["x"]) - assert c._loaded_tools is None - - -class TestDropFolded: - def test_drops_only_folded(self): - c = _Client() - set_folded_tool_names(c, ["hide_me"]) - tools = [_tool("keep"), _tool("hide_me"), _tool("keep2")] - kept = drop_folded_tools(c, tools) - assert [t.tool_name for t in kept] == ["keep", "keep2"] - - def test_no_fold_set_is_passthrough(self): - c = _Client() - tools = [_tool("a"), _tool("b")] - assert drop_folded_tools(c, tools) is tools - - def test_empty_fold_set_is_passthrough(self): - c = _Client() - set_folded_tool_names(c, []) - tools = [_tool("a")] - # An empty set is falsy → treated as "nothing folded". - assert drop_folded_tools(c, tools) == tools - - def test_folded_names_empty_default(self): - assert folded_tool_names(_Client()) == set() diff --git a/backend/tests/agents/main_agent/session/test_oauth_consent_hook.py b/backend/tests/agents/main_agent/session/test_oauth_consent_hook.py index ff712cd05..d68406ad6 100644 --- a/backend/tests/agents/main_agent/session/test_oauth_consent_hook.py +++ b/backend/tests/agents/main_agent/session/test_oauth_consent_hook.py @@ -350,126 +350,6 @@ async def test_resume_without_token_cancels_tool(self): assert "google" in event.cancel_tool -class TestOAuthConsentHookSkillFoldedTools: - """Regression guard for the skills-mode consent gap. - - A skill-bound external MCP tool executes through the `skill_executor` - meta-tool, so `selected_tool` is the executor (not an MCPAgentTool) and - `provider_lookup` returns None. Before the `tool_use_provider_lookup` - fallback existed, the gate silently skipped these calls: the tool ran - tokenless, returned an auth error, and the model apologized in text - instead of the turn pausing with an `oauth_required` interrupt. - """ - - @staticmethod - def _tool_use_lookup(tool_use: dict) -> str | None: - # Stands in for skills/mcp_binding.make_folded_tool_provider_lookup - # (covered by its own tests); resolves the executor's folded target. - if tool_use.get("name") != "skill_executor": - return None - if (tool_use.get("input") or {}).get("tool_name") == "gmail_search": - return "google" - return None - - def _executor_event(self): - event = _make_event(provider_id=None) - event.tool_use = { - "toolUseId": "tu_skill", - "name": "skill_executor", - "input": { - "skill_name": "gmail-for-employees", - "tool_name": "gmail_search", - "tool_input": {"query": "from:hr"}, - }, - } - return event - - @pytest.mark.asyncio - async def test_skill_bound_tool_without_token_pauses_with_oauth_required(self): - identity = MagicMock() - identity.get_token_for_user = AsyncMock( - return_value=TokenResult(authorization_url="https://accounts/consent") - ) - - hook = OAuthConsentHook( - user_id="alice", - provider_lookup=lambda _tool: None, # executor isn't an MCPAgentTool - scopes_lookup=lambda _: ["gmail.readonly"], - tool_use_provider_lookup=self._tool_use_lookup, - ) - event = self._executor_event() - - with patch( - "agents.main_agent.session.hooks.oauth_consent.get_agentcore_identity_client", - return_value=identity, - ): - with pytest.raises(InterruptException) as excinfo: - await hook._gate(event) - - interrupt = excinfo.value.interrupt - assert interrupt.name == "oauth:google" - assert interrupt.reason == { - "type": "oauth_required", - "providerId": "google", - "authorizationUrl": "https://accounts/consent", - } - - @pytest.mark.asyncio - async def test_skill_bound_tool_with_cached_token_proceeds(self): - oauth_token_cache.set("alice", "google", "warm-token") - hook = OAuthConsentHook( - user_id="alice", - provider_lookup=lambda _tool: None, - scopes_lookup=lambda _: [], - tool_use_provider_lookup=self._tool_use_lookup, - ) - event = self._executor_event() - - await hook._gate(event) - - assert event.cancel_tool is None - - @pytest.mark.asyncio - async def test_401_from_folded_tool_clears_cache_and_retries(self): - """The fold preserves error status (FoldedMCPTool returns a - ToolResult-shaped dict), so the AfterToolCallEvent retry path must - work through the executor too.""" - oauth_token_cache.set("alice", "google", "stale-token") - hook = OAuthConsentHook( - user_id="alice", - provider_lookup=lambda _tool: None, - scopes_lookup=lambda _: [], - tool_use_provider_lookup=self._tool_use_lookup, - ) - event = self._executor_event() - event.invocation_state = {} - event.result = { - "toolUseId": "tu_skill", - "status": "error", - "content": [{"text": "Error calling tool (HTTP 401): Unauthorized"}], - } - event.retry = False - - await hook._handle_auth_failure(event) - - assert event.retry is True - assert oauth_token_cache.get("alice", "google") is None - - @pytest.mark.asyncio - async def test_without_tool_use_lookup_executor_is_not_gated(self): - """Plain ChatAgent wiring (no lookup) keeps the old behavior.""" - hook = OAuthConsentHook( - user_id="alice", - provider_lookup=lambda _tool: None, - scopes_lookup=lambda _: [], - ) - event = self._executor_event() - - await hook._gate(event) - - assert event.cancel_tool is None - - class TestParallelToolCallsSameProvider: """Regression guard for the OAuth interrupt collision concern. diff --git a/backend/tests/agents/main_agent/session/test_tool_approval.py b/backend/tests/agents/main_agent/session/test_tool_approval.py index d9f3a952c..3eeacd0d1 100644 --- a/backend/tests/agents/main_agent/session/test_tool_approval.py +++ b/backend/tests/agents/main_agent/session/test_tool_approval.py @@ -10,7 +10,6 @@ from unittest.mock import MagicMock from agents.main_agent.session.hooks.tool_approval import ( - FoldedToolApproval, MCPExternalApprovalHook, ) @@ -148,115 +147,3 @@ def test_register_hooks_subscribes_to_before_tool_call(self): registry.add_callback.assert_called_once() args = registry.add_callback.call_args.args assert args[0] is BeforeToolCallEvent - - -class TestApprovalHookSkillFoldedTools: - """Regression guard for the skills-mode approval bypass. - - A skill-bound external MCP tool executes through the `skill_executor` - meta-tool, so `selected_tool` is the executor (not an MCPAgentTool) and - `approval_names_lookup` returns an empty set — and `tool_use["name"]` - is "skill_executor", so the direct name match could never fire either. - Before the `tool_use_approval_lookup` fallback existed, an admin's - needs_approval=True flag was silently bypassed whenever the tool was - bound to a skill: the call ran with no user prompt. - """ - - @staticmethod - def _tool_use_lookup(tool_use: dict) -> FoldedToolApproval | None: - # Stands in for skills/mcp_binding.make_folded_tool_approval_lookup - # (covered by its own tests); resolves the executor's folded target - # and applies the needs_approval check itself. - if tool_use.get("name") != "skill_executor": - return None - tool_input = tool_use.get("input") or {} - if tool_input.get("tool_name") != "gmail_send": - return None - return FoldedToolApproval( - tool_name="gmail_send", tool_input=tool_input.get("tool_input") - ) - - def _executor_event(self, tool_name="gmail_send", interrupt_response=None): - event = _make_event("skill_executor", tool_use_id="tu_skill") - event.tool_use["input"] = { - "skill_name": "gmail-for-employees", - "tool_name": tool_name, - "tool_input": {"to": "hr@example.com", "body": "resignation"}, - } - event.interrupt = MagicMock(return_value=interrupt_response) - return event - - def test_flagged_folded_tool_pauses_and_describes_inner_tool(self): - """The interrupt's toolName/toolInput must describe the folded tool - (gmail_send and its args) — not skill_executor — so the frontend - dialog is meaningful. toolUseId stays the executor's, since that is - the streamed tool_use block the frontend correlates with.""" - import json - - hook = MCPExternalApprovalHook( - approval_names_lookup=lambda _: set(), # executor isn't an MCPAgentTool - tool_use_approval_lookup=self._tool_use_lookup, - ) - event = self._executor_event(interrupt_response="approved") - hook._gate(event) - - event.interrupt.assert_called_once() - reason = event.interrupt.call_args.kwargs["reason"] - assert reason["type"] == "tool_approval_required" - assert reason["toolName"] == "gmail_send" - assert reason["toolUseId"] == "tu_skill" - assert json.loads(reason["toolInput"]) == { - "to": "hr@example.com", - "body": "resignation", - } - # Approved → executor proceeds. - assert event.cancel_tool is None - - def test_declined_folded_tool_cancels_with_inner_tool_name(self): - hook = MCPExternalApprovalHook( - approval_names_lookup=lambda _: set(), - tool_use_approval_lookup=self._tool_use_lookup, - ) - event = self._executor_event(interrupt_response="declined") - hook._gate(event) - - assert event.cancel_tool is not None - assert "gmail_send" in event.cancel_tool - assert "skill_executor" not in event.cancel_tool - - def test_unflagged_folded_tool_runs_without_pause(self): - hook = MCPExternalApprovalHook( - approval_names_lookup=lambda _: set(), - tool_use_approval_lookup=self._tool_use_lookup, - ) - event = self._executor_event(tool_name="gmail_search") - hook._gate(event) - - event.interrupt.assert_not_called() - assert event.cancel_tool is None - - def test_without_tool_use_lookup_executor_is_not_gated(self): - """Plain ChatAgent wiring (no lookup) keeps the old behavior.""" - hook = MCPExternalApprovalHook(approval_names_lookup=lambda _: set()) - event = self._executor_event() - hook._gate(event) - - event.interrupt.assert_not_called() - assert event.cancel_tool is None - - def test_direct_path_takes_precedence_over_folded_lookup(self): - """A directly-selected flagged tool interrupts via the direct path; - the folded lookup must not be consulted for it.""" - - def exploding_lookup(_tool_use: dict): - raise AssertionError("folded lookup consulted on the direct path") - - hook = MCPExternalApprovalHook( - approval_names_lookup=lambda _: {"send_email"}, - tool_use_approval_lookup=exploding_lookup, - ) - event = _make_event("send_email", interrupt_response="approved") - hook._gate(event) - - event.interrupt.assert_called_once() - assert event.cancel_tool is None diff --git a/backend/tests/agents/main_agent/skills/__init__.py b/backend/tests/agents/main_agent/skills/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/tests/agents/main_agent/skills/conftest.py b/backend/tests/agents/main_agent/skills/conftest.py deleted file mode 100644 index 7a24a0c05..000000000 --- a/backend/tests/agents/main_agent/skills/conftest.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Shared fixtures for skill tests.""" - -import os -import pytest -import tempfile - -from agents.main_agent.skills.skill_registry import SkillRegistry - - -@pytest.fixture -def temp_skills_dir(tmp_path): - """Create a temporary skills directory with sample SKILL.md files.""" - # Create web-search skill - web_dir = tmp_path / "web-search" - web_dir.mkdir() - (web_dir / "SKILL.md").write_text( - '---\n' - 'name: web-search\n' - 'description: Search the web for current information\n' - 'type: tool\n' - '---\n' - '\n' - '# Web Search\n' - '\n' - 'Use this skill to search the web.\n' - ) - - # Create visualization skill - viz_dir = tmp_path / "visualization" - viz_dir.mkdir() - (viz_dir / "SKILL.md").write_text( - '---\n' - 'name: visualization\n' - 'description: Create charts and graphs\n' - 'type: tool\n' - '---\n' - '\n' - '# Visualization\n' - '\n' - 'Create data visualizations.\n' - ) - - # Create composite skill - comp_dir = tmp_path / "data-analysis" - comp_dir.mkdir() - (comp_dir / "SKILL.md").write_text( - '---\n' - 'name: data-analysis\n' - 'description: Analyze data with search and visualization\n' - 'type: composite\n' - 'compose:\n' - ' - web-search\n' - ' - visualization\n' - '---\n' - '\n' - '# Data Analysis\n' - '\n' - 'Combined data analysis skill.\n' - ) - - return str(tmp_path) - - -@pytest.fixture -def registry(temp_skills_dir): - """Create a SkillRegistry with discovered skills.""" - reg = SkillRegistry(temp_skills_dir) - reg.discover_skills() - return reg - - -@pytest.fixture -def mock_tool(): - """Create a mock tool with _skill_name metadata.""" - def web_search_tool(query: str) -> str: - return f"Results for: {query}" - web_search_tool.tool_name = "web_search" - web_search_tool._skill_name = "web-search" - web_search_tool.tool_spec = {"name": "web_search", "description": "Search the web", "inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}}} - return web_search_tool - - -@pytest.fixture -def viz_tool(): - """Create a mock visualization tool.""" - def create_chart(data: dict) -> str: - return "chart created" - create_chart.tool_name = "create_chart" - create_chart._skill_name = "visualization" - create_chart.tool_spec = {"name": "create_chart", "description": "Create a chart"} - return create_chart diff --git a/backend/tests/agents/main_agent/skills/test_mcp_binding.py b/backend/tests/agents/main_agent/skills/test_mcp_binding.py deleted file mode 100644 index bc29f0ec4..000000000 --- a/backend/tests/agents/main_agent/skills/test_mcp_binding.py +++ /dev/null @@ -1,442 +0,0 @@ -"""Tests for cross-source MCP skill binding + folding (PR-6b). - -Covers the genuinely-hard piece: resolving a skill's gateway / external MCP -bound tool ids to foldable adapters that execute through the MCP client, with -no live MCP server (clients are faked). -""" - -from types import SimpleNamespace - -import pytest - -from agents.main_agent.skills.mcp_binding import ( - FoldedMCPTool, - _stringify_mcp_result, - make_folded_tool_approval_lookup, - make_folded_tool_provider_lookup, - resolve_mcp_bindings, -) - - -def _mcp_agent_tool(agent_name, server_name=None, spec=None): - """Fake of a Strands MCPAgentTool (the slice the resolver reads).""" - return SimpleNamespace( - tool_name=agent_name, - tool_spec=spec or {"name": agent_name, "inputSchema": {"json": {}}}, - mcp_tool=SimpleNamespace(name=server_name or agent_name), - ) - - -class _FakeClient: - """Records call_tool_sync; serves list_tools_sync from a fixed list.""" - - def __init__(self, tools=None, result=None, raise_on_list=False): - self._tools = tools or [] - self._result = result - self._raise_on_list = raise_on_list - self.calls = [] - - def list_tools_sync(self, *a, **k): - if self._raise_on_list: - raise RuntimeError("session not active") - return list(self._tools) - - def call_tool_sync(self, tool_use_id, name, arguments=None, **k): - self.calls.append((tool_use_id, name, arguments)) - return self._result - - -class TestResolveGateway: - def test_expands_and_folds(self): - client = _FakeClient() - res = resolve_mcp_bindings( - gateway_ids=["gateway_wiki"], - external_ids=[], - gateway_client=client, - expand_gateway=lambda ids: [ - "gateway_wikipedia___search", - "gateway_wikipedia___get_article", - ], - external_client_lookup=lambda tid: None, - ) - tools = res.catalog_map["gateway_wiki"] - assert [t._mcp_tool_name for t in tools] == [ - "wikipedia___search", - "wikipedia___get_article", - ] - # Agent-facing name == gateway tool name (no `gateway_` prefix). - assert res.fold_by_client[client] == { - "wikipedia___search", - "wikipedia___get_article", - } - assert res.unresolved == [] - - def test_already_expanded_runtime_id_passes_through(self): - client = _FakeClient() - res = resolve_mcp_bindings( - gateway_ids=["gateway_target___tool"], - external_ids=[], - gateway_client=client, - expand_gateway=lambda ids: list(ids), # already runtime form - external_client_lookup=lambda tid: None, - ) - assert res.catalog_map["gateway_target___tool"][0]._mcp_tool_name == "target___tool" - - def test_no_gateway_client_is_unresolved(self): - res = resolve_mcp_bindings( - gateway_ids=["gateway_wiki"], - external_ids=[], - gateway_client=None, - expand_gateway=lambda ids: ids, - external_client_lookup=lambda tid: None, - ) - assert res.unresolved == ["gateway_wiki"] - assert res.catalog_map == {} - - def test_expand_failure_is_unresolved(self): - def boom(ids): - raise RuntimeError("catalog down") - - res = resolve_mcp_bindings( - gateway_ids=["gateway_wiki"], - external_ids=[], - gateway_client=_FakeClient(), - expand_gateway=boom, - external_client_lookup=lambda tid: None, - ) - assert res.unresolved == ["gateway_wiki"] - - -class TestResolveExternal: - def test_lists_server_and_folds_each_tool(self): - tools = [ - _mcp_agent_tool("search", server_name="search"), - _mcp_agent_tool("fetch", server_name="fetch_raw"), - ] - client = _FakeClient(tools=tools) - res = resolve_mcp_bindings( - gateway_ids=[], - external_ids=["my_server"], - gateway_client=None, - expand_gateway=lambda ids: ids, - external_client_lookup=lambda tid: client, - ) - bound = res.catalog_map["my_server"] - # Server-side name preserved for call_tool_sync; agent-facing for fold. - assert {(t.tool_name, t._mcp_tool_name) for t in bound} == { - ("search", "search"), - ("fetch", "fetch_raw"), - } - assert res.fold_by_client[client] == {"search", "fetch"} - # Spec captured eagerly from the listed tool. - assert bound[0].tool_spec["name"] == "search" - - def test_missing_client_is_unresolved(self): - res = resolve_mcp_bindings( - gateway_ids=[], - external_ids=["gone"], - gateway_client=None, - expand_gateway=lambda ids: ids, - external_client_lookup=lambda tid: None, - ) - assert res.unresolved == ["gone"] - - def test_list_failure_is_unresolved(self): - res = resolve_mcp_bindings( - gateway_ids=[], - external_ids=["srv"], - gateway_client=None, - expand_gateway=lambda ids: ids, - external_client_lookup=lambda tid: _FakeClient(raise_on_list=True), - ) - assert res.unresolved == ["srv"] - - def test_empty_server_is_unresolved(self): - res = resolve_mcp_bindings( - gateway_ids=[], - external_ids=["srv"], - gateway_client=None, - expand_gateway=lambda ids: ids, - external_client_lookup=lambda tid: _FakeClient(tools=[]), - ) - assert res.unresolved == ["srv"] - - -class TestFoldedMCPToolExecution: - def test_invoke_routes_through_client(self): - client = _FakeClient( - result={"status": "success", "content": [{"text": "the answer"}]} - ) - tool = FoldedMCPTool(client, mcp_tool_name="wikipedia___search") - out = tool.invoke({"query": "strands"}) - assert out == "the answer" - # call_tool_sync got the server-side name + args. - (_, name, args) = client.calls[0] - assert name == "wikipedia___search" - assert args == {"query": "strands"} - - def test_invoke_handles_none_input(self): - client = _FakeClient(result={"content": [{"text": "ok"}]}) - FoldedMCPTool(client, mcp_tool_name="t").invoke(None) - assert client.calls[0][2] == {} - - def test_invoke_surfaces_exceptions_as_error_status_result(self): - """A client exception must keep its error status through the fold — - Strands' @tool decorator passes status+content dicts through, and the - OAuth consent hook's 401-retry heuristic only fires on error-status - results.""" - - class _Boom(_FakeClient): - def call_tool_sync(self, *a, **k): - raise RuntimeError("mcp down") - - out = FoldedMCPTool(_Boom(), mcp_tool_name="t").invoke({}) - assert out["status"] == "error" - assert "mcp down" in out["content"][0]["text"] - - def test_invoke_preserves_error_status_from_mcp_result(self): - client = _FakeClient( - result={"status": "error", "content": [{"text": "HTTP 401 Unauthorized"}]} - ) - out = FoldedMCPTool(client, mcp_tool_name="t").invoke({}) - assert out["status"] == "error" - assert "401" in out["content"][0]["text"] - - def test_is_mcp_folded_marker(self): - assert FoldedMCPTool(_FakeClient(), mcp_tool_name="t").is_mcp_folded is True - - -class TestFoldedMCPToolSpec: - def test_eager_spec_returned(self): - spec = {"name": "x", "inputSchema": {"json": {"type": "object"}}} - tool = FoldedMCPTool(_FakeClient(), mcp_tool_name="x", tool_spec=spec) - assert tool.tool_spec is spec - - def test_lazy_spec_resolves_from_client(self): - # Gateway path: no spec at build time; resolved by listing at dispatch. - listed = [_mcp_agent_tool("target___tool", spec={"name": "target___tool", "k": 1})] - client = _FakeClient(tools=listed) - tool = FoldedMCPTool(client, mcp_tool_name="target___tool") - assert tool.tool_spec["k"] == 1 - - def test_lazy_spec_falls_back_to_name(self): - client = _FakeClient(raise_on_list=True) - tool = FoldedMCPTool(client, mcp_tool_name="t") - assert tool.tool_spec == {"name": "t"} - - -class TestFoldedToolProviderLookup: - """`make_folded_tool_provider_lookup` lets the OAuth consent gate see - through the skill fold: skill_executor tool_use input → bound - FoldedMCPTool → owning client → provider_id.""" - - def _registry_with_folded_gmail(self, client): - from agents.main_agent.skills.skill_registry import SkillRegistry - - registry = SkillRegistry() - registry.load_records( - [ - SimpleNamespace( - skill_id="gmail-for-employees", - description="Gmail", - instructions="Use Gmail tools.", - compose=[], - bound_tool_ids=["gmail_mcp"], - resources=[], - ) - ] - ) - folded = FoldedMCPTool( - client, mcp_tool_name="gmail_search", agent_tool_name="gmail_search" - ) - registry.bind_catalog_tools({"gmail_mcp": [folded]}) - return registry - - def _executor_tool_use(self, skill_name="gmail-for-employees", tool_name="gmail_search"): - return { - "toolUseId": "tu_1", - "name": "skill_executor", - "input": {"skill_name": skill_name, "tool_name": tool_name}, - } - - def test_resolves_provider_for_folded_tool(self): - client = _FakeClient() - registry = self._registry_with_folded_gmail(client) - lookup = make_folded_tool_provider_lookup( - registry, lambda c: "google" if c is client else None - ) - assert lookup(self._executor_tool_use()) == "google" - - def test_ignores_non_executor_tool_use(self): - client = _FakeClient() - registry = self._registry_with_folded_gmail(client) - lookup = make_folded_tool_provider_lookup(registry, lambda c: "google") - assert lookup({"name": "gmail_search", "input": {}}) is None - - def test_unknown_skill_or_tool_resolves_none(self): - client = _FakeClient() - registry = self._registry_with_folded_gmail(client) - lookup = make_folded_tool_provider_lookup(registry, lambda c: "google") - assert lookup(self._executor_tool_use(skill_name="nope")) is None - assert lookup(self._executor_tool_use(tool_name="nope")) is None - - def test_local_non_folded_tool_resolves_none(self): - from agents.main_agent.skills.skill_registry import SkillRegistry - - registry = SkillRegistry() - registry.load_records( - [ - SimpleNamespace( - skill_id="local-skill", - description="", - instructions="", - compose=[], - bound_tool_ids=["local_tool"], - resources=[], - ) - ] - ) - registry.bind_catalog_tools( - {"local_tool": SimpleNamespace(tool_name="local_tool")} - ) - lookup = make_folded_tool_provider_lookup(registry, lambda c: "google") - assert ( - lookup(self._executor_tool_use("local-skill", "local_tool")) is None - ) - - def test_unmapped_client_resolves_none(self): - # Gateway clients aren't in the provider map (SigV4, not user OAuth). - client = _FakeClient() - registry = self._registry_with_folded_gmail(client) - lookup = make_folded_tool_provider_lookup(registry, lambda c: None) - assert lookup(self._executor_tool_use()) is None - - def test_malformed_input_resolves_none(self): - client = _FakeClient() - registry = self._registry_with_folded_gmail(client) - lookup = make_folded_tool_provider_lookup(registry, lambda c: "google") - assert lookup({}) is None - assert lookup({"name": "skill_executor"}) is None - assert lookup({"name": "skill_executor", "input": "not-a-dict"}) is None - - -class TestFoldedToolApprovalLookup: - """`make_folded_tool_approval_lookup` lets the per-tool approval gate see - through the skill fold: skill_executor tool_use input → bound - FoldedMCPTool → owning client's needs_approval set → the inner tool's - name + args for the approval dialog.""" - - def _registry_with_folded_gmail(self, client): - from agents.main_agent.skills.skill_registry import SkillRegistry - - registry = SkillRegistry() - registry.load_records( - [ - SimpleNamespace( - skill_id="gmail-for-employees", - description="Gmail", - instructions="Use Gmail tools.", - compose=[], - bound_tool_ids=["gmail_mcp"], - resources=[], - ) - ] - ) - folded = FoldedMCPTool( - client, mcp_tool_name="gmail_send", agent_tool_name="gmail_send" - ) - registry.bind_catalog_tools({"gmail_mcp": [folded]}) - return registry - - def _executor_tool_use( - self, skill_name="gmail-for-employees", tool_name="gmail_send" - ): - return { - "toolUseId": "tu_1", - "name": "skill_executor", - "input": { - "skill_name": skill_name, - "tool_name": tool_name, - "tool_input": {"to": "hr@example.com"}, - }, - } - - def test_resolves_flagged_folded_tool_with_inner_args(self): - client = _FakeClient() - registry = self._registry_with_folded_gmail(client) - lookup = make_folded_tool_approval_lookup( - registry, lambda c: {"gmail_send"} if c is client else set() - ) - target = lookup(self._executor_tool_use()) - assert target is not None - assert target.tool_name == "gmail_send" - assert target.tool_input == {"to": "hr@example.com"} - - def test_unflagged_folded_tool_resolves_none(self): - client = _FakeClient() - registry = self._registry_with_folded_gmail(client) - lookup = make_folded_tool_approval_lookup( - registry, lambda c: {"some_other_tool"} - ) - assert lookup(self._executor_tool_use()) is None - - def test_unflagged_client_resolves_none(self): - # Gateway clients have no needs_approval snapshot — empty set. - client = _FakeClient() - registry = self._registry_with_folded_gmail(client) - lookup = make_folded_tool_approval_lookup(registry, lambda c: set()) - assert lookup(self._executor_tool_use()) is None - - def test_ignores_non_executor_tool_use(self): - client = _FakeClient() - registry = self._registry_with_folded_gmail(client) - lookup = make_folded_tool_approval_lookup(registry, lambda c: {"gmail_send"}) - assert lookup({"name": "gmail_send", "input": {}}) is None - - def test_unknown_skill_or_tool_resolves_none(self): - client = _FakeClient() - registry = self._registry_with_folded_gmail(client) - lookup = make_folded_tool_approval_lookup(registry, lambda c: {"gmail_send"}) - assert lookup(self._executor_tool_use(skill_name="nope")) is None - assert lookup(self._executor_tool_use(tool_name="nope")) is None - - def test_local_non_folded_tool_resolves_none(self): - from agents.main_agent.skills.skill_registry import SkillRegistry - - registry = SkillRegistry() - registry.load_records( - [ - SimpleNamespace( - skill_id="local-skill", - description="", - instructions="", - compose=[], - bound_tool_ids=["local_tool"], - resources=[], - ) - ] - ) - registry.bind_catalog_tools( - {"local_tool": SimpleNamespace(tool_name="local_tool")} - ) - lookup = make_folded_tool_approval_lookup( - registry, lambda c: {"local_tool"} - ) - assert ( - lookup(self._executor_tool_use("local-skill", "local_tool")) is None - ) - - -class TestStringify: - def test_joins_text_blocks(self): - result = {"content": [{"text": "a"}, {"text": "b"}]} - assert _stringify_mcp_result(result) == "a\nb" - - def test_non_text_content_json_dumps(self): - result = {"content": [{"json": {"k": 1}}]} - out = _stringify_mcp_result(result) - assert "json" in out and "k" in out - - def test_object_shaped_result(self): - result = SimpleNamespace(content=[SimpleNamespace(text="hi")]) - assert _stringify_mcp_result(result) == "hi" diff --git a/backend/tests/agents/main_agent/skills/test_skill_agent_db.py b/backend/tests/agents/main_agent/skills/test_skill_agent_db.py deleted file mode 100644 index 1689a44a2..000000000 --- a/backend/tests/agents/main_agent/skills/test_skill_agent_db.py +++ /dev/null @@ -1,282 +0,0 @@ -"""Tests for SkillAgent's DB-source helpers (PR-6). - -Covers the repository fetch + ACTIVE filtering that feeds the registry, plus -the status check — without constructing a full agent (which needs a live model -+ session manager). -""" - -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -from agents.main_agent import skill_agent - - -def _rec(skill_id, status="active"): - return SimpleNamespace( - skill_id=skill_id, - description="", - instructions="body", - compose=[], - bound_tool_ids=[], - resources=[], - status=status, - ) - - -class TestIsActiveStatus: - def test_accepts_string_active(self): - assert skill_agent._is_active_status("active") is True - - def test_accepts_enum_repr_active(self): - # use_enum_values is on in the model, but be robust to "SkillStatus.ACTIVE". - assert skill_agent._is_active_status("SkillStatus.ACTIVE") is True - - def test_rejects_non_active(self): - assert skill_agent._is_active_status("draft") is False - assert skill_agent._is_active_status("disabled") is False - - -class TestFetchSkillRecords: - def test_empty_ids_short_circuit(self): - assert skill_agent._fetch_skill_records([]) == [] - - def test_filters_to_active_records(self): - repo = MagicMock() - repo.batch_get_skills = AsyncMock( - return_value=[ - _rec("active_one", status="active"), - _rec("draft_one", status="draft"), - _rec("disabled_one", status="disabled"), - ] - ) - # get_skill_catalog_repository is imported inside the function, so - # patch it at the source module. - with patch( - "apis.shared.skills.repository.get_skill_catalog_repository", - return_value=repo, - ): - records = skill_agent._fetch_skill_records( - ["active_one", "draft_one", "disabled_one"] - ) - - assert [r.skill_id for r in records] == ["active_one"] - - def test_degrades_to_empty_on_repo_error(self): - repo = MagicMock() - repo.batch_get_skills = AsyncMock(side_effect=RuntimeError("boom")) - with patch( - "apis.shared.skills.repository.get_skill_catalog_repository", - return_value=repo, - ): - # Never raises into agent construction — returns [] so the agent - # degrades to chat. - assert skill_agent._fetch_skill_records(["x"]) == [] - - -class TestToolUseProviderLookupWiring: - """SkillAgent must hand the OAuth consent hook a tool_use-based provider - resolver over ITS registry — that's what lets the consent gate see a - skill-bound external MCP tool behind skill_executor (the skills-mode - `oauth_required` regression). Built without a full agent: the override - only reads `self._registry` and the global external MCP integration. - """ - - def test_lookup_resolves_folded_tool_via_integration(self): - from agents.main_agent.skills.mcp_binding import FoldedMCPTool - from agents.main_agent.skills.skill_registry import SkillRegistry - - client = object() - registry = SkillRegistry() - registry.load_records( - [ - SimpleNamespace( - skill_id="gmail-for-employees", - description="", - instructions="", - compose=[], - bound_tool_ids=["gmail_mcp"], - resources=[], - ) - ] - ) - registry.bind_catalog_tools( - {"gmail_mcp": [FoldedMCPTool(client, mcp_tool_name="gmail_search")]} - ) - - agent = object.__new__(skill_agent.SkillAgent) - agent._registry = registry - - integration = MagicMock() - integration.provider_for_client = ( - lambda c: "google" if c is client else None - ) - with patch( - "agents.main_agent.integrations.external_mcp_client.get_external_mcp_integration", - return_value=integration, - ): - lookup = agent._build_tool_use_provider_lookup() - - assert lookup( - { - "name": "skill_executor", - "input": { - "skill_name": "gmail-for-employees", - "tool_name": "gmail_search", - }, - } - ) == "google" - assert lookup({"name": "some_other_tool", "input": {}}) is None - - -class TestToolUseApprovalLookupWiring: - """SkillAgent must hand the per-tool approval hook a tool_use-based - resolver over ITS registry — that's what lets the hook see an admin's - needs_approval flag on a skill-bound external MCP tool behind - skill_executor (the skills-mode approval-bypass regression). Built - without a full agent: the override only reads `self._registry` and the - global external MCP integration. - """ - - def test_lookup_resolves_flagged_folded_tool_via_integration(self): - from agents.main_agent.skills.mcp_binding import FoldedMCPTool - from agents.main_agent.skills.skill_registry import SkillRegistry - - client = object() - registry = SkillRegistry() - registry.load_records( - [ - SimpleNamespace( - skill_id="gmail-for-employees", - description="", - instructions="", - compose=[], - bound_tool_ids=["gmail_mcp"], - resources=[], - ) - ] - ) - registry.bind_catalog_tools( - {"gmail_mcp": [FoldedMCPTool(client, mcp_tool_name="gmail_send")]} - ) - - agent = object.__new__(skill_agent.SkillAgent) - agent._registry = registry - - integration = MagicMock() - integration.approval_names_for_client = ( - lambda c: {"gmail_send"} if c is client else set() - ) - with patch( - "agents.main_agent.integrations.external_mcp_client.get_external_mcp_integration", - return_value=integration, - ): - lookup = agent._build_tool_use_approval_lookup() - - target = lookup( - { - "name": "skill_executor", - "input": { - "skill_name": "gmail-for-employees", - "tool_name": "gmail_send", - "tool_input": {"to": "hr@example.com"}, - }, - } - ) - assert target is not None - assert target.tool_name == "gmail_send" - assert target.tool_input == {"to": "hr@example.com"} - assert lookup({"name": "some_other_tool", "input": {}}) is None - - -class _FoldAwareClient: - """Stand-in external MCP client that folds like the real ones. - - ``list_tools_sync`` re-derives the full server list every call and applies - ``drop_folded_tools`` against the persisted fold set — exactly the seam - that poisons a re-bind when the fold from a prior build is still present. - """ - - def __init__(self, names): - self._names = list(names) - self._loaded_tools = None - - def list_tools_sync(self): - from agents.main_agent.integrations.mcp_tool_folding import ( - drop_folded_tools, - ) - - tools = [ - SimpleNamespace( - tool_name=n, - tool_spec={"name": n}, - mcp_tool=SimpleNamespace(name=n), - ) - for n in self._names - ] - return drop_folded_tools(self, tools) - - -class TestBindMcpToolsRebindsAcrossBuilds: - """A skill-bound external server must stay foldable on EVERY agent build, - not just the first. Clients are process-global and reused; the fold set - persists on them and accumulates, and resolve enumerates through the same - fold-filtered list_tools_sync — so without a per-build reset the second - build sees zero tools (the reported "works once, then disappears"). """ - - @staticmethod - def _build_once(client): - from agents.main_agent.tools.tool_filter import ToolFilter - from agents.main_agent.skills.skill_registry import SkillRegistry - - registry = SkillRegistry() - registry.load_records( - [ - SimpleNamespace( - skill_id="canvas_check", - description="", - instructions="", - compose=[], - bound_tool_ids=["canvas::a"], - resources=[], - ) - ] - ) - - agent = object.__new__(skill_agent.SkillAgent) - agent._registry = registry - agent._db_mode = True - agent.tool_registry = SimpleNamespace(has_tool=lambda _t: False) - tool_filter = ToolFilter(SimpleNamespace(has_tool=lambda _b: False)) - tool_filter.set_external_mcp_tools(["canvas"]) - agent.tool_filter = tool_filter - agent.gateway_integration = SimpleNamespace(client=None) - agent._expand_gateway_tool_ids = lambda _ids: [] - agent.user_id = "u1" - - integration = SimpleNamespace(get_client=lambda _tid, _uid=None: client) - with patch( - "agents.main_agent.integrations.external_mcp_client.get_external_mcp_integration", - return_value=integration, - ): - agent._bind_mcp_tools() - return registry - - def test_second_build_still_resolves_and_folds(self): - from agents.main_agent.integrations.mcp_tool_folding import ( - folded_tool_names, - ) - - client = _FoldAwareClient(["a", "b"]) # shared across both builds - - reg1 = self._build_once(client) - assert reg1.get_tools("canvas_check"), "first build should bind tools" - assert folded_tool_names(client) == {"a", "b"}, "first build folds them" - - # Second build: a fresh registry (as in production) but the SAME cached - # client, still carrying build 1's fold. The reset must let it rebind. - reg2 = self._build_once(client) - assert reg2.get_tools("canvas_check"), ( - "second build must still resolve the bound tools (regression: stale " - "fold made list_tools_sync return nothing)" - ) - assert folded_tool_names(client) == {"a", "b"} diff --git a/backend/tests/agents/main_agent/skills/test_skill_registry.py b/backend/tests/agents/main_agent/skills/test_skill_registry.py deleted file mode 100644 index 064822f7e..000000000 --- a/backend/tests/agents/main_agent/skills/test_skill_registry.py +++ /dev/null @@ -1,324 +0,0 @@ -"""Tests for SkillRegistry — discovery, binding, and three-level access.""" - -from types import SimpleNamespace - -import pytest -from agents.main_agent.skills.skill_registry import SkillRegistry - - -class TestDiscovery: - """Req SK-1: Skill discovery from SKILL.md files.""" - - def test_discovers_all_skills(self, registry): - assert registry.get_skill_count() == 3 - - def test_discovers_skill_names(self, registry): - names = sorted(registry.get_skill_names()) - assert names == ["data-analysis", "visualization", "web-search"] - - def test_empty_directory_returns_zero(self, tmp_path): - reg = SkillRegistry(str(tmp_path)) - assert reg.discover_skills() == 0 - - def test_nonexistent_directory_returns_zero(self): - reg = SkillRegistry("/nonexistent/path") - assert reg.discover_skills() == 0 - - def test_has_skill_true(self, registry): - assert registry.has_skill("web-search") is True - - def test_has_skill_false(self, registry): - assert registry.has_skill("nonexistent") is False - - -class TestFrontmatterParsing: - """Req SK-2: YAML frontmatter parsing without PyYAML.""" - - def test_parses_name(self, registry): - assert registry.has_skill("web-search") - - def test_parses_composite_compose_list(self, registry): - skills = registry._skills - assert "data-analysis" in skills - assert skills["data-analysis"]["compose"] == ["web-search", "visualization"] - - def test_parses_type(self, registry): - assert registry._skills["web-search"]["type"] == "tool" - assert registry._skills["data-analysis"]["type"] == "composite" - - def test_parses_description(self, registry): - assert registry._skills["web-search"]["description"] == "Search the web for current information" - - def test_no_frontmatter_returns_empty(self): - result = SkillRegistry._parse_frontmatter("Just plain markdown") - assert result == {} - - -class TestToolBinding: - """Req SK-3: Tool binding via _skill_name metadata.""" - - def test_binds_tool_to_skill(self, registry, mock_tool): - bound = registry.bind_tools([mock_tool]) - assert bound == 1 - assert len(registry.get_tools("web-search")) == 1 - - def test_ignores_tool_without_skill_name(self, registry): - def orphan_tool(): pass - bound = registry.bind_tools([orphan_tool]) - assert bound == 0 - - def test_ignores_tool_with_unknown_skill(self, registry): - def stray_tool(): pass - stray_tool._skill_name = "nonexistent-skill" - bound = registry.bind_tools([stray_tool]) - assert bound == 0 - - def test_binds_multiple_tools(self, registry, mock_tool, viz_tool): - bound = registry.bind_tools([mock_tool, viz_tool]) - assert bound == 2 - - -class TestCatalog: - """Req SK-4: Level 1 — catalog generation for system prompt.""" - - def test_catalog_contains_skill_names(self, registry): - catalog = registry.get_catalog() - assert "web-search" in catalog - assert "visualization" in catalog - assert "data-analysis" in catalog - - def test_catalog_contains_descriptions(self, registry): - catalog = registry.get_catalog() - assert "Search the web for current information" in catalog - - def test_catalog_shows_composite_combines(self, registry): - catalog = registry.get_catalog() - assert "combines:" in catalog - - def test_empty_registry_returns_empty_string(self): - reg = SkillRegistry("/nonexistent") - assert reg.get_catalog() == "" - - def test_catalog_shows_tool_count(self, registry, mock_tool): - registry.bind_tools([mock_tool]) - catalog = registry.get_catalog() - assert "(1 tools)" in catalog - - -class TestInstructions: - """Req SK-5: Level 2 — instructions loading.""" - - def test_loads_instructions_without_frontmatter(self, registry): - instructions = registry.load_instructions("web-search") - assert instructions is not None - assert "# Web Search" in instructions - assert "---" not in instructions - assert "name:" not in instructions - - def test_unknown_skill_returns_none(self, registry): - assert registry.load_instructions("nonexistent") is None - - -class TestTools: - """Req SK-6: Level 3 — tool access.""" - - def test_get_tools_returns_bound_tools(self, registry, mock_tool): - registry.bind_tools([mock_tool]) - tools = registry.get_tools("web-search") - assert len(tools) == 1 - assert tools[0].tool_name == "web_search" - - def test_composite_skill_aggregates_tools(self, registry, mock_tool, viz_tool): - registry.bind_tools([mock_tool, viz_tool]) - tools = registry.get_tools("data-analysis") - assert len(tools) == 2 - - def test_unknown_skill_returns_empty(self, registry): - assert registry.get_tools("nonexistent") == [] - - def test_get_tool_schemas(self, registry, mock_tool): - registry.bind_tools([mock_tool]) - schemas = registry.get_tool_schemas("web-search") - assert len(schemas) == 1 - assert schemas[0]["name"] == "web_search" - - -class _Rec: - """Minimal SkillDefinition stand-in (duck-typed by load_records).""" - - def __init__(self, skill_id, description="", instructions="", compose=None, - bound_tool_ids=None, resources=None, status="active"): - self.skill_id = skill_id - self.description = description - self.instructions = instructions - self.compose = compose or [] - self.bound_tool_ids = bound_tool_ids or [] - self.resources = resources or [] - self.status = status - - -class TestDbSource: - """Req SK-6 (PR-6): DynamoDB-backed skill records (admin-managed).""" - - def test_load_records_populates_registry(self): - reg = SkillRegistry() - n = reg.load_records([ - _Rec("pdf_workflows", description="PDFs", instructions="# PDF body", - bound_tool_ids=["fill_pdf_form"]), - _Rec("doc_basics", description="Docs"), - ]) - assert n == 2 - assert reg.get_skill_count() == 2 - assert reg.has_skill("pdf_workflows") - # Catalog lists the DB skills by skill_id + description. - catalog = reg.get_catalog() - assert "pdf_workflows" in catalog and "PDFs" in catalog - - def test_load_instructions_returns_inline_body(self): - reg = SkillRegistry() - reg.load_records([_Rec("pdf_workflows", instructions="# Inline body")]) - # DB mode: instructions come from the record, not a file. - assert reg.load_instructions("pdf_workflows") == "# Inline body" - - def test_all_bound_tool_ids_unions_and_dedupes(self): - reg = SkillRegistry() - reg.load_records([ - _Rec("a", bound_tool_ids=["t1", "t2"]), - _Rec("b", bound_tool_ids=["t2", "t3"]), - ]) - assert sorted(reg.all_bound_tool_ids()) == ["t1", "t2", "t3"] - - def test_bind_catalog_tools_binds_matching_ids_only(self): - reg = SkillRegistry() - reg.load_records([_Rec("a", bound_tool_ids=["local_tool", "gateway_x"])]) - - def local_tool(): - return "ok" - local_tool.tool_name = "local_tool" - - # Only the resolvable (local) id is in the map; the gateway id is not. - reg.bind_catalog_tools({"local_tool": local_tool}) - tools = reg.get_tools("a") - assert tools == [local_tool] - # Schema reflects the bound local tool. - assert reg.get_tool_schemas("a")[0]["name"] == "local_tool" - - def test_bind_catalog_tools_is_idempotent(self): - reg = SkillRegistry() - reg.load_records([_Rec("a", bound_tool_ids=["t"])]) - - def t(): - return 1 - t.tool_name = "t" - - reg.bind_catalog_tools({"t": t}) - reg.bind_catalog_tools({"t": t}) - assert reg.get_tools("a") == [t] # not double-bound - - def test_db_composite_aggregates_child_tools(self): - reg = SkillRegistry() - reg.load_records([ - _Rec("child", bound_tool_ids=["t"]), - _Rec("parent", compose=["child"]), - ]) - - def t(): - return 1 - t.tool_name = "t" - - reg.bind_catalog_tools({"t": t}) - assert reg.get_tools("parent") == [t] - - def test_bind_catalog_tools_accepts_list_value(self): - # One catalog id can expand to several runtime tools (gateway target / - # external server with many) — bind_catalog_tools accepts a list. - reg = SkillRegistry() - reg.load_records([_Rec("a", bound_tool_ids=["server"])]) - - t1 = SimpleNamespace(tool_name="search") - t2 = SimpleNamespace(tool_name="fetch") - reg.bind_catalog_tools({"server": [t1, t2]}) - - tools = reg.get_tools("a") - assert tools == [t1, t2] - - def test_bind_catalog_tools_list_is_idempotent(self): - reg = SkillRegistry() - reg.load_records([_Rec("a", bound_tool_ids=["server"])]) - t1 = SimpleNamespace(tool_name="search") - reg.bind_catalog_tools({"server": [t1]}) - reg.bind_catalog_tools({"server": [t1]}) - assert reg.get_tools("a") == [t1] - - -def _ref(filename, s3_key="k", content_type="text/markdown", size=10): - return { - "filename": filename, - "s3_key": s3_key, - "content_type": content_type, - "size": size, - } - - -class _FakeStore: - """Stand-in for SkillResourceStore keyed by s3_key.""" - - def __init__(self, data): - self._data = data - - def get(self, s3_key): - if s3_key not in self._data: - from apis.shared.skills.resource_store import SkillResourceStoreError - - raise SkillResourceStoreError(f"missing {s3_key}") - return self._data[s3_key] - - -class TestReferenceFiles: - """Req (PR-6b): reference-file progressive disclosure level.""" - - def test_get_resource_names(self): - reg = SkillRegistry() - reg.load_records([ - _Rec("a", resources=[_ref("forms.md", "k1"), _ref("reference.md", "k2")]) - ]) - assert reg.get_resource_names("a") == ["forms.md", "reference.md"] - - def test_get_resource_names_empty_when_none(self): - reg = SkillRegistry() - reg.load_records([_Rec("a")]) - assert reg.get_resource_names("a") == [] - - def test_read_resource_returns_text(self): - reg = SkillRegistry() - reg.load_records([_Rec("a", resources=[_ref("forms.md", "k1")])]) - store = _FakeStore({"k1": b"# Forms\nfill them in"}) - out = reg.read_resource("a", "forms.md", store=store) - assert out["content"] == "# Forms\nfill them in" - assert out["filename"] == "forms.md" - - def test_read_resource_unknown_skill_returns_none(self): - reg = SkillRegistry() - assert reg.read_resource("nope", "x.md", store=_FakeStore({})) is None - - def test_read_resource_missing_file_errors(self): - reg = SkillRegistry() - reg.load_records([_Rec("a", resources=[_ref("forms.md", "k1")])]) - out = reg.read_resource("a", "absent.md", store=_FakeStore({})) - assert "error" in out - - def test_read_resource_storage_error_is_error_dict(self): - reg = SkillRegistry() - reg.load_records([_Rec("a", resources=[_ref("forms.md", "k1")])]) - # Store has no k1 → raises SkillResourceStoreError → error dict. - out = reg.read_resource("a", "forms.md", store=_FakeStore({})) - assert "error" in out - - def test_read_resource_binary_is_noted_not_dumped(self): - reg = SkillRegistry() - reg.load_records([_Rec("a", resources=[_ref("logo.png", "k1", "image/png")])]) - store = _FakeStore({"k1": b"\x89PNG\x00\xff"}) - out = reg.read_resource("a", "logo.png", store=store) - assert "content" not in out - assert "note" in out - assert out["content_type"] == "image/png" diff --git a/backend/tests/agents/main_agent/skills/test_skill_tools.py b/backend/tests/agents/main_agent/skills/test_skill_tools.py deleted file mode 100644 index f7e4e097d..000000000 --- a/backend/tests/agents/main_agent/skills/test_skill_tools.py +++ /dev/null @@ -1,289 +0,0 @@ -"""Tests for skill_dispatcher and skill_executor tools.""" - -import json -import pytest - -from agents.main_agent.skills.skill_registry import SkillRegistry -from agents.main_agent.skills.skill_tools import ( - make_skill_tools, - skill_dispatcher, - skill_executor, - set_dispatcher_registry, -) - - -class _DbRec: - """Minimal SkillDefinition stand-in for DB-mode registries.""" - - def __init__(self, skill_id, instructions="# body", bound_tool_ids=None, - resources=None): - self.skill_id = skill_id - self.description = f"desc {skill_id}" - self.instructions = instructions - self.compose = [] - self.bound_tool_ids = bound_tool_ids or [] - self.resources = resources or [] - self.status = "active" - - -class _FakeStore: - def __init__(self, data): - self._data = data - - def get(self, s3_key): - return self._data[s3_key] - - -@pytest.fixture(autouse=True) -def setup_registry(registry, mock_tool, viz_tool): - """Wire up registry with tools for all tests in this module.""" - registry.bind_tools([mock_tool, viz_tool]) - set_dispatcher_registry(registry) - yield - set_dispatcher_registry(None) - - -class TestSkillDispatcher: - """Req SD-1: skill_dispatcher loads instructions and schemas.""" - - def test_returns_instructions(self, registry): - result = json.loads(skill_dispatcher(skill_name="web-search")) - assert "instructions" in result - assert "# Web Search" in result["instructions"] - - def test_returns_tool_schemas(self, registry): - result = json.loads(skill_dispatcher(skill_name="web-search")) - assert "tool_schemas" in result - assert len(result["tool_schemas"]) == 1 - assert result["tool_schemas"][0]["name"] == "web_search" - - def test_unknown_skill_returns_error(self): - result = json.loads(skill_dispatcher(skill_name="nonexistent")) - assert "error" in result - assert "Unknown skill" in result["error"] - - def test_unknown_skill_lists_available(self): - result = json.loads(skill_dispatcher(skill_name="nonexistent")) - assert "available_skills" in result - - def test_no_registry_returns_error(self): - set_dispatcher_registry(None) - result = json.loads(skill_dispatcher(skill_name="web-search")) - assert "error" in result - assert "not initialized" in result["error"] - - -class TestSkillExecutor: - """Req SE-1: skill_executor runs tools within a skill.""" - - def test_executes_tool(self): - result = skill_executor( - skill_name="web-search", - tool_name="web_search", - tool_input={"query": "test search"} - ) - assert result == "Results for: test search" - - def test_json_string_input_parsed(self): - result = skill_executor( - skill_name="web-search", - tool_name="web_search", - tool_input='{"query": "parsed"}' - ) - assert result == "Results for: parsed" - - def test_unknown_skill_returns_error(self): - result = json.loads(skill_executor( - skill_name="nonexistent", - tool_name="anything", - )) - assert "error" in result - - def test_unknown_tool_returns_error_with_available(self): - result = json.loads(skill_executor( - skill_name="web-search", - tool_name="nonexistent_tool", - )) - assert "error" in result - assert "available_tools" in result - - def test_no_registry_returns_error(self): - set_dispatcher_registry(None) - result = json.loads(skill_executor( - skill_name="web-search", - tool_name="web_search", - )) - assert "error" in result - - -class TestReferenceDisclosure: - """Req (PR-6b): skill_dispatcher serves reference files on demand.""" - - def _registry(self): - reg = SkillRegistry() - reg.load_records([ - _DbRec( - "research", - instructions="# Research\nSee forms.md", - resources=[{ - "filename": "forms.md", - "s3_key": "k1", - "content_type": "text/markdown", - "size": 7, - }], - ) - ]) - return reg - - def test_dispatch_lists_available_references(self): - dispatch, _ = make_skill_tools(self._registry()) - result = json.loads(dispatch(skill_name="research")) - assert result["available_references"] == ["forms.md"] - assert "reference_hint" in result - - def test_dispatch_reads_reference(self, monkeypatch): - import apis.shared.skills.resource_store as rs - - monkeypatch.setattr( - rs, "get_skill_resource_store", - lambda: _FakeStore({"k1": b"# Forms\nfill"}), - ) - dispatch, _ = make_skill_tools(self._registry()) - result = json.loads(dispatch(skill_name="research", reference="forms.md")) - assert result["content"] == "# Forms\nfill" - assert result["filename"] == "forms.md" - - def test_dispatch_unknown_reference_lists_available(self): - dispatch, _ = make_skill_tools(self._registry()) - result = json.loads(dispatch(skill_name="research", reference="absent.md")) - assert "error" in result - assert result["available_references"] == ["forms.md"] - - -class TestFoldedMCPExecutor: - """Req (PR-6b): skill_executor runs a folded gateway/external MCP tool - through the MCP client (covers the gateway + external execution path).""" - - def test_executor_routes_through_client(self): - from agents.main_agent.skills.mcp_binding import FoldedMCPTool - - class _Client: - def __init__(self): - self.calls = [] - - def call_tool_sync(self, tool_use_id, name, arguments=None, **k): - self.calls.append((name, arguments)) - return {"content": [{"text": "folded result"}]} - - client = _Client() - reg = SkillRegistry() - reg.load_records([_DbRec("g", bound_tool_ids=["gateway_x"])]) - folded = FoldedMCPTool(client, mcp_tool_name="target___tool") - reg.bind_catalog_tools({"gateway_x": [folded]}) - - _, execute = make_skill_tools(reg) - out = execute( - skill_name="g", tool_name="target___tool", tool_input={"q": "hi"} - ) - assert out == "folded result" - assert client.calls[0] == ("target___tool", {"q": "hi"}) - - def test_dispatch_shows_folded_tool_schema(self): - from agents.main_agent.skills.mcp_binding import FoldedMCPTool - - reg = SkillRegistry() - reg.load_records([_DbRec("g", bound_tool_ids=["gateway_x"])]) - spec = {"name": "target___tool", "inputSchema": {"json": {}}} - folded = FoldedMCPTool(None, mcp_tool_name="target___tool", tool_spec=spec) - reg.bind_catalog_tools({"gateway_x": [folded]}) - - dispatch, _ = make_skill_tools(reg) - result = json.loads(dispatch(skill_name="g")) - assert result["tool_schemas"][0]["name"] == "target___tool" - - -class TestDecorators: - """Req SD-2: Skill decorators apply metadata.""" - - def test_skill_decorator_sets_attribute(self): - from agents.main_agent.skills.decorators import skill - - @skill("my-skill") - def my_tool(): - pass - - assert my_tool._skill_name == "my-skill" - - def test_register_skill_sets_attributes(self): - from agents.main_agent.skills.decorators import register_skill - - def tool_a(): pass - def tool_b(): pass - - register_skill("batch-skill", tools=[tool_a, tool_b]) - assert tool_a._skill_name == "batch-skill" - assert tool_b._skill_name == "batch-skill" - - -class TestMakeSkillTools: - """Req SD-3 (PR-6): per-agent meta-tools bound to their own registry. - - The concurrency fix: two agents serving different users must not share - skill state through a process-global registry. - """ - - def _registry_with(self, skill_id): - from agents.main_agent.skills.skill_registry import SkillRegistry - - reg = SkillRegistry() - - class _Rec: - def __init__(self, sid): - self.skill_id = sid - self.description = f"desc {sid}" - self.instructions = f"# {sid} body" - self.compose = [] - self.bound_tool_ids = [] - self.resources = [] - self.status = "active" - - reg.load_records([_Rec(skill_id)]) - return reg - - def test_each_pair_resolves_against_its_own_registry(self): - from agents.main_agent.skills.skill_tools import ( - make_skill_tools, - set_dispatcher_registry, - ) - - # A stray global registry must NOT leak into the closures. - set_dispatcher_registry(None) - - reg_a = self._registry_with("skill_a") - reg_b = self._registry_with("skill_b") - dispatch_a, _ = make_skill_tools(reg_a) - dispatch_b, _ = make_skill_tools(reg_b) - - a = json.loads(dispatch_a(skill_name="skill_a")) - assert "# skill_a body" in a["instructions"] - - # reg_b doesn't know skill_a → its dispatcher reports it unknown, - # proving the pairs are isolated (no shared global). - cross = json.loads(dispatch_b(skill_name="skill_a")) - assert "error" in cross and "Unknown skill" in cross["error"] - - b = json.loads(dispatch_b(skill_name="skill_b")) - assert "# skill_b body" in b["instructions"] - - def test_factory_pair_ignores_module_global(self): - from agents.main_agent.skills.skill_tools import ( - make_skill_tools, - set_dispatcher_registry, - ) - - reg = self._registry_with("only_skill") - dispatch, _ = make_skill_tools(reg) - # Even with the global cleared, the closure works. - set_dispatcher_registry(None) - result = json.loads(dispatch(skill_name="only_skill")) - assert "# only_skill body" in result["instructions"] diff --git a/backend/tests/agents/main_agent/skills/test_strands_mapping.py b/backend/tests/agents/main_agent/skills/test_strands_mapping.py new file mode 100644 index 000000000..1e2b427d9 --- /dev/null +++ b/backend/tests/agents/main_agent/skills/test_strands_mapping.py @@ -0,0 +1,181 @@ +"""Skills v2 PR-2 — DB→Strands Skill mapping + ChatAgent plugin wiring.""" + +from types import SimpleNamespace + +import pytest +from strands import AgentSkills, Skill + +from agents.main_agent.skills import strands_mapping as sm + + +def _ref(filename, *, kind="reference", content_type="text/markdown", size=10): + subdir = {"reference": "references", "script": "scripts", "asset": "assets"}[kind] + return SimpleNamespace( + filename=filename, + kind=kind, + content_type=content_type, + size=size, + s3_key=f"skills/pdf_workflows/{subdir}/{filename}", + ) + + +def _record(**kw): + base = dict( + skill_id="pdf_workflows", + display_name="PDF Workflows", + description="Work with PDF files.", + instructions="# PDF\nExtract and fill PDFs.", + allowed_tools=[], + skill_metadata={}, + resources=[], + status="active", + ) + base.update(kw) + return SimpleNamespace(**base) + + +class _FakeStore: + """Stand-in for SkillResourceStore keyed by s3_key → bytes.""" + + def __init__(self, objects=None, raises=False): + self._objects = objects or {} + self._raises = raises + + def get(self, s3_key): + if self._raises: + raise sm.SkillResourceStoreError("disabled") + return self._objects[s3_key] + + +@pytest.mark.parametrize( + "skill_id,expected", + [ + ("pdf_workflows", "pdf-workflows"), + ("Web Research", "web-research"), + ("already-slug", "already-slug"), + ("UPPER_Snake_Case", "upper-snake-case"), + ("weird__multi___underscore", "weird-multi-underscore"), + ("-leading-and-trailing-", "leading-and-trailing"), + ("!!!", "skill"), # empties out → defensive fallback + ], +) +def test_slugify_is_agentskills_valid(skill_id, expected): + import re + + slug = sm.slugify_skill_name(skill_id) + assert slug == expected + assert re.fullmatch(r"[a-z0-9]([a-z0-9-]*[a-z0-9])?", slug) + + +def test_record_to_strands_skill_maps_fields(): + skill = sm.record_to_strands_skill( + _record(allowed_tools=["web_search"], skill_metadata={"license": "Apache-2.0"}) + ) + assert isinstance(skill, Skill) + assert skill.name == "pdf-workflows" # slug, not the human name + assert skill.description == "Work with PDF files." + assert skill.instructions.startswith("# PDF") + assert skill.allowed_tools == ["web_search"] + # true skill_id + human name recoverable from metadata; frontmatter passthrough kept + assert skill.metadata["skill_id"] == "pdf_workflows" + assert skill.metadata["display_name"] == "PDF Workflows" + assert skill.metadata["license"] == "Apache-2.0" + + +def test_record_to_strands_skill_empty_allowed_tools_is_none(): + # Strands treats [] and None differently for the advisory field; empty → None. + skill = sm.record_to_strands_skill(_record(allowed_tools=[])) + assert skill.allowed_tools is None + + +def test_build_skills_plugin_none_when_empty(): + assert sm.build_skills_plugin(None) is None + assert sm.build_skills_plugin([]) is None + + +def test_build_skills_plugin_builds_agentskills(monkeypatch): + recs = [_record(skill_id="pdf_workflows"), _record(skill_id="doc_drafting", display_name="Doc")] + monkeypatch.setattr(sm, "fetch_active_skill_records", lambda ids: recs) + plugin = sm.build_skills_plugin(["pdf_workflows", "doc_drafting"]) + assert isinstance(plugin, AgentSkills) + names = {s.name for s in plugin.get_available_skills()} + assert names == {"pdf-workflows", "doc-drafting"} + + +def test_build_skills_plugin_none_when_no_active_records(monkeypatch): + monkeypatch.setattr(sm, "fetch_active_skill_records", lambda ids: []) + assert sm.build_skills_plugin(["gone"]) is None + + +def test_instructions_gain_reference_file_listing(): + rec = _record(resources=[_ref("forms.md"), _ref("run.py", kind="script")]) + skill = sm.record_to_strands_skill(rec) + assert "## Available reference files" in skill.instructions + assert "references/forms.md" in skill.instructions + assert "scripts/run.py" in skill.instructions + # No listing when there are no resources. + assert "Available reference files" not in sm.record_to_strands_skill( + _record() + ).instructions + + +class TestBuildSkillsRuntime: + def test_returns_none_none_when_empty(self): + assert sm.build_skills_runtime(None) == (None, None) + assert sm.build_skills_runtime([]) == (None, None) + + def test_returns_plugin_and_tool(self, monkeypatch): + monkeypatch.setattr(sm, "fetch_active_skill_records", lambda ids: [_record()]) + plugin, read_tool = sm.build_skills_runtime(["pdf_workflows"]) + assert isinstance(plugin, AgentSkills) + assert read_tool.tool_name == "read_skill_file" + + +class TestReadSkillFile: + def _tool(self, monkeypatch, records, store): + monkeypatch.setattr(sm, "get_skill_resource_store", lambda: store) + return sm.make_read_skill_file_tool(records) + + def test_reads_reference_text(self, monkeypatch): + rec = _record(resources=[_ref("forms.md")]) + store = _FakeStore({"skills/pdf_workflows/references/forms.md": b"# Forms body"}) + read = self._tool(monkeypatch, [rec], store) + # Resolvable by bare filename or by standard bundle path. + assert read(skill_name="pdf-workflows", path="forms.md") == "# Forms body" + assert ( + read(skill_name="pdf-workflows", path="references/forms.md") + == "# Forms body" + ) + + def test_script_is_inert_labeled(self, monkeypatch): + rec = _record(resources=[_ref("run.py", kind="script", content_type="text/x-python")]) + store = _FakeStore({"skills/pdf_workflows/scripts/run.py": b"print('hi')"}) + read = self._tool(monkeypatch, [rec], store) + out = read(skill_name="pdf-workflows", path="scripts/run.py") + assert "NOT" in out and "executable" in out + assert "print('hi')" in out + + def test_binary_asset_is_described_not_dumped(self, monkeypatch): + rec = _record(resources=[_ref("logo.png", kind="asset", content_type="image/png", size=2048)]) + store = _FakeStore({"skills/pdf_workflows/assets/logo.png": b"\x89PNG..."}) + read = self._tool(monkeypatch, [rec], store) + out = read(skill_name="pdf-workflows", path="assets/logo.png") + assert "binary" in out and "image/png" in out + assert "\x89PNG" not in out + + def test_unknown_skill_rejected(self, monkeypatch): + read = self._tool(monkeypatch, [_record()], _FakeStore()) + out = read(skill_name="not-a-skill", path="forms.md") + assert "not available" in out + + def test_missing_file_lists_available(self, monkeypatch): + rec = _record(resources=[_ref("forms.md")]) + read = self._tool(monkeypatch, [rec], _FakeStore()) + out = read(skill_name="pdf-workflows", path="ghost.md") + assert "No reference file" in out and "references/forms.md" in out + + def test_store_error_is_handled(self, monkeypatch): + rec = _record(resources=[_ref("forms.md")]) + read = self._tool(monkeypatch, [rec], _FakeStore(raises=True)) + out = read(skill_name="pdf-workflows", path="forms.md") + assert "Could not read" in out diff --git a/backend/tests/apis/app_api/admin/services/test_skill_access.py b/backend/tests/apis/app_api/admin/services/test_skill_access.py deleted file mode 100644 index 85c606663..000000000 --- a/backend/tests/apis/app_api/admin/services/test_skill_access.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Tests for SkillAccessService. - -Mirrors test_tool_access.py: filter_allowed_skills sources its "universe of -known skills" from the DynamoDB-backed catalog (via the freshness snapshot). -Unlike tools, skills have no dynamically-loaded ("gateway_*") form, so there -is no prefix-passthrough case. -""" - -from types import SimpleNamespace -from unittest.mock import AsyncMock, patch - -import pytest - -from apis.app_api.admin.services.skill_access import SkillAccessService -from apis.shared.auth.models import User -from apis.shared.rbac.models import UserEffectivePermissions -from apis.shared.skills import freshness - - -@pytest.fixture(autouse=True) -def _reset_freshness(): - freshness._reset_for_tests() - yield - freshness._reset_for_tests() - - -def _user(roles=None) -> User: - return User( - email="admin@example.com", - user_id="admin-1", - name="Admin", - roles=roles or [], - ) - - -def _permissions(skills, app_roles=("admin",)) -> UserEffectivePermissions: - return UserEffectivePermissions( - user_id="admin-1", - app_roles=list(app_roles), - tools=[], - models=[], - skills=list(skills), - quota_tier=None, - resolved_at="2026-06-09T00:00:00Z", - ) - - -def _service(permissions: UserEffectivePermissions) -> SkillAccessService: - role_service = AsyncMock() - role_service.resolve_user_permissions = AsyncMock(return_value=permissions) - return SkillAccessService(app_role_service=role_service) - - -def _patch_catalog(skill_ids): - """Patch the repository.list_skills call that backs the freshness snapshot.""" - repo = SimpleNamespace( - list_skills=AsyncMock( - return_value=[SimpleNamespace(skill_id=sid) for sid in skill_ids] - ) - ) - return patch( - "apis.shared.skills.repository.get_skill_catalog_repository", - return_value=repo, - ) - - -@pytest.mark.asyncio -async def test_can_access_skill_wildcard(): - service = _service(_permissions(["*"])) - assert await service.can_access_skill(_user(), "pdf_workflows") is True - - -@pytest.mark.asyncio -async def test_can_access_skill_specific(): - service = _service(_permissions(["pdf_workflows"])) - assert await service.can_access_skill(_user(), "pdf_workflows") is True - assert await service.can_access_skill(_user(), "doc_basics") is False - - -@pytest.mark.asyncio -async def test_wildcard_user_expands_to_all_catalog_skills(): - service = _service(_permissions(["*"])) - - with _patch_catalog(["pdf_workflows", "doc_basics"]): - result = await service.filter_allowed_skills(_user(), None) - - assert set(result) == {"pdf_workflows", "doc_basics"} - - -@pytest.mark.asyncio -async def test_wildcard_user_filters_out_unknown_skill(): - """Wildcard means 'every known skill', not every id a client claims.""" - service = _service(_permissions(["*"])) - - with _patch_catalog(["pdf_workflows"]): - result = await service.filter_allowed_skills( - _user(), - requested_skills=["pdf_workflows", "made_up_skill"], - ) - - assert result == ["pdf_workflows"] - - -@pytest.mark.asyncio -async def test_non_wildcard_user_sees_intersection_of_granted_and_catalog(): - service = _service(_permissions(["pdf_workflows", "ghost_skill"])) - - with _patch_catalog(["pdf_workflows", "doc_basics"]): - result = await service.filter_allowed_skills(_user(), None) - - # ghost_skill is granted but not in the catalog → excluded. - assert set(result) == {"pdf_workflows"} - - -@pytest.mark.asyncio -async def test_non_wildcard_user_denies_unauthorized_request(): - service = _service(_permissions(["pdf_workflows"])) - - with _patch_catalog(["pdf_workflows", "doc_basics"]): - result = await service.filter_allowed_skills( - _user(), - requested_skills=["pdf_workflows", "doc_basics"], - ) - - assert result == ["pdf_workflows"] - - -@pytest.mark.asyncio -async def test_check_access_and_filter_reports_denied(): - service = _service(_permissions(["pdf_workflows"])) - - with _patch_catalog(["pdf_workflows", "doc_basics"]): - allowed, denied = await service.check_access_and_filter( - _user(), - requested_skills=["pdf_workflows", "doc_basics"], - ) - - assert allowed == ["pdf_workflows"] - assert denied == ["doc_basics"] - - -@pytest.mark.asyncio -async def test_check_access_and_filter_strict_raises(): - service = _service(_permissions(["pdf_workflows"])) - - with _patch_catalog(["pdf_workflows", "doc_basics"]): - with pytest.raises(ValueError, match="not authorized"): - await service.check_access_and_filter( - _user(), - requested_skills=["doc_basics"], - strict=True, - ) - - -@pytest.mark.asyncio -async def test_get_user_allowed_skills(): - service = _service(_permissions(["pdf_workflows", "doc_basics"])) - assert await service.get_user_allowed_skills(_user()) == {"pdf_workflows", "doc_basics"} diff --git a/backend/tests/apis/app_api/agent_designer/test_bindable_catalog.py b/backend/tests/apis/app_api/agent_designer/test_bindable_catalog.py index 29c8ace91..93a5021b3 100644 --- a/backend/tests/apis/app_api/agent_designer/test_bindable_catalog.py +++ b/backend/tests/apis/app_api/agent_designer/test_bindable_catalog.py @@ -89,13 +89,13 @@ async def test_skills_hydrated_when_flag_on(self, monkeypatch): monkeypatch.setattr(f"{MODULE}.skills_enabled", lambda: True) monkeypatch.setattr(f"{MODULE}.resolve_accessible_skill_ids", AsyncMock(return_value=["pdf"])) skill = SimpleNamespace(skill_id="pdf", display_name="PDF", description="PDF tools", - bound_tool_ids=["t1"], compose=[]) + compose=[]) repo = MagicMock() repo.batch_get_skills = AsyncMock(return_value=[skill]) monkeypatch.setattr(f"{MODULE}.get_skill_catalog_repository", lambda: repo) items = await bc.list_bindable("skill", _user()) assert items[0].ref == "pdf" - assert items[0].meta["boundToolIds"] == ["t1"] + assert items[0].meta["compose"] == [] # --------------------------------------------------------------------------- kb diff --git a/backend/tests/apis/app_api/skills/conftest.py b/backend/tests/apis/app_api/skills/conftest.py index 08021a1cc..d673c02fd 100644 --- a/backend/tests/apis/app_api/skills/conftest.py +++ b/backend/tests/apis/app_api/skills/conftest.py @@ -110,19 +110,52 @@ def skill_service(app_roles_table, skill_resource_store): from apis.shared.rbac.repository import AppRoleRepository from apis.shared.rbac.service import AppRoleService from apis.shared.skills.repository import SkillCatalogRepository - from apis.shared.tools.repository import ToolCatalogRepository cache = AppRoleCache() role_repo = AppRoleRepository(table_name=TABLE) return SkillCatalogService( repository=SkillCatalogRepository(table_name=TABLE), - tool_repository=ToolCatalogRepository(table_name=TABLE), app_role_service=AppRoleService(repository=role_repo, cache=cache), app_role_admin_service=AppRoleAdminService(repository=role_repo, cache=cache), resource_store=skill_resource_store, ) +@pytest.fixture() +def user_skill_service(skill_service): + """UserSkillService sharing the moto-backed catalog service + repository.""" + from apis.app_api.skills.user_service import UserSkillService + + return UserSkillService( + repository=skill_service.repository, + catalog_service=skill_service, + ) + + +@pytest.fixture() +def author_user() -> User: + """A plain (non-admin) user who authors their own skills.""" + return User( + user_id="user-author", + email="author@example.com", + name="Author", + roles=["user"], + raw_token="test-token", + ) + + +@pytest.fixture() +def other_user() -> User: + """A second plain user — used to prove owner isolation.""" + return User( + user_id="user-other", + email="other@example.com", + name="Other", + roles=["user"], + raw_token="test-token", + ) + + @pytest.fixture() def admin_user() -> User: return User( diff --git a/backend/tests/apis/app_api/skills/test_admin_skill_routes.py b/backend/tests/apis/app_api/skills/test_admin_skill_routes.py index 8fdc54d4c..2a285df55 100644 --- a/backend/tests/apis/app_api/skills/test_admin_skill_routes.py +++ b/backend/tests/apis/app_api/skills/test_admin_skill_routes.py @@ -10,7 +10,6 @@ from apis.shared.auth import require_admin from apis.shared.rbac.models import AppRoleCreate -from apis.shared.tools.models import ToolDefinition, ToolProtocol, ToolStatus from apis.app_api.admin.skills import routes as skill_routes @@ -29,24 +28,11 @@ def _create_body(skill_id="pdf_workflows", **kw): "displayName": "PDF Workflows", "description": "Fill, merge and split PDFs.", "instructions": "# PDF Workflows", - "boundToolIds": [], } body.update(kw) return body -async def _seed_tool(tool_repo, tool_id, status=ToolStatus.ACTIVE): - await tool_repo.create_tool( - ToolDefinition( - tool_id=tool_id, - display_name=tool_id, - description="x", - protocol=ToolProtocol.LOCAL, - status=status, - ) - ) - - def test_create_and_get(client): resp = client.post("/skills/", json=_create_body()) assert resp.status_code == 200, resp.text @@ -74,24 +60,6 @@ def test_list(client): assert {s["skillId"] for s in body["skills"]} == {"skill_one", "skill_two"} -def test_create_rejects_unknown_bound_tool(client): - resp = client.post( - "/skills/", json=_create_body(boundToolIds=["ghost_tool"]) - ) - assert resp.status_code == 400 - assert "unknown tool" in resp.json()["detail"] - - -@pytest.mark.asyncio -async def test_create_with_active_bound_tool(client, tool_repo): - await _seed_tool(tool_repo, "fill_pdf_form") - resp = client.post( - "/skills/", json=_create_body(boundToolIds=["fill_pdf_form"]) - ) - assert resp.status_code == 200, resp.text - assert resp.json()["boundToolIds"] == ["fill_pdf_form"] - - def test_update(client): client.post("/skills/", json=_create_body()) resp = client.put("/skills/pdf_workflows", json={"displayName": "PDF Tools"}) diff --git a/backend/tests/apis/app_api/skills/test_my_skill_routes.py b/backend/tests/apis/app_api/skills/test_my_skill_routes.py new file mode 100644 index 000000000..131b1f32c --- /dev/null +++ b/backend/tests/apis/app_api/skills/test_my_skill_routes.py @@ -0,0 +1,184 @@ +"""HTTP surface of the My Skills routes (Skills v2 PR-3). + +Asserts the contract the SPA depends on: camelCase DTOs, session-cookie auth +(never Bearer-only), and the status mapping for the owner-scoped errors. +""" + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from apis.app_api.skills import routes as skill_routes +from apis.shared.auth import get_current_user_from_session + + +@pytest.fixture() +def client(user_skill_service, author_user, monkeypatch): + monkeypatch.setattr( + skill_routes, "get_user_skill_service", lambda: user_skill_service + ) + app = FastAPI() + app.include_router(skill_routes.router) + app.dependency_overrides[get_current_user_from_session] = lambda: author_user + with TestClient(app) as c: + yield c + + +@pytest.fixture() +def other_client(user_skill_service, other_user, monkeypatch): + """Same app, authenticated as a different user.""" + monkeypatch.setattr( + skill_routes, "get_user_skill_service", lambda: user_skill_service + ) + app = FastAPI() + app.include_router(skill_routes.router) + app.dependency_overrides[get_current_user_from_session] = lambda: other_user + with TestClient(app) as c: + yield c + + +def test_my_skills_routes_use_session_auth_not_bearer(): + """A Bearer-only dependency here would 401-loop the cookie-bearing SPA. + + Walks the TRANSITIVE dependency tree, not just each route's direct + dependencies. The routes depend on the session directly today (the + ``require_skills_capability`` wrapper they used to nest under was removed), + but the invariant being pinned is "the session dependency is reachable from + every /mine route", which is what actually keeps the cookie-bearing SPA off + the 401-redirect loop — asserting the flat shape instead would break on any + future wrapper while proving less. + """ + paths = [ + r + for r in skill_routes.router.routes + if getattr(r, "path", "").startswith("/skills/mine") + ] + assert paths, "expected /mine routes to be registered" + + def _calls(dependant): + for sub in dependant.dependencies: + if sub.call is not None: + yield sub.call + yield from _calls(sub) + + for route in paths: + assert get_current_user_from_session in set(_calls(route.dependant)), route.path + + +def test_create_list_get_roundtrip(client): + created = client.post( + "/skills/mine", + json={ + "displayName": "Grant Writing", + "description": "How we write grant narratives.", + "instructions": "Start with the abstract.", + }, + ) + assert created.status_code == 200 + body = created.json() + assert body["skillId"] == "grant_writing" + assert body["displayName"] == "Grant Writing" + + listed = client.get("/skills/mine") + assert listed.status_code == 200 + assert listed.json()["totalCount"] == 1 + assert listed.json()["skills"][0]["skillId"] == "grant_writing" + + fetched = client.get("/skills/mine/grant_writing") + assert fetched.status_code == 200 + assert fetched.json()["instructions"] == "Start with the abstract." + + +def test_create_rejects_a_blank_description(client): + resp = client.post( + "/skills/mine", json={"displayName": "Named", "description": " "} + ) + assert resp.status_code == 400 + + +def test_update_and_delete(client): + client.post( + "/skills/mine", json={"displayName": "Notes", "description": "Old."} + ) + + updated = client.put("/skills/mine/notes", json={"description": "New."}) + assert updated.status_code == 200 + assert updated.json()["description"] == "New." + + deleted = client.delete("/skills/mine/notes") + assert deleted.status_code == 200 + assert client.get("/skills/mine/notes").status_code == 404 + + +def test_update_cannot_rehome_ownership(client, user_skill_service, author_user): + """``ownerId``/``visibility`` are absent from the DTO, so they're ignored.""" + client.post("/skills/mine", json={"displayName": "Notes", "description": "d"}) + + resp = client.put( + "/skills/mine/notes", + json={"description": "d2", "ownerId": "system", "visibility": "admin"}, + ) + assert resp.status_code == 200 + + stored = client.get("/skills/mine/notes") + assert stored.status_code == 200 + # Still owned by the author — proven by it still being reachable as theirs. + mine = client.get("/skills/mine").json() + assert [s["skillId"] for s in mine["skills"]] == ["notes"] + + +def test_another_users_skill_is_404_everywhere(client, other_client): + client.post("/skills/mine", json={"displayName": "Notes", "description": "d"}) + + assert other_client.get("/skills/mine/notes").status_code == 404 + assert other_client.put("/skills/mine/notes", json={"description": "x"}).status_code == 404 + assert other_client.delete("/skills/mine/notes").status_code == 404 + assert other_client.get("/skills/mine/notes/resources").status_code == 404 + assert other_client.get("/skills/mine").json()["totalCount"] == 0 + + +def test_resource_upload_read_and_delete(client): + client.post("/skills/mine", json={"displayName": "Notes", "description": "d"}) + + uploaded = client.post( + "/skills/mine/notes/resources", + files={"file": ("forms.md", b"# Forms", "text/markdown")}, + ) + assert uploaded.status_code == 200 + refs = uploaded.json()["resources"] + assert refs[0]["filename"] == "forms.md" + assert refs[0]["s3Key"] == "skills/notes/references/forms.md" + + read = client.get("/skills/mine/notes/resources/forms.md") + assert read.status_code == 200 + assert read.content == b"# Forms" + + removed = client.delete("/skills/mine/notes/resources/forms.md") + assert removed.status_code == 200 + assert removed.json()["resources"] == [] + + +def test_script_uploads_are_accepted_and_stored_inert(client): + client.post("/skills/mine", json={"displayName": "Notes", "description": "d"}) + + uploaded = client.post( + "/skills/mine/notes/resources", + files={"file": ("build.py", b"print(1)", "text/x-python")}, + data={"kind": "script"}, + ) + + assert uploaded.status_code == 200 + ref = uploaded.json()["resources"][0] + assert ref["kind"] == "script" + assert ref["s3Key"] == "skills/notes/scripts/build.py" + + +def test_resource_upload_rejects_a_traversal_filename(client): + client.post("/skills/mine", json={"displayName": "Notes", "description": "d"}) + + resp = client.post( + "/skills/mine/notes/resources", + files={"file": ("../../etc/passwd", b"x", "text/plain")}, + ) + + assert resp.status_code == 400 diff --git a/backend/tests/apis/app_api/skills/test_skill_catalog_service.py b/backend/tests/apis/app_api/skills/test_skill_catalog_service.py index 4897fae64..03c180e0f 100644 --- a/backend/tests/apis/app_api/skills/test_skill_catalog_service.py +++ b/backend/tests/apis/app_api/skills/test_skill_catalog_service.py @@ -1,40 +1,26 @@ """SkillCatalogService tests (moto-backed). -Covers CRUD, bound-tool validation against the tool catalog, allowedAppRoles -hydration, and bidirectional role sync (writing granted_skills onto AppRoles). +Covers CRUD, allowedAppRoles hydration, and bidirectional role sync (writing +granted_skills onto AppRoles). """ import pytest from apis.shared.rbac.models import AppRoleCreate from apis.shared.skills.models import SkillDefinition, SkillStatus -from apis.shared.tools.models import ToolDefinition, ToolProtocol, ToolStatus -def _skill(skill_id="pdf_workflows", bound_tool_ids=None, **kw) -> SkillDefinition: +def _skill(skill_id="pdf_workflows", **kw) -> SkillDefinition: defaults = dict( skill_id=skill_id, display_name="PDF Workflows", description="Fill, merge and split PDFs.", instructions="# PDF Workflows", - bound_tool_ids=bound_tool_ids if bound_tool_ids is not None else [], ) defaults.update(kw) return SkillDefinition(**defaults) -async def _seed_tool(tool_repo, tool_id, status=ToolStatus.ACTIVE): - await tool_repo.create_tool( - ToolDefinition( - tool_id=tool_id, - display_name=tool_id, - description="x", - protocol=ToolProtocol.LOCAL, - status=status, - ) - ) - - async def _seed_role(skill_service, role_id, admin_user, **kw): return await skill_service.app_role_admin_service.create_role( AppRoleCreate(role_id=role_id, display_name=role_id, **kw), admin_user @@ -54,6 +40,34 @@ async def test_create_and_get(skill_service, admin_user): assert fetched.display_name == "PDF Workflows" +@pytest.mark.asyncio +async def test_create_writes_skill_md_projection(skill_service, admin_user): + # The S3 prefix becomes a valid agentskills.io bundle: SKILL.md is projected + # from the row with a slugged `name`. + await skill_service.create_skill( + _skill(allowed_tools=["web_search"]), admin_user + ) + content = skill_service.resource_store.get( + "skills/pdf_workflows/SKILL.md" + ).decode() + assert content.startswith("---\n") + assert "name: pdf-workflows" in content + assert "allowed-tools:" in content + assert "# PDF Workflows" in content + + +@pytest.mark.asyncio +async def test_update_refreshes_skill_md_projection(skill_service, admin_user): + await skill_service.create_skill(_skill(), admin_user) + await skill_service.update_skill( + "pdf_workflows", {"instructions": "# Rewritten body"}, admin_user + ) + content = skill_service.resource_store.get( + "skills/pdf_workflows/SKILL.md" + ).decode() + assert "# Rewritten body" in content + + @pytest.mark.asyncio async def test_get_all_skills_status_filter(skill_service, admin_user): await skill_service.create_skill(_skill("skill_one", status=SkillStatus.ACTIVE), admin_user) @@ -99,117 +113,6 @@ async def test_delete_missing_returns_false(skill_service, admin_user): assert await skill_service.delete_skill("nope", admin_user) is False -# ── bound-tool validation ──────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_create_with_active_bound_tool(skill_service, tool_repo, admin_user): - await _seed_tool(tool_repo, "fill_pdf_form") - created = await skill_service.create_skill( - _skill(bound_tool_ids=["fill_pdf_form"]), admin_user - ) - assert created.bound_tool_ids == ["fill_pdf_form"] - - -@pytest.mark.asyncio -async def test_create_rejects_unknown_bound_tool(skill_service, admin_user): - with pytest.raises(ValueError, match="unknown tool"): - await skill_service.create_skill( - _skill(bound_tool_ids=["does_not_exist"]), admin_user - ) - # Nothing persisted. - assert await skill_service.get_skill("pdf_workflows") is None - - -@pytest.mark.asyncio -async def test_create_rejects_disabled_bound_tool(skill_service, tool_repo, admin_user): - await _seed_tool(tool_repo, "old_tool", status=ToolStatus.DISABLED) - with pytest.raises(ValueError, match="non-active tool"): - await skill_service.create_skill( - _skill(bound_tool_ids=["old_tool"]), admin_user - ) - - -@pytest.mark.asyncio -async def test_update_revalidates_bound_tools(skill_service, tool_repo, admin_user): - await _seed_tool(tool_repo, "fill_pdf_form") - await skill_service.create_skill(_skill(bound_tool_ids=["fill_pdf_form"]), admin_user) - - with pytest.raises(ValueError, match="unknown tool"): - await skill_service.update_skill( - "pdf_workflows", {"bound_tool_ids": ["ghost"]}, admin_user - ) - - -# ── scoped (per-tool) bindings ─────────────────────────────────────────────── - - -async def _seed_mcp_tool(tool_repo, tool_id, tool_names, status=ToolStatus.ACTIVE): - """Seed an external-MCP catalog tool with a curated tools[] list.""" - from apis.shared.tools.models import MCPServerConfig, MCPToolEntry - - await tool_repo.create_tool( - ToolDefinition( - tool_id=tool_id, - display_name=tool_id, - description="x", - protocol=ToolProtocol.MCP_EXTERNAL, - status=status, - mcp_config=MCPServerConfig( - server_url="https://example.com/mcp", - tools=[MCPToolEntry(name=n) for n in tool_names], - ), - ) - ) - - -@pytest.mark.asyncio -async def test_bind_scoped_tool_exposed_by_server(skill_service, tool_repo, admin_user): - await _seed_mcp_tool(tool_repo, "gmail", ["send", "search", "draft"]) - created = await skill_service.create_skill( - _skill(bound_tool_ids=["gmail::send", "gmail::search"]), admin_user - ) - assert created.bound_tool_ids == ["gmail::send", "gmail::search"] - - -@pytest.mark.asyncio -async def test_bind_scoped_tool_not_exposed_is_rejected(skill_service, tool_repo, admin_user): - await _seed_mcp_tool(tool_repo, "gmail", ["send", "search"]) - with pytest.raises(ValueError, match="not exposed by their server"): - await skill_service.create_skill( - _skill(bound_tool_ids=["gmail::delete_everything"]), admin_user - ) - - -@pytest.mark.asyncio -async def test_bind_scoped_tool_on_server_with_no_curated_list(skill_service, tool_repo, admin_user): - # No curated tools[] (discovered live) → the name can't be validated - # statically and is accepted. - await _seed_mcp_tool(tool_repo, "dyn", []) - created = await skill_service.create_skill( - _skill(bound_tool_ids=["dyn::live_tool"]), admin_user - ) - assert created.bound_tool_ids == ["dyn::live_tool"] - - -@pytest.mark.asyncio -async def test_scoped_binding_on_local_tool_is_rejected(skill_service, tool_repo, admin_user): - # A local tool is a single tool — per-tool scoping is meaningless. - await _seed_tool(tool_repo, "fill_pdf_form") - with pytest.raises(ValueError, match="non-MCP tool"): - await skill_service.create_skill( - _skill(bound_tool_ids=["fill_pdf_form::x"]), admin_user - ) - - -@pytest.mark.asyncio -async def test_scoped_binding_on_unknown_base_is_rejected(skill_service, admin_user): - with pytest.raises(ValueError, match="unknown tool"): - await skill_service.create_skill( - _skill(bound_tool_ids=["ghost::tool"]), admin_user - ) - - # ── role sync + hydration ──────────────────────────────────────────────────── diff --git a/backend/tests/apis/app_api/skills/test_skill_resource_routes.py b/backend/tests/apis/app_api/skills/test_skill_resource_routes.py index 06a3d6cd6..698c7c8d2 100644 --- a/backend/tests/apis/app_api/skills/test_skill_resource_routes.py +++ b/backend/tests/apis/app_api/skills/test_skill_resource_routes.py @@ -2,7 +2,7 @@ Covers the reference-file lifecycle: upload → list manifest → read bytes → re-upload (replace) → delete, plus validation (filename, size, empty, caps), -content-hash dedupe / orphan GC, and 404s for missing skills/files. +the standard-bundle key layout, orphan GC, and 404s for missing skills/files. """ import boto3 @@ -36,7 +36,6 @@ def _create_skill(client, skill_id="pdf_workflows"): "displayName": "PDF Workflows", "description": "Fill, merge and split PDFs.", "instructions": "# PDF Workflows", - "boundToolIds": [], }, ) assert resp.status_code == 200, resp.text @@ -50,6 +49,11 @@ def _bucket_keys(): ] +def _resource_keys(): + """Bucket keys excluding the write-through SKILL.md projection objects.""" + return [k for k in _bucket_keys() if not k.endswith("/SKILL.md")] + + def _upload(client, skill_id, filename, body, content_type="text/markdown"): return client.post( f"/skills/{skill_id}/resources", @@ -69,7 +73,8 @@ def test_upload_then_list_manifest(self, client): assert ref["filename"] == "forms.md" assert ref["size"] == len(b"# Forms") assert ref["contentType"] == "text/markdown" - assert ref["s3Key"] == f"skills/pdf_workflows/{ref['contentHash']}" + assert ref["s3Key"] == "skills/pdf_workflows/references/forms.md" + assert ref["kind"] == "reference" listed = client.get("/skills/pdf_workflows/resources") assert listed.status_code == 200 @@ -108,7 +113,7 @@ def test_read_missing_file_404(self, client): ) -class TestReplaceAndDedupe: +class TestReplaceAndLayout: def test_reupload_same_filename_replaces(self, client): _create_skill(client) _upload(client, "pdf_workflows", "forms.md", b"v1") @@ -123,35 +128,34 @@ def test_reupload_same_filename_replaces(self, client): == b"v2-longer" ) - def test_reupload_garbage_collects_orphaned_object(self, client): + def test_reupload_same_filename_overwrites_in_place(self, client): _create_skill(client) _upload(client, "pdf_workflows", "forms.md", b"v1") _upload(client, "pdf_workflows", "forms.md", b"v2") - # Only the current object remains; the v1 object was GC'd. - keys = _bucket_keys() - assert len(keys) == 1 + # Same (kind, filename) → same key → exactly one resource object. + assert _resource_keys() == ["skills/pdf_workflows/references/forms.md"] - def test_identical_content_two_filenames_dedupes_object(self, client): + def test_two_filenames_are_two_objects(self, client): + # Dedupe is dropped: identical content under two filenames is two objects. _create_skill(client) _upload(client, "pdf_workflows", "a.md", b"identical") _upload(client, "pdf_workflows", "b.md", b"identical") - # Manifest has both filenames... names = [ r["filename"] for r in client.get("/skills/pdf_workflows/resources").json()["resources"] ] assert names == ["a.md", "b.md"] - # ...but only one S3 object (content-addressed). - assert len(_bucket_keys()) == 1 + assert sorted(_resource_keys()) == [ + "skills/pdf_workflows/references/a.md", + "skills/pdf_workflows/references/b.md", + ] - def test_delete_one_keeps_shared_object(self, client): - # a.md and b.md share one content-addressed object; deleting a.md must - # NOT delete the object b.md still references. + def test_delete_one_leaves_the_other(self, client): _create_skill(client) _upload(client, "pdf_workflows", "a.md", b"identical") _upload(client, "pdf_workflows", "b.md", b"identical") client.delete("/skills/pdf_workflows/resources/a.md") - assert len(_bucket_keys()) == 1 + assert _resource_keys() == ["skills/pdf_workflows/references/b.md"] assert client.get("/skills/pdf_workflows/resources/b.md").content == b"identical" @@ -162,7 +166,8 @@ def test_delete_removes_from_manifest_and_object(self, client): resp = client.delete("/skills/pdf_workflows/resources/forms.md") assert resp.status_code == 200 assert resp.json()["resources"] == [] - assert _bucket_keys() == [] + # The resource object is gone; the SKILL.md projection is untouched. + assert _resource_keys() == [] assert ( client.get("/skills/pdf_workflows/resources/forms.md").status_code == 404 ) diff --git a/backend/tests/apis/app_api/skills/test_user_skill_service.py b/backend/tests/apis/app_api/skills/test_user_skill_service.py new file mode 100644 index 000000000..509648bb8 --- /dev/null +++ b/backend/tests/apis/app_api/skills/test_user_skill_service.py @@ -0,0 +1,376 @@ +"""UserSkillService — the owner-scoped user-authored tier (Skills v2 PR-3). + +Covers the two things that make this tier safe: ids are allocated server-side +without ever colliding across tiers, and ownership is checked on every path. +""" + +import pytest + +from apis.app_api.skills.user_service import ( + MAX_SKILLS_PER_USER, + UserSkillError, + UserSkillLimitError, + UserSkillNotFoundError, + slugify_skill_id, +) +from apis.shared.skills.models import ( + SYSTEM_OWNER_ID, + SkillDefinition, + SkillStatus, + SkillVisibility, +) + +pytestmark = pytest.mark.asyncio + + +# ============================================================================= +# slugify_skill_id +# ============================================================================= + + +@pytest.mark.parametrize( + "display_name,expected", + [ + ("Docx", "docx"), + ("PDF Workflows", "pdf_workflows"), + (" Grant Writing! ", "grant_writing"), + ("Weekly-Report/Builder", "weekly_report_builder"), + # Pattern demands a leading letter, so a digit-led name gets a prefix + # rather than a truncation that would change its meaning. + ("3D Modeling", "skill_3d_modeling"), + # Non-Latin script slugifies to nothing usable → the fallback stem. + ("日本語", "skill"), + ("", "skill"), + # Minimum length is 3. + ("Q", "q_x"), + ], +) +def test_slugify_skill_id_shapes(display_name, expected): + assert slugify_skill_id(display_name) == expected + + +def test_slugify_skill_id_always_matches_the_id_pattern(): + import re + + from apis.shared.skills.models import SKILL_ID_PATTERN + + names = ["Docx", "3D", "日本語", "", "Q", "A" * 200, "--- ??? ---"] + for name in names: + assert re.match(SKILL_ID_PATTERN, slugify_skill_id(name)), name + + +# ============================================================================= +# create +# ============================================================================= + + +async def test_create_allocates_id_and_stamps_ownership( + user_skill_service, author_user +): + skill = await user_skill_service.create_my_skill( + author_user, + display_name="Grant Writing", + description="How we write grant narratives.", + instructions="# Grant Writing\n\nStart with the abstract.", + ) + + assert skill.skill_id == "grant_writing" + assert skill.owner_id == author_user.user_id + assert skill.visibility == SkillVisibility.PRIVATE + assert skill.status == SkillStatus.ACTIVE + assert skill.created_by == author_user.user_id + + +async def test_create_suffixes_around_a_catalog_collision( + user_skill_service, skill_service, admin_user, author_user +): + """A user naming their skill after a catalog skill succeeds, suffixed. + + Rejecting with a 409 would also disclose the existence of a catalog skill + the user may not be granted. + """ + await skill_service.create_skill( + SkillDefinition( + skill_id="docx", + display_name="Docx", + description="Admin catalog docx skill.", + instructions="body", + ), + admin_user, + ) + + mine = await user_skill_service.create_my_skill( + author_user, display_name="Docx", description="My own docx notes." + ) + + assert mine.skill_id == "docx_2" + assert mine.owner_id == author_user.user_id + + +async def test_create_suffixes_around_another_users_skill( + user_skill_service, author_user, other_user +): + first = await user_skill_service.create_my_skill( + author_user, display_name="Docx", description="Mine." + ) + second = await user_skill_service.create_my_skill( + other_user, display_name="Docx", description="Theirs." + ) + + assert first.skill_id == "docx" + assert second.skill_id == "docx_2" + + +async def test_create_requires_name_and_description(user_skill_service, author_user): + with pytest.raises(UserSkillError): + await user_skill_service.create_my_skill( + author_user, display_name=" ", description="Has a description." + ) + with pytest.raises(UserSkillError): + await user_skill_service.create_my_skill( + author_user, display_name="Named", description=" " + ) + + +async def test_create_enforces_the_per_user_cap( + user_skill_service, author_user, monkeypatch +): + monkeypatch.setattr( + "apis.app_api.skills.user_service.MAX_SKILLS_PER_USER", 2, raising=False + ) + # The service reads the module-level constant at call time. + import apis.app_api.skills.user_service as mod + + monkeypatch.setattr(mod, "MAX_SKILLS_PER_USER", 2) + + await user_skill_service.create_my_skill( + author_user, display_name="One", description="d" + ) + await user_skill_service.create_my_skill( + author_user, display_name="Two", description="d" + ) + + with pytest.raises(UserSkillLimitError): + await user_skill_service.create_my_skill( + author_user, display_name="Three", description="d" + ) + + assert MAX_SKILLS_PER_USER == 50 # the shipped default is unchanged + + +# ============================================================================= +# ownership isolation +# ============================================================================= + + +async def test_list_my_skills_returns_only_own( + user_skill_service, skill_service, admin_user, author_user, other_user +): + await user_skill_service.create_my_skill( + author_user, display_name="Mine A", description="d" + ) + await user_skill_service.create_my_skill( + author_user, display_name="Mine B", description="d" + ) + await user_skill_service.create_my_skill( + other_user, display_name="Theirs", description="d" + ) + await skill_service.create_skill( + SkillDefinition( + skill_id="catalog_one", + display_name="Catalog", + description="d", + instructions="", + ), + admin_user, + ) + + mine = await user_skill_service.list_my_skills(author_user) + + assert [s.display_name for s in mine] == ["Mine A", "Mine B"] + + +async def test_another_users_skill_is_not_found_not_forbidden( + user_skill_service, author_user, other_user +): + """404, not 403 — this surface never confirms someone else's skill exists.""" + theirs = await user_skill_service.create_my_skill( + other_user, display_name="Theirs", description="d" + ) + + with pytest.raises(UserSkillNotFoundError): + await user_skill_service.get_my_skill(theirs.skill_id, author_user) + with pytest.raises(UserSkillNotFoundError): + await user_skill_service.update_my_skill( + theirs.skill_id, {"description": "hijacked"}, author_user + ) + with pytest.raises(UserSkillNotFoundError): + await user_skill_service.delete_my_skill(theirs.skill_id, author_user) + + +async def test_catalog_skills_are_not_editable_through_the_user_tier( + user_skill_service, skill_service, admin_user, author_user +): + await skill_service.create_skill( + SkillDefinition( + skill_id="catalog_one", + display_name="Catalog", + description="d", + instructions="", + ), + admin_user, + ) + + with pytest.raises(UserSkillNotFoundError): + await user_skill_service.update_my_skill( + "catalog_one", {"instructions": "hijacked"}, author_user + ) + + +# ============================================================================= +# update / delete +# ============================================================================= + + +async def test_update_writes_authored_fields(user_skill_service, author_user): + skill = await user_skill_service.create_my_skill( + author_user, display_name="Notes", description="Old." + ) + + updated = await user_skill_service.update_my_skill( + skill.skill_id, + {"description": "New.", "instructions": "# Notes", "allowed_tools": ["web"]}, + author_user, + ) + + assert updated.description == "New." + assert updated.instructions == "# Notes" + assert updated.allowed_tools == ["web"] + # Ownership is untouched by an update. + assert updated.owner_id == author_user.user_id + assert updated.visibility == SkillVisibility.PRIVATE + + +async def test_delete_is_hard_and_purges_bundle_objects( + user_skill_service, skill_resource_store, author_user +): + skill = await user_skill_service.create_my_skill( + author_user, display_name="Notes", description="d" + ) + refs = await user_skill_service.add_resource( + skill.skill_id, "forms.md", b"# Forms", "text/markdown", author_user + ) + s3_key = refs[0].s3_key + assert skill_resource_store.get(s3_key) == b"# Forms" + + await user_skill_service.delete_my_skill(skill.skill_id, author_user) + + assert await user_skill_service.repository.get_skill(skill.skill_id) is None + from apis.shared.skills.resource_store import SkillResourceStoreError + + with pytest.raises(SkillResourceStoreError): + skill_resource_store.get(s3_key) + + +# ============================================================================= +# bundle files +# ============================================================================= + + +async def test_resources_land_in_the_standard_bundle_layout( + user_skill_service, author_user +): + skill = await user_skill_service.create_my_skill( + author_user, display_name="Notes", description="d" + ) + + await user_skill_service.add_resource( + skill.skill_id, "forms.md", b"# Forms", "text/markdown", author_user + ) + refs = await user_skill_service.add_resource( + skill.skill_id, "build.py", b"print(1)", "text/x-python", author_user, + kind="script", + ) + + by_name = {r.filename: r for r in refs} + assert by_name["forms.md"].s3_key == f"skills/{skill.skill_id}/references/forms.md" + assert by_name["build.py"].s3_key == f"skills/{skill.skill_id}/scripts/build.py" + assert by_name["build.py"].kind == "script" + + +async def test_resource_routes_enforce_ownership( + user_skill_service, author_user, other_user +): + skill = await user_skill_service.create_my_skill( + author_user, display_name="Notes", description="d" + ) + await user_skill_service.add_resource( + skill.skill_id, "forms.md", b"# Forms", "text/markdown", author_user + ) + + for call in ( + user_skill_service.list_resources(skill.skill_id, other_user), + user_skill_service.read_resource(skill.skill_id, "forms.md", other_user), + user_skill_service.delete_resource(skill.skill_id, "forms.md", other_user), + ): + with pytest.raises(UserSkillNotFoundError): + await call + + +async def test_skill_md_projection_is_written_for_user_skills( + user_skill_service, skill_resource_store, author_user +): + """A user skill's S3 prefix must be a valid, portable agentskills.io bundle.""" + skill = await user_skill_service.create_my_skill( + author_user, + display_name="Grant Writing", + description="How we write grant narratives.", + instructions="Start with the abstract.", + ) + + content = skill_resource_store.get(f"skills/{skill.skill_id}/SKILL.md").decode() + + assert content.startswith("---") + assert "How we write grant narratives." in content + assert "Start with the abstract." in content + + +# ============================================================================= +# tier boundaries +# ============================================================================= + + +async def test_admin_catalog_list_excludes_user_skills( + user_skill_service, skill_service, admin_user, author_user +): + await skill_service.create_skill( + SkillDefinition( + skill_id="catalog_one", + display_name="Catalog", + description="d", + instructions="", + ), + admin_user, + ) + await user_skill_service.create_my_skill( + author_user, display_name="Mine", description="d" + ) + + catalog = await skill_service.get_all_skills(include_roles=False) + + assert [s.skill_id for s in catalog] == ["catalog_one"] + assert all(s.owner_id == SYSTEM_OWNER_ID for s in catalog) + + +async def test_user_skills_cannot_be_granted_to_app_roles( + user_skill_service, skill_service, admin_user, author_user +): + """Granting a private user skill to a role would leak it to that role.""" + mine = await user_skill_service.create_my_skill( + author_user, display_name="Mine", description="d" + ) + + with pytest.raises(ValueError, match="user-authored"): + await skill_service.set_roles_for_skill(mine.skill_id, ["some_role"], admin_user) + with pytest.raises(ValueError, match="user-authored"): + await skill_service.add_roles_to_skill(mine.skill_id, ["some_role"], admin_user) diff --git a/backend/tests/apis/app_api/test_chat_settings_routes.py b/backend/tests/apis/app_api/test_chat_settings_routes.py deleted file mode 100644 index a84dcb894..000000000 --- a/backend/tests/apis/app_api/test_chat_settings_routes.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Route tests for the chat-mode policy endpoints. - -Covers the admin surface (``GET/PUT /admin/settings/chat``) and the -user-facing SPA read (``GET /system/chat-settings``). -""" - -from __future__ import annotations - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from apis.shared.auth import require_admin -from apis.shared.auth.dependencies import get_current_user_from_session -from apis.shared.auth.models import User -from apis.shared.platform_settings.models import ChatModeSettings -from apis.shared.platform_settings.service import ChatModeSettingsService - -from apis.app_api.admin.settings import routes as admin_settings_routes -from apis.app_api.system import routes as system_routes - - -def _admin() -> User: - return User( - user_id="admin-1", - email="admin@example.com", - name="Admin", - roles=["admin"], - raw_token="test-token", - ) - - -def _user() -> User: - return User( - user_id="user-1", - email="user@example.com", - name="User", - roles=["default"], - raw_token="test-token", - ) - - -class _InMemoryRepo: - """Duck-typed stand-in for PlatformSettingsRepository.""" - - def __init__(self, stored: ChatModeSettings | None = None): - self.stored = stored - - @property - def enabled(self) -> bool: - return True - - async def get_chat_mode_settings(self): - return self.stored - - async def put_chat_mode_settings(self, settings) -> None: - self.stored = settings - - -@pytest.fixture -def repo() -> _InMemoryRepo: - return _InMemoryRepo() - - -@pytest.fixture -def client(repo: _InMemoryRepo, monkeypatch: pytest.MonkeyPatch) -> TestClient: - # These tests exercise the policy surface as it behaves with the skills - # feature enabled; the disabled-feature override is covered separately. - monkeypatch.setenv("SKILLS_ENABLED", "true") - service = ChatModeSettingsService(repository=repo, cache_ttl_seconds=0.0) - monkeypatch.setattr( - admin_settings_routes, "get_chat_mode_settings_service", lambda: service - ) - monkeypatch.setattr( - system_routes, "get_chat_mode_settings_service", lambda: service - ) - - app = FastAPI() - app.include_router(admin_settings_routes.router, prefix="/admin") - app.include_router(system_routes.router) - app.dependency_overrides[require_admin] = _admin - app.dependency_overrides[get_current_user_from_session] = _user - return TestClient(app) - - -class TestAdminChatSettings: - def test_get_returns_defaults_when_unconfigured(self, client: TestClient): - response = client.get("/admin/settings/chat") - assert response.status_code == 200 - body = response.json() - assert body["defaultMode"] == "skill" - assert body["allowModeToggle"] is True - assert body["updatedBy"] is None - - def test_put_persists_and_stamps_audit_fields( - self, client: TestClient, repo: _InMemoryRepo - ): - response = client.put( - "/admin/settings/chat", - json={"defaultMode": "chat", "allowModeToggle": False}, - ) - assert response.status_code == 200 - body = response.json() - assert body["defaultMode"] == "chat" - assert body["allowModeToggle"] is False - assert body["updatedBy"] == "admin@example.com" - assert body["updatedAt"] is not None - - assert repo.stored is not None - assert repo.stored.default_mode == "chat" - - # Subsequent GET reflects the stored policy - follow_up = client.get("/admin/settings/chat") - assert follow_up.json()["defaultMode"] == "chat" - - def test_put_rejects_invalid_mode(self, client: TestClient): - response = client.put( - "/admin/settings/chat", - json={"defaultMode": "voice", "allowModeToggle": True}, - ) - assert response.status_code == 422 - - -class TestSystemChatSettings: - def test_returns_policy_flags_only(self, client: TestClient): - client.put( - "/admin/settings/chat", - json={"defaultMode": "chat", "allowModeToggle": False}, - ) - - response = client.get("/system/chat-settings") - assert response.status_code == 200 - body = response.json() - assert body == { - "defaultMode": "chat", - "allowModeToggle": False, - "skillsEnabled": True, - } - - def test_defaults_when_unconfigured(self, client: TestClient): - response = client.get("/system/chat-settings") - assert response.status_code == 200 - assert response.json() == { - "defaultMode": "skill", - "allowModeToggle": True, - "skillsEnabled": True, - } - - -class TestSystemChatSettingsSkillsDisabled: - """When SKILLS_ENABLED is off, the public read ignores any stored policy - and forces tools/chat mode so the SPA hides the skills surfaces.""" - - @pytest.fixture - def client_off(self, monkeypatch: pytest.MonkeyPatch) -> TestClient: - monkeypatch.setenv("SKILLS_ENABLED", "false") - # A stored policy that *would* enable skills — the flag must win. - repo = _InMemoryRepo( - ChatModeSettings(default_mode="skill", allow_mode_toggle=True) - ) - service = ChatModeSettingsService(repository=repo, cache_ttl_seconds=0.0) - monkeypatch.setattr( - system_routes, "get_chat_mode_settings_service", lambda: service - ) - app = FastAPI() - app.include_router(system_routes.router) - app.dependency_overrides[get_current_user_from_session] = _user - return TestClient(app) - - def test_forces_chat_and_reports_disabled(self, client_off: TestClient): - response = client_off.get("/system/chat-settings") - assert response.status_code == 200 - assert response.json() == { - "defaultMode": "chat", - "allowModeToggle": False, - "skillsEnabled": False, - } diff --git a/backend/tests/apis/app_api/test_skills_feature_flag.py b/backend/tests/apis/app_api/test_skills_feature_flag.py index c1463b2af..4f97b7f74 100644 --- a/backend/tests/apis/app_api/test_skills_feature_flag.py +++ b/backend/tests/apis/app_api/test_skills_feature_flag.py @@ -1,11 +1,17 @@ """Tests for the SKILLS_ENABLED feature gate. -The skills feature (admin catalog, user picker, skills mode) is deferred and -disabled by default. These tests pin two things: - -* the ``skills_enabled()`` helper's parsing + default-off behavior, and -* that the admin skills + chat-mode-policy subrouters are unmounted while the - flag is off and mounted while it is on. +Skills v2 PR-5 flipped this to **default ON with a kill switch** (the +``SCHEDULED_RUNS_ENABLED`` house style): unset or empty resolves enabled, only +the literal ``"false"`` disables. These tests pin three things: + +* the ``skills_enabled()`` helper's parsing + default-ON behavior — including + the empty-string case, which an unset GitHub Actions variable produces and + which must NOT read as disabled, +* that the admin skills subrouter mounts with the flag on and unmounts with it + explicitly off, and +* that the flag is now the *only* gate on the user-facing surfaces — every + route requires a session, and the removed ``skills`` capability gate has not + crept back. The admin subrouter mounts conditionally at import time, so — like the fine-tuning gate in tests/routes/test_admin_lows.py — these reload the module @@ -29,24 +35,28 @@ class TestSkillsEnabledFlag: - def test_defaults_off_when_unset(self, monkeypatch): + def test_defaults_on_when_unset(self, monkeypatch): monkeypatch.delenv("SKILLS_ENABLED", raising=False) - assert skills_enabled() is False + assert skills_enabled() is True @pytest.mark.parametrize( "value, expected", [ - ("true", True), - ("True", True), - ("TRUE", True), ("false", False), - ("0", False), - ("1", False), - ("yes", False), - ("", False), + ("False", False), + ("FALSE", False), + (" false ", False), + ("true", True), + ("0", True), + ("no", True), + # An unset GitHub Actions variable forwards as the empty string. + # It must resolve ENABLED — reading it as "off" is how a kill + # switch silently dark-ships a live feature. + ("", True), + (" ", True), ], ) - def test_parses_env_value(self, monkeypatch, value, expected): + def test_only_literal_false_disables(self, monkeypatch, value, expected): monkeypatch.setenv("SKILLS_ENABLED", value) assert skills_enabled() is expected @@ -63,10 +73,9 @@ def admin_router_paths(): here can't leak mounted skills routes into later tests.""" def _load(*, enabled: bool) -> set[str]: - if enabled: - os.environ["SKILLS_ENABLED"] = "true" - else: - os.environ.pop("SKILLS_ENABLED", None) + # Under the default-ON flag, "disabled" must be set EXPLICITLY — + # popping the var now means enabled. + os.environ["SKILLS_ENABLED"] = "true" if enabled else "false" importlib.reload(admin_routes_module) return {getattr(route, "path", "") for route in admin_routes_module.router.routes} @@ -79,10 +88,79 @@ def _load(*, enabled: bool) -> set[str]: def test_admin_skills_unmounted_when_disabled(admin_router_paths): paths = admin_router_paths(enabled=False) assert not any("/skills" in p for p in paths) - assert not any(p.endswith("/settings/chat") for p in paths) def test_admin_skills_mounted_when_enabled(admin_router_paths): paths = admin_router_paths(enabled=True) assert any("/skills" in p for p in paths) - assert any(p.endswith("/settings/chat") for p in paths) + + +# --------------------------------------------------------------------------- +# `skills` capability gate on the user-facing surfaces +# --------------------------------------------------------------------------- + + +class TestSkillsRouteAuth: + """``SKILLS_ENABLED`` is the only gate on *whether* these routes exist. + + The per-user ``skills`` capability that used to gate them was removed (see + the "Access model" note in ``apis.app_api.skills.routes``): it could not be + granted from the admin roles UI, so an admin granting a catalog skill to a + role had no in-product way to make it visible. Access is now governed by + ``grantedSkills`` per role, which the roles UI *can* edit. + + What still has to hold is that every route requires a session. + """ + + def test_every_user_facing_route_requires_a_session(self): + """No skills route may be reachable unauthenticated. + + The capability gate is gone, so ``get_current_user_from_session`` is the + only thing standing between these routes and an anonymous caller. A new + route added without it would expose authoring and preferences to the + internet, so assert the dependency is present on every one rather than + trusting each route to remember it. + """ + from apis.app_api.skills import routes as skills_routes + + unauthenticated = [] + for route in skills_routes.router.routes: + deps = getattr(route, "dependant", None) + names = _dependency_names(deps) + if "get_current_user_from_session" not in names: + unauthenticated.append(getattr(route, "path", "?")) + + assert unauthenticated == [], ( + f"skills routes missing session auth: {unauthenticated}" + ) + + def test_no_capability_gate_remains(self): + """Pin the removal. + + Re-adding a capability dependency here would silently re-break the + admin flow (grant a skill to a role → users still can't see it, with no + UI to fix it). If a future change genuinely needs one, it has to make + the capability grantable from the roles UI first. + """ + from apis.app_api.skills import routes as skills_routes + + assert not hasattr(skills_routes, "require_skills_capability") + + gated = [ + getattr(route, "path", "?") + for route in skills_routes.router.routes + if any( + "capability" in name + for name in _dependency_names(getattr(route, "dependant", None)) + ) + ] + assert gated == [], f"capability-gated skills routes reintroduced: {gated}" + + +def _dependency_names(dependant) -> set: + """Names of a route's sub-dependency callables (empty set if none).""" + return { + sub.call.__name__ + for sub in getattr(dependant, "dependencies", []) + if getattr(sub, "call", None) + } diff --git a/backend/tests/apis/app_api/test_user_skills_routes.py b/backend/tests/apis/app_api/test_user_skills_routes.py index ffcd8e55c..a1cf23cf8 100644 --- a/backend/tests/apis/app_api/test_user_skills_routes.py +++ b/backend/tests/apis/app_api/test_user_skills_routes.py @@ -25,13 +25,12 @@ def _user() -> User: ) -def _skill(skill_id: str, display_name: str, status=SkillStatus.ACTIVE, bound=None): +def _skill(skill_id: str, display_name: str, status=SkillStatus.ACTIVE): return SkillDefinition( skill_id=skill_id, display_name=display_name, description=f"{display_name} description", instructions="...", - bound_tool_ids=bound or [], status=status, ) @@ -77,10 +76,10 @@ class TestGetUserSkills: def test_lists_active_accessible_skills_with_prefs_merged(self, monkeypatch): repo = _FakeRepo( skills=[ - _skill("web_research", "Web Research", bound=["t1", "t2"]), + _skill("web_research", "Web Research"), _skill("pdf_workflows", "PDF Workflows"), ], - prefs={"web_research": False}, + prefs={"web_research": True}, ) client = _make_client(monkeypatch, ["web_research", "pdf_workflows"], repo) @@ -88,14 +87,16 @@ def test_lists_active_accessible_skills_with_prefs_merged(self, monkeypatch): assert body["totalCount"] == 2 by_id = {s["skillId"]: s for s in body["skills"]} - # Toggled-off skill: explicit preference surfaces, effective off - assert by_id["web_research"]["userEnabled"] is False - assert by_id["web_research"]["isEnabled"] is False - assert by_id["web_research"]["boundToolCount"] == 2 + # Toggled-on skill: explicit preference surfaces, effective on + assert by_id["web_research"]["userEnabled"] is True + assert by_id["web_research"]["isEnabled"] is True - # Untouched skill: no preference, enabled by default + # Untouched skill: no preference, DISABLED by default. Skills v2 D6 + # flips this from v1 — the picker is opt-in, unlike tools, and this must + # agree with the runtime's absent-selection-means-no-skills default or + # the UI would show skills as active that the turn never loads. assert by_id["pdf_workflows"]["userEnabled"] is None - assert by_id["pdf_workflows"]["isEnabled"] is True + assert by_id["pdf_workflows"]["isEnabled"] is False # Sorted by display name assert [s["skillId"] for s in body["skills"]] == [ diff --git a/backend/tests/apis/inference_api/test_agent_binding_resolver.py b/backend/tests/apis/inference_api/test_agent_binding_resolver.py index c894679d1..046edc4fc 100644 --- a/backend/tests/apis/inference_api/test_agent_binding_resolver.py +++ b/backend/tests/apis/inference_api/test_agent_binding_resolver.py @@ -80,17 +80,22 @@ def _tool_binding(ref: str) -> AgentBinding: return AgentBinding(kind="tool", ref=ref) -def _patch_skill_access(monkeypatch, allowed, *, enabled=True) -> MagicMock: - """Patch the skills flag + AppRole gate for skill resolution. ``allowed`` is a bool or a - set of skill ids the invoker may access.""" +def _patch_skill_access(monkeypatch, allowed, *, enabled=True) -> AsyncMock: + """Patch the skills flag + the §6 invoke-through predicate for skill resolution. + + ``allowed`` is ``True`` (everything resolves) or a set of skill ids that do. + The predicate itself is exercised against real records in + ``tests/apis/shared/skills/test_access.py``; here it is a seam so the + resolver's own block/dedupe behavior is tested in isolation. + """ monkeypatch.setattr(f"{MODULE}.skills_enabled", lambda: enabled) - svc = MagicMock() - if isinstance(allowed, bool): - svc.can_access_skill = AsyncMock(return_value=allowed) - else: - svc.can_access_skill = AsyncMock(side_effect=lambda user, sid: sid in allowed) - monkeypatch.setattr(f"{MODULE}.get_app_role_service", lambda: svc) - return svc + predicate = AsyncMock( + side_effect=lambda invoker, refs, owner_id: ( + set(refs) if allowed is True else {r for r in refs if r in allowed} + ) + ) + monkeypatch.setattr(f"{MODULE}.resolve_invocable_skill_ids", predicate) + return predicate def _skill_binding(ref: str) -> AgentBinding: @@ -258,10 +263,10 @@ class TestSkillResolution: @pytest.mark.asyncio async def test_no_skill_binding_is_none(self, monkeypatch): # No skill binding ⇒ plan.skills is None and we never consult the flag or RBAC. - svc = _patch_skill_access(monkeypatch, True) + predicate = _patch_skill_access(monkeypatch, True) plan = await resolve_agent_invocation(_assistant(bindings=[]), _user()) assert plan.skills is None - svc.can_access_skill.assert_not_awaited() + predicate.assert_not_awaited() @pytest.mark.asyncio async def test_accessible_skills_become_override(self, monkeypatch): @@ -299,8 +304,31 @@ async def test_missing_skill_blocks_with_message(self, monkeypatch): @pytest.mark.asyncio async def test_skill_access_checked_against_invoker(self, monkeypatch): - svc = _patch_skill_access(monkeypatch, True) + predicate = _patch_skill_access(monkeypatch, True) await resolve_agent_invocation(_assistant(bindings=[_skill_binding("research")]), _user()) - # can_access_skill(invoker, skill_id) — the INVOKING user, not the author. - args = svc.can_access_skill.await_args.args - assert args[0].user_id == "u-bob" and args[1] == "research" + # (invoker, refs, agent_owner_id) — resolved against the INVOKING user, + # with the AGENT's owner supplied for the invoke-through clause. Passing + # the invoker as the owner would collapse clause 3 into a no-op; passing + # the author as the subject would skip per-invoker gating entirely. + invoker, refs, owner_id = predicate.await_args.args + assert invoker.user_id == "u-bob" + assert refs == ["research"] + assert owner_id == "u-alice" + + @pytest.mark.asyncio + async def test_refs_deduped_before_predicate(self, monkeypatch): + # The predicate does a batch read per call; duplicate bindings must not + # multiply it, and the resolved order must follow first-binding order. + predicate = _patch_skill_access(monkeypatch, True) + await resolve_agent_invocation( + _assistant( + bindings=[ + _skill_binding("writing"), + _skill_binding("research"), + _skill_binding("writing"), + ] + ), + _user(), + ) + assert predicate.await_count == 1 + assert predicate.await_args.args[1] == ["writing", "research"] diff --git a/backend/tests/apis/inference_api/test_chat_service.py b/backend/tests/apis/inference_api/test_chat_service.py index e7b435c67..d01af4012 100644 --- a/backend/tests/apis/inference_api/test_chat_service.py +++ b/backend/tests/apis/inference_api/test_chat_service.py @@ -241,11 +241,13 @@ async def test_skills_hash_separates_skill_agent_cache_slots( @pytest.mark.asyncio async def test_chat_path_unaffected_by_skills_hash(mock_create_agent, mock_freshness_hash): - """The default chat path passes no accessible skills → skills_hash empty, - cache behaves exactly as before, and accessible_skill_ids isn't forwarded. + """The default chat path passes no accessible skills → skills_hash empty and + cache behaves exactly as before. Skills v2: ChatAgent (the target of both + "chat" and "skill" types) accepts accessible_skill_ids, so it is forwarded — + as None on the chat path, which adds no AgentSkills plugin. """ a = await service.get_agent(session_id="s", user_id="u") a_again = await service.get_agent(session_id="s", user_id="u") assert a is a_again assert mock_create_agent.call_count == 1 - assert "accessible_skill_ids" not in mock_create_agent.call_args.kwargs + assert mock_create_agent.call_args.kwargs.get("accessible_skill_ids") is None diff --git a/backend/tests/rbac/test_app_role_skills.py b/backend/tests/rbac/test_app_role_skills.py index 667bf17d9..dd1f66922 100644 --- a/backend/tests/rbac/test_app_role_skills.py +++ b/backend/tests/rbac/test_app_role_skills.py @@ -4,7 +4,6 @@ test_app_role_admin_service.py: - skills union merge across roles (service) - wildcard skills (service) -- can_access_skill: wildcard / match / no-match (service) - only enabled roles contribute skills (service) - inheritance merge of granted_skills (admin _compute_effective_permissions) - add_skill_to_role / remove_skill_from_role (admin) @@ -89,42 +88,6 @@ async def test_wildcard_in_skills(service, mock_app_role_repo, make_app_role, us assert "*" in perms.skills -@pytest.mark.asyncio -async def test_can_access_skill_with_wildcard(service, mock_app_role_repo, make_app_role, user): - role_a = make_app_role(role_id="editor", skills=["*"], priority=1) - mock_app_role_repo.get_roles_for_jwt_role.side_effect = lambda r: { - "Editor": ["editor"], - "Viewer": [], - }.get(r, []) - mock_app_role_repo.get_role.side_effect = lambda rid: {"editor": role_a}.get(rid) - - assert await service.can_access_skill(user, "any_skill") is True - - -@pytest.mark.asyncio -async def test_can_access_skill_with_match(service, mock_app_role_repo, make_app_role, user): - role_a = make_app_role(role_id="editor", skills=["pdf_workflows"], priority=1) - mock_app_role_repo.get_roles_for_jwt_role.side_effect = lambda r: { - "Editor": ["editor"], - "Viewer": [], - }.get(r, []) - mock_app_role_repo.get_role.side_effect = lambda rid: {"editor": role_a}.get(rid) - - assert await service.can_access_skill(user, "pdf_workflows") is True - - -@pytest.mark.asyncio -async def test_can_access_skill_with_no_match(service, mock_app_role_repo, make_app_role, user): - role_a = make_app_role(role_id="editor", skills=["pdf_workflows"], priority=1) - mock_app_role_repo.get_roles_for_jwt_role.side_effect = lambda r: { - "Editor": ["editor"], - "Viewer": [], - }.get(r, []) - mock_app_role_repo.get_role.side_effect = lambda rid: {"editor": role_a}.get(rid) - - assert await service.can_access_skill(user, "other_skill") is False - - @pytest.mark.asyncio async def test_only_enabled_roles_contribute_skills(service, mock_app_role_repo, make_app_role, user): role_enabled = make_app_role(role_id="editor", skills=["skill_a"], enabled=True) diff --git a/backend/tests/routes/test_agent_mode_policy.py b/backend/tests/routes/test_agent_mode_policy.py deleted file mode 100644 index b158782b7..000000000 --- a/backend/tests/routes/test_agent_mode_policy.py +++ /dev/null @@ -1,296 +0,0 @@ -"""Tests for the chat-mode policy + per-turn skill selection on /invocations. - -Covers (skills-mode PR-2, docs/specs/skills-mode.md): -- _resolve_effective_agent_type — admin policy vs. client agent_type -- _apply_enabled_skills_filter — RBAC ∩ client enabled_skills -- route-level threading of both into get_agent -- PausedTurnSnapshot.enabled_skills round-trip -""" - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from apis.inference_api.chat.routes import ( - _apply_enabled_skills_filter, - _resolve_effective_agent_type, - router, -) -from apis.shared.auth.dependencies import get_current_user_trusted -from apis.shared.platform_settings.models import ChatModeSettings - - -# --------------------------------------------------------------------------- -# Fixtures (mirror tests/routes/test_inference.py) -# --------------------------------------------------------------------------- - - -@pytest.fixture -def app(): - _app = FastAPI() - _app.include_router(router) - return _app - - -@pytest.fixture -def trusted_user(make_user): - return make_user(raw_token="fake-jwt-token") - - -@pytest.fixture -def authed_client(app, trusted_user): - app.dependency_overrides[get_current_user_trusted] = lambda: trusted_user - return TestClient(app) - - -@pytest.fixture(autouse=True) -def _skills_enabled(monkeypatch): - """This module exercises skills-mode behavior, so run with the feature on. - The disabled-feature override (force chat) is covered by its own test that - flips this back off.""" - monkeypatch.setenv("SKILLS_ENABLED", "true") - - -class _StubSettingsService: - def __init__(self, settings: ChatModeSettings): - self._settings = settings - - async def get_settings(self) -> ChatModeSettings: - return self._settings - - -def _mock_agent(): - agent = MagicMock() - - async def fake_stream(*args, **kwargs): - yield "event: done\ndata: {}\n\n" - - agent.stream_async = fake_stream - return agent - - -# --------------------------------------------------------------------------- -# _resolve_effective_agent_type -# --------------------------------------------------------------------------- - - -class TestResolveEffectiveAgentType: - def test_toggle_allowed_honors_client_choice(self): - settings = ChatModeSettings(default_mode="skill", allow_mode_toggle=True) - assert _resolve_effective_agent_type("chat", settings) == "chat" - assert _resolve_effective_agent_type("skill", settings) == "skill" - - def test_omitted_falls_back_to_admin_default(self): - assert ( - _resolve_effective_agent_type( - None, ChatModeSettings(default_mode="skill", allow_mode_toggle=True) - ) - == "skill" - ) - assert ( - _resolve_effective_agent_type( - None, ChatModeSettings(default_mode="chat", allow_mode_toggle=False) - ) - == "chat" - ) - - def test_toggle_disabled_overrides_client_choice(self): - settings = ChatModeSettings(default_mode="skill", allow_mode_toggle=False) - assert _resolve_effective_agent_type("chat", settings) == "skill" - - settings = ChatModeSettings(default_mode="chat", allow_mode_toggle=False) - assert _resolve_effective_agent_type("skill", settings) == "chat" - - def test_toggle_disabled_matching_choice_passes(self): - settings = ChatModeSettings(default_mode="skill", allow_mode_toggle=False) - assert _resolve_effective_agent_type("skill", settings) == "skill" - - def test_non_mode_types_bypass_the_policy(self): - settings = ChatModeSettings(default_mode="skill", allow_mode_toggle=False) - assert _resolve_effective_agent_type("voice", settings) == "voice" - - -# --------------------------------------------------------------------------- -# _apply_enabled_skills_filter -# --------------------------------------------------------------------------- - - -class TestApplyEnabledSkillsFilter: - def test_none_means_all_accessible(self): - assert _apply_enabled_skills_filter(["a", "b"], None) == ["a", "b"] - - def test_intersection_preserves_accessible_order(self): - assert _apply_enabled_skills_filter(["a", "b", "c"], ["c", "a"]) == ["a", "c"] - - def test_client_cannot_grant_inaccessible_skills(self): - assert _apply_enabled_skills_filter(["a"], ["a", "forbidden"]) == ["a"] - - def test_empty_selection_yields_zero_skills(self): - assert _apply_enabled_skills_filter(["a", "b"], []) == [] - - def test_empty_accessible_stays_empty(self): - assert _apply_enabled_skills_filter([], ["a"]) == [] - - -# --------------------------------------------------------------------------- -# Route-level threading into get_agent -# --------------------------------------------------------------------------- - - -class TestInvocationsModePolicy: - def _invoke(self, authed_client, payload, *, settings, accessible): - get_agent_mock = MagicMock(return_value=_mock_agent()) - resolve_mock = AsyncMock(return_value=accessible) - with patch( - "apis.inference_api.chat.routes.get_agent", get_agent_mock - ), patch( - "apis.inference_api.chat.routes.is_quota_enforcement_enabled", - return_value=False, - ), patch( - "apis.inference_api.chat.routes._resolve_accessible_skill_ids", - resolve_mock, - ), patch( - "apis.inference_api.chat.routes.get_chat_mode_settings_service", - return_value=_StubSettingsService(settings), - ): - resp = authed_client.post("/invocations", json=payload) - _ = resp.text # force the streaming generator to run - - assert resp.status_code == 200 - return get_agent_mock.call_args.kwargs - - def test_enabled_skills_narrows_the_skill_set(self, authed_client): - kwargs = self._invoke( - authed_client, - { - "session_id": "sess-1", - "message": "hi", - "enabled_skills": ["pdf_workflows", "forbidden_skill"], - }, - settings=ChatModeSettings(default_mode="skill", allow_mode_toggle=True), - accessible=["web_research", "pdf_workflows"], - ) - assert kwargs["agent_type"] == "skill" - assert kwargs["accessible_skill_ids"] == ["pdf_workflows"] - - def test_omitted_enabled_skills_keeps_all_accessible(self, authed_client): - kwargs = self._invoke( - authed_client, - {"session_id": "sess-2", "message": "hi"}, - settings=ChatModeSettings(default_mode="skill", allow_mode_toggle=True), - accessible=["web_research"], - ) - assert kwargs["accessible_skill_ids"] == ["web_research"] - - def test_toggle_disabled_forces_admin_default(self, authed_client): - kwargs = self._invoke( - authed_client, - {"session_id": "sess-3", "message": "hi", "agent_type": "chat"}, - settings=ChatModeSettings(default_mode="skill", allow_mode_toggle=False), - accessible=["web_research"], - ) - # Client asked for chat; policy forces the skill default through. - assert kwargs["agent_type"] == "skill" - assert kwargs["accessible_skill_ids"] == ["web_research"] - - def test_admin_default_chat_skips_skill_resolution(self, authed_client): - get_agent_mock = MagicMock(return_value=_mock_agent()) - resolve_mock = AsyncMock(return_value=["web_research"]) - with patch( - "apis.inference_api.chat.routes.get_agent", get_agent_mock - ), patch( - "apis.inference_api.chat.routes.is_quota_enforcement_enabled", - return_value=False, - ), patch( - "apis.inference_api.chat.routes._resolve_accessible_skill_ids", - resolve_mock, - ), patch( - "apis.inference_api.chat.routes.get_chat_mode_settings_service", - return_value=_StubSettingsService( - ChatModeSettings(default_mode="chat", allow_mode_toggle=True) - ), - ): - resp = authed_client.post( - "/invocations", json={"session_id": "sess-4", "message": "hi"} - ) - _ = resp.text - - assert resp.status_code == 200 - resolve_mock.assert_not_awaited() - kwargs = get_agent_mock.call_args.kwargs - assert kwargs["agent_type"] == "chat" - assert kwargs["accessible_skill_ids"] is None - - def test_skills_disabled_forces_chat_over_skill_request( - self, authed_client, monkeypatch - ): - # Feature off + client explicitly asks for skill + policy allows it: - # the turn must still route through the ChatAgent with no skills. - monkeypatch.setenv("SKILLS_ENABLED", "false") - get_agent_mock = MagicMock(return_value=_mock_agent()) - resolve_mock = AsyncMock(return_value=["web_research"]) - with patch( - "apis.inference_api.chat.routes.get_agent", get_agent_mock - ), patch( - "apis.inference_api.chat.routes.is_quota_enforcement_enabled", - return_value=False, - ), patch( - "apis.inference_api.chat.routes._resolve_accessible_skill_ids", - resolve_mock, - ), patch( - "apis.inference_api.chat.routes.get_chat_mode_settings_service", - return_value=_StubSettingsService( - ChatModeSettings(default_mode="skill", allow_mode_toggle=True) - ), - ): - resp = authed_client.post( - "/invocations", - json={ - "session_id": "sess-off", - "message": "hi", - "agent_type": "skill", - }, - ) - _ = resp.text - - assert resp.status_code == 200 - resolve_mock.assert_not_awaited() - kwargs = get_agent_mock.call_args.kwargs - assert kwargs["agent_type"] == "chat" - assert kwargs["accessible_skill_ids"] is None - - -# --------------------------------------------------------------------------- -# PausedTurnSnapshot.enabled_skills -# --------------------------------------------------------------------------- - - -class TestSnapshotEnabledSkills: - def test_round_trips_through_alias(self): - from apis.shared.sessions.models import PausedTurnSnapshot - - snap = PausedTurnSnapshot( - agent_type="skill", - enabled_skills=["web_research"], - captured_at="2026-06-11T12:00:00+00:00", - expires_at="2026-06-11T13:00:00+00:00", - ) - dumped = snap.model_dump(by_alias=True) - assert dumped["enabledSkills"] == ["web_research"] - - restored = PausedTurnSnapshot.model_validate(dumped) - assert restored.enabled_skills == ["web_research"] - - def test_legacy_snapshot_defaults_to_none(self): - from apis.shared.sessions.models import PausedTurnSnapshot - - snap = PausedTurnSnapshot.model_validate( - { - "agentType": "skill", - "capturedAt": "2026-06-11T12:00:00+00:00", - "expiresAt": "2026-06-11T13:00:00+00:00", - } - ) - assert snap.enabled_skills is None diff --git a/backend/tests/routes/test_inference.py b/backend/tests/routes/test_inference.py index cb5f75052..45fc9173c 100644 --- a/backend/tests/routes/test_inference.py +++ b/backend/tests/routes/test_inference.py @@ -258,18 +258,25 @@ def test_missing_session_id_returns_422(self, authed_app, authed_client): # --------------------------------------------------------------------------- -# PR-7: default agent_type flips to "skill" +# Skills v2: default agent_type is "chat" (skills are opt-in) # --------------------------------------------------------------------------- -class TestDefaultAgentTypeFlip: - """When the client omits agent_type, the turn routes through SkillAgent.""" +class TestDefaultAgentType: + """Skills v2 D6: skills are opt-in and driven by the per-turn SELECTION, + not by agent_type. + + PR-4 decouples the two. Skills resolve on any turn that sends a non-empty + ``enabled_skills`` (the model-settings picker), including a plain chat turn; + an absent or empty selection means *no skills* — the reverse of v1, where + absent meant "everything accessible". ``agent_type`` no longer gates + anything: ``"skill"`` is a ChatAgent alias kept only so stale SPA sessions + don't 422. + """ @pytest.fixture(autouse=True) def _skills_enabled(self, monkeypatch): - # The skill-default behavior only applies when the feature is on; off - # by default, every turn is forced to chat (covered in - # tests/routes/test_agent_mode_policy.py). + # Skills only resolve when the feature is on; off by default. monkeypatch.setenv("SKILLS_ENABLED", "true") def _mock_agent(self): @@ -281,7 +288,7 @@ async def fake_stream(*args, **kwargs): agent.stream_async = fake_stream return agent - def test_omitted_agent_type_defaults_to_skill(self, authed_app, authed_client): + def test_omitted_agent_type_defaults_to_chat(self, authed_app, authed_client): get_agent_mock = MagicMock(return_value=self._mock_agent()) resolve_mock = AsyncMock(return_value=["web_research"]) with patch( @@ -295,18 +302,134 @@ def test_omitted_agent_type_defaults_to_skill(self, authed_app, authed_client): ): resp = authed_client.post( "/invocations", - json={"session_id": "sess-skill", "message": "hi"}, + json={"session_id": "sess-default", "message": "hi"}, ) _ = resp.text # force the streaming generator to run assert resp.status_code == 200 - # Skills were resolved even though the client sent no agent_type, - # and get_agent was built as a skill agent with them threaded in. + # No selection sent → plain chat, no skills, and — because the opt-in + # default short-circuits before the resolver — no RBAC/skill-table read + # at all. That is what keeps skills free for turns that don't want them. + resolve_mock.assert_not_awaited() + kwargs = get_agent_mock.call_args.kwargs + assert kwargs["agent_type"] == "chat" + assert kwargs["accessible_skill_ids"] is None + + def test_selection_resolves_skills_on_a_plain_chat_turn(self, authed_app, authed_client): + get_agent_mock = MagicMock(return_value=self._mock_agent()) + resolve_mock = AsyncMock(return_value=["web_research", "writing"]) + with patch( + "apis.inference_api.chat.routes.get_agent", get_agent_mock + ), patch( + "apis.inference_api.chat.routes.is_quota_enforcement_enabled", + return_value=False, + ), patch( + "apis.inference_api.chat.routes._resolve_accessible_skill_ids", + resolve_mock, + ): + resp = authed_client.post( + "/invocations", + json={ + "session_id": "sess-picker", + "message": "hi", + "enabled_skills": ["web_research"], + }, + ) + _ = resp.text + + assert resp.status_code == 200 + # The picker drives skills on an ordinary chat turn — no agent_type sent. resolve_mock.assert_awaited() kwargs = get_agent_mock.call_args.kwargs - assert kwargs["agent_type"] == "skill" + assert kwargs["agent_type"] == "chat" + # Narrowed to the selection, never widened to the full accessible set. + assert kwargs["accessible_skill_ids"] == ["web_research"] + + def test_selection_cannot_grant_an_inaccessible_skill(self, authed_app, authed_client): + get_agent_mock = MagicMock(return_value=self._mock_agent()) + resolve_mock = AsyncMock(return_value=["web_research"]) + with patch( + "apis.inference_api.chat.routes.get_agent", get_agent_mock + ), patch( + "apis.inference_api.chat.routes.is_quota_enforcement_enabled", + return_value=False, + ), patch( + "apis.inference_api.chat.routes._resolve_accessible_skill_ids", + resolve_mock, + ): + resp = authed_client.post( + "/invocations", + json={ + "session_id": "sess-forged", + "message": "hi", + "enabled_skills": ["web_research", "someone-elses-private-skill"], + }, + ) + _ = resp.text + + assert resp.status_code == 200 + # The picker is a UI convenience, not a grant. A forged body naming a + # skill the user cannot reach is intersected away. + kwargs = get_agent_mock.call_args.kwargs assert kwargs["accessible_skill_ids"] == ["web_research"] + def test_empty_selection_is_no_skills(self, authed_app, authed_client): + get_agent_mock = MagicMock(return_value=self._mock_agent()) + resolve_mock = AsyncMock(return_value=["web_research"]) + with patch( + "apis.inference_api.chat.routes.get_agent", get_agent_mock + ), patch( + "apis.inference_api.chat.routes.is_quota_enforcement_enabled", + return_value=False, + ), patch( + "apis.inference_api.chat.routes._resolve_accessible_skill_ids", + resolve_mock, + ): + resp = authed_client.post( + "/invocations", + json={ + "session_id": "sess-empty", + "message": "hi", + "enabled_skills": [], + }, + ) + _ = resp.text + + assert resp.status_code == 200 + # Every skill unchecked in the picker is indistinguishable from never + # having opened it — no skills, no reads. + resolve_mock.assert_not_awaited() + assert get_agent_mock.call_args.kwargs["accessible_skill_ids"] is None + + def test_flag_off_ignores_the_selection(self, authed_app, authed_client, monkeypatch): + monkeypatch.setenv("SKILLS_ENABLED", "false") + get_agent_mock = MagicMock(return_value=self._mock_agent()) + resolve_mock = AsyncMock(return_value=["web_research"]) + with patch( + "apis.inference_api.chat.routes.get_agent", get_agent_mock + ), patch( + "apis.inference_api.chat.routes.is_quota_enforcement_enabled", + return_value=False, + ), patch( + "apis.inference_api.chat.routes._resolve_accessible_skill_ids", + resolve_mock, + ): + resp = authed_client.post( + "/invocations", + json={ + "session_id": "sess-flag-off", + "message": "hi", + "enabled_skills": ["web_research"], + }, + ) + _ = resp.text + + assert resp.status_code == 200 + # SKILLS_ENABLED is the master gate; a selection can't route around it. + # (This is the state of every deployed env until PR-5 flips it.) + resolve_mock.assert_not_awaited() + assert get_agent_mock.call_args.kwargs["accessible_skill_ids"] is None + def test_explicit_chat_opts_out(self, authed_app, authed_client): get_agent_mock = MagicMock(return_value=self._mock_agent()) resolve_mock = AsyncMock(return_value=["web_research"]) @@ -330,7 +453,7 @@ def test_explicit_chat_opts_out(self, authed_app, authed_client): _ = resp.text assert resp.status_code == 200 - # Explicit chat → no skill resolution, no skills forwarded. + # Explicit chat with no selection → no skill resolution, none forwarded. resolve_mock.assert_not_awaited() kwargs = get_agent_mock.call_args.kwargs assert kwargs["agent_type"] == "chat" diff --git a/backend/tests/shared/test_platform_settings.py b/backend/tests/shared/test_platform_settings.py deleted file mode 100644 index 8d78021b9..000000000 --- a/backend/tests/shared/test_platform_settings.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Unit tests for platform-wide chat-mode settings (models, repository, service).""" - -import pytest -from unittest.mock import MagicMock, patch - -from botocore.exceptions import ClientError -from pydantic import ValidationError - -from apis.shared.platform_settings.models import ( - DEFAULT_CHAT_MODE, - ChatModeSettings, - ChatModeSettingsUpdate, -) -from apis.shared.platform_settings.repository import ( - CHAT_MODE_PK, - CHAT_MODE_SK, - PlatformSettingsRepository, -) -from apis.shared.platform_settings.service import ChatModeSettingsService - -pytestmark = pytest.mark.asyncio - - -# ========================================================================= -# Model tests -# ========================================================================= - - -class TestChatModeSettings: - def test_defaults_reproduce_pre_settings_behavior(self): - """Defaults must match the hardcoded server behavior (skill default, toggling allowed).""" - settings = ChatModeSettings() - assert settings.default_mode == DEFAULT_CHAT_MODE == "skill" - assert settings.allow_mode_toggle is True - assert settings.updated_at is None - assert settings.updated_by is None - - def test_invalid_mode_rejected(self): - with pytest.raises(ValidationError): - ChatModeSettings(default_mode="voice") - - def test_dynamo_round_trip(self): - from datetime import datetime, timezone - - original = ChatModeSettings( - default_mode="chat", - allow_mode_toggle=False, - updated_at=datetime(2026, 6, 11, 12, 0, tzinfo=timezone.utc), - updated_by="admin@example.com", - ) - restored = ChatModeSettings.from_dynamo_item(original.to_dynamo_item()) - assert restored == original - - def test_from_dynamo_item_tolerates_missing_fields(self): - restored = ChatModeSettings.from_dynamo_item({}) - assert restored.default_mode == "skill" - assert restored.allow_mode_toggle is True - - -class TestChatModeSettingsUpdate: - def test_accepts_camel_case_aliases(self): - update = ChatModeSettingsUpdate.model_validate( - {"defaultMode": "chat", "allowModeToggle": False} - ) - assert update.default_mode == "chat" - assert update.allow_mode_toggle is False - - def test_accepts_snake_case_field_names(self): - update = ChatModeSettingsUpdate(default_mode="skill", allow_mode_toggle=True) - assert update.default_mode == "skill" - - def test_invalid_mode_rejected(self): - with pytest.raises(ValidationError): - ChatModeSettingsUpdate.model_validate( - {"defaultMode": "agent", "allowModeToggle": True} - ) - - -# ========================================================================= -# Repository tests -# ========================================================================= - - -class TestPlatformSettingsRepository: - def _make_repo(self) -> PlatformSettingsRepository: - """Create a repository with a mocked DynamoDB table.""" - with patch("apis.shared.platform_settings.repository.boto3") as mock_boto: - mock_table = MagicMock() - mock_resource = MagicMock() - mock_resource.Table.return_value = mock_table - mock_boto.Session.return_value.resource.return_value = mock_resource - mock_boto.resource.return_value = mock_resource - - repo = PlatformSettingsRepository(table_name="test-table") - repo._table = mock_table - return repo - - async def test_get_returns_none_when_item_missing(self): - repo = self._make_repo() - repo._table.get_item.return_value = {} - - result = await repo.get_chat_mode_settings() - assert result is None - - repo._table.get_item.assert_called_once_with( - Key={"PK": CHAT_MODE_PK, "SK": CHAT_MODE_SK} - ) - - async def test_get_parses_stored_item(self): - repo = self._make_repo() - repo._table.get_item.return_value = { - "Item": { - "PK": CHAT_MODE_PK, - "SK": CHAT_MODE_SK, - "defaultMode": "chat", - "allowModeToggle": False, - "updatedBy": "admin@example.com", - } - } - - result = await repo.get_chat_mode_settings() - assert result is not None - assert result.default_mode == "chat" - assert result.allow_mode_toggle is False - assert result.updated_by == "admin@example.com" - - async def test_get_raises_on_client_error(self): - repo = self._make_repo() - repo._table.get_item.side_effect = ClientError( - {"Error": {"Code": "InternalServerError", "Message": "boom"}}, "GetItem" - ) - with pytest.raises(ClientError): - await repo.get_chat_mode_settings() - - async def test_put_writes_sentinel_item(self): - repo = self._make_repo() - await repo.put_chat_mode_settings( - ChatModeSettings(default_mode="chat", allow_mode_toggle=False) - ) - - item = repo._table.put_item.call_args.kwargs["Item"] - assert item["PK"] == CHAT_MODE_PK - assert item["SK"] == CHAT_MODE_SK - assert item["defaultMode"] == "chat" - assert item["allowModeToggle"] is False - - async def test_disabled_without_table_name(self): - with patch.dict("os.environ", {}, clear=False): - with patch("os.getenv", side_effect=lambda k, d=None: d): - repo = PlatformSettingsRepository() - assert repo.enabled is False - assert await repo.get_chat_mode_settings() is None - with pytest.raises(RuntimeError): - await repo.put_chat_mode_settings(ChatModeSettings()) - - -# ========================================================================= -# Service tests (TTL cache + degradation) -# ========================================================================= - - -class _FakeClock: - def __init__(self): - self.now = 1000.0 - - def __call__(self) -> float: - return self.now - - -class TestChatModeSettingsService: - async def test_returns_defaults_when_nothing_stored(self): - service = ChatModeSettingsService( - repository=_AsyncRepo(None), clock=_FakeClock() - ) - - settings = await service.get_settings() - assert settings.default_mode == "skill" - assert settings.allow_mode_toggle is True - - async def test_caches_within_ttl(self): - repo = _AsyncRepo(ChatModeSettings(default_mode="chat")) - clock = _FakeClock() - service = ChatModeSettingsService( - repository=repo, cache_ttl_seconds=60.0, clock=clock - ) - - first = await service.get_settings() - clock.now += 30.0 - second = await service.get_settings() - - assert first.default_mode == second.default_mode == "chat" - assert repo.get_calls == 1 - - async def test_refetches_after_ttl_expiry(self): - repo = _AsyncRepo(ChatModeSettings(default_mode="chat")) - clock = _FakeClock() - service = ChatModeSettingsService( - repository=repo, cache_ttl_seconds=60.0, clock=clock - ) - - await service.get_settings() - repo.stored = ChatModeSettings(default_mode="skill") - clock.now += 61.0 - - refreshed = await service.get_settings() - assert refreshed.default_mode == "skill" - assert repo.get_calls == 2 - - async def test_returns_defaults_on_read_error(self): - repo = _AsyncRepo(None, raise_on_get=True) - service = ChatModeSettingsService(repository=repo, clock=_FakeClock()) - - settings = await service.get_settings() - assert settings.default_mode == "skill" - assert settings.allow_mode_toggle is True - - async def test_update_writes_through_and_busts_cache(self): - repo = _AsyncRepo(ChatModeSettings(default_mode="skill")) - clock = _FakeClock() - service = ChatModeSettingsService( - repository=repo, cache_ttl_seconds=60.0, clock=clock - ) - - await service.get_settings() # prime the cache - updated = await service.update_settings( - ChatModeSettingsUpdate(default_mode="chat", allow_mode_toggle=False), - updated_by="admin@example.com", - ) - - assert repo.stored.default_mode == "chat" - assert updated.updated_by == "admin@example.com" - assert updated.updated_at is not None - - # Cached value reflects the update without another read - current = await service.get_settings() - assert current.default_mode == "chat" - assert repo.get_calls == 1 - - -class _AsyncRepo: - """Duck-typed in-memory stand-in for PlatformSettingsRepository.""" - - def __init__(self, stored, raise_on_get: bool = False): - self.stored = stored - self.raise_on_get = raise_on_get - self.get_calls = 0 - - @property - def enabled(self) -> bool: - return True - - async def get_chat_mode_settings(self): - self.get_calls += 1 - if self.raise_on_get: - raise RuntimeError("read failed") - return self.stored - - async def put_chat_mode_settings(self, settings) -> None: - self.stored = settings diff --git a/backend/tests/shared/test_sessions_metadata.py b/backend/tests/shared/test_sessions_metadata.py index 6ac142456..a20650170 100644 --- a/backend/tests/shared/test_sessions_metadata.py +++ b/backend/tests/shared/test_sessions_metadata.py @@ -1215,3 +1215,80 @@ async def test_end_to_end_create_activity_list_delete(self, sessions_metadata_ta await SessionService().delete_session("u1", "s1") sessions, _ = await list_user_sessions("u1") assert sessions == [] + + +class TestListContractOnMarker: + """Issue #175 Phase 3 — list read contracts to GSI-only once the marker is set.""" + + @staticmethod + def _set_marker(table): + table.put_item(Item={"PK": "MIGRATION#session-sk", "SK": "STATE", "complete": True}) + + @pytest.mark.asyncio + async def test_dual_read_when_marker_absent(self, sessions_metadata_table, monkeypatch): + import apis.shared.sessions.metadata as md + monkeypatch.setattr(md, "_migration_complete_cache", False) + _put_legacy_row(sessions_metadata_table, "leg", "2026-01-02T00:00:00Z") + _put_migrated_row(sessions_metadata_table, "mig", "2026-01-01T00:00:00Z") + + sessions, _ = await md.list_user_sessions("u1") + assert sorted(s.session_id for s in sessions) == ["leg", "mig"] # union of both + + @pytest.mark.asyncio + async def test_gsi_only_when_marker_set(self, sessions_metadata_table, monkeypatch): + import apis.shared.sessions.metadata as md + monkeypatch.setattr(md, "_migration_complete_cache", False) + _put_legacy_row(sessions_metadata_table, "leg", "2026-01-02T00:00:00Z") + _put_migrated_row(sessions_metadata_table, "mig", "2026-01-01T00:00:00Z") + self._set_marker(sessions_metadata_table) + + sessions, _ = await md.list_user_sessions("u1") + assert [s.session_id for s in sessions] == ["mig"] # legacy row excluded + + @pytest.mark.asyncio + async def test_marker_result_is_memoised(self, sessions_metadata_table, monkeypatch): + import apis.shared.sessions.metadata as md + monkeypatch.setattr(md, "_migration_complete_cache", False) + self._set_marker(sessions_metadata_table) + _put_migrated_row(sessions_metadata_table, "mig", "2026-01-01T00:00:00Z") + + await md.list_user_sessions("u1") + assert md._migration_complete_cache is True # cached after first observation + + @pytest.mark.asyncio + async def test_gsi_failure_falls_back_to_legacy_even_with_marker(self, sessions_metadata_table, monkeypatch): + """Marker set but the GSI query errors → still read legacy so the list never blanks.""" + import boto3 + import apis.shared.sessions.metadata as md + from botocore.exceptions import ClientError + + monkeypatch.setattr(md, "_migration_complete_cache", False) + _put_legacy_row(sessions_metadata_table, "leg", "2026-01-02T00:00:00Z") + self._set_marker(sessions_metadata_table) + real_table = sessions_metadata_table + + class _GsiFailingTable: + def get_item(self, **kw): + return real_table.get_item(**kw) # marker lookup goes through + + def query(self, **kw): + if kw.get("IndexName") == "SessionRecencyIndex": + raise ClientError( + {"Error": {"Code": "ValidationException", + "Message": "The table does not have the specified index: SessionRecencyIndex"}}, + "Query", + ) + return real_table.query(**kw) + + class _FakeResource: + def Table(self, _name): + return _GsiFailingTable() + + real_resource = boto3.resource + monkeypatch.setattr( + boto3, "resource", + lambda svc, **kw: _FakeResource() if svc == "dynamodb" else real_resource(svc, **kw), + ) + + sessions, _ = await md.list_user_sessions("u1") + assert [s.session_id for s in sessions] == ["leg"] # fell back to legacy, not blank diff --git a/backend/tests/shared/test_skills_access.py b/backend/tests/shared/test_skills_access.py index 1f38176a2..390e54b70 100644 --- a/backend/tests/shared/test_skills_access.py +++ b/backend/tests/shared/test_skills_access.py @@ -5,7 +5,10 @@ import pytest from apis.shared.auth.models import User -from apis.shared.skills.access import resolve_accessible_skill_ids +from apis.shared.skills.access import ( + resolve_accessible_skill_ids, + resolve_invocable_skill_ids, +) pytestmark = pytest.mark.asyncio @@ -53,3 +56,197 @@ async def test_failure_degrades_to_no_skills(self): ): result = await resolve_accessible_skill_ids(_user()) assert result == [] + + +def _owned(*skill_ids): + """Patch the owner-index query to return skills with these ids.""" + records = [MagicMock(skill_id=sid) for sid in skill_ids] + repo = MagicMock() + repo.list_skills_by_owner = AsyncMock(return_value=records) + return patch( + "apis.shared.skills.repository.get_skill_catalog_repository", + return_value=repo, + ) + + +class TestOwnedSkillUnion: + """Ownership is its own grant — a user always reaches skills they authored.""" + + async def test_owned_skills_are_unioned_onto_catalog_grants(self): + role_service = MagicMock() + role_service.get_accessible_skills = AsyncMock(return_value=["pdf_workflows"]) + with patch( + "apis.shared.rbac.service.get_app_role_service", + return_value=role_service, + ), _owned("my_notes"): + result = await resolve_accessible_skill_ids(_user()) + + assert result == ["pdf_workflows", "my_notes"] + + async def test_owned_skills_reach_a_user_with_no_role_grants(self): + role_service = MagicMock() + role_service.get_accessible_skills = AsyncMock(return_value=[]) + with patch( + "apis.shared.rbac.service.get_app_role_service", + return_value=role_service, + ), _owned("my_notes"): + result = await resolve_accessible_skill_ids(_user()) + + assert result == ["my_notes"] + + async def test_a_skill_both_granted_and_owned_appears_once(self): + role_service = MagicMock() + role_service.get_accessible_skills = AsyncMock(return_value=["my_notes"]) + with patch( + "apis.shared.rbac.service.get_app_role_service", + return_value=role_service, + ), _owned("my_notes"): + result = await resolve_accessible_skill_ids(_user()) + + assert result == ["my_notes"] + + async def test_owner_lookup_failure_still_yields_catalog_grants(self): + role_service = MagicMock() + role_service.get_accessible_skills = AsyncMock(return_value=["pdf_workflows"]) + with patch( + "apis.shared.rbac.service.get_app_role_service", + return_value=role_service, + ), patch( + "apis.shared.skills.repository.get_skill_catalog_repository", + side_effect=RuntimeError("dynamo down"), + ): + result = await resolve_accessible_skill_ids(_user()) + + assert result == ["pdf_workflows"] + + +AGENT_OWNER = "user-alice" + + +def _repo(*, owned=(), records=()): + """Patch the repo for both halves of the predicate. + + ``owned`` feeds the GSI4 owner query (clause 2, the *invoker's* own skills); + ``records`` are ``(skill_id, owner_id)`` pairs returned by the batch read + that clause 3 uses to test owner-match. + """ + repo = MagicMock() + repo.list_skills_by_owner = AsyncMock( + return_value=[MagicMock(skill_id=sid) for sid in owned] + ) + repo.batch_get_skills = AsyncMock( + return_value=[ + MagicMock(skill_id=sid, owner_id=oid) for sid, oid in records + ] + ) + return patch( + "apis.shared.skills.repository.get_skill_catalog_repository", + return_value=repo, + ), repo + + +def _roles(*granted): + role_service = MagicMock() + role_service.get_accessible_skills = AsyncMock(return_value=list(granted)) + return patch( + "apis.shared.rbac.service.get_app_role_service", + return_value=role_service, + ) + + +class TestInvokeThroughPredicate: + """§6/D7 — a bound skill resolves via catalog grant ∪ ownership ∪ invoke-through. + + Clause 3 (invoke-through) is what makes sharing an Agent with custom skills + useful at all: the invoker holds no grant on the author's skill, but the + share is the grant boundary, exactly as it already is for an assistant's KB + documents. + """ + + async def test_catalog_grant_resolves(self): + repo_patch, _ = _repo() + with _roles("pdf_workflows"), repo_patch: + allowed = await resolve_invocable_skill_ids( + _user(), ["pdf_workflows"], AGENT_OWNER + ) + assert "pdf_workflows" in allowed + + async def test_own_skill_resolves_without_any_role(self): + # The author binding their OWN skill into their OWN agent. This was + # broken before PR-4: the resolver gated on roles only, so an author + # was blocked on their own invocation. + repo_patch, _ = _repo(owned=["my_notes"]) + with _roles(), repo_patch: + allowed = await resolve_invocable_skill_ids( + _user(), ["my_notes"], AGENT_OWNER + ) + assert "my_notes" in allowed + + async def test_invoke_through_grants_the_agent_owners_skill(self): + # Alice authored `alices_style`, bound it to her agent, shared with us. + repo_patch, _ = _repo(records=[("alices_style", AGENT_OWNER)]) + with _roles(), repo_patch: + allowed = await resolve_invocable_skill_ids( + _user(), ["alices_style"], AGENT_OWNER + ) + assert "alices_style" in allowed + + async def test_chain_sharing_is_blocked_by_owner_match(self): + # `carols_skill` was merely shared TO Alice. Binding it to Alice's agent + # and sharing that agent must NOT launder it to a wider audience — + # invoke-through extends the agent owner's OWN skills and nothing else. + repo_patch, _ = _repo(records=[("carols_skill", "user-carol")]) + with _roles(), repo_patch: + allowed = await resolve_invocable_skill_ids( + _user(), ["carols_skill"], AGENT_OWNER + ) + assert "carols_skill" not in allowed + + async def test_a_system_owned_agent_gets_no_invoke_through(self): + # Owner-match on "system" would hand the entire admin catalog to anyone + # who could invoke a system-owned agent, bypassing RBAC entirely. + repo_patch, repo = _repo(records=[("payroll_runbook", "system")]) + with _roles(), repo_patch: + allowed = await resolve_invocable_skill_ids( + _user(), ["payroll_runbook"], "system" + ) + assert "payroll_runbook" not in allowed + repo.batch_get_skills.assert_not_awaited() + + async def test_wildcard_role_does_not_reach_a_private_authored_skill(self): + # The old `can_access_skill` returned True for "*" against ANY id. + # Routing through the shared resolver expands "*" over the CATALOG only, + # so another user's private skill stays unreachable. + repo_patch, _ = _repo(records=[("carols_private", "user-carol")]) + with _roles("*"), patch( + "apis.shared.skills.freshness.get_all_skill_ids", + AsyncMock(return_value=frozenset({"pdf_workflows"})), + ), repo_patch: + allowed = await resolve_invocable_skill_ids( + _user(), ["carols_private"], AGENT_OWNER + ) + assert "carols_private" not in allowed + + async def test_no_batch_read_when_every_ref_already_resolves(self): + # Clause 3 costs a batch read; it must not run when clauses 1+2 cover + # the bindings, which is the common case. + repo_patch, repo = _repo(owned=["my_notes"]) + with _roles("pdf_workflows"), repo_patch: + allowed = await resolve_invocable_skill_ids( + _user(), ["pdf_workflows", "my_notes"], AGENT_OWNER + ) + assert {"pdf_workflows", "my_notes"} <= allowed + repo.batch_get_skills.assert_not_awaited() + + async def test_batch_read_failure_degrades_to_not_granted(self): + repo = MagicMock() + repo.list_skills_by_owner = AsyncMock(return_value=[]) + repo.batch_get_skills = AsyncMock(side_effect=RuntimeError("dynamo down")) + with _roles(), patch( + "apis.shared.skills.repository.get_skill_catalog_repository", + return_value=repo, + ): + allowed = await resolve_invocable_skill_ids( + _user(), ["alices_style"], AGENT_OWNER + ) + assert "alices_style" not in allowed diff --git a/backend/tests/shared/test_skills_bundle.py b/backend/tests/shared/test_skills_bundle.py new file mode 100644 index 000000000..5613d0f64 --- /dev/null +++ b/backend/tests/shared/test_skills_bundle.py @@ -0,0 +1,67 @@ +"""Tests for the agentskills.io bundle helpers (Skills v2).""" + +import yaml + +from apis.shared.skills.bundle import generate_skill_md, slugify_skill_name + + +def _parse(md: str): + assert md.startswith("---\n") + _, fm, body = md.split("---\n", 2) + return yaml.safe_load(fm), body.strip() + + +class TestSlugify: + def test_underscore_to_hyphen_lowercase(self): + assert slugify_skill_name("PDF_Workflows") == "pdf-workflows" + + def test_collapses_and_trims_hyphens(self): + assert slugify_skill_name("--weird__multi space--") == "weird-multi-space" + + def test_empty_falls_back(self): + assert slugify_skill_name("!!!") == "skill" + + +class TestGenerateSkillMd: + def test_minimal(self): + md = generate_skill_md( + skill_id="pdf_workflows", + description="Work with PDFs.", + instructions="# PDF\nDo the thing.", + ) + fm, body = _parse(md) + assert fm == {"name": "pdf-workflows", "description": "Work with PDFs."} + assert body == "# PDF\nDo the thing." + + def test_allowed_tools_only_when_present(self): + without = generate_skill_md( + skill_id="s", description="d", instructions="i", allowed_tools=[] + ) + assert "allowed-tools" not in _parse(without)[0] + with_tools = generate_skill_md( + skill_id="s", description="d", instructions="i", allowed_tools=["web_search"] + ) + assert _parse(with_tools)[0]["allowed-tools"] == ["web_search"] + + def test_metadata_passthrough_excludes_reserved(self): + md = generate_skill_md( + skill_id="s", + description="d", + instructions="i", + skill_metadata={"license": "Apache-2.0", "name": "override-ignored"}, + ) + fm = _parse(md)[0] + assert fm["license"] == "Apache-2.0" + # The projection owns `name`; a metadata `name` never overrides the slug. + assert fm["name"] == "s" + + def test_round_trips_as_valid_frontmatter(self): + md = generate_skill_md( + skill_id="my_skill", + description="Use this when: the user asks. Colons: allowed.", + instructions="body", + allowed_tools=["a", "b"], + ) + fm, _ = _parse(md) + assert fm["name"] == "my-skill" + assert fm["description"].startswith("Use this when") diff --git a/backend/tests/shared/test_skills_models.py b/backend/tests/shared/test_skills_models.py index 79655fd68..bf775ab3a 100644 --- a/backend/tests/shared/test_skills_models.py +++ b/backend/tests/shared/test_skills_models.py @@ -30,7 +30,6 @@ def _skill(**kw) -> SkillDefinition: class TestSkillRoundTrip: def test_to_dynamo_item_keys_and_camel_case(self): skill = _skill( - bound_tool_ids=["fill_pdf_form", "gateway_weather"], compose=["doc_basics"], category="document", status=SkillStatus.ACTIVE, @@ -50,7 +49,8 @@ def test_to_dynamo_item_keys_and_camel_case(self): # snake_case -> camelCase, same convention as ToolDefinition. assert item["skillId"] == "pdf_workflows" assert item["displayName"] == "PDF Workflows" - assert item["boundToolIds"] == ["fill_pdf_form", "gateway_weather"] + # Skills v2: no tool bindings persisted. + assert "boundToolIds" not in item assert item["compose"] == ["doc_basics"] assert item["status"] == "active" assert item["category"] == "document" @@ -63,7 +63,6 @@ def test_to_dynamo_item_keys_and_camel_case(self): def test_round_trip_preserves_fields(self): skill = _skill( - bound_tool_ids=["fill_pdf_form"], compose=["doc_basics"], category="document", owner_id="user-42", @@ -77,7 +76,6 @@ def test_round_trip_preserves_fields(self): assert restored.display_name == "PDF Workflows" assert restored.description == skill.description assert restored.instructions == skill.instructions - assert restored.bound_tool_ids == ["fill_pdf_form"] assert restored.compose == ["doc_basics"] assert restored.status == "active" assert restored.category == "document" @@ -88,7 +86,6 @@ def test_round_trip_preserves_fields(self): def test_defaults(self): skill = _skill() - assert skill.bound_tool_ids == [] assert skill.compose == [] assert skill.status == "active" assert skill.category is None @@ -106,7 +103,6 @@ def test_from_dynamo_item_tolerates_missing_optional_fields(self): } restored = SkillDefinition.from_dynamo_item(item) assert restored.skill_id == "minimal_skill" - assert restored.bound_tool_ids == [] assert restored.owner_id == "system" assert restored.visibility == "admin" assert isinstance(restored.created_at, datetime) @@ -148,20 +144,40 @@ def test_resources_serialized_to_dynamo_item(self): "size", "contentType", "s3Key", + "kind", } assert first["filename"] == "forms.md" assert first["contentHash"] == "a" * 64 + assert first["kind"] == "reference" def test_resources_round_trip_preserves_manifest(self): - skill = _skill(resources=[self._ref(filename="forms.md", size=10)]) + skill = _skill( + resources=[self._ref(filename="run.py", size=10, kind="script")] + ) restored = SkillDefinition.from_dynamo_item(skill.to_dynamo_item()) assert len(restored.resources) == 1 ref = restored.resources[0] - assert ref.filename == "forms.md" + assert ref.filename == "run.py" assert ref.size == 10 assert ref.content_type == "text/markdown" assert ref.s3_key.startswith("skills/pdf_workflows/") + assert ref.kind == "script" + + def test_legacy_resource_row_defaults_kind_reference(self): + # A v1 manifest entry (no `kind`) deserializes to reference. + item = _skill().to_dynamo_item() + item["resources"] = [ + { + "filename": "forms.md", + "contentHash": "b" * 64, + "size": 5, + "contentType": "text/markdown", + "s3Key": "skills/pdf_workflows/" + "b" * 64, + } + ] + restored = SkillDefinition.from_dynamo_item(item) + assert restored.resources[0].kind == "reference" def test_size_coerced_from_decimal(self): # DynamoDB returns numbers as Decimal; from_dynamo_item coerces to int. diff --git a/backend/tests/shared/test_skills_repository.py b/backend/tests/shared/test_skills_repository.py index f2b9d422f..9c156b5fe 100644 --- a/backend/tests/shared/test_skills_repository.py +++ b/backend/tests/shared/test_skills_repository.py @@ -14,8 +14,7 @@ def _make_skill(skill_id="pdf_workflows", **kw) -> SkillDefinition: skill_id=skill_id, display_name="PDF Workflows", description="Fill, merge and split PDFs.", - instructions="# PDF Workflows\nUse the bound tools.", - bound_tool_ids=["fill_pdf_form"], + instructions="# PDF Workflows\nInstructions for working with PDFs.", ) defaults.update(kw) return SkillDefinition(**defaults) @@ -30,7 +29,6 @@ async def test_create_and_get(self, skill_repository): result = await skill_repository.get_skill("pdf_workflows") assert result is not None assert result.display_name == "PDF Workflows" - assert result.bound_tool_ids == ["fill_pdf_form"] @pytest.mark.asyncio async def test_get_nonexistent(self, skill_repository): @@ -49,6 +47,54 @@ async def test_list_skills(self, skill_repository): skills = await skill_repository.list_skills() assert {s.skill_id for s in skills} == {"skill_one", "skill_two"} + @pytest.mark.asyncio + async def test_list_skills_filters_by_owner(self, skill_repository): + await skill_repository.create_skill(_make_skill("catalog_one")) + await skill_repository.create_skill(_make_skill("mine", owner_id="user-1")) + + catalog = await skill_repository.list_skills(owner_id="system") + mine = await skill_repository.list_skills(owner_id="user-1") + + assert {s.skill_id for s in catalog} == {"catalog_one"} + assert {s.skill_id for s in mine} == {"mine"} + + @pytest.mark.asyncio + async def test_list_skills_by_owner_uses_the_owner_index(self, skill_repository): + """GSI4 partition query — the 'list my skills' path for the user tier.""" + await skill_repository.create_skill(_make_skill("catalog_one")) + await skill_repository.create_skill( + _make_skill("mine_b", display_name="Bravo", owner_id="user-1") + ) + await skill_repository.create_skill( + _make_skill("mine_a", display_name="Alpha", owner_id="user-1") + ) + await skill_repository.create_skill(_make_skill("theirs", owner_id="user-2")) + + mine = await skill_repository.list_skills_by_owner("user-1") + + # Sorted by display name, and scoped strictly to that owner. + assert [s.skill_id for s in mine] == ["mine_a", "mine_b"] + + @pytest.mark.asyncio + async def test_list_skills_by_owner_filters_status(self, skill_repository): + await skill_repository.create_skill(_make_skill("active_one", owner_id="user-1")) + await skill_repository.create_skill( + _make_skill("draft_one", owner_id="user-1", status=SkillStatus.DRAFT) + ) + + active = await skill_repository.list_skills_by_owner( + "user-1", status=SkillStatus.ACTIVE.value + ) + + assert [s.skill_id for s in active] == ["active_one"] + + @pytest.mark.asyncio + async def test_list_skills_by_owner_is_empty_for_an_unknown_owner( + self, skill_repository + ): + await skill_repository.create_skill(_make_skill("catalog_one")) + assert await skill_repository.list_skills_by_owner("nobody") == [] + @pytest.mark.asyncio async def test_list_does_not_pick_up_other_pk_items( self, skill_repository, role_repository @@ -80,18 +126,18 @@ async def test_update_skill(self, skill_repository): await skill_repository.create_skill(_make_skill()) updated = await skill_repository.update_skill( "pdf_workflows", - {"display_name": "PDF Tools", "bound_tool_ids": ["fill_pdf_form", "merge_pdf"]}, + {"display_name": "PDF Tools", "compose": ["doc_basics"]}, admin_user_id="admin-2", ) assert updated is not None assert updated.display_name == "PDF Tools" - assert updated.bound_tool_ids == ["fill_pdf_form", "merge_pdf"] + assert updated.compose == ["doc_basics"] assert updated.updated_by == "admin-2" # Persisted. reloaded = await skill_repository.get_skill("pdf_workflows") assert reloaded.display_name == "PDF Tools" - assert reloaded.bound_tool_ids == ["fill_pdf_form", "merge_pdf"] + assert reloaded.compose == ["doc_basics"] @pytest.mark.asyncio async def test_update_nonexistent_returns_none(self, skill_repository): diff --git a/backend/tests/shared/test_skills_resource_store.py b/backend/tests/shared/test_skills_resource_store.py index f1f39be4a..6d81b0c56 100644 --- a/backend/tests/shared/test_skills_resource_store.py +++ b/backend/tests/shared/test_skills_resource_store.py @@ -1,7 +1,8 @@ -"""Tests for the S3-backed skill reference-file store (PR-4). +"""Tests for the S3-backed skill resource store (Skills v2). -moto-backed: exercises content-hash keying, dedupe (no second put for -identical bytes), get/delete round-trip, and the not-configured guard. +moto-backed: exercises the standard agentskills.io bundle layout +(``references|scripts|assets``), the SKILL.md projection write, get/delete +round-trip, overwrite-in-place, and the not-configured guard. """ import boto3 @@ -12,7 +13,8 @@ SkillResourceStore, SkillResourceStoreError, compute_content_hash, - content_key, + resource_key, + skill_md_key, get_skill_resource_store, ) @@ -42,13 +44,22 @@ def store(s3_client): return SkillResourceStore(bucket_name=BUCKET, s3_client=s3_client) -class TestContentKey: - def test_key_is_content_addressed(self): - digest = compute_content_hash(b"# Notes") - assert content_key("pdf_workflows", digest) == ( - f"skills/pdf_workflows/{digest}" +class TestKeys: + def test_reference_key_uses_bundle_layout(self): + assert resource_key("pdf_workflows", "reference", "forms.md") == ( + "skills/pdf_workflows/references/forms.md" ) + def test_script_and_asset_dirs(self): + assert resource_key("s", "script", "run.py") == "skills/s/scripts/run.py" + assert resource_key("s", "asset", "logo.png") == "skills/s/assets/logo.png" + + def test_unknown_kind_falls_back_to_references(self): + assert resource_key("s", "bogus", "f.md") == "skills/s/references/f.md" + + def test_skill_md_key(self): + assert skill_md_key("pdf_workflows") == "skills/pdf_workflows/SKILL.md" + def test_hash_is_sha256_hex(self): import hashlib @@ -56,65 +67,73 @@ def test_hash_is_sha256_hex(self): class TestPutGetDelete: - def test_put_returns_content_addressed_key(self, store): + def test_put_returns_bundle_key(self, store): key = store.put( skill_id="pdf_workflows", + filename="forms.md", content=b"# Notes", content_type="text/markdown", ) - assert key == content_key("pdf_workflows", compute_content_hash(b"# Notes")) + assert key == "skills/pdf_workflows/references/forms.md" def test_get_returns_bytes(self, store): key = store.put( - skill_id="pdf_workflows", content=b"hello", content_type="text/plain" + skill_id="pdf_workflows", + filename="a.txt", + content=b"hello", + content_type="text/plain", ) assert store.get(key) == b"hello" - def test_put_is_idempotent_dedupe(self, store, s3_client): - # Two puts of identical content land on one object (content-addressed). - k1 = store.put( - skill_id="pdf_workflows", content=b"same", content_type="text/plain" - ) - k2 = store.put( - skill_id="pdf_workflows", content=b"same", content_type="text/plain" + def test_put_overwrites_in_place(self, store, s3_client): + # Same (kind, filename) → same key → one object, latest content wins. + store.put(skill_id="s", filename="a.txt", content=b"v1", content_type="text/plain") + key = store.put( + skill_id="s", filename="a.txt", content=b"v2", content_type="text/plain" ) - assert k1 == k2 listed = s3_client.list_objects_v2(Bucket=BUCKET).get("Contents", []) assert len(listed) == 1 + assert store.get(key) == b"v2" - def test_different_content_distinct_keys(self, store, s3_client): - store.put(skill_id="s", content=b"a", content_type="text/plain") - store.put(skill_id="s", content=b"b", content_type="text/plain") + def test_distinct_filenames_distinct_objects(self, store, s3_client): + store.put(skill_id="s", filename="a.txt", content=b"a", content_type="text/plain") + store.put(skill_id="s", filename="b.txt", content=b"a", content_type="text/plain") listed = s3_client.list_objects_v2(Bucket=BUCKET).get("Contents", []) + # No dedupe: identical content under two filenames is two objects. assert len(listed) == 2 - def test_same_content_scoped_per_skill(self, store, s3_client): - # The key includes the skill_id, so identical content under two skills - # is two objects (dedupe is per-skill, matching the key layout). - store.put(skill_id="skill_a", content=b"shared", content_type="text/plain") - store.put(skill_id="skill_b", content=b"shared", content_type="text/plain") - listed = s3_client.list_objects_v2(Bucket=BUCKET).get("Contents", []) - assert len(listed) == 2 + def test_kinds_land_in_distinct_dirs(self, store, s3_client): + store.put(skill_id="s", filename="f.md", content=b"r", content_type="text/plain", kind="reference") + store.put(skill_id="s", filename="f.md", content=b"x", content_type="text/plain", kind="script") + keys = {o["Key"] for o in s3_client.list_objects_v2(Bucket=BUCKET).get("Contents", [])} + assert keys == {"skills/s/references/f.md", "skills/s/scripts/f.md"} + + def test_put_skill_md(self, store, s3_client): + key = store.put_skill_md(skill_id="pdf_workflows", content="---\nname: x\n---\n\nbody\n") + assert key == "skills/pdf_workflows/SKILL.md" + assert store.get(key).decode() == "---\nname: x\n---\n\nbody\n" + head = s3_client.head_object(Bucket=BUCKET, Key=key) + assert head["ContentType"] == "text/markdown" def test_put_sets_content_type(self, store, s3_client): key = store.put( - skill_id="s", content=b"# md", content_type="text/markdown" + skill_id="s", filename="m.md", content=b"# md", content_type="text/markdown" ) head = s3_client.head_object(Bucket=BUCKET, Key=key) assert head["ContentType"] == "text/markdown" def test_delete_removes_object(self, store, s3_client): - key = store.put(skill_id="s", content=b"x", content_type="text/plain") + key = store.put(skill_id="s", filename="x.txt", content=b"x", content_type="text/plain") store.delete(key) assert s3_client.list_objects_v2(Bucket=BUCKET).get("Contents", []) == [] def test_get_missing_raises(self, store): with pytest.raises(SkillResourceStoreError): - store.get("skills/s/deadbeef") + store.get("skills/s/references/missing.md") def test_delete_missing_is_noop(self, store): # No object at the key — delete must not raise (S3 delete is idempotent). - store.delete("skills/s/deadbeef") + store.delete("skills/s/references/missing.md") class TestNotConfigured: @@ -127,13 +146,13 @@ def test_put_raises_when_disabled(self, monkeypatch): monkeypatch.delenv("S3_SKILL_RESOURCES_BUCKET_NAME", raising=False) s = SkillResourceStore() with pytest.raises(SkillResourceStoreError): - s.put(skill_id="s", content=b"x", content_type="text/plain") + s.put(skill_id="s", filename="x.txt", content=b"x", content_type="text/plain") def test_get_raises_when_disabled(self, monkeypatch): monkeypatch.delenv("S3_SKILL_RESOURCES_BUCKET_NAME", raising=False) s = SkillResourceStore() with pytest.raises(SkillResourceStoreError): - s.get("skills/s/abc") + s.get("skills/s/references/abc.md") def test_delete_silent_when_disabled(self, monkeypatch): monkeypatch.delenv("S3_SKILL_RESOURCES_BUCKET_NAME", raising=False) diff --git a/backend/tests/shared/test_user_settings_agent_mode.py b/backend/tests/shared/test_user_settings_agent_mode.py deleted file mode 100644 index 4058a752b..000000000 --- a/backend/tests/shared/test_user_settings_agent_mode.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Tests for the preferredAgentMode user setting (skills-mode PR-3).""" - -import pytest -from pydantic import ValidationError - -from apis.shared.user_settings.models import UserSettings, UserSettingsUpdate -from apis.shared.user_settings.repository import DEFAULT_SETTINGS - - -class TestPreferredAgentModeModels: - def test_settings_accept_camel_case_alias(self): - settings = UserSettings.model_validate({"preferredAgentMode": "chat"}) - assert settings.preferred_agent_mode == "chat" - - def test_update_rejects_unknown_mode(self): - with pytest.raises(ValidationError): - UserSettingsUpdate.model_validate({"preferredAgentMode": "voice"}) - - def test_defaults_to_none(self): - assert UserSettings().preferred_agent_mode is None - - -class TestRepositoryDefaults: - def test_default_settings_include_preferred_agent_mode(self): - # The repository's get_settings extracts a fixed key set from the - # Dynamo item; a key missing from DEFAULT_SETTINGS would be silently - # dropped on read. - assert "preferredAgentMode" in DEFAULT_SETTINGS - assert DEFAULT_SETTINGS["preferredAgentMode"] is None diff --git a/backend/tests/test_backfill_artifact_tool_merge.py b/backend/tests/test_backfill_artifact_tool_merge.py new file mode 100644 index 000000000..3f3be9131 --- /dev/null +++ b/backend/tests/test_backfill_artifact_tool_merge.py @@ -0,0 +1,333 @@ +"""Tests for the artifact tool-merge backfill script. + +The script collapses the two artifact catalog rows (``create_artifact`` + +``update_artifact``) into a single "Artifacts" toggle keyed on +``create_artifact``. Its real job is not the rename but the promotion: every +place the retired id could be the *only* thing granting artifact access must +be moved onto the keeper before the retired id is deleted, or a role/user/ +assistant silently loses the capability. +""" + +import os +import sys + +import boto3 +import pytest +from moto import mock_aws + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) + +from backfill_artifact_tool_merge import ( # noqa: E402 + delete_retired_catalog_row, + promote_assistant_bindings, + promote_role_grants, + promote_user_preferences, + retitle_catalog_row, +) + +REGION = "us-east-1" +ROLES_TABLE = "test-app-roles" +ASSISTANTS_TABLE = "test-assistants" + + +@pytest.fixture() +def aws(monkeypatch): + monkeypatch.setenv("AWS_DEFAULT_REGION", REGION) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") + monkeypatch.setenv("AWS_SESSION_TOKEN", "testing") + with mock_aws(): + yield + + +@pytest.fixture() +def roles(aws): + """app-roles table, including the GSI2 the grant lookup queries.""" + ddb = boto3.resource("dynamodb", region_name=REGION) + ddb.create_table( + TableName=ROLES_TABLE, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + {"AttributeName": "GSI2PK", "AttributeType": "S"}, + {"AttributeName": "GSI2SK", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[ + { + "IndexName": "ToolRoleMappingIndex", + "KeySchema": [ + {"AttributeName": "GSI2PK", "KeyType": "HASH"}, + {"AttributeName": "GSI2SK", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }, + ], + BillingMode="PAY_PER_REQUEST", + ) + table = ddb.Table(ROLES_TABLE) + table.put_item( + Item={ + "PK": "TOOL#create_artifact", + "SK": "METADATA", + "toolId": "create_artifact", + "displayName": "Create Artifact", + } + ) + table.put_item( + Item={ + "PK": "TOOL#update_artifact", + "SK": "METADATA", + "toolId": "update_artifact", + "displayName": "Update Artifact", + } + ) + return table + + +@pytest.fixture() +def assistants(aws): + ddb = boto3.resource("dynamodb", region_name=REGION) + ddb.create_table( + TableName=ASSISTANTS_TABLE, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + return ddb.Table(ASSISTANTS_TABLE) + + +def _put_role(table, role_id, granted): + table.put_item( + Item={ + "PK": f"ROLE#{role_id}", + "SK": "DEFINITION", + "roleId": role_id, + "grantedTools": list(granted), + "effectivePermissions": {"tools": list(granted)}, + } + ) + for tool_id in granted: + table.put_item( + Item={ + "PK": f"ROLE#{role_id}", + "SK": f"TOOL_GRANT#{tool_id}", + "GSI2PK": f"TOOL#{tool_id}", + "GSI2SK": f"ROLE#{role_id}", + "roleId": role_id, + "displayName": role_id.upper(), + "enabled": True, + } + ) + + +def _definition(table, role_id): + return table.get_item(Key={"PK": f"ROLE#{role_id}", "SK": "DEFINITION"})["Item"] + + +def _has(table, pk, sk): + return "Item" in table.get_item(Key={"PK": pk, "SK": sk}) + + +def _put_prefs(table, user_id, prefs): + table.put_item( + Item={"PK": f"USER#{user_id}", "SK": "TOOL_PREFERENCES", "toolPreferences": prefs} + ) + + +def _prefs(table, user_id): + return table.get_item(Key={"PK": f"USER#{user_id}", "SK": "TOOL_PREFERENCES"})[ + "Item" + ]["toolPreferences"] + + +class TestCatalogRow: + def test_retitles_keeper_and_deletes_retired(self, roles): + assert retitle_catalog_row(roles, apply=True) == 1 + assert delete_retired_catalog_row(roles, apply=True) == 1 + + item = roles.get_item(Key={"PK": "TOOL#create_artifact", "SK": "METADATA"})[ + "Item" + ] + assert item["displayName"] == "Artifacts" + assert not _has(roles, "TOOL#update_artifact", "METADATA") + + def test_dry_run_does_not_mutate(self, roles): + retitle_catalog_row(roles, apply=False) + delete_retired_catalog_row(roles, apply=False) + + item = roles.get_item(Key={"PK": "TOOL#create_artifact", "SK": "METADATA"})[ + "Item" + ] + assert item["displayName"] == "Create Artifact" + assert _has(roles, "TOOL#update_artifact", "METADATA") + + def test_rerun_is_a_noop(self, roles): + retitle_catalog_row(roles, apply=True) + delete_retired_catalog_row(roles, apply=True) + + assert retitle_catalog_row(roles, apply=True) == 0 + assert delete_retired_catalog_row(roles, apply=True) == 0 + + +class TestRoleGrants: + def test_role_holding_only_retired_id_is_promoted(self, roles): + """The case that would otherwise silently revoke artifact access.""" + _put_role(roles, "a", ["update_artifact", "other"]) + + promote_role_grants(roles, apply=True) + + definition = _definition(roles, "a") + assert sorted(definition["grantedTools"]) == ["create_artifact", "other"] + assert sorted(definition["effectivePermissions"]["tools"]) == [ + "create_artifact", + "other", + ] + assert _has(roles, "ROLE#a", "TOOL_GRANT#create_artifact") + assert not _has(roles, "ROLE#a", "TOOL_GRANT#update_artifact") + + def test_role_holding_both_is_just_pruned(self, roles): + _put_role(roles, "b", ["create_artifact", "update_artifact"]) + + promote_role_grants(roles, apply=True) + + assert _definition(roles, "b")["grantedTools"] == ["create_artifact"] + assert _has(roles, "ROLE#b", "TOOL_GRANT#create_artifact") + assert not _has(roles, "ROLE#b", "TOOL_GRANT#update_artifact") + + def test_wildcard_role_gains_no_explicit_grant(self, roles): + """`*` already covers the keeper — don't narrow it to a concrete list.""" + _put_role(roles, "c", ["*"]) + roles.put_item( + Item={ + "PK": "ROLE#c", + "SK": "TOOL_GRANT#update_artifact", + "GSI2PK": "TOOL#update_artifact", + "GSI2SK": "ROLE#c", + "roleId": "c", + "enabled": True, + } + ) + + promote_role_grants(roles, apply=True) + + assert _definition(roles, "c")["grantedTools"] == ["*"] + assert not _has(roles, "ROLE#c", "TOOL_GRANT#create_artifact") + assert not _has(roles, "ROLE#c", "TOOL_GRANT#update_artifact") + + def test_dry_run_does_not_mutate(self, roles): + _put_role(roles, "a", ["update_artifact"]) + + promote_role_grants(roles, apply=False) + + assert _definition(roles, "a")["grantedTools"] == ["update_artifact"] + assert _has(roles, "ROLE#a", "TOOL_GRANT#update_artifact") + + +class TestUserPreferences: + def test_explicit_enable_carries_over(self, roles): + _put_prefs(roles, "u1", {"update_artifact": True}) + + promote_user_preferences(roles, apply=True) + + assert _prefs(roles, "u1") == {"create_artifact": True} + + def test_create_wins_when_both_are_set(self, roles): + _put_prefs(roles, "u2", {"create_artifact": False, "update_artifact": True}) + + promote_user_preferences(roles, apply=True) + + assert _prefs(roles, "u2") == {"create_artifact": False} + + def test_explicit_disable_does_not_carry_over(self, roles): + """Turning update off never meant "disable artifacts entirely" — the + user left create at its default-on, so the key just drops.""" + _put_prefs(roles, "u3", {"update_artifact": False}) + + promote_user_preferences(roles, apply=True) + + assert _prefs(roles, "u3") == {} + + def test_unrelated_prefs_untouched(self, roles): + _put_prefs(roles, "u4", {"other": True}) + + assert promote_user_preferences(roles, apply=True) == 0 + assert _prefs(roles, "u4") == {"other": True} + + def test_dry_run_does_not_mutate(self, roles): + _put_prefs(roles, "u1", {"update_artifact": True}) + + promote_user_preferences(roles, apply=False) + + assert _prefs(roles, "u1") == {"update_artifact": True} + + +class TestAssistantBindings: + def test_sole_retired_binding_is_promoted(self, assistants): + assistants.put_item( + Item={ + "PK": "AST#1", + "SK": "METADATA", + "bindings": [{"kind": "tool", "ref": "update_artifact"}], + } + ) + + promote_assistant_bindings(assistants, apply=True) + + item = assistants.get_item(Key={"PK": "AST#1", "SK": "METADATA"})["Item"] + assert item["bindings"] == [{"kind": "tool", "ref": "create_artifact"}] + + def test_binding_pair_is_deduped(self, assistants): + assistants.put_item( + Item={ + "PK": "AST#2", + "SK": "METADATA", + "bindings": [ + {"kind": "tool", "ref": "create_artifact"}, + {"kind": "tool", "ref": "update_artifact"}, + {"kind": "skill", "ref": "s"}, + ], + } + ) + + promote_assistant_bindings(assistants, apply=True) + + item = assistants.get_item(Key={"PK": "AST#2", "SK": "METADATA"})["Item"] + assert item["bindings"] == [ + {"kind": "tool", "ref": "create_artifact"}, + {"kind": "skill", "ref": "s"}, + ] + + def test_unrelated_bindings_untouched(self, assistants): + assistants.put_item( + Item={ + "PK": "AST#3", + "SK": "METADATA", + "bindings": [{"kind": "tool", "ref": "other"}], + } + ) + + assert promote_assistant_bindings(assistants, apply=True) == 0 + + def test_dry_run_does_not_mutate(self, assistants): + assistants.put_item( + Item={ + "PK": "AST#1", + "SK": "METADATA", + "bindings": [{"kind": "tool", "ref": "update_artifact"}], + } + ) + + promote_assistant_bindings(assistants, apply=False) + + item = assistants.get_item(Key={"PK": "AST#1", "SK": "METADATA"})["Item"] + assert item["bindings"] == [{"kind": "tool", "ref": "update_artifact"}] diff --git a/backend/tests/test_backfill_session_static_sk.py b/backend/tests/test_backfill_session_static_sk.py new file mode 100644 index 000000000..9e299621d --- /dev/null +++ b/backend/tests/test_backfill_session_static_sk.py @@ -0,0 +1,190 @@ +"""Tests for the Phase 2 static-SK backfill script (issue #175).""" + +import os +import sys + +import boto3 +import pytest +from moto import mock_aws + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) + +from backfill_session_static_sk import ( # noqa: E402 + build_static_item, + count_remaining_legacy, + is_ghost, + maybe_set_marker, + run, +) + +REGION = "us-east-1" +TABLE = "test-sessions-metadata" + + +def _create_table(ddb): + ddb.create_table( + TableName=TABLE, + KeySchema=[{"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + {"AttributeName": "GSI4_PK", "AttributeType": "S"}, + {"AttributeName": "GSI4_SK", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "SessionRecencyIndex", + "KeySchema": [{"AttributeName": "GSI4_PK", "KeyType": "HASH"}, + {"AttributeName": "GSI4_SK", "KeyType": "RANGE"}], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + return boto3.resource("dynamodb", region_name=REGION).Table(TABLE) + + +def _legacy_active(table, sid, la): + table.put_item(Item={ + "PK": "USER#u1", "SK": f"S#ACTIVE#{la}#{sid}", + "GSI_PK": f"SESSION#{sid}", "GSI_SK": "META", + "sessionId": sid, "userId": "u1", "title": "T", "status": "active", + "createdAt": "2026-01-01T00:00:00Z", "lastMessageAt": la, "messageCount": 1, + }) + + +def _legacy_deleted(table, sid, da): + table.put_item(Item={ + "PK": "USER#u1", "SK": f"S#DELETED#{da}#{sid}", + "GSI_PK": f"SESSION#{sid}", "GSI_SK": "META", + "sessionId": sid, "userId": "u1", "title": "T", "status": "deleted", + "createdAt": "2026-01-01T00:00:00Z", "lastMessageAt": "2026-01-01T00:00:00Z", + "messageCount": 3, "deleted": True, "deletedAt": da, + }) + + +def _ghost(table, sid, la): + # bare stub: no GSI_SK=META, no required fields + table.put_item(Item={"PK": "USER#u1", "SK": f"S#ACTIVE#{la}#{sid}"}) + + +def _static_active(table, sid, la): + table.put_item(Item={ + "PK": "USER#u1", "SK": f"S#{sid}", + "GSI_PK": f"SESSION#{sid}", "GSI_SK": "META", + "GSI4_PK": "USER#u1", "GSI4_SK": f"{la}#{sid}", + "sessionId": sid, "userId": "u1", "title": "T", "status": "active", + "createdAt": "2026-01-01T00:00:00Z", "lastMessageAt": la, "messageCount": 1, + }) + + +def _meta_rows(table): + return [i for i in table.scan()["Items"] if i.get("GSI_SK") == "META"] + + +class TestClassification: + def test_is_ghost_detects_bare_stub(self): + assert is_ghost({"PK": "USER#u1", "SK": "S#ACTIVE#2026#x"}) is True + + def test_is_ghost_missing_required_field(self): + assert is_ghost({"GSI_SK": "META", "sessionId": "x"}) is True # missing title etc. + + def test_valid_row_is_not_ghost(self): + row = {"GSI_SK": "META", "sessionId": "x", "userId": "u", "title": "t", + "status": "active", "createdAt": "c", "lastMessageAt": "l", "messageCount": 1} + assert is_ghost(row) is False + + def test_build_static_item_active_gets_gsi4(self): + row = {"PK": "USER#u1", "SK": "S#ACTIVE#2026-01-02T00:00:00Z#s1", + "userId": "u1", "status": "active", "lastMessageAt": "2026-01-02T00:00:00Z"} + out = build_static_item(row, "s1") + assert out["SK"] == "S#s1" + assert out["GSI4_PK"] == "USER#u1" + assert out["GSI4_SK"] == "2026-01-02T00:00:00Z#s1" + + def test_build_static_item_deleted_no_gsi4(self): + row = {"PK": "USER#u1", "SK": "S#DELETED#2026-01-02T00:00:00Z#s1", + "userId": "u1", "status": "deleted", "lastMessageAt": "2026-01-01T00:00:00Z"} + out = build_static_item(row, "s1") + assert out["SK"] == "S#s1" + assert "GSI4_PK" not in out + assert out["status"] == "deleted" and out["deleted"] is True + + +class TestBackfillRun: + @pytest.fixture() + def table(self): + with mock_aws(): + ddb = boto3.client("dynamodb", region_name=REGION) + yield _create_table(ddb) + + def test_dry_run_writes_nothing(self, table): + _legacy_active(table, "s1", "2026-01-01T00:00:00Z") + _ghost(table, "g1", "2026-01-02T00:00:00Z") + before = table.scan()["Items"] + + stats = run(TABLE, REGION, apply=False, sleep=0, limit=None, set_marker=False) + + assert stats["migrated"] == 1 and stats["ghosts"] == 1 + assert table.scan()["Items"] == before # unchanged + + def test_apply_migrates_active_deletes_ghost(self, table): + _legacy_active(table, "s1", "2026-01-02T00:00:00Z") + _legacy_deleted(table, "s2", "2026-01-03T00:00:00Z") + _ghost(table, "g1", "2026-01-04T00:00:00Z") + _static_active(table, "s3", "2026-01-05T00:00:00Z") # already migrated + + stats = run(TABLE, REGION, apply=True, sleep=0, limit=None, set_marker=False) + assert stats == {"migrated": 2, "ghosts": 1, "already_static": 0, "skipped_live_migrated": 0} + + items = table.scan()["Items"] + # ghost gone, no legacy rows remain + assert not any(i["SK"].startswith("S#ACTIVE#") for i in items) + assert not any(i["SK"].startswith("S#DELETED#") for i in items) + rows = {i["sessionId"]: i for i in _meta_rows(table)} + assert rows["s1"]["SK"] == "S#s1" and rows["s1"]["GSI4_PK"] == "USER#u1" # active → GSI4 + assert rows["s2"]["SK"] == "S#s2" and "GSI4_PK" not in rows["s2"] # deleted → no GSI4 + assert rows["s2"]["status"] == "deleted" + assert rows["s3"]["SK"] == "S#s3" # untouched static + + def test_idempotent_second_run_is_noop(self, table): + _legacy_active(table, "s1", "2026-01-02T00:00:00Z") + run(TABLE, REGION, apply=True, sleep=0, limit=None, set_marker=False) + after_first = _meta_rows(table) + + stats2 = run(TABLE, REGION, apply=True, sleep=0, limit=None, set_marker=False) + assert stats2["migrated"] == 0 and stats2["ghosts"] == 0 + assert _meta_rows(table) == after_first + + def test_conditional_put_skips_live_migrated_row(self, table): + # legacy row AND a fresher static row already exist for the same session + _legacy_active(table, "s1", "2026-01-02T00:00:00Z") + table.put_item(Item={ + "PK": "USER#u1", "SK": "S#s1", "GSI_PK": "SESSION#s1", "GSI_SK": "META", + "GSI4_PK": "USER#u1", "GSI4_SK": "2026-09-09T00:00:00Z#s1", + "sessionId": "s1", "userId": "u1", "title": "FRESH", "status": "active", + "createdAt": "2026-01-01T00:00:00Z", "lastMessageAt": "2026-09-09T00:00:00Z", + "messageCount": 9, + }) + + stats = run(TABLE, REGION, apply=True, sleep=0, limit=None, set_marker=False) + assert stats["skipped_live_migrated"] == 1 + + rows = _meta_rows(table) + assert len(rows) == 1 # legacy orphan dropped + assert rows[0]["title"] == "FRESH" # live row NOT clobbered + assert rows[0]["messageCount"] == 9 + + def test_marker_gated_on_zero_legacy(self, table): + _legacy_active(table, "s1", "2026-01-02T00:00:00Z") + + # legacy still present → marker withheld + assert maybe_set_marker(table, apply=True) is False + assert count_remaining_legacy(table) == 1 + marker = table.get_item(Key={"PK": "MIGRATION#session-sk", "SK": "STATE"}).get("Item") + assert marker is None + + # migrate, then marker sets + run(TABLE, REGION, apply=True, sleep=0, limit=None, set_marker=True) + assert count_remaining_legacy(table) == 0 + marker = table.get_item(Key={"PK": "MIGRATION#session-sk", "SK": "STATE"}).get("Item") + assert marker is not None and marker["complete"] is True diff --git a/backend/tests/test_backfill_skill_bundles.py b/backend/tests/test_backfill_skill_bundles.py new file mode 100644 index 000000000..e66627e64 --- /dev/null +++ b/backend/tests/test_backfill_skill_bundles.py @@ -0,0 +1,257 @@ +"""Tests for the skill-bundle backfill script (Skills v2 PR-3 follow-up). + +The script's job is to make a v1 skill's S3 prefix a valid agentskills.io +bundle: a ``SKILL.md`` projection plus resources in the standard directory +layout, with the manifest pointing at the new keys. +""" + +import os +import sys + +import boto3 +import pytest +from moto import mock_aws + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) + +from backfill_skill_bundles import ( # noqa: E402 + is_legacy_key, + migrate_resources, + scan_skills, + write_projection, +) + +REGION = "us-east-1" +TABLE = "test-app-roles" +BUCKET = "test-skill-resources" + +LEGACY_HASH = "1b6af35536890b2d21fbe1be58b2155d869f20a73acec0d16afeee71dda3fd92" +LEGACY_KEY = f"skills/web_research/{LEGACY_HASH}" +STANDARD_KEY = "skills/web_research/references/extraction_tips.md" + + +@pytest.fixture() +def aws(monkeypatch): + monkeypatch.setenv("AWS_DEFAULT_REGION", REGION) + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing") + monkeypatch.setenv("AWS_SESSION_TOKEN", "testing") + with mock_aws(): + yield + + +@pytest.fixture() +def table(aws): + ddb = boto3.client("dynamodb", region_name=REGION) + ddb.create_table( + TableName=TABLE, + KeySchema=[ + {"AttributeName": "PK", "KeyType": "HASH"}, + {"AttributeName": "SK", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "PK", "AttributeType": "S"}, + {"AttributeName": "SK", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + return boto3.resource("dynamodb", region_name=REGION).Table(TABLE) + + +@pytest.fixture() +def s3(aws): + client = boto3.client("s3", region_name=REGION) + client.create_bucket(Bucket=BUCKET) + return client + + +def _legacy_row(): + return { + "PK": "SKILL#web_research", + "SK": "METADATA", + "skillId": "web_research", + "displayName": "Web Research Assistant", + "description": "Research a topic and produce citable notes.", + "instructions": "# Web Research Assistant\n\nFetch, extract, cite.", + "resources": [ + { + "filename": "extraction_tips.md", + "contentHash": LEGACY_HASH, + "size": 824, + "contentType": "text/markdown", + "s3Key": LEGACY_KEY, + # NB: no `kind` — v1 rows predate the field. + } + ], + } + + +# ============================================================================= +# is_legacy_key +# ============================================================================= + + +def test_content_hash_keys_are_legacy(): + assert is_legacy_key(LEGACY_KEY, "web_research") is True + + +@pytest.mark.parametrize( + "key", + [ + "skills/web_research/references/extraction_tips.md", + "skills/web_research/scripts/build.py", + "skills/web_research/assets/logo.png", + ], +) +def test_standard_layout_keys_are_not_legacy(key): + assert is_legacy_key(key, "web_research") is False + + +def test_unrecognized_prefix_is_treated_as_legacy(): + """A key from another skill's prefix gets normalized rather than trusted.""" + assert is_legacy_key("skills/other/refs/x.md", "web_research") is True + + +# ============================================================================= +# migrate_resources +# ============================================================================= + + +def test_dry_run_touches_nothing(s3): + s3.put_object(Bucket=BUCKET, Key=LEGACY_KEY, Body=b"# Tips") + + migrated, moved = migrate_resources( + s3, BUCKET, "web_research", _legacy_row()["resources"], + apply=False, delete_legacy=False, + ) + + assert moved == 1 + assert migrated[0]["s3Key"] == STANDARD_KEY # what *would* be written + # ...but S3 is untouched. + listing = s3.list_objects_v2(Bucket=BUCKET).get("Contents", []) + assert [o["Key"] for o in listing] == [LEGACY_KEY] + + +def test_apply_copies_into_the_standard_layout(s3): + s3.put_object(Bucket=BUCKET, Key=LEGACY_KEY, Body=b"# Tips") + + migrated, moved = migrate_resources( + s3, BUCKET, "web_research", _legacy_row()["resources"], + apply=True, delete_legacy=False, + ) + + assert moved == 1 + assert migrated[0]["s3Key"] == STANDARD_KEY + assert migrated[0]["kind"] == "reference" + assert s3.get_object(Bucket=BUCKET, Key=STANDARD_KEY)["Body"].read() == b"# Tips" + # Legacy object survives by default — the copy is non-destructive. + assert s3.head_object(Bucket=BUCKET, Key=LEGACY_KEY) + + +def test_delete_legacy_removes_the_old_object(s3): + s3.put_object(Bucket=BUCKET, Key=LEGACY_KEY, Body=b"# Tips") + + migrate_resources( + s3, BUCKET, "web_research", _legacy_row()["resources"], + apply=True, delete_legacy=True, + ) + + assert s3.get_object(Bucket=BUCKET, Key=STANDARD_KEY)["Body"].read() == b"# Tips" + with pytest.raises(s3.exceptions.ClientError): + s3.head_object(Bucket=BUCKET, Key=LEGACY_KEY) + + +def test_missing_legacy_object_leaves_the_manifest_alone(s3): + """Rewriting a key to bytes that don't exist would be a lie.""" + migrated, moved = migrate_resources( + s3, BUCKET, "web_research", _legacy_row()["resources"], + apply=True, delete_legacy=False, + ) + + assert moved == 0 + assert migrated[0]["s3Key"] == LEGACY_KEY + + +def test_is_idempotent(s3): + s3.put_object(Bucket=BUCKET, Key=LEGACY_KEY, Body=b"# Tips") + + once, _ = migrate_resources( + s3, BUCKET, "web_research", _legacy_row()["resources"], + apply=True, delete_legacy=True, + ) + twice, moved_again = migrate_resources( + s3, BUCKET, "web_research", once, apply=True, delete_legacy=True, + ) + + assert moved_again == 0 + assert twice == once + + +# ============================================================================= +# write_projection +# ============================================================================= + + +def test_projection_is_a_valid_bundle_head(s3): + write_projection(s3, BUCKET, _legacy_row(), apply=True) + + body = s3.get_object(Bucket=BUCKET, Key="skills/web_research/SKILL.md")[ + "Body" + ].read().decode() + + assert body.startswith("---") + assert "name: web-research" in body + assert "Research a topic and produce citable notes." in body + assert "Fetch, extract, cite." in body + + +def test_projection_carries_frontmatter_passthrough(s3): + row = _legacy_row() + row["allowedTools"] = ["fetch_url_content"] + row["skillMetadata"] = {"license": "MIT"} + + write_projection(s3, BUCKET, row, apply=True) + body = s3.get_object(Bucket=BUCKET, Key="skills/web_research/SKILL.md")[ + "Body" + ].read().decode() + + assert "fetch_url_content" in body + assert "license: MIT" in body + + +def test_projection_dry_run_writes_nothing(s3): + write_projection(s3, BUCKET, _legacy_row(), apply=False) + + assert s3.list_objects_v2(Bucket=BUCKET).get("Contents", []) == [] + + +# ============================================================================= +# scan_skills +# ============================================================================= + + +def test_scan_can_target_one_skill(table): + table.put_item(Item=_legacy_row()) + table.put_item( + Item={ + "PK": "SKILL#other", + "SK": "METADATA", + "skillId": "other", + "description": "d", + "instructions": "i", + } + ) + + assert [r["skillId"] for r in scan_skills(table, "web_research")] == ["web_research"] + assert len(scan_skills(table, None)) == 2 + + +def test_scan_ignores_non_skill_rows(table): + table.put_item(Item=_legacy_row()) + table.put_item(Item={"PK": "ROLE#admin", "SK": "METADATA", "roleId": "admin"}) + + assert [r["skillId"] for r in scan_skills(table, None)] == ["web_research"] + + +def test_scan_returns_empty_for_a_missing_skill(table): + assert scan_skills(table, "nope") == [] diff --git a/backend/tests/test_seed_system_admin_jwt.py b/backend/tests/test_seed_system_admin_jwt.py index 94f5319b3..aece22925 100644 --- a/backend/tests/test_seed_system_admin_jwt.py +++ b/backend/tests/test_seed_system_admin_jwt.py @@ -131,7 +131,7 @@ def test_creates_default_tools(self, dynamodb_table): """Creates the default tool entries.""" result = seed_default_tools(TABLE_NAME, REGION) - assert result.created == 7 + assert result.created == 6 assert result.failed == 0 # Verify fetch_url_content @@ -187,7 +187,7 @@ def test_creates_default_tools(self, dynamodb_table): ) item = resp["Item"] assert item["toolId"] == "create_artifact" - assert item["displayName"] == "Create Artifact" + assert item["displayName"] == "Artifacts" assert item["category"] == "document" assert item["protocol"] == "local" assert item["enabledByDefault"] is True @@ -195,17 +195,12 @@ def test_creates_default_tools(self, dynamodb_table): assert item["GSI1PK"] == "CATEGORY#document" assert item["GSI1SK"] == "TOOL#create_artifact" - # Verify update_artifact + # update_artifact is no longer its own catalog row: create_artifact is + # the single gate key that provisions the whole artifact toolset. resp = dynamodb_table.get_item( Key={"PK": "TOOL#update_artifact", "SK": "METADATA"} ) - item = resp["Item"] - assert item["toolId"] == "update_artifact" - assert item["displayName"] == "Update Artifact" - assert item["category"] == "document" - assert item["protocol"] == "local" - assert item["enabledByDefault"] is True - assert item["isPublic"] is True + assert "Item" not in resp # Verify create_word_document (single toggle for the whole Word toolset) resp = dynamodb_table.get_item( @@ -227,7 +222,7 @@ def test_skips_existing_tools(self, dynamodb_table): result = seed_default_tools(TABLE_NAME, REGION) - assert result.skipped == 7 + assert result.skipped == 6 assert result.created == 0 def test_partial_skip(self, dynamodb_table): @@ -241,7 +236,7 @@ def test_partial_skip(self, dynamodb_table): result = seed_default_tools(TABLE_NAME, REGION) - assert result.created == 6 + assert result.created == 5 assert result.skipped == 1 @@ -256,13 +251,13 @@ def test_creates_skill_and_grants_to_default(self, dynamodb_table, monkeypatch): assert result.created == 1 assert result.failed == 0 - # SKILL# record — instructions + bound LOCAL tool + active. + # SKILL# record — instructions (knowledge bundle) + active. item = dynamodb_table.get_item( Key={"PK": f"SKILL#{EXAMPLE_SKILL_ID}", "SK": "METADATA"} )["Item"] assert item["skillId"] == EXAMPLE_SKILL_ID assert item["status"] == "active" - assert item["boundToolIds"] == ["fetch_url_content"] + assert "boundToolIds" not in item assert item["instructions"] assert item["GSI4PK"] == "OWNER#system" # No bucket configured → seeded without reference bytes. @@ -319,7 +314,43 @@ def test_uploads_reference_file_to_s3_when_bucket_set( assert len(item["resources"]) == 1 ref = item["resources"][0] assert ref["filename"] == "extraction_tips.md" - assert ref["s3Key"].startswith(f"skills/{EXAMPLE_SKILL_ID}/") - # Bytes really landed in S3 at the content-addressed key. + # Standard agentskills.io bundle layout, not the v1 content-hash key — + # a seeded prefix must be a portable bundle on day one (Skills v2). + assert ref["s3Key"] == f"skills/{EXAMPLE_SKILL_ID}/references/extraction_tips.md" + assert ref["kind"] == "reference" body = s3.get_object(Bucket=bucket, Key=ref["s3Key"])["Body"].read() assert b"Extraction Tips" in body + + def test_writes_the_skill_md_projection(self, dynamodb_table, monkeypatch): + """The seeded prefix must be a valid bundle, not just loose bytes. + + Without SKILL.md the prefix cannot be handed to a managed Harness + (``{"s3": {"uri": ...}}``) or exported as-is. + """ + bucket = "test-skill-resources" + s3 = boto3.client("s3", region_name=REGION) + s3.create_bucket(Bucket=bucket) + monkeypatch.setenv("S3_SKILL_RESOURCES_BUCKET_NAME", bucket) + seed_default_role(TABLE_NAME, REGION) + + seed_example_skills(TABLE_NAME, REGION) + + body = s3.get_object( + Bucket=bucket, Key=f"skills/{EXAMPLE_SKILL_ID}/SKILL.md" + )["Body"].read().decode() + + assert body.startswith("---") + assert "name: web-research" in body + assert "Fetch web pages and turn them into accurate, citable notes." in body + assert "# Web Research Assistant" in body + + def test_seeded_instructions_name_no_retired_v1_tools(self): + """The v1 disclosure meta-tools were deleted in Skills v2 PR-1/PR-2. + + Seed prose naming them would tell the model to call tools that no + longer exist — the exact drift that stranded dev's row. + """ + from seed_bootstrap_data import EXAMPLE_SKILL_INSTRUCTIONS + + assert "skill_executor" not in EXAMPLE_SKILL_INSTRUCTIONS + assert "skill_dispatcher" not in EXAMPLE_SKILL_INSTRUCTIONS diff --git a/backend/uv.lock b/backend/uv.lock index 1244c9c81..1c575a2fc 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ [[package]] name = "agentcore-stack" -version = "1.7.1" +version = "1.8.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, diff --git a/docs/specs/admin-skills-rbac-tool-binding.md b/docs/specs/admin-skills-rbac-tool-binding.md index 7b32f6356..d1f4bdbbc 100644 --- a/docs/specs/admin-skills-rbac-tool-binding.md +++ b/docs/specs/admin-skills-rbac-tool-binding.md @@ -1,5 +1,11 @@ # Admin-Managed Skills with RBAC + Tool Binding +> **Tool-binding portions SUPERSEDED (2026-07-16) by +> `skills-as-agent-primitive.md`.** Skills v2 PR-1 removed `bound_tool_ids`, the +> MCP tool-folding machinery, and the hook shims; skills are now pure knowledge +> bundles that bind no tools. The RBAC/admin-catalog/reference-file portions of +> this spec remain accurate. Retained for build history. + **Status:** Design / Plan — **re-scoped 2026-06-09** **Author:** (drafted with Claude) **Date:** 2026-06-08 (rev. 2026-06-09) diff --git a/docs/specs/agent-directory.md b/docs/specs/agent-directory.md new file mode 100644 index 000000000..8ae8ae039 --- /dev/null +++ b/docs/specs/agent-directory.md @@ -0,0 +1,316 @@ +# Agent Directory — a browse-and-discover surface for published Agents + +**Status:** Draft / proposal (2026-07-18) +**Author:** Phil Merrell (drafted with Claude) +**Targets branch:** `develop` +**Depends on:** [`agent-designer.md`](agent-designer.md) Phases 1–2 (the Agent record + the +bindable catalog) — shipped. +**Resolves:** the "per-invoker capability preview" open question in +[`agent-designer.md`](agent-designer.md) (D4 below). +**Related:** agentic-platform primitives F6b Registry +([`agentic-platform-primitives.md`](agentic-platform-primitives.md)); Skills v2 invoke-through +sharing ([`skills-as-agent-primitive.md`](skills-as-agent-primitive.md) §6/D7); assistant sharing / +collaborative editing (issue #113). + +## Summary + +Give users a **directory** — a browse page and a detail page — where they discover Agents other +people have published, understand what an Agent does and what it can reach, see whether it will +actually run *for them*, and start a conversation with it. + +The Agent Designer answered *how do I build one*. Nothing answers *how do I find one someone else +built*. Today the only discovery affordance in the product is a "Shared with me" grouping inside the +list page ([`assistant-list.component.html:26`](../../frontend/ai.client/src/app/assistants/components/assistant-list.component.html)), +which requires the author to already know your email address. That makes every Agent a private +artifact and every good Agent a thing you hear about by word of mouth — the opposite of the +dogfooding flywheel the primitives epic is built around (`agentic-platform-primitives.md` §5). + +This spec adds **no new primitive**. It is a surface over the Agent record that already exists. + +--- + +## D1 — One noun: Agent. We do not introduce "Plugin" + +Both Claude and ChatGPT ship a three-layer stack: a **capability** layer (skills, connectors, MCP +apps), a **bundle** layer that packages capabilities for distribution (both now call this a +"plugin"), and a **persona** layer you converse with (Projects, Custom GPTs). They need the bundle +layer because their capability layer is installed *into a workspace*, separately from any persona. + +**Our Agent already is the bundle.** `bindings[]` — `knowledge_base | tool | skill | memory_space` +over one governed `modelConfig` — is precisely what a vendor plugin packages, and it is already +attached to the persona rather than floating beside it. Introducing a second "Plugin" noun would ask +users to learn a distinction our data model does not make, and would fragment one binding surface +into two. + +The directory is therefore a **view over Agents**, not a store of a new kind of thing. The vendor +plugin-detail *layout* is worth copying almost field-for-field; the vendor plugin *entity* is not: + +| Vendor plugin-detail field | Our Agent field | Status | +|---|---|---| +| Icon, name, publisher | `emoji`, `name`, `ownerName` | exists | +| Description | `description` | exists | +| Example prompts | `starters[]` | exists | +| "What it can access" | `bindings[]` | exists | +| Category filters | `tags[]` + `listing.category` | tags exist | +| Install button | "Start chat" / pin (D6) | new | + +Every field but the last two already exists on the record. This is a read-view build, not a +data-model build. + +**When "Plugin" becomes a real question:** if users start assembling the *same* tool+skill+KB +combination onto three or more Agents, a reusable binding bundle earns its own noun. That is exactly +the "Skill vs. Agent reconciliation" question parked in `agent-designer.md`, and `bindings[].kind` +is an open enum specifically so the answer stays additive. Do not pre-build it. The directory will +produce the evidence — a bundle that gets re-bound repeatedly is visible in the listing data. + +## D2 — Publication is a sparse index, and the read path mostly exists + +The `rag-assistants` table already carries a `VisibilityStatusIndex` (GSI2, +`GSI2_PK = VISIBILITY#{visibility}`), defined in +[`rag-data-construct.ts:142`](../../infrastructure/lib/constructs/rag/rag-data-construct.ts) and +written on every put and update ([`service.py:174-177`](../../backend/src/apis/shared/assistants/service.py)). +`PUBLIC` **access** also still resolves — a non-owner opening a PUBLIC agent gets `"viewer"` +([`service.py:265`](../../backend/src/apis/shared/assistants/service.py)). + +What was removed is only the *listing*: `list_user_assistants` force-sets `include_public = False` +with the comment *"the feature is removed"* ([`service.py:672`](../../backend/src/apis/shared/assistants/service.py)), +disabled in `ad4437e9` when explicit email sharing superseded a public index. So the plumbing is +live and populated; the query was switched off, not the infrastructure. + +We do **not** simply re-enable that query, for two reasons: `VISIBILITY#PUBLIC` is a single hot +partition holding every public agent forever, and it cannot be filtered by category without a scan. +Instead add a **sparse `AgentDirectoryIndex` (GSI5), written only when an agent is listed**: + +``` +GSI5_PK = LISTED#{category} # sparse — absent unless listing.listed +GSI5_SK = CREATED#{created_at} # stable pagination key +``` + +GSI5 is the next free slot on `rag-assistants`: GSI_/GSI2/GSI3/GSI4 are `OwnerStatusIndex`, +`VisibilityStatusIndex`, `SharedWithIndex`, and `DueSyncIndex` respectively. `DueSyncIndex` is the +direct precedent — a sparse index on the *same table* whose keys exist only on `SYNCPOL#` items in +the active state, so the dispatcher's query physically cannot see paused policies +([`rag-data-construct.ts:154-163`](../../infrastructure/lib/constructs/rag/rag-data-construct.ts)). +Same trick here: an unlisted agent has no GSI5 key, so the directory query cannot return it — +unpublication is enforced by physics, not by a filter someone can forget. Category-partitioning also +gives the browse page's chips a direct query instead of a filtered scan. + +**Ranking caveat, stated plainly:** `GSI5_SK` is `created_at`, so the directory is **newest-first**. +Sorting by popularity needs a mutable sort key (a hot-item rewrite on every use) or a periodic +recompute; v1 does neither. `usage_count` is returned and rendered as a signal, and "featured" +(D7) is the manual override. A real popularity ranking is deferred to F6b, not silently approximated. + +## D3 — `listed` is separate from `visibility`; existing PUBLIC agents are not auto-listed + +It is tempting to make `visibility == 'PUBLIC'` mean "in the directory." That is wrong, and it is +the one decision here with a data-safety consequence. + +`PUBLIC` today means *anyone with the link may use this* — the share dialog explicitly surfaces a +shareable URL for PUBLIC assistants +([`share-assistant-dialog.component.ts:39`](../../frontend/ai.client/src/app/assistants/components/share-assistant-dialog.component.ts)). +Unlisted-but-linkable is a legitimate, already-supported mode, and users have been choosing `PUBLIC` +under those semantics for as long as the option has existed. If listing were derived from +visibility, shipping this spec would **retroactively publish every existing PUBLIC agent to the +whole institution**, with no author consent and no review. + +So: `visibility` stays the **access gate**; a separate `listing` block is the **publication state**. +Backfill is `listed: false` for every existing record — publication becomes an explicit forward act +by each author, never an inference from past configuration. + +This also gives publication somewhere to put the metadata visibility cannot carry: category, +publication timestamp, featured flag, takedown state. + +Note the live gap this closes: `PUBLIC` is already an option in the Agent form +([`agent-form.page.html:104`](../../frontend/ai.client/src/app/agents/agent-form/agent-form.page.html)) +and nothing in the product surfaces the result. Today it is a dead-end dropdown. + +## D4 — The detail page previews runnability; it does not promise it + +`agent-designer.md` D5 sets a **block-on-missing** policy: every gated capability re-resolves +against the *invoking* user at run time, and a missing one blocks with a message. That rule is +right, and a directory strains it. Share-by-email means the author knows who the sharee is; a +directory means anyone can open anything. Block-on-missing plus open browsing equals a catalog where +an unknown share of entries fail on first message. + +The fix is not to weaken D5 — it is to **tell the user before they start**. The detail page +resolves the Agent's `modelConfig` + `bindings` against the viewer's own +`GET /agents/bindable` results (already RBAC-filtered per D4 of the Designer spec) and renders one +of three states: + +- **Ready** — every binding resolves for you. +- **Runs with limits** — an optional binding is unavailable; the agent still starts. +- **Not available to you** — a required binding (model, restricted tool, memory space) does not + resolve; name what is missing and who to ask. + +This is the "per-invoker capability preview" that `agent-designer.md` parks as an open question. A +directory forces it: it is the difference between a catalog and a minefield. The computation is a +set-difference against an endpoint that already exists — no new access logic (Designer D4 stands: we +compose the five per-primitive checks, we do not invent a sixth). + +## D5 — Publishing amplifies invoke-through, so publishing must disclose it + +Skills v2 D7 makes skill sharing **invoke-through**: a `skill` binding resolves when +`skill.owner_id == agent.owner_id` and the invoker has access to the Agent — the owner-match clause +is what prevents chain-sharing +([`access.py`](../../backend/src/apis/shared/skills/access.py) module docstring). + +Under email sharing, "the invoker has access" is a list the author typed. Listing an Agent changes +that population to *everyone*, which means **publishing an Agent effectively publishes the contents +of every skill its author wrote and bound to it**. That is a correct consequence of D7 and not a bug +— but it must be an *informed* act, not a side effect of a toggle. + +Requirements: + +1. The publish dialog **enumerates** what publication exposes: "N skills you authored will be + readable by anyone who runs this agent," listed by name. +2. `memory_space` bindings **block publication.** A memory space is personal data; D5's re-resolve + already denies a sharee who lacks the space, so a published agent bound to one is a guaranteed + "Not available to you" for every viewer *and* an invitation to leak by future policy drift. + Refuse at publish time with a clear message rather than shipping a listing that cannot run. +3. Unlisting is immediate and revokes nothing retroactively — sessions already started continue. + Say so in the UI; do not imply unlisting is a recall. + +## D6 — No install, no fork. "Add to my agents" is a pin + +Vendors need an install step because their bundle must be added to a workspace before it is usable. +Ours are addressable by id and already run for anyone with access — there is nothing to install. + +The install button therefore degrades to a **pin**: the Agent appears in your `/agents` list under a +"Pinned" group, and disappears when unpinned. No copy is made. + +**Explicitly not copy-on-install.** Forking a published Agent into the user's own store would +duplicate the record (version drift the moment the author updates it), re-owner the bindings (which +breaks invoke-through in the confusing direction — the fork's new owner does not own the skills, so +they silently stop resolving), and multiply takedown surface. If users want a variant, they can +build one in the Designer; that is what the Designer is for. + +--- + +## Data model + +Additive to the existing Agent record (`apis/shared/assistants/models.py`) — no new table, per +Designer D2: + +``` +Agent { + ... # unchanged + listing?: AgentListing # absent = not published (the backfill default, D3) +} + +AgentListing { + listed: bool # drives the sparse GSI5 write (D2) + category: str # directory partition; from a curated set (open question) + listedAt: str # ISO 8601 + listedBy: str # ownerId at publication time + featured: bool = false # admin-set (D7); ordering override only + takedown?: { by, at, reason } # admin unlist; blocks re-publish until cleared +} +``` + +`pinned` is **not** on the Agent — it is per-user. Store pins on the user record / a small +`AGENT_PIN#{userId}` item; a pin is one user's view state, never a mutation of someone else's agent. + +**Directory read shape.** `AgentResponse` carries `instructions`, and the directory must not. +A published Agent's system prompt is the author's work; browsing a catalog should not dump it. Add +an `AgentListingResponse` — `agentId, name, description, emoji, ownerName, tags, category, +usageCount, featured, bindingSummary[]` — where `bindingSummary` is `{kind, label}` per binding +(what it can reach, not the ids). No `instructions`, no `ref` values. + +⚠️ **Behavior change to decide (open question):** `GET /agents/{id}` today returns full +`AgentResponse` *including* `instructions` to a PUBLIC viewer. Once agents are broadly listed, that +is a much larger exposure than it was under link-sharing. Recommend gating `instructions` to +`permission in ("owner", "editor")` on the detail read. + +## APIs / contracts + +All SPA-facing, `Depends(get_current_user_from_session)` per the CLAUDE.md app-api rule, all behind +the D8 gate. + +| Route | Purpose | +|---|---| +| `GET /agents/directory?category=&cursor=` | Browse. Sparse-GSI query → `AgentListingResponse[]` + cursor. Newest-first (D2). | +| `GET /agents/directory/categories` | Category chips + counts for the browse header. | +| `GET /agents/{id}` | Detail. Existing route; add `listing` + the `instructions` gate. | +| `GET /agents/{id}/runnability` | Per-invoker capability preview (D4) → `{state, missing[]}`. | +| `POST /agents/{id}/listing` | Publish (owner only). Runs the D5 disclosure checks; 400 on a `memory_space` binding. | +| `DELETE /agents/{id}/listing` | Unlist (owner or admin). | +| `POST /agents/{id}/pin` / `DELETE` | Add to / remove from my agents (D6). | +| `GET /admin/agents/listings` | Admin curation queue (D7). Under `/admin/` per the route convention. | +| `PATCH /admin/agents/listings/{id}` | Feature / unfeature / take down. | + +`GET /agents/{id}/runnability` composes `bindable_catalog.list_bindable` per kind and diffs against +the agent's bindings — it introduces no new access service (Designer D4). + +## D7 — Curation is admin-light: feature and take down, no pre-publish review + +A review queue gates publication on staff attention, which for an internal platform means +publication effectively stops. Publish is **self-service and immediate**; admins get an ordering lever +(`featured`) and a remedy (`takedown`). The population is authenticated institutional users under +existing RBAC — the governance posture the platform already inherits +(`feedback_governance_via_identity_claims`), not an open marketplace. + +Takedown sets `listing.takedown` and clears the sparse GSI key, so the agent stays reachable by +direct link (visibility is unchanged — D3) but leaves the directory. + +## D8 — Ship gated, GA by grant + +House cross-cutting pattern (`agentic-platform-primitives.md` §4): kill switch +`AGENT_DIRECTORY_ENABLED` (**default on**, `=false` disables — per the feature-flag convention) plus +an RBAC capability `agent-directory` that 404s the routes for ungranted roles, mirroring the +`skills` gate from Skills v2 PR-5. Ships to prod scoped; GA is one role grant, no redeploy. + +## Frontend + +New route `/directory`, plus `/directory/:agentId`. + +**Browse** — search box, category chips, a featured row, then a card grid: emoji, name, owner, +one-line description, `usageCount`, and a runnability dot. Cursor pagination. Search in v1 is a +substring match over `name`/`description`/`tags` **within the loaded page** — a real index is F6b. +Label it honestly ("searching loaded results") rather than implying full-corpus search. + +**Detail** — the vendor layout mapped to our fields (D1): header (emoji, name, owner, category, +Start chat, pin), description, **"Try asking"** rendered from `starters[]`, **"What this agent +uses"** rendered from `bindingSummary[]` grouped by kind, and the runnability banner (D4) at the top +when the state is anything but Ready. + +Reuse the `redesign-tokens` list-page idiom (`rounded-2xl` / `text-sm`, flat, no heavy section +cards), `@angular/cdk/dialog` for the publish dialog, signal-based state, and the existing tooltip +requirement on icon-only buttons. + +## Phasing + +``` +Phase 0 This spec +Phase 1 listing block + sparse GSI5 + publish/unlist API + backfill listed=false (D2/D3) +Phase 2 GET /agents/directory + the browse page ← the surface +Phase 3 Detail page + GET /{id}/runnability + the instructions gate (D4) +Phase 4 Pin / "Add to my agents" (D6) +Phase 5 Admin curation: feature + takedown (D7) +Later Ranking + full-corpus search via the Registry catalog (F6b) +``` + +Phases 2 and 3 are the user-visible payoff and could ship together; 4 and 5 are independent and +parallelizable. + +## Non-goals (v1) + +- **No "Plugin" primitive** (D1). One noun. +- **No install / fork / copy** (D6). Pin only. +- **No pre-publish review workflow** (D7). Self-service publish, admin takedown. +- **No ratings, reviews, or comments.** Social proof is `usageCount` + `featured`. +- **No popularity ranking** (D2). Newest-first, honestly labeled. +- **No cross-tenant or external publishing.** The directory is institution-scoped. +- **No agent versioning or changelogs.** A published Agent is a live pointer; updates are immediate. + +## Open questions + +- **Instructions visibility.** Should a non-owner viewing a listed Agent see its `instructions`? + Recommended: no (gate to owner/editor) — but this changes today's PUBLIC-viewer behavior and needs + a call. +- **Category taxonomy.** A curated fixed set (clean chips, needs governance) or promoted `tags[]` + (zero admin, messy)? Recommend a small fixed set for v1 with tags as free-text underneath. +- **Pin vs. usage in ranking.** Does a pin count toward `usageCount`, or is it a separate signal? + They mean different things (intent vs. traffic) and conflating them makes both useless. +- **Quota attribution.** Confirm the invoker pays for a directory-launched run, not the author — + the existing per-user model implies yes, but a published Agent is the first case where a stranger's + usage is attributable to someone else's configuration. diff --git a/docs/specs/skill-creator.md b/docs/specs/skill-creator.md new file mode 100644 index 000000000..bc95b7d21 --- /dev/null +++ b/docs/specs/skill-creator.md @@ -0,0 +1,165 @@ +# Skill Creator — guided skill authoring, and the agent write path it needs + +**Status:** Draft / proposal (2026-07-19) +**Author:** Phil Merrell (drafted with Claude) +**Targets branch:** `develop` +**Depends on:** [`skills-as-agent-primitive.md`](skills-as-agent-primitive.md) PR-1–PR-5 — merged. [`skill-bundle-import.md`](skill-bundle-import.md) PR-1 (nested paths) and its server-side `SKILL.md` parser — **draft, branch-only**; see D7. +**Related:** [`user-markdown-memory.md`](user-markdown-memory.md) §W1/B3 (`memory_write` — the sole precedent for an agent tool that writes user-owned platform content); [`agentic-platform-primitives.md`](agentic-platform-primitives.md) F1 (headless entrypoint — the prerequisite for the eval half, see D8); [`agent-designer.md`](agent-designer.md) (the authoring surface for Agents, which this deliberately does not extend); [`admin-skills-rbac-tool-binding.md`](admin-skills-rbac-tool-binding.md) §0.4 (the existing drag-and-drop import-prefill flow, which PR-1 rides). +**Prior art:** [Anthropic's open-source `skill-creator`](https://github.com/anthropics/skills/blob/main/skills/skill-creator/SKILL.md). + +## Summary + +Users can author skills today — `/skills/mine` CRUD, bundle upload, and the My Skills form all shipped in Skills v2 PR-3. What they cannot do is author a *good* one without already knowing what makes a skill trigger reliably and stay lean. Anthropic ships a `skill-creator` skill that closes exactly that gap, and it is worth adopting. + +But it is two things welded together, and they have very different fates here: + +| Upstream half | Fate in our stack | +|---|---| +| **Authoring methodology** — interview the user, write a description that triggers, structure instructions for progressive disclosure | Ports cleanly. Pure content. Works today with **zero new code**. | +| **Eval harness** — `evals/evals.json`, parallel with-skill vs. baseline runs, `scripts/aggregate_benchmark`, `claude -p` optimization loop, HTML review viewer | Does not port. Assumes a filesystem, subagent spawning, and script execution. Our `scripts/` are **accept-and-inert by design** ([Skills v2 D3](skills-as-agent-primitive.md)), and there is no subagent facility in `ChatAgent`. | + +So this spec does two things. It ships the methodology as a platform skill immediately (PR-1, no code), and it specifies the one genuinely missing primitive that makes the experience feel like authoring rather than dictation: **an agent write path into the user's own skills** (PR-2–PR-4). The eval half is scoped out and hung off F1 (D8). + +## Why now + +Three things just became true at once: + +1. Skills v2 PR-5 flipped `SKILLS_ENABLED` on by default and gated the surfaces behind the `skills` capability. GA is now a grant, not a deploy. +2. My Skills works end-to-end but its sidenav entry was just removed (PR #690) pending a navigation decision. A skill-creator is a plausible *answer* to that question — you reach your skills through the thing that helps you make them — which makes this spec partly a discovery-path proposal. +3. `memory_write` established the pattern for an agent tool that writes user-owned content and re-checks permission per call. There is now a house precedent to copy instead of invent. + +## D1 — The skill creator is a skill, not a feature + +It ships as an admin-catalog skill (`owner_id="system"`, `visibility=ADMIN`), granted to roles like any other. No new page, no new route, no new nav entry. + +This is the whole point of the Skills v2 primitive and the cheapest possible test of whether the methodology lands. It also means the rollout controls already exist: grant it to a pilot role, widen to `default`, or revoke — all without a deploy. The rejected alternative — a bespoke "create a skill" wizard in the SPA — would duplicate the My Skills form, couple authoring guidance to a release cycle, and teach us nothing about whether skills are a good delivery vehicle for procedural knowledge. + +## D2 — We adapt the content. We do not vendor upstream's SKILL.md + +Upstream's instructions tell the agent to spawn parallel subagents, run `scripts/run_loop`, write `iteration-N/` directories, and open an HTML viewer. Dropped in verbatim, our agent will repeatedly attempt operations that are impossible in this runtime, narrate steps that never happen, and produce a first-run experience that reads as broken. + +Keep: the intent-capture interview, the description-writing guidance (including the deliberate "pushiness" to combat undertriggering), progressive-disclosure discipline, the "keep prompts lean, explain reasoning" principle, and the instinct to bundle repeated helper content as reference files. + +Cut: everything downstream of `evals/`, all script execution, all subagent orchestration, the `claude -p` loop, and the viewer. + +Add (ours, not upstream's): our `SKILL.md` frontmatter dialect as `generate_skill_md` emits it, the fact that `allowed-tools` is **advisory only and never enforced** ([Skills v2 D4](skills-as-agent-primitive.md)), the fact that `scripts/` are stored but never run, our caps (50 skills/user, 50 files/skill, 1 MiB/file), and the reload rule from D6. + +Attribution and license terms for the derived content are an open question (§Open questions). + +## D3 — PR-1 ships with zero new code, by routing through the form + +The agent drafts a complete `SKILL.md` — frontmatter and body — in the conversation. The user copies it into the My Skills form, which already parses frontmatter into fields and body into instructions (`my-skill-form.page.ts`; the same import-prefill flow described in [`admin-skills-rbac-tool-binding.md`](admin-skills-rbac-tool-binding.md) §0.4). + +That is a real, shippable v0. It tests the methodology — the expensive-to-validate part — before we build any tooling for it. If guided authoring turns out not to produce skills people keep, we learn that for the cost of writing one markdown file. + +It is also honest about its seam: the handoff is a copy-paste, and the skill's instructions should say so plainly rather than pretending otherwise. + +## D4 — The write path is agent tools executing as the invoking user + +This is the missing primitive. Today **no agent tool can write a skill**: nothing but `read_skill_file` touches the skill-resources bucket and that is `get`-only, and no tool in the codebase calls back into app-api over HTTP. + +Nor should it. The inference-api boundary means only `/invocations` and `/ping` are reachable through the AgentCore Runtime gateway (restated as a load-bearing invariant in [`invocation-path-refactor.md`](invocation-path-refactor.md) — also branch-only at time of writing), and every existing resource-writing tool — `memory_write`, `create_artifact`, the Word document tools — goes direct to the service layer via boto3. The skill-authoring tools follow that pattern. + +Permission model, mirroring `memory_write` exactly: **the tool executes as the invoking user, and the service re-checks ownership on every call.** For skills, ownership *is* the grant (`resolve_owned_skill_ids`), and `require_owned` already collapses "not yours" into a 404. The tool surface is a UX affordance, not the security boundary. + +## D5 — Tools take inline text; no new HTTP endpoint is required + +The only bundle-file ingress today is `POST /skills/mine/{id}/resources`, **multipart binary upload only**. An agent produces text, not multipart, and — per D4 — does not speak HTTP to app-api at all. + +So the write tools call a service-layer method that accepts inline `content: str`. No new route. The SPA keeps its multipart upload path unchanged; both converge on the same `SkillCatalogService` file machinery that the user and admin tiers already share, so bundles written by the agent are byte-identical in layout to bundles uploaded by hand. + +Two consequences that are easy to miss and expensive to rediscover: + +- **Go through `SkillUserService`, not raw boto3.** You get the `SKILL.md` S3 write-through projection, `_gc_orphaned` cleanup, and — the sharp edge — the `freshness.invalidate()` call. Bypass it and the 10-second TTL cache serves a stale catalog immediately after the agent writes. +- **Slug the name on the DB→`Skill` mapping.** Per [`skills-v2-pr2-spike-findings.md`](skills-v2-pr2-spike-findings.md), Strands `Skill(...)` does no name validation at all, so a display name the agent invents can produce an invalid skill name that fails silently downstream. + +## D6 — A skill written mid-turn is not usable that turn + +`build_skills_runtime` runs at agent construction, so the turn's skill set is fixed before the first token. A skill the agent creates at 14:32 does not exist for the agent at 14:33 in the same conversation. + +We do not fight this. Re-constructing the agent mid-turn to pick up a just-written skill would invalidate the `skills_hash` cache key, disturb cache points that [the PR-2 spike](skills-v2-pr2-spike-findings.md) verified are preserved, and interact badly with interrupt-resume snapshot replay — a lot of risk for a nicety. + +Instead the skill-creator's instructions state it plainly and end the authoring flow with an explicit "start a new conversation to try it" step. Left unstated, this reads as a bug: the user asks the agent to use the skill it just wrote, and it has no idea what they mean. + +## D7 — Nested paths and the server-side parser belong to `skill-bundle-import`, not here + +[`skill-bundle-import.md`](skill-bundle-import.md) (draft, on `feature/skill-bundle-import-spec`) already specifies both: + +- **Nested bundle paths** (its D4 + §6.4 path validator). Our filename regex forbids separators, so every kind is a flat directory today. Upstream's skill-creator bundles `agents/grader.md` and `eval-viewer/generate_review.py` — keys we literally cannot represent. +- **A server-side `SKILL.md` parser** (its §7.4) — the inverse of `generate_skill_md`, which does not exist; parsing lives only in the SPA form. + +Both are genuine prerequisites for a richer skill-creator, and both are **already owned elsewhere**. This spec depends on them and specifies neither. If the bundle-import spec stalls, the nested-path work should move here rather than being duplicated — but that is a re-assignment to make explicitly, not by accident. + +Note the ordering consequence: PR-1 and PR-2–PR-4 do **not** need nested paths (flat `references/` is enough for a skill-creator that writes one or two reference files). Only a skill-creator that bundles its own helper tree does. + +## D8 — Evals are out of scope, and belong to the headless lane + +Comparative eval — run a prompt with and without the skill, grade both, aggregate — needs parallel agent runs and script execution. `ChatAgent` has neither, and `scripts/` are inert by a deliberate security decision we should not trade away for this. + +The right home is F1, the headless run entrypoint, whose stated unblock list in [`agentic-platform-primitives.md`](agentic-platform-primitives.md) already names "eval harnesses" — the only mention of evals anywhere in `docs/specs/`. When F1's act-as-user path is production-grade, an eval loop becomes a headless orchestration over it, not a chat-runtime feature. + +Recording the dependency and moving on is the correct call. A half-eval in the chat runtime — "here are three prompts, try them yourself" — is worse than none, because it looks like the real thing. + +## Data model + +**No schema changes.** Skills written by the agent are ordinary user-tier rows: `owner_id = `, `visibility = PRIVATE`, server-allocated `skill_id`, same `resources[]` manifest, same S3 bundle layout. That is the point — an agent-authored skill and a hand-authored one are indistinguishable at rest, so every existing surface (My Skills, the picker, invoke-through, delete) works on them with no changes. + +The caps in `SkillCatalogService` apply unchanged and become failure modes an agent can actually hit in a loop: `MAX_SKILLS_PER_USER = 50` (surfaces as 409), `MAX_RESOURCES_PER_SKILL = 50`, `MAX_RESOURCE_BYTES = 1_048_576`. The tools must return these as legible errors the model can recover from, not raw exceptions. + +## Tools + +Named against the `/skills/mine` surface so they never read as siblings of the existing `read_skill_file`, which does something different (reads reference files of skills *active in the current turn*, structurally scoped so no id can escape the turn's set). + +| Tool | Shape | Notes | +|---|---|---| +| `my_skill_list` | `() -> [{skill_id, display_name, description, status}]` | Owned only, via GSI4 | +| `my_skill_read` | `(skill_id) -> {metadata, instructions, resources[]}` | Ownership re-checked; not-yours = not-found | +| `my_skill_create` | `(display_name, description, instructions, category?, skill_metadata?) -> skill_id` | Server allocates `skill_id` with collision suffixing | +| `my_skill_update` | `(skill_id, ...partial) -> ok` | Re-projects `SKILL.md` | +| `my_skill_write_file` | `(skill_id, kind, filename, content: str) -> ok` | Inline text (D5); upsert by `(kind, filename)` | +| `my_skill_delete_file` | `(skill_id, filename) -> ok` | Scoped to one skill's bundle | + +**Deliberately absent: whole-skill delete.** User-tier delete is a hard delete plus S3 purge with no undo. That stays a UI action behind human intent. An agent that can create skills in a loop should not also be able to destroy them in one. + +## Gating + +Follow the Word-document pattern in `apis/inference_api/chat/routes.py:456` rather than the catalog: a context-bound factory built per request, capturing `user_id` by closure (the runtime does not populate `ToolContext`), gated on a single tool id in `enabled_tools` that provisions the whole toolset — `SKILL_AUTHORING_TOOL_IDS = {"my_skill_create"}`, mirroring `WORD_DOCUMENT_TOOL_IDS`. + +Two additional gates, both cheap: + +- `skills_enabled()` — no authoring tools when the kill switch is off, matching how the router unmounts. +- The `skills` capability. Note the existing asymmetry: the capability gates the app-api surfaces but deliberately **not** the runtime, so invoke-through keeps working for users without it. Authoring is a *write* into the user's own catalog and is the app-api-shaped side of that line, so it should honor the capability even though it executes in the runtime. + +## Frontend + +PR-1 needs nothing. PR-2–PR-4 need nothing strictly, but two small things make the loop feel finished: + +- A link from the conversation to the created skill (`/my-skills/{id}`), so the D6 "new conversation" step lands somewhere concrete. +- Resolving the My Skills navigation question (PR #690). Guided authoring is a plausible entry point — you reach your skills through the thing that made them — but that is a product call, not a consequence of this spec. + +## Phasing + +- **PR-1 — the skill, content only.** Author the adapted `skill-creator` SKILL.md; seed as an admin-catalog skill; grant to a pilot role. No code. Ships the methodology and the copy-paste flow (D3). +- **PR-2 — service layer.** Inline-text write method on `SkillUserService` + ownership re-check + `freshness.invalidate()`. Unit-tested against the caps. No agent surface yet. +- **PR-3 — the tools.** `my_skill_*` factories, `SKILL_AUTHORING_TOOL_IDS` gate, wired into the `extra_tools` chain. Legible cap/permission errors. +- **PR-4 — the skill, rewritten for the tools.** Replace the copy-paste handoff with direct authoring; add the D6 reload step. +- **PR-5 — GA by grant.** Widen to `default` once the pilot says the skills produced are worth keeping. + +PR-1 is independently valuable and independently revertable. If it lands and nobody uses the output, PR-2–PR-5 should not be built. + +## Non-goals (v1) + +- Eval/benchmark tooling of any kind (D8). +- Script execution. `scripts/` stay accept-and-inert. +- Nested bundle paths and server-side `SKILL.md` parsing — depended upon, owned by [`skill-bundle-import.md`](skill-bundle-import.md) (D7). +- Authoring *admin-catalog* skills via the agent. User tier only; the admin catalog is governed content. +- Authoring Agents. That is [`agent-designer.md`](agent-designer.md)'s surface, and conflating the two would put a second authoring path on the same primitive. +- Sharing/publishing flows. An agent-authored skill is `PRIVATE` like any other; sharing rides existing invoke-through. + +## Open questions + +1. **Licensing and attribution.** Upstream `skill-creator` is Anthropic's open-source work. D2 makes ours a derivative-by-methodology rather than a copy, but we should confirm the license terms and decide what attribution the shipped skill carries. +2. **Does the agent need to read the skill it is editing?** `my_skill_read` returns full instructions, which for a large skill is a meaningful chunk of context. Worth checking against [`tool-search-token-bloat-strategy.md`](tool-search-token-bloat-strategy.md) before defaulting to full-body reads. +3. **Should PR-1 be admin-catalog or a seeded user skill?** D1 says admin-catalog, but seeding a *copy* into each user's tier would let them edit the creator itself. Probably wrong — divergent copies, no upgrade path — but worth one round of thought. +4. **Prompt-injection surface.** [`skill-bundle-import.md`](skill-bundle-import.md) §6.10 is honest that a skill bundle is by construction instructions a model will follow. Agent-authored skills are self-authored so the trust story is better, but a skill written from content the agent fetched off the web is not. Does `my_skill_write_file` need provenance guidance in the skill's own instructions? +5. **Is this the My Skills entry point?** Raised under Frontend. A product call. diff --git a/docs/specs/skills-as-agent-primitive.md b/docs/specs/skills-as-agent-primitive.md new file mode 100644 index 000000000..971716cbc --- /dev/null +++ b/docs/specs/skills-as-agent-primitive.md @@ -0,0 +1,391 @@ +# Skills as an Agent Primitive (Skills v2) + +**Status:** Direction locked 2026-07-16 (product owner). Supersedes the tool-binding +sections of `admin-skills-rbac-tool-binding.md` and the mode-toggle design in +`skills-mode.md`. The v1 build history and its gotchas remain documented in those +specs; where this document conflicts with them, this document wins. + +--- + +## 1. Summary + +A **Skill** becomes a pure knowledge bundle — instructions plus supporting +reference material, packaged per the open [agentskills.io](https://agentskills.io/specification) +standard — with **no tool bindings**. Tools, model, knowledge base, memory +spaces, and skills all bind in exactly one place: the **Agent**, via the Agent +Designer. Skills additionally gain a **user-authored tier**: any user can upload +their own skills and bind them to agents they author. + +The runtime replaces our homegrown progressive-disclosure machinery +(`SkillRegistry` dispatcher/executor, MCP tool folding) with the **Strands +`AgentSkills` vended plugin**, which implements the same standard natively and +is present in our pinned `strands-agents==1.47.0`. + +### Why + +1. **One canvas.** The Agent Designer epic (`agent-designer.md`) established the + Agent as the single RBAC-governed primitive-binding surface. Skills-with- + bound-tools was a second, competing proto-canvas — the reconciliation + question that spec explicitly parked. Answer: skills are an *ingredient* on + the one canvas, not a canvas. +2. **Deletes a bug class.** Every serious skills bug in v1 came from tool + folding hiding the real tool name behind `skill_executor`: the OAuth-consent + hole (PR #477), the approval hole (PR #478), the MCP-Apps `ui_resource` + non-render, and the still-open early-mount/`ui_tool_input_partial` gap. All + of that scaffolding exists to serve `bound_tool_ids`. Cut the field and the + scaffolding — and its failure modes — go with it. +3. **Converges on the open standard.** AgentCore Harness attaches skills in the + agentskills.io format from a source union (managed catalog / git / S3 / + filesystem path). Storing our skills as standard bundles makes every skill a + portable artifact: the same bundle a user uploads to us is directly + attachable to a managed Harness (`{"s3": {"uri": ...}}`) if/when the headless + lane adopts it (`managed-harness` spike: adopt-with-boundary). +4. **Framework over custom code.** Strands ships `AgentSkills` + `Skill` + (programmatic construction, `` system-prompt injection, + one `skills` activation tool, no execution). Preference confirmed: remove + custom code in favor of framework-supported code. + +### Migration cost: none + +`SKILLS_ENABLED=false` in every environment. No production data binds tools to +skills. Existing `bound_tool_ids` values (including the `web_research` seed) are +**silently dropped** — a redesign, not a migration. + +--- + +## 2. What changes + +| Area | v1 (built, dark) | v2 (this spec) | +|---|---|---| +| Skill shape | instructions + reference files + `bound_tool_ids` | instructions + reference files (agentskills.io bundle); **no tools** | +| Tool access | skill grants/folds its bound tools | tools come **only** from Agent bindings + RBAC | +| Runtime | `SkillAgent` subclass, `skill_dispatcher`/`skill_executor` meta-tools, MCP folding | `ChatAgent` + Strands `AgentSkills` plugin; `SkillAgent` retired | +| Chat exposure | "skills mode" toggle (agent_type flip), admin chat-mode policy | mode gone; skills selected per-turn like `enabled_tools`, **opt-in by default** | +| Authorship | admin catalog only | admin catalog **+ user-uploaded** (owner-scoped) | +| `allowed-tools` frontmatter | n/a (we had real bindings) | parsed and stored as **advisory metadata only**; never enforced | +| `scripts/` in bundles | out of scope | **accept-and-inert**: stored, listed, labeled non-executable, never run | +| Sharing | n/a | **invoke-through** access via shared Agents (§6) | + +### What is deleted (PR-1) + +- `bound_tool_ids` on `SkillDefinition` and all DTOs; the admin tool-picker dialog. +- `agents/main_agent/skills/mcp_binding.py` (`FoldedMCPTool`, `resolve_mcp_bindings`, + `make_folded_tool_provider_lookup`, `make_folded_tool_approval_lookup`). +- `agents/main_agent/integrations/mcp_tool_folding.py` and the `drop_folded_tools` + calls in `FilteredMCPClient` / `UICapableMCPClient`. +- The `tool_use_provider_lookup` / `tool_use_approval_lookup` fold shims on + `OAuthConsentHook` / `MCPExternalApprovalHook`, and the `_resolve_ui_tool_name` + `skill_executor` unwrap in `stream_coordinator`. +- Skills mode: the SPA mode toggle, `preferredAgentMode`, the admin chat-mode + policy surface, and the `skill`/`chat` forcing in `_resolve_effective_agent_type`. +- (PR-2) the homegrown disclosure stack: `skill_registry.py` catalog/dispatch, + `skill_tools.py`, `skill_dispatcher`/`skill_executor`, and `SkillAgent` itself. + +### What is kept + +- `apis/shared/skills/` model/repository/resource_store/access — slimmed, not replaced. +- The `SkillOwnerIndex` GSI4 (`OWNER#`) — provisioned in v1 PR-1 for exactly the + user-authored tier. +- Admin skills RBAC (`granted_skills`, `/admin/skills/{id}/roles`) and the admin + skills page (minus the tool picker). +- The skills-mode PR-2 request plumbing, **verbatim**: `enabled_skills` on the + invocation request, `_apply_enabled_skills_filter` (RBAC ∩ selection), + `skills_hash` in the agent cache key, `enabled_skills` in the paused-turn + snapshot. This *is* the "select skills like enabled tools" mechanism. +- Agent Designer skill bindings (REPLACE semantics) — now orthogonal to tools. +- `SKILLS_ENABLED` stays the master gate until v2 is dogfooded. + +--- + +## 3. Decisions + +- **D1 — Skill ⊥ tools.** A skill may *mention* tools in its text (prose or + `allowed-tools` frontmatter); the platform never grants, mounts, or folds a + tool because a skill names it. The tool universe for a turn is exclusively + (Agent tool bindings | request `enabled_tools`) ∩ RBAC, unchanged from the + agent-designer spec. +- **D2 — agentskills.io is the storage and interchange format.** A skill is a + bundle: `SKILL.md` (YAML frontmatter `name`/`description` + optional + `allowed-tools`/`metadata`/`license`/`compatibility`; markdown body) plus + optional `references/`, `scripts/`, `assets/`. Import and export are + round-trip-faithful. +- **D3 — Strands `AgentSkills` plugin is the runtime** (pending the PR-2 spike, + §8). `ChatAgent` conditionally adds the plugin when the turn's effective + skill set is non-empty. No `SkillAgent`, no agent_type `"skill"`. +- **D4 — `allowed-tools` is advisory.** Strands parses it and marks it + "Experimental: not yet enforced"; we store and display it. Optional Designer + soft warning when a bound skill declares a tool the agent doesn't bind. + Never an error, never a grant. +- **D5 — `scripts/` accept-and-inert.** Bundles containing scripts upload + successfully; scripts are stored and listed with a "not executable on this + platform" label and are never executed. Execution is a deliberate future + feature (likely AgentCore Code Interpreter as the sandbox); stored bundles + are already data-compatible with that future. +- **D6 — Opt-in selection in plain chat.** Unlike tools (opt-out), skills + default to *disabled*; a user explicitly enables the skills they want. As the + catalog grows (admin + user-authored), opt-in caps prompt bloat and + instruction conflicts. Agent-bound skills are always active on that agent — + the author opted in at design time; invokers do not re-toggle them. +- **D7 — Invoke-through sharing** (§6). Sharing an Agent shares the *use* of + its bound skills, mirroring the assistant-KB precedent. Chain-sharing is + blocked by an owner-match clause. +- **D8 — Two authorship tiers, one table.** Admin catalog skills (RBAC + `granted_skills`) and user-owned skills (GSI4) are the same record type with + different `owner_id`/governance. No parallel store. + +--- + +## 4. Data model + +`SkillDefinition` (DynamoDB `app-roles` table, `PK=SKILL#{id}`, unchanged keys): + +``` +id, name, description # frontmatter-equivalent +instructions # SKILL.md body +allowed_tools: list[str] = [] # NEW — advisory, parsed from frontmatter +skill_metadata: dict = {} # NEW — frontmatter passthrough (license, compatibility, metadata) +resources: [SkillResourceRef] # manifest; ref gains a `kind`: reference|script|asset +owner_id # "system" for admin catalog; user id for uploads +status # active|disabled +# REMOVED: bound_tool_ids +``` + +**S3 layout** (existing `skill-resources` bucket) normalizes to the standard +bundle shape so a prefix *is* a valid agentskills.io skill: + +``` +skills/{skill_id}/SKILL.md # generated from name/description/allowed_tools + instructions +skills/{skill_id}/references/{file} +skills/{skill_id}/scripts/{file} # inert (D5) +skills/{skill_id}/assets/{file} +``` + +DynamoDB remains the source of truth for metadata/instructions; the S3 +`SKILL.md` is a write-through projection so the prefix is always attachable +elsewhere (harness lane) and exportable as-is. Content-hash dedupe from v1's +resource store is dropped in favor of plain per-skill keys — bundles are small, +and the standard layout is worth more than dedupe. Per-file cap stays 1 MiB, +per-skill file count cap stays 50 (revisit if real bundles need more; the +harness allows 1 GB). + +**Source union (future-shaped, not built now):** the record gains +`source: {kind: "inline"}` today. Later kinds mirror the harness: +`{kind: "s3", uri}` and `{kind: "git", url, path, credential_provider}` (git +auth via the AgentCore Identity token vault we already operate). Deferred until +a concrete need; the field exists so adding kinds is additive. + +--- + +## 5. Runtime + +### Effective skill set for a turn + +``` +own = skills owned by the invoker (GSI4) +catalog = RBAC-accessible admin skills (granted_skills, "*" honored) +agent = skills bound on the invoked Agent that pass §6 access +selected = request.enabled_skills (explicit opt-in ids; None/absent → []) + +plain chat turn: (own ∪ catalog) ∩ selected +agent turn: agent bindings (REPLACE — selection ignored, as with tools) +``` + +Note the plain-chat default flips from v1: absent `enabled_skills` now means +**no skills** (D6 opt-in), not "all accessible". `skills_hash` hashes the +effective set exactly as today, so caching and paused-turn resume carry over. + +### Agent construction + +```python +if effective_skills: + strands_skills = [ + Skill(name=r.name, description=r.description, + instructions=r.instructions, allowed_tools=r.allowed_tools or None, + metadata={"skill_id": r.id, **r.skill_metadata}) + for r in effective_skill_records + ] + plugins.append(AgentSkills(skills=strands_skills)) +``` + +The plugin injects `` (~100 tokens/skill) before each +invocation and exposes the `skills` activation tool (L1→L2 disclosure). + +### Reference files (L3 disclosure) + +The plugin returns resource *listings* for path-backed skills but does not read +files; programmatic `Skill` instances carry no filesystem. We keep one thin +local tool: + +``` +read_skill_file(skill_name: str, path: str) -> str +``` + +- Resolves `path` against the skill's `resources` manifest (no traversal — + manifest lookup, not filesystem). +- Serves bytes from S3 via the existing `SkillResourceStore` (runtime already + has read-only bucket access from v1 PR-4/6a). +- `scripts/*` requests return the content prefixed with the inert-script notice + (D5); binary assets return a descriptive note, never raw bytes. +- Enforces the §6 access predicate per call. +- The `Skill.instructions` body is suffixed with a generated "Available + reference files" section listing manifest paths, so the model knows what it + can request after activation. + +This is the one deliberate piece of custom code that remains; it is the +S3-vs-filesystem adapter, not a parallel disclosure engine. + +### What replaces `SkillAgent` + +Nothing. `ChatAgent` gains the conditional plugin; agent_type `"skill"` is +removed from the request contract (accepted-but-coerced to `"chat"` during a +deprecation window so stale SPA sessions don't 422). + +--- + +## 6. Sharing & permissions + +### The scenario + +User A authors a custom skill, binds it to their Agent, shares the Agent with +user B (viewer/editor, issue-#113 model). The binding resolver (agent-designer +D5) re-checks every binding against the *invoker* — B holds no grant on A's +skill, so the naive rule blocks the turn. That would make sharing agents with +custom skills useless. + +### Rule: invoke-through access + +Precedent: a shared assistant already exposes the owner's KB documents to +invited users *through invocation* — the share is the grant boundary for +content welded to it. A skill is the same class of durable content behind the +same auth (governance = identity claims, not content inspection). + +A skill binding on an Agent resolves for an invoker when **any** of: + +1. **Catalog grant** — RBAC `granted_skills` reaches the skill; or +2. **Ownership** — the invoker owns the skill; or +3. **Invoke-through** — `skill.owner_id == agent.owner_id` **and** the invoker + has share-access to the agent. + +Clause 3's owner-match blocks **chain-sharing**: a skill shared *to* A cannot +be laundered to a wider audience by binding it to A's agent and sharing the +agent — invoke-through only extends the agent owner's *own* skills. + +Failing all three → the existing D5 block-with-message behavior. + +`read_skill_file` applies the same predicate on every call (the tool is the +only read path for reference bytes at runtime). + +**Design-time rule (unchanged shape):** bindable skills = own ∪ catalog-granted. +You cannot bind a skill that was merely shared to you (until explicit +skill-level shares exist, phase 2). + +**Disclosure:** sharing an agent shares its skill *content* — invokers can read +reference files through disclosure and instructions shape visible behavior. +Identical to the KB posture; the share dialog copy must say it: *"People with +access can use this agent's skills and knowledge."* + +--- + +## 7. Surfaces + +- **Admin skills page** — kept as-is minus the tool-picker dialog; gains the + standard-bundle upload (below) and an `allowed-tools` display chip. +- **My Skills (new user page)** — list/create/edit/delete own skills; upload a + `SKILL.md` + individual files (existing `parseSkillMarkdown` import-prefill; + zip support still deferred — no new dep). Reference/script/asset files upload + into the standard layout; scripts get the inert label. +- **Chat input** — skills join the model-settings panel as an opt-in picker + (per-skill user prefs from v1 persist the selection; default unchecked per + D6). No mode toggle. +- **Agent Designer** — the existing skill multi-select now sources its palette + from `GET /agents/bindable?kind=skill` = catalog-granted ∪ own. Optional D4 + soft warning row. Chat-input lock behavior for bound skills (PR #603/#606 + machinery) carries over unchanged. + +--- + +## 8. Plan + +- **PR-1 — The great deletion.** Remove `bound_tool_ids` end-to-end, the fold + machinery, the hook shims, the stream-coordinator unwrap, skills mode + (backend policy + SPA toggle). Flag stays off; suite green. Largest-negative- + diff PR of the epic. +- **PR-2 — Spike + runtime swap. ✅ DONE** (branch + `feature/skills-v2-runtime-swap`). *Spike gate:* **GO** — see + `skills-v2-pr2-spike-findings.md` (20/20; `AgentSkills` block-level injection + preserves cache points, `skills_hash` cache key + paused-turn resume compose, + `agent.state` round-trips our session manager). Delivered: `AgentSkills` + plugin wired into `ChatAgent`; `read_skill_file` S3 adapter; `SkillAgent` / + `skill_registry` / `skill_tools` retired (`"skill"` is now a ChatAgent alias); + agentskills.io S3 bundle layout + `SKILL.md` write-through projection; + `allowed_tools`/`skill_metadata` + resource `kind` on the model. +- **PR-3 — User-authored tier. ✅ MERGED** (PR #683). + Owner-scoped `/skills/mine` CRUD + bundle-upload routes (session auth), + `list_skills_by_owner` over GSI4, `UserSkillService` (ownership resolved on + every path; a skill you don't own is 404, never 403), and the My Skills SPA + page (list + form, SKILL.md import prefill, reference/script/asset upload). + Two tier boundaries were closed along the way, both of which would have + leaked private skills: + 1. `get_all_skill_ids` (the RBAC `"*"` wildcard expansion) now lists **only + catalog skills** — a wildcard grant must never sweep in other users' + authored skills. + 2. The admin role-grant endpoints refuse a user-authored skill + (`_require_catalog_skill`), since granting one to an AppRole would hand a + private document to a whole role. The admin catalog list is likewise + scoped to `owner_id == "system"`. + `resolve_accessible_skill_ids` now returns **catalog ∪ own**, which is what + makes an authored skill reachable at runtime at all. Skill ids are allocated + server-side from the display name and suffixed on collision (`docx` → + `docx_2`), keeping ids globally unique — the runtime activation key is the + slugified id, so same-named skills in one turn would be ambiguous to the + model — without a 409 that would disclose an invisible skill's existence. +- **PR-4 — Selection surfaces. ✅ DONE** (branch + `feature/skills-v2-selection-surfaces`). Chat opt-in picker wired end-to-end + (the SPA now sends `enabled_skills`; the picker's untouched default flipped to + *off* on both sides); the invoke-through predicate + (`resolve_invocable_skill_ids`) in the binding resolver; share-dialog + disclosure copy. The Designer palette union needed no code — `/agents/bindable` + already delegates to `resolve_accessible_skill_ids`, which PR-3 widened to + catalog ∪ own. + + Three latent bugs surfaced, all from skills having never actually run on the + plain-chat path before: + 1. Skill resolution was gated on `agent_type == "skill"`, so the picker could + never have taken effect. Skills are now driven by the *selection*, on any + turn; `agent_type` gates nothing (`"skill"` remains a ChatAgent alias only + so stale SPA sessions don't 422). + 2. The binding resolver gated on `AppRoleService.can_access_skill`, which has + no ownership clause (an author was blocked on their **own** authored skill + when they invoked their own Agent) and whose `"*"` wildcard matched *any* + id, including another user's private skill. Routing clauses 1+2 through + `resolve_accessible_skill_ids` fixes both — this was the third `"*"` + over-expansion in the epic, after PR-3's two. + 3. Paused-turn resume and the construction snapshot both keyed skills off + `agent_type == "skill"`, which would have orphaned the paused agent of any + plain-chat turn carrying skills. Both now key off the snapshot's own + `enabled_skills`. + + `read_skill_file` needed no per-call predicate: its record set *is* the turn's + effective skill set, so there is no id the model can name to reach a skill the + invoker cannot use — structurally stronger than re-checking a caller-supplied + id, and it keeps one resolution point. +- **PR-5 — Flip.** `SKILLS_ENABLED=true` on dev-ai; dogfood with a real + Anthropic-format bundle (e.g. their `docx` skill) uploaded as a user skill + and bound to an Agent. +- **Later:** `git`/`s3` source kinds; explicit skill-level shares; script + execution via a sandbox; harness-lane attachment of the same bundles. + +--- + +## 9. Open items + +- **Spike go/no-go (PR-2)** is the only open technical risk; everything else is + deletion or reuse of proven v1 machinery. +- ~~**`enabled_skills` default flip** (absent → none, per D6)~~ — **landed in + PR-4.** No other client sends skill turns: the API-key surface, the schedules + form, and the assistant preview all omit the field, so they inherit opt-in + semantics naturally. The flip is also what keeps skills free for turns that + don't want them — an absent selection short-circuits before any RBAC or + skill-table read. +- **Deprecation window** for `agent_type="skill"` in stored session preferences + and paused snapshots: coerce to `"chat"` on read; remove after one release. diff --git a/docs/specs/skills-mode.md b/docs/specs/skills-mode.md index fbb94b826..51d8fc509 100644 --- a/docs/specs/skills-mode.md +++ b/docs/specs/skills-mode.md @@ -1,5 +1,10 @@ # Skills Mode — user-visible mode toggle + admin policy +> **SUPERSEDED (2026-07-16) by `skills-as-agent-primitive.md`.** The skills-mode +> toggle, `preferredAgentMode`, and the admin chat-mode policy were removed in +> Skills v2 PR-1 ("the great deletion"); skills are now opt-in per-turn, not a +> mode. This document is retained for build history only. + > Status: DRAFT (spec only, nothing built). Follows on from > `admin-skills-rbac-tool-binding.md` Phase 1 (PR-1..7, all merged — default > `agent_type` is already `"skill"` server-side as of #470). This spec makes the diff --git a/docs/specs/skills-v2-pr2-spike-findings.md b/docs/specs/skills-v2-pr2-spike-findings.md new file mode 100644 index 000000000..df12dfdb5 --- /dev/null +++ b/docs/specs/skills-v2-pr2-spike-findings.md @@ -0,0 +1,67 @@ +# Skills v2 PR-2 — AgentSkills spike gate findings + +**Decision: GO ✅** — wire the Strands `AgentSkills` plugin into `ChatAgent`. + +Spike run against the pinned `strands-agents==1.48.0` (the spec assumed 1.47.0; the pin +was bumped for the cachePoint-after-document fix — `AgentSkills`/`Skill` are present and +unchanged in 1.48.0). Harness: `scratchpad/skills_v2_spike.py`, 20/20 checks pass. It +builds the Strands `Agent` exactly as `agent_factory.py` does (`CountTokensBedrockModel`, +`SequentialToolExecutor`, `caching_enabled=True`) plus `plugins=[AgentSkills(...)]`, then +fires the `BeforeInvocation` hook and the activation/state paths directly (no network). + +## Gate items (spec §8) + +| Gate | Result | Evidence | +|---|---|---| +| Plugin composes with our `Agent` construction | ✅ | `skills` activation tool auto-registers; `BeforeInvocation` hook fires through the agent's plugin registry | +| Prompt injection composes with our prompt assembly | ✅ | `` (names + descriptions = L1 disclosure) injected; original prompt preserved; idempotent across turns (single block) | +| Cache points preserved | ✅ | See "cache-point path" below — the block-level path preserves a `cachePoint` block and never duplicates it | +| `skills_hash` discrimination | ✅ | Narrowing the effective skill set changes the injected XML → the effective set (already the `skills_hash` input) drives the prompt | +| `agent.state` persistence survives the session manager | ✅ | `SessionAgent.from_agent` serializes `agent.state.get()` (incl. the plugin's `agent_skills` key); `initialize` restores via `agent.state = AgentState(...)`; activated skills round-trip; resume re-injection idempotent | + +## Key mechanics confirmed + +- **We always get the cache-point-safe path.** `split_system_prompt()` turns even a plain + **string** `system_prompt` (our construction) into `[{"text": prompt}]`, so + `agent.system_prompt_content` is never `None` and the plugin's `_on_before_invocation` + always takes the **block-level** injection path (remove-prior-block + append), which + preserves cache points and other structured blocks. The string-manipulation fallback is + effectively dead code for us. +- **Injection is idempotent per agent.** The plugin tracks `last_injected_xml` in + `agent.state["agent_skills"]` and removes it before re-appending, so a cached agent reused + across turns never accumulates duplicate `` blocks. For a stable skill + set the injected bytes are stable turn-to-turn → prompt cache stays warm. +- **State round-trips through our session manager unchanged.** `TurnBasedSessionManager` + only overrides `initialize()` to layer compaction and calls `super().initialize()`; the + Strands `RepositorySessionManager` `sync_agent`/`initialize` pair carries `agent.state` + (including `agent_skills.activated_skills` / `last_injected_xml`) via `SessionAgent.state`. + +## Caveats to carry into the PR-2 build (non-blocking) + +1. **Slugged `Skill.name`.** The `Skill(...)` constructor does **no** name validation + (validation only fires on `from_content`/`from_file`/`from_url`). A human-friendly DB + name like `"Web Research"` constructs fine and is activatable by that exact string. But + `Skill.name` is (a) the injected label, (b) the `skills` tool activation key, and (c) the + agentskills.io directory/`SKILL.md` name for the S3 projection + harness portability + (spec §4). **Map DB → `Skill` with a slug** (lowercase-hyphen, matching + `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) as `Skill.name`, carrying the human title in + `description`/`metadata`. Derive the slug from the skill id or a stored slug field. +2. **Benign resume warning.** On a cross-container / cache-miss resume the agent's base + prompt is rebuilt fresh while the restored `agent.state` still holds a stale + `last_injected_xml`; the plugin logs `unable to find previously injected skills XML in + system prompt, re-appending` and re-appends. Output stays correct and single-block — this + is expected log noise in cloud (Memory restore is degraded / the in-process agent cache is + the real cross-turn continuity), not an error. +3. **Cloud state continuity is best-effort, and that's fine.** Even if `agent.state` is not + restored in cloud (write-only Memory), the skill set is rebuilt from the effective records + each turn and `` is re-injected; the model re-activates as needed + (L2 disclosure is per-turn ephemeral, and activated instructions already live in message + history as a tool_result). No hard dependency on state restore. + +## What PR-2 now proceeds to build (spec §8) + +Wire `AgentSkills` into `ChatAgent` (conditional plugin when the effective skill set is +non-empty), add the thin `read_skill_file` S3 adapter, retire `SkillAgent` + +`skill_registry`/`skill_tools` disclosure, and normalize the S3 layout with a `SKILL.md` +write-through projection. On a no-go this doc would instead record the fallback to a +fold-free homegrown dispatcher — not needed. diff --git a/frontend/ai.client/package-lock.json b/frontend/ai.client/package-lock.json index 6bf69a5b2..92fcfcfe6 100644 --- a/frontend/ai.client/package-lock.json +++ b/frontend/ai.client/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai.client", - "version": "1.7.1", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai.client", - "version": "1.7.1", + "version": "1.8.0", "dependencies": { "@angular/cdk": "21.2.14", "@angular/common": "21.2.17", diff --git a/frontend/ai.client/package.json b/frontend/ai.client/package.json index d3669a9eb..359bc4d20 100644 --- a/frontend/ai.client/package.json +++ b/frontend/ai.client/package.json @@ -1,6 +1,6 @@ { "name": "ai.client", - "version": "1.7.1", + "version": "1.8.0", "scripts": { "ng": "ng", "start": "ng serve", diff --git a/frontend/ai.client/public/logos/github-dark.svg b/frontend/ai.client/public/logos/github-dark.svg new file mode 100644 index 000000000..f57354196 --- /dev/null +++ b/frontend/ai.client/public/logos/github-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/ai.client/public/logos/github-light.svg b/frontend/ai.client/public/logos/github-light.svg new file mode 100644 index 000000000..4e37ca061 --- /dev/null +++ b/frontend/ai.client/public/logos/github-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/ai.client/src/app/admin/admin.layout.ts b/frontend/ai.client/src/app/admin/admin.layout.ts index 035a290be..17634372b 100644 --- a/frontend/ai.client/src/app/admin/admin.layout.ts +++ b/frontend/ai.client/src/app/admin/admin.layout.ts @@ -5,7 +5,6 @@ import { inject, } from '@angular/core'; import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; -import { ChatModeService } from '../services/chat-mode/chat-mode.service'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { heroArrowLeft, @@ -137,7 +136,6 @@ interface NavGroup { }) export class AdminLayout { private router = inject(Router); - private chatMode = inject(ChatModeService); private readonly allNavGroups: NavGroup[] = [ { @@ -175,17 +173,11 @@ export class AdminLayout { ]; /** - * Nav groups with the Skills entry hidden while the skills feature is - * disabled for this environment (deferred release). The pages stay routed - * but unlinked; the backend forces tools mode and 404s the skills APIs. + * Nav groups. The Skills entry is always linked; access is governed by + * admin RBAC and the skills route only mounts when the feature is enabled + * (SKILLS_ENABLED), so no client-side feature gate is needed here. */ - readonly navGroups = computed(() => { - if (this.chatMode.skillsEnabled()) return this.allNavGroups; - return this.allNavGroups.map((group) => ({ - ...group, - items: group.items.filter((item) => item.route !== '/admin/skills'), - })); - }); + readonly navGroups = computed(() => this.allNavGroups); onMobileNavChange(event: Event): void { const select = event.target as HTMLSelectElement; diff --git a/frontend/ai.client/src/app/admin/skills/components/tool-picker-dialog.component.ts b/frontend/ai.client/src/app/admin/skills/components/tool-picker-dialog.component.ts deleted file mode 100644 index 5b0178f32..000000000 --- a/frontend/ai.client/src/app/admin/skills/components/tool-picker-dialog.component.ts +++ /dev/null @@ -1,498 +0,0 @@ -import { - Component, - ChangeDetectionStrategy, - inject, - signal, - computed, -} from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; -import { NgIcon, provideIcons } from '@ng-icons/core'; -import { - heroXMark, - heroMagnifyingGlass, - heroChevronRight, - heroChevronDown, - heroArrowPath, -} from '@ng-icons/heroicons/outline'; -import { AdminToolService } from '../../tools/services/admin-tool.service'; -import { TOOL_CATEGORIES, AdminTool } from '../../tools/models/admin-tool.model'; -import { makeScopedToolId, parseScopedToolId } from '../../../shared/utils/scoped-tool-id'; - -/** - * Data passed to the tool picker dialog: the currently-bound tool IDs (may be - * bare catalog ids or scoped `toolId::mcpToolName` ids). - */ -export interface ToolPickerDialogData { - selectedToolIds: string[]; -} - -/** - * Result: the chosen tool IDs if confirmed, or undefined if cancelled. - */ -export type ToolPickerDialogResult = string[] | undefined; - -/** A single tool exposed by an MCP server (curated or discovered live). */ -interface ServerTool { - name: string; - description?: string | null; -} - -@Component({ - selector: 'app-tool-picker-dialog', - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [FormsModule, NgIcon], - providers: [ - provideIcons({ - heroXMark, - heroMagnifyingGlass, - heroChevronRight, - heroChevronDown, - heroArrowPath, - }), - ], - host: { - class: 'block', - '(keydown.escape)': 'onCancel()', - }, - template: ` - - - - -
- -
- `, - styles: ` - @import "tailwindcss"; - - @custom-variant dark (&:where(.dark, .dark *)); - - .dialog-backdrop { - animation: backdrop-fade-in 200ms ease-out; - } - - @keyframes backdrop-fade-in { - from { opacity: 0; } - to { opacity: 1; } - } - - .dialog-panel { - animation: dialog-fade-in-up 200ms ease-out; - } - - @keyframes dialog-fade-in-up { - from { - opacity: 0; - transform: translateY(1rem) scale(0.97); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } - } - `, -}) -export class ToolPickerDialogComponent { - protected readonly dialogRef = inject(DialogRef); - protected readonly data = inject(DIALOG_DATA); - - private adminToolService = inject(AdminToolService); - - readonly toolsResource = this.adminToolService.toolsResource; - readonly searchQuery = signal(''); - readonly selectedToolIds = signal>(new Set(this.data.selectedToolIds)); - readonly expanded = signal>(new Set()); - readonly discovering = signal>(new Set()); - readonly discovered = signal>({}); - readonly discoverError = signal>({}); - - /** - * Only ACTIVE tools are bindable — the backend rejects binding an unknown or - * non-active tool (see SkillCatalogService._validate_bound_tools). - */ - readonly activeTools = computed(() => - this.adminToolService.getTools().filter((t) => t.status === 'active') - ); - - readonly filteredTools = computed(() => { - const query = this.searchQuery().toLowerCase().trim(); - const tools = this.activeTools(); - const filtered = query - ? tools.filter( - (t) => - t.displayName.toLowerCase().includes(query) || - t.toolId.toLowerCase().includes(query) || - t.category.toLowerCase().includes(query) || - t.protocol.toLowerCase().includes(query) - ) - : tools; - return [...filtered].sort((a, b) => a.displayName.localeCompare(b.displayName)); - }); - - readonly selectedCountLabel = computed(() => `${this.selectedToolIds().size} selected`); - - categoryLabel(category: string): string { - return TOOL_CATEGORIES.find((c) => c.value === category)?.label ?? category; - } - - isMcpServer(tool: AdminTool): boolean { - return tool.protocol === 'mcp' || tool.protocol === 'mcp_external'; - } - - /** The tools a server exposes — its curated list, else any discovered live. */ - serverToolsFor(tool: AdminTool): ServerTool[] { - const curated = tool.mcpConfig?.tools ?? tool.mcpGatewayConfig?.tools ?? []; - if (curated.length > 0) { - return curated.map((t) => ({ name: t.name, description: t.description })); - } - return this.discovered()[tool.toolId] ?? []; - } - - /** Names of the individual tools currently selected for a given server. */ - selectedScopedNames(toolId: string): string[] { - const names: string[] = []; - for (const id of this.selectedToolIds()) { - const { base, name } = parseScopedToolId(id); - if (base === toolId && name) { - names.push(name); - } - } - return names; - } - - /** Whole server ('all'), a subset ('partial'), or nothing ('none'). */ - serverState(tool: AdminTool): 'all' | 'partial' | 'none' { - const set = this.selectedToolIds(); - if (set.has(tool.toolId)) { - return 'all'; - } - return this.selectedScopedNames(tool.toolId).length > 0 ? 'partial' : 'none'; - } - - isSubToolSelected(tool: AdminTool, name: string): boolean { - const set = this.selectedToolIds(); - return set.has(tool.toolId) || set.has(makeScopedToolId(tool.toolId, name)); - } - - toggleExpanded(toolId: string): void { - this.expanded.update((set) => { - const next = new Set(set); - if (next.has(toolId)) { - next.delete(toolId); - } else { - next.add(toolId); - } - return next; - }); - } - - /** Toggle binding the whole server (clears any per-tool subset). */ - toggleServer(tool: AdminTool): void { - const wasOff = this.serverState(tool) === 'none'; - this.selectedToolIds.update((set) => { - const next = this.withoutBase(set, tool.toolId); - if (wasOff) { - next.add(tool.toolId); - } - return next; - }); - } - - /** Toggle a single tool of a server (switches the server to a subset). */ - toggleSubTool(tool: AdminTool, name: string): void { - const scoped = makeScopedToolId(tool.toolId, name); - this.selectedToolIds.update((set) => { - const next = new Set(set); - // Customizing a whole-server binding expands it into explicit per-tool - // ids for everything currently known, then toggles the chosen one. - if (next.has(tool.toolId)) { - next.delete(tool.toolId); - for (const sub of this.serverToolsFor(tool)) { - next.add(makeScopedToolId(tool.toolId, sub.name)); - } - } - if (next.has(scoped)) { - next.delete(scoped); - } else { - next.add(scoped); - } - return next; - }); - } - - /** Toggle a non-MCP (single) tool. */ - toggleTool(toolId: string): void { - this.selectedToolIds.update((set) => { - const next = new Set(set); - if (next.has(toolId)) { - next.delete(toolId); - } else { - next.add(toolId); - } - return next; - }); - } - - async discover(tool: AdminTool): Promise { - this.discovering.update((s) => new Set(s).add(tool.toolId)); - this.discoverError.update((m) => { - const next = { ...m }; - delete next[tool.toolId]; - return next; - }); - try { - const res = await this.adminToolService.discoverSavedToolTools(tool.toolId); - this.discovered.update((d) => ({ - ...d, - [tool.toolId]: res.tools.map((t) => ({ name: t.name, description: t.description })), - })); - if (res.tools.length === 0) { - this.discoverError.update((m) => ({ - ...m, - [tool.toolId]: 'The server returned no tools.', - })); - } - } catch { - this.discoverError.update((m) => ({ - ...m, - [tool.toolId]: 'Could not list this server’s tools. Bind the whole server instead.', - })); - } finally { - this.discovering.update((s) => { - const next = new Set(s); - next.delete(tool.toolId); - return next; - }); - } - } - - clearAll(): void { - this.selectedToolIds.set(new Set()); - } - - confirm(): void { - this.dialogRef.close(Array.from(this.selectedToolIds())); - } - - onCancel(): void { - this.dialogRef.close(undefined); - } - - /** A copy of `set` with the bare id and every scoped id for `base` removed. */ - private withoutBase(set: Set, base: string): Set { - const next = new Set(); - for (const id of set) { - if (parseScopedToolId(id).base !== base) { - next.add(id); - } - } - return next; - } -} diff --git a/frontend/ai.client/src/app/admin/skills/index.ts b/frontend/ai.client/src/app/admin/skills/index.ts index 7b4e3e095..fcff37ca1 100644 --- a/frontend/ai.client/src/app/admin/skills/index.ts +++ b/frontend/ai.client/src/app/admin/skills/index.ts @@ -4,4 +4,3 @@ export * from './services/admin-skill.service'; export * from './pages/skill-list.page'; export * from './pages/skill-form.page'; export * from './components/skill-role-dialog.component'; -export * from './components/tool-picker-dialog.component'; diff --git a/frontend/ai.client/src/app/admin/skills/models/admin-skill.model.ts b/frontend/ai.client/src/app/admin/skills/models/admin-skill.model.ts index 13632a2dd..33702f8ad 100644 --- a/frontend/ai.client/src/app/admin/skills/models/admin-skill.model.ts +++ b/frontend/ai.client/src/app/admin/skills/models/admin-skill.model.ts @@ -36,7 +36,6 @@ export interface AdminSkill { displayName: string; description: string; instructions: string; - boundToolIds: string[]; compose: string[]; resources: SkillResourceRef[]; status: SkillStatus; @@ -93,7 +92,6 @@ export interface SkillCreateRequest { displayName: string; description: string; instructions?: string; - boundToolIds?: string[]; compose?: string[]; status?: SkillStatus; category?: string | null; @@ -106,7 +104,6 @@ export interface SkillUpdateRequest { displayName?: string; description?: string; instructions?: string; - boundToolIds?: string[]; compose?: string[]; status?: SkillStatus; category?: string | null; diff --git a/frontend/ai.client/src/app/admin/skills/models/skill-import.util.spec.ts b/frontend/ai.client/src/app/admin/skills/models/skill-import.util.spec.ts index 2fab108dc..8d195a17e 100644 --- a/frontend/ai.client/src/app/admin/skills/models/skill-import.util.spec.ts +++ b/frontend/ai.client/src/app/admin/skills/models/skill-import.util.spec.ts @@ -98,3 +98,55 @@ describe('isValidSkillId', () => { (id) => expect(isValidSkillId(id)).toBe(false) ); }); + +describe('parseSkillMarkdown — frontmatter passthrough (Skills v2 D2/D4)', () => { + it('carries non-reserved frontmatter into metadata', () => { + const parsed = parseSkillMarkdown( + ['---', 'name: Docx', 'description: Word docs.', 'license: MIT', 'version: 2.1', '---', '', 'Body.'].join('\n'), + ); + + expect(parsed.metadata).toEqual({ license: 'MIT', version: '2.1' }); + }); + + it('excludes the reserved keys from metadata', () => { + const parsed = parseSkillMarkdown( + ['---', 'name: Docx', 'description: Word docs.', 'allowed-tools: Read', '---', '', 'Body.'].join('\n'), + ); + + expect(parsed.metadata).toEqual({}); + expect(parsed.name).toBe('Docx'); + expect(parsed.description).toBe('Word docs.'); + }); + + it('splits a comma-separated allowed-tools scalar', () => { + const parsed = parseSkillMarkdown( + ['---', 'name: Docx', 'allowed-tools: Read, Write, Bash', '---', '', 'Body.'].join('\n'), + ); + + expect(parsed.allowedTools).toEqual(['Read', 'Write', 'Bash']); + }); + + it('splits an inline-array allowed-tools value', () => { + const parsed = parseSkillMarkdown( + ['---', 'name: Docx', 'allowed-tools: [Read, "Write"]', '---', '', 'Body.'].join('\n'), + ); + + expect(parsed.allowedTools).toEqual(['Read', 'Write']); + }); + + it('returns empty passthrough when there is no frontmatter', () => { + const parsed = parseSkillMarkdown('Just a body.'); + + expect(parsed.allowedTools).toEqual([]); + expect(parsed.metadata).toEqual({}); + expect(parsed.instructions).toBe('Just a body.'); + }); + + it('returns empty allowedTools when the key is absent', () => { + const parsed = parseSkillMarkdown( + ['---', 'name: Docx', '---', '', 'Body.'].join('\n'), + ); + + expect(parsed.allowedTools).toEqual([]); + }); +}); diff --git a/frontend/ai.client/src/app/admin/skills/models/skill-import.util.ts b/frontend/ai.client/src/app/admin/skills/models/skill-import.util.ts index 8d26007f1..567dc5f6b 100644 --- a/frontend/ai.client/src/app/admin/skills/models/skill-import.util.ts +++ b/frontend/ai.client/src/app/admin/skills/models/skill-import.util.ts @@ -22,8 +22,28 @@ export interface ParsedSkillMarkdown { description: string; /** The markdown body after the frontmatter (→ instructions). */ instructions: string; + /** + * Frontmatter `allowed-tools`, split into names. **Advisory only** — the + * platform never grants a tool because a skill names it (Skills v2 D1/D4). + * Empty when the key is absent. + */ + allowedTools: string[]; + /** + * Every other frontmatter key (license, compatibility, arbitrary keys), + * carried verbatim so an imported bundle round-trips (D2). `name`, + * `description` and `allowed-tools` are excluded — they have their own + * fields above, and the backend regenerates them into the SKILL.md + * projection. + */ + metadata: Record; } +/** + * Frontmatter keys that map to first-class fields rather than metadata + * passthrough. Mirrors the backend `_RESERVED_METADATA_KEYS`. + */ +const RESERVED_KEYS = new Set(['name', 'description', 'allowed-tools', 'instructions']); + /** * Parse a SKILL.md into prefill fields. * @@ -37,15 +57,48 @@ export function parseSkillMarkdown(text: string): ParsedSkillMarkdown { const normalized = text.replace(/\r\n/g, '\n'); const fm = extractFrontmatter(normalized); if (!fm) { - return { name: '', description: '', instructions: normalized.trim() }; + return { + name: '', + description: '', + instructions: normalized.trim(), + allowedTools: [], + metadata: {}, + }; + } + + const metadata: Record = {}; + for (const [key, value] of Object.entries(fm.fields)) { + if (!RESERVED_KEYS.has(key)) { + metadata[key] = value; + } } + return { name: fm.fields['name'] ?? '', description: fm.fields['description'] ?? '', instructions: fm.body.trim(), + allowedTools: parseToolList(fm.fields['allowed-tools'] ?? ''), + metadata, }; } +/** + * Split an `allowed-tools` frontmatter value into tool names. + * + * The ecosystem writes this either as a comma-separated scalar + * (`allowed-tools: Read, Write`) or as an inline YAML array + * (`allowed-tools: [Read, Write]`). Both collapse to the same list; a + * block-sequence form (one `- item` per line) is not a `key: value` scalar and + * so never reaches here. + */ +function parseToolList(value: string): string[] { + const inner = value.trim().replace(/^\[/, '').replace(/\]$/, ''); + return inner + .split(',') + .map((tool) => stripQuotes(tool.trim())) + .filter((tool) => tool.length > 0); +} + interface Frontmatter { fields: Record; body: string; diff --git a/frontend/ai.client/src/app/admin/skills/pages/skill-form.page.ts b/frontend/ai.client/src/app/admin/skills/pages/skill-form.page.ts index 1f1c94cd7..e1a19930b 100644 --- a/frontend/ai.client/src/app/admin/skills/pages/skill-form.page.ts +++ b/frontend/ai.client/src/app/admin/skills/pages/skill-form.page.ts @@ -8,8 +8,6 @@ import { } from '@angular/core'; import { RouterLink, Router, ActivatedRoute } from '@angular/router'; import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; -import { Dialog } from '@angular/cdk/dialog'; -import { firstValueFrom } from 'rxjs'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { heroArrowLeft, @@ -17,12 +15,10 @@ import { heroTrash, heroEye, heroArrowUpTray, - heroWrenchScrewdriver, heroDocumentText, heroXMark, } from '@ng-icons/heroicons/outline'; import { AdminSkillService } from '../services/admin-skill.service'; -import { AdminToolService } from '../../tools/services/admin-tool.service'; import { SkillResourceRef, SkillStatus, @@ -34,12 +30,6 @@ import { parseSkillMarkdown, slugifySkillId, } from '../models/skill-import.util'; -import { - ToolPickerDialogComponent, - ToolPickerDialogData, - ToolPickerDialogResult, -} from '../components/tool-picker-dialog.component'; -import { parseScopedToolId } from '../../../shared/utils/scoped-tool-id'; @Component({ selector: 'app-skill-form', @@ -52,7 +42,6 @@ import { parseScopedToolId } from '../../../shared/utils/scoped-tool-id'; heroTrash, heroEye, heroArrowUpTray, - heroWrenchScrewdriver, heroDocumentText, heroXMark, }), @@ -76,7 +65,7 @@ import { parseScopedToolId } from '../../../shared/utils/scoped-tool-id'; {{ isEditMode() ? 'Edit Skill' : 'Create Skill' }}

- {{ isEditMode() ? 'Update skill instructions, reference files and bound tools.' : 'Author a new skill, or import a SKILL.md to prefill it.' }} + {{ isEditMode() ? 'Update skill instructions and reference files.' : 'Author a new skill, or import a SKILL.md to prefill it.' }}

@@ -222,45 +211,6 @@ import { parseScopedToolId } from '../../../shared/utils/scoped-tool-id'; > - -
-
-
-

Bound tools

-

- Catalog tools this skill carries. The agent loads only these when the skill is active. -

-
- -
- @if (boundToolIds().length > 0) { -
- @for (toolId of boundToolIds(); track toolId) { - - {{ toolDisplayName(toolId) }} - - - } -
- } @else { -

No tools bound yet.

- } -
-
@@ -440,9 +390,7 @@ export class SkillFormPage implements OnInit { private fb = inject(FormBuilder); private router = inject(Router); private route = inject(ActivatedRoute); - private dialog = inject(Dialog); private adminSkillService = inject(AdminSkillService); - private adminToolService = inject(AdminToolService); readonly statuses = SKILL_STATUSES; readonly categories = SKILL_CATEGORIES; @@ -455,9 +403,6 @@ export class SkillFormPage implements OnInit { readonly isEditMode = computed(() => !!this.skillId()); - // Bound tools (managed via the picker dialog). - readonly boundToolIds = signal([]); - // Reference files: live manifest in edit mode, staged Files in create mode. readonly resources = signal([]); readonly pendingFiles = signal([]); @@ -502,7 +447,6 @@ export class SkillFormPage implements OnInit { status: skill.status, category: skill.category ?? '', }); - this.boundToolIds.set([...skill.boundToolIds]); this.resources.set([...skill.resources]); } catch (err: unknown) { this.error.set(err instanceof Error ? err.message : 'Failed to load skill.'); @@ -517,14 +461,6 @@ export class SkillFormPage implements OnInit { return (event.target as HTMLInputElement | HTMLTextAreaElement).value; } - toolDisplayName(toolId: string): string { - // A scoped id (`base::mcpToolName`) binds one tool of a server — show - // "Server · tool" so the chip reads cleanly. - const { base, name } = parseScopedToolId(toolId); - const serverName = this.adminToolService.getToolById(base)?.displayName ?? base; - return name ? `${serverName} · ${name}` : serverName; - } - formatSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; @@ -552,29 +488,13 @@ export class SkillFormPage implements OnInit { } this.form.patchValue(patch); this.importNotice.set( - 'Imported from SKILL.md. Tool bindings are not imported — pick them below. Review the Skill ID, then add reference files.' + 'Imported from SKILL.md. Review the Skill ID, then add reference files.' ); } catch { this.error.set('Could not read the selected SKILL.md file.'); } } - // --- Bound tools ----------------------------------------------------------- - - async openToolPicker(): Promise { - const dialogRef = this.dialog.open(ToolPickerDialogComponent, { - data: { selectedToolIds: this.boundToolIds() } as ToolPickerDialogData, - }); - const result = await firstValueFrom(dialogRef.closed); - if (result !== undefined) { - this.boundToolIds.set(result); - } - } - - removeTool(toolId: string): void { - this.boundToolIds.update((ids) => ids.filter((id) => id !== toolId)); - } - // --- Reference files ------------------------------------------------------- toggleNewFile(): void { @@ -690,7 +610,6 @@ export class SkillFormPage implements OnInit { instructions: v.instructions, status: v.status, category, - boundToolIds: this.boundToolIds(), }); } else { const created = await this.adminSkillService.createSkill({ @@ -700,7 +619,6 @@ export class SkillFormPage implements OnInit { instructions: v.instructions, status: v.status, category, - boundToolIds: this.boundToolIds(), }); // Upload any staged reference files now that the skill exists. for (const file of this.pendingFiles()) { diff --git a/frontend/ai.client/src/app/admin/skills/pages/skill-list.page.ts b/frontend/ai.client/src/app/admin/skills/pages/skill-list.page.ts index 9c15bcad4..cd8fa722e 100644 --- a/frontend/ai.client/src/app/admin/skills/pages/skill-list.page.ts +++ b/frontend/ai.client/src/app/admin/skills/pages/skill-list.page.ts @@ -17,14 +17,11 @@ import { heroPencilSquare, heroTrash, heroUserGroup, - heroWrenchScrewdriver, heroDocumentText, } from '@ng-icons/heroicons/outline'; import { AdminSkillService } from '../services/admin-skill.service'; import { AdminSkill, SKILL_STATUSES } from '../models/admin-skill.model'; import { AppRolesService } from '../../roles/services/app-roles.service'; -import { parseScopedToolId } from '../../../shared/utils/scoped-tool-id'; -import { AdminToolService } from '../../tools/services/admin-tool.service'; import { SkillRoleDialogComponent, SkillRoleDialogData, @@ -43,7 +40,6 @@ import { heroPencilSquare, heroTrash, heroUserGroup, - heroWrenchScrewdriver, heroDocumentText, }), ], @@ -55,7 +51,7 @@ import {

Skill Catalog

- Author skills that bundle instructions, reference files and bound tools, then grant them to roles. + Author skills that bundle instructions and reference files, then grant them to roles.

- - -
+
@if (tool.serverTools && tool.serverTools.length > 0) { @for (sub of tool.serverTools; track sub.name) {
- {{ sub.name }} + {{ sub.name }} @if (sub.description) { - {{ sub.description }} + {{ sub.description }} }
@@ -569,14 +667,29 @@

-

} @@ -589,14 +702,17 @@

-

Conversation Mode

-

Apply a custom set of instructions to this conversation.

+

+ Conversation Mode +

+

+ Apply a custom set of instructions to this conversation. +

@if (systemPromptsService.loading()) { @@ -612,10 +728,15 @@

+ aria-describedby="mode-none-desc" + />
- None (default) -

Standard assistant behaviour.

+ None (default) +

+ Standard assistant behaviour. +

@@ -628,10 +749,18 @@

+ [attr.aria-describedby]="'mode-desc-' + prompt.prompt_id" + />
- {{ prompt.name }} -

{{ prompt.description }}

+ {{ + prompt.name + }} +

+ {{ prompt.description }} +

} diff --git a/frontend/ai.client/src/app/components/model-settings/model-settings.spec.ts b/frontend/ai.client/src/app/components/model-settings/model-settings.spec.ts index 7313177cb..3774bfa81 100644 --- a/frontend/ai.client/src/app/components/model-settings/model-settings.spec.ts +++ b/frontend/ai.client/src/app/components/model-settings/model-settings.spec.ts @@ -4,7 +4,6 @@ import { ElementRef, signal } from '@angular/core'; import { ModelService } from '../../session/services/model/model.service'; import { ToolService } from '../../services/tool/tool.service'; import { SkillService } from '../../services/skill/skill.service'; -import { ChatModeService } from '../../services/chat-mode/chat-mode.service'; import { ManagedModel } from '../../admin/manage-models/models/managed-model.model'; describe('ModelSettings', () => { @@ -50,32 +49,23 @@ describe('ModelSettings', () => { providers: [ { provide: ModelService, useValue: mockModelService }, { provide: ToolService, useValue: mockToolService }, - // Mock the two services that otherwise fire real (failing) HTTP in - // this spec — SkillService auto-loads /skills/ via an effect and - // ChatModeService fetches /system/chat-settings on construction. Left - // real, their async error logs can land during worker teardown and - // fail the run with an unhandled rejection. + // Mock SkillService so the component doesn't hold a real one (which + // would fire /skills/ HTTP); its async error logs can otherwise land + // during worker teardown and fail the run with an unhandled rejection. { provide: SkillService, useValue: { skills: signal([]), + visibleSkills: signal([]), enabledSkillIds: signal([]), enabledCount: signal(0), hasSkills: signal(false), loading: signal(false), + error: signal(null), + agentLocked: signal(false), toggleSkill: vi.fn(), }, }, - { - provide: ChatModeService, - useValue: { - mode: signal('chat'), - canToggle: signal(false), - isSkillsMode: signal(false), - skillsEnabled: signal(false), - setMode: vi.fn(), - }, - }, { provide: ElementRef, useValue: { nativeElement: document.createElement('div') } }, ], }); diff --git a/frontend/ai.client/src/app/components/model-settings/model-settings.ts b/frontend/ai.client/src/app/components/model-settings/model-settings.ts index 830d11131..8b298320e 100644 --- a/frontend/ai.client/src/app/components/model-settings/model-settings.ts +++ b/frontend/ai.client/src/app/components/model-settings/model-settings.ts @@ -4,7 +4,6 @@ import { heroXMark, heroCheck, heroChevronDown, heroChevronRight, heroArrowPath, import { ModelService } from '../../session/services/model/model.service'; import { ToolService, Tool } from '../../services/tool/tool.service'; import { SkillService } from '../../services/skill/skill.service'; -import { ChatMode, ChatModeService } from '../../services/chat-mode/chat-mode.service'; import { SystemPromptsService } from '../../services/system-prompts/system-prompts.service'; import { KNOWN_PARAMS, @@ -54,7 +53,6 @@ export class ModelSettings { protected modelService = inject(ModelService); protected toolService = inject(ToolService); protected skillService = inject(SkillService); - protected chatModeService = inject(ChatModeService); protected systemPromptsService = inject(SystemPromptsService); // Input to control visibility @@ -192,6 +190,15 @@ export class ModelSettings { this.hasBeenOpened.set(true); } + // Load the skills picker lazily on first open. SkillService deliberately + // has no constructor load (unlike ToolService): skills are opt-in and the + // feature is off in every deployed env until PR-5, so a boot-time fetch + // would be a guaranteed 404 for every user. `initialized` is set even on + // that 404, so this probes at most once per session. + if (isOpen && !this.skillService.initialized()) { + void this.skillService.loadSkills(); + } + // Prevent background scrolling when panel is open if (isOpen) { document.body.style.overflow = 'hidden'; @@ -357,10 +364,6 @@ export class ModelSettings { this.isSkillsOpen.update((open) => !open); } - setMode(mode: ChatMode): void { - this.chatModeService.setMode(mode, this.sessionId()); - } - toggleSkill(skillId: string): void { this.skillService.toggleSkill(skillId) .catch(err => console.error('Failed to toggle skill:', err)); diff --git a/frontend/ai.client/src/app/components/sidenav/sidenav.html b/frontend/ai.client/src/app/components/sidenav/sidenav.html index a4fac5e30..c08673ca7 100644 --- a/frontend/ai.client/src/app/components/sidenav/sidenav.html +++ b/frontend/ai.client/src/app/components/sidenav/sidenav.html @@ -62,6 +62,12 @@ Preview
} + +