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
} + +