mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-14 07:52:25 -06:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e86305c143 | |||
| d0fc42195a | |||
| 760321f7ee | |||
| 693e51f782 | |||
| ba07409724 | |||
| c76a61841e | |||
| 341d2f604f | |||
| 3f7f8495d6 | |||
| dc464ac313 | |||
| 52d59cf7b7 | |||
| 2dc885ab4d | |||
| 14488f43e0 | |||
| 90f2070146 | |||
| 1e551830ea | |||
| 84cc212ecd | |||
| da4025d338 | |||
| 88085c29ff | |||
| 3152667a0c | |||
| 4b44d88401 | |||
| 8957b9ce0e | |||
| 28a6b0dd33 | |||
| 7bc17cc072 |
@@ -145,7 +145,7 @@ Turnstone includes a built-in governance layer for enterprise deployments — ma
|
||||
- **RBAC** — 15 granular permissions, 3 built-in roles (admin / operator / viewer), custom roles, privilege escalation prevention
|
||||
- **OIDC SSO** — single sign-on via any OpenID Connect provider (Okta, Azure AD, Google, Keycloak); Authorization Code Flow with PKCE, auto-provisioning, claim-based role mapping with demotion propagation; see [docs/oidc.md](docs/oidc.md)
|
||||
- **Tool policies** — glob-pattern rules (`allow` / `deny` / `ask`) with priority ordering; automate approvals or lock down dangerous tools
|
||||
- **Skills** — reusable behavioral profiles with system prompts, `{{variable}}` substitution, session config (model, temperature, token budget), install-time security scanning, version history, external discovery (skills.sh / GitHub), and runtime `load_skill` tool for model-driven skill activation
|
||||
- **Skills** — reusable behavioral profiles with system prompts, `{{variable}}` substitution, session config (model, temperature, token budget), install-time security scanning, version history, external discovery (skills.sh / GitHub), and runtime `skill` tool for model-driven skill activation
|
||||
- **Usage tracking** — per-request token and tool metrics, aggregation by day / model / user, automatic 90-day pruning
|
||||
- **Audit logging** — append-only event trail for all admin mutations, IP-aware, 365-day retention
|
||||
|
||||
|
||||
@@ -250,13 +250,11 @@ class "MCPClientManager" as MCPMgr {
|
||||
|
||||
' ToolSearchManager
|
||||
class "ToolSearchManager" as ToolSearchMgr {
|
||||
- _all_tools: list[dict]
|
||||
- _always_on: list[dict]
|
||||
- _deferred: list[dict]
|
||||
- _expanded: dict[str, None]
|
||||
- _index: BM25Index
|
||||
--
|
||||
+ should_activate() → bool
|
||||
+ get_visible_tools() → list[dict]
|
||||
+ get_deferred_tools() → list[dict]
|
||||
+ get_expanded_names() → list[str]
|
||||
|
||||
@@ -33,7 +33,7 @@ package "Core Modules" as core #181825 {
|
||||
}
|
||||
|
||||
package "Session Runtime" as runtime #181825 {
|
||||
rectangle "load_skill tool\nsession.py" as loadtool
|
||||
rectangle "skill tool\nsession.py" as loadtool
|
||||
rectangle "set_skill()\nsession.py" as setskill
|
||||
rectangle "_load_skills()\nsession.py" as loadskills
|
||||
}
|
||||
@@ -80,6 +80,7 @@ importui --> install : POST (github source)
|
||||
' Annotations
|
||||
note right of parser
|
||||
YAML frontmatter -> ParsedSkill
|
||||
allowed-tools (standard) -> allowed_tools (internal)
|
||||
Anthropic + Hermes tag formats
|
||||
Name validation (lowercase+hyphens)
|
||||
end note
|
||||
|
||||
+20
-2
@@ -70,7 +70,7 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
|
||||
`{{node_id}}` (server node ID). Unrecognized placeholders are kept as-is.
|
||||
- **Runtime switching**: `/template <name>` to switch, `/template clear` to revert
|
||||
to defaults, `/template` to show current. Persisted across resume.
|
||||
- **Model-driven loading**: The `load_skill` built-in tool lets the model
|
||||
- **Model-driven loading**: The `skill` built-in tool lets the model
|
||||
discover and activate skills mid-conversation. `search` action finds skills
|
||||
by query (auto-approved); `load` action activates by name (requires user
|
||||
approval since it changes session behavior). Main session only.
|
||||
@@ -83,11 +83,15 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
|
||||
precedence on name collision. MCP-synced content updates reset `is_default` to
|
||||
prevent compromised servers from injecting defaults. Admin UI shows origin badge
|
||||
and disables edit/delete for MCP-sourced skills.
|
||||
- **Spec fields**: Skills support the full Agent Skills standard frontmatter:
|
||||
`name`, `description`, `license`, `compatibility`, `metadata` (author, version),
|
||||
`allowed-tools`. The `license` and `compatibility` fields are preserved on import
|
||||
and editable in the admin UI. See https://agentskills.io/specification.
|
||||
- **Security scanning**: Skills are automatically scanned at creation and update
|
||||
time. The scanner evaluates four risk axes: content risk (command execution,
|
||||
data exfiltration), supply chain risk (pipe-to-shell, transitive installs),
|
||||
vulnerability risk (prompt injection, insecure credentials), and declared
|
||||
capability risk (from `allowed_tools`). Results populate the `scan_status`
|
||||
capability risk (from `allowed-tools` in SKILL.md). Results populate the `scan_status`
|
||||
(safe/low/medium/high/critical) and `scan_report` (JSON breakdown) columns.
|
||||
These fields are system-managed and cannot be overwritten via the admin API.
|
||||
- **Discovery**: External skills can be discovered and installed from registries:
|
||||
@@ -100,6 +104,20 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
|
||||
Discovery view has search bar, result cards, and "Import from GitHub" modal.
|
||||
- SDK: `discover_skills(q)` and `install_skill(source, skill_id=..., url=...)`
|
||||
on both Python and TypeScript console clients.
|
||||
- **Runtime config on installed skills**: Installed (readonly) skills can have
|
||||
their runtime configuration edited — model, temperature, reasoning effort,
|
||||
token budget, max tokens, agent max turns, auto-approve, allowed tools,
|
||||
and enabled flag. The server restricts updates to these fields only via
|
||||
`_SKILL_RUNTIME_CONFIG_FIELDS` filtering; spec/content fields (name,
|
||||
description, tags, license, compatibility, content, activation) remain
|
||||
immutable. The admin UI shows "Save Config" instead of "Save" for these
|
||||
skills. Audit action: `skill.update.config`.
|
||||
- **Admin UI**: Create/Edit skill modals use a two-column spec manifest layout
|
||||
(left: Identity / Manifest / Deployment; right: Skill Content editor with
|
||||
monospace font). Runtime Config is a collapsible 3-column grid below.
|
||||
License uses an SPDX identifier dropdown (MIT, Apache-2.0, GPL-3.0, etc.).
|
||||
Installed skills show a cyan origin badge with source URL, spec fields are
|
||||
disabled, and all collapsible sections auto-expand in view mode.
|
||||
|
||||
### Usage Tracking
|
||||
|
||||
|
||||
+1
-1
@@ -299,7 +299,7 @@ four independent risk axes:
|
||||
obfuscation, download-execute chains, executable URLs from untrusted domains
|
||||
3. **Vulnerability risk** — prompt injection patterns, insecure credential
|
||||
handling, third-party content exposure (indirect prompt injection surface)
|
||||
4. **Declared capability risk** — parsed from the skill's `allowed_tools` field.
|
||||
4. **Declared capability risk** — parsed from `allowed-tools` in the skill's SKILL.md.
|
||||
`Bash(*)` (unrestricted shell) is high risk. `Bash(git:*)` is low.
|
||||
Read-only tools are safe.
|
||||
|
||||
|
||||
+5
-5
@@ -492,7 +492,7 @@ data.get("mergedAt") is not None
|
||||
|
||||
---
|
||||
|
||||
### load_skill
|
||||
### skill
|
||||
|
||||
Discover and activate skills at runtime during a conversation. The model can
|
||||
search for available skills and load one by name, replacing the current active
|
||||
@@ -543,7 +543,7 @@ pre-configure skills at workstream creation.
|
||||
| `watch` | Monitor | No (create) | No | No | `command` |
|
||||
| `read_resource`| MCP | No | Yes | Yes | `uri` |
|
||||
| `use_prompt` | MCP | No | Yes | Yes | `name` |
|
||||
| `load_skill` | Skills | No (load) | No | No | `name` |
|
||||
| `skill` | Skills | No (load) | No | No | `name` |
|
||||
| `tool_search`| Search | Yes | No | No | `query` |
|
||||
|
||||
---
|
||||
@@ -591,9 +591,9 @@ CLI flags override the config file:
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Threshold check**: At session startup, `ToolSearchManager.should_activate()`
|
||||
counts total tools (built-in + MCP). If the count is below the threshold, tool
|
||||
search stays off and all tools are sent to the model directly.
|
||||
1. **Threshold check**: At session startup, if the total tool count (built-in + MCP)
|
||||
is below the threshold, tool search stays off and all tools are sent to the model
|
||||
directly.
|
||||
|
||||
2. **Partitioning**: When active, tools are split into two sets:
|
||||
- **Always-on** -- the 17 built-in tools (members of `BUILTIN_TOOL_NAMES`).
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "turnstone"
|
||||
version = "0.8.0"
|
||||
version = "0.8.3"
|
||||
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
|
||||
readme = "README.md"
|
||||
license = "BUSL-1.1"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Console API",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.2",
|
||||
"description": "Cluster-wide visibility and control across all turnstone nodes."
|
||||
},
|
||||
"paths": {
|
||||
@@ -1821,7 +1821,7 @@
|
||||
},
|
||||
"/v1/api/admin/skills/install": {
|
||||
"post": {
|
||||
"summary": "Install a skill from an external source",
|
||||
"summary": "Install skill(s) from an external source",
|
||||
"operationId": "v1_api_admin_skills_install_post",
|
||||
"tags": [
|
||||
"Admin"
|
||||
@@ -1842,7 +1842,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SkillInfo"
|
||||
"$ref": "#/components/schemas/SkillInstallResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2470,6 +2470,195 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/skills/{skill_id}/resources": {
|
||||
"get": {
|
||||
"summary": "List resource files for a skill",
|
||||
"operationId": "v1_api_admin_skills_{skill_id}_resources_get",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "skill_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ListSkillResourcesResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"summary": "Upload a resource file to a skill",
|
||||
"operationId": "v1_api_admin_skills_{skill_id}_resources_post",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "skill_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateSkillResourceRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SkillResourceInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Error 400",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Error 409",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/skills/{skill_id}/resources/{path}": {
|
||||
"get": {
|
||||
"summary": "Get a single skill resource by path",
|
||||
"operationId": "v1_api_admin_skills_{skill_id}_resources_{path}_get",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "skill_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SkillResourceInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"summary": "Delete a skill resource by path",
|
||||
"operationId": "v1_api_admin_skills_{skill_id}_resources_{path}_delete",
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "skill_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "path",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"404": {
|
||||
"description": "Error 404",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/api/admin/memories": {
|
||||
"get": {
|
||||
"summary": "List structured memories",
|
||||
@@ -6506,6 +6695,35 @@
|
||||
"title": "SkillInstallRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"SkillInstallResponse": {
|
||||
"properties": {
|
||||
"installed": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SkillInfo"
|
||||
},
|
||||
"title": "Installed",
|
||||
"type": "array"
|
||||
},
|
||||
"skipped": {
|
||||
"default": [],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SkillInstallSkipped"
|
||||
},
|
||||
"title": "Skipped",
|
||||
"type": "array"
|
||||
},
|
||||
"total": {
|
||||
"default": 0,
|
||||
"title": "Total",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"installed"
|
||||
],
|
||||
"title": "SkillInstallResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"SkillInfo": {
|
||||
"properties": {
|
||||
"template_id": {
|
||||
@@ -6665,6 +6883,16 @@
|
||||
"title": "Allowed Tools",
|
||||
"type": "string"
|
||||
},
|
||||
"license": {
|
||||
"default": "",
|
||||
"title": "License",
|
||||
"type": "string"
|
||||
},
|
||||
"compatibility": {
|
||||
"default": "",
|
||||
"title": "Compatibility",
|
||||
"type": "string"
|
||||
},
|
||||
"scan_status": {
|
||||
"default": "",
|
||||
"title": "Scan Status",
|
||||
@@ -6680,6 +6908,11 @@
|
||||
"title": "Scan Version",
|
||||
"type": "string"
|
||||
},
|
||||
"resource_count": {
|
||||
"default": 0,
|
||||
"title": "Resource Count",
|
||||
"type": "integer"
|
||||
},
|
||||
"created": {
|
||||
"title": "Created",
|
||||
"type": "string"
|
||||
@@ -6703,6 +6936,24 @@
|
||||
"title": "SkillInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"SkillInstallSkipped": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"title": "Reason",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"reason"
|
||||
],
|
||||
"title": "SkillInstallSkipped",
|
||||
"type": "object"
|
||||
},
|
||||
"SkillVersionInfo": {
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -6866,6 +7117,16 @@
|
||||
"default": "[]",
|
||||
"title": "Allowed Tools",
|
||||
"type": "string"
|
||||
},
|
||||
"license": {
|
||||
"default": "",
|
||||
"title": "License",
|
||||
"type": "string"
|
||||
},
|
||||
"compatibility": {
|
||||
"default": "",
|
||||
"title": "Compatibility",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -7116,6 +7377,30 @@
|
||||
],
|
||||
"default": null,
|
||||
"title": "Allowed Tools"
|
||||
},
|
||||
"license": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "License"
|
||||
},
|
||||
"compatibility": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Compatibility"
|
||||
}
|
||||
},
|
||||
"title": "UpdateSkillRequest",
|
||||
@@ -7153,6 +7438,88 @@
|
||||
"title": "ListSkillVersionsResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"SkillResourceInfo": {
|
||||
"properties": {
|
||||
"resource_id": {
|
||||
"title": "Resource Id",
|
||||
"type": "string"
|
||||
},
|
||||
"skill_id": {
|
||||
"title": "Skill Id",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"title": "Path",
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"default": "",
|
||||
"title": "Content",
|
||||
"type": "string"
|
||||
},
|
||||
"content_type": {
|
||||
"default": "text/plain",
|
||||
"title": "Content Type",
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"default": 0,
|
||||
"title": "Size",
|
||||
"type": "integer"
|
||||
},
|
||||
"created": {
|
||||
"title": "Created",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resource_id",
|
||||
"skill_id",
|
||||
"path",
|
||||
"created"
|
||||
],
|
||||
"title": "SkillResourceInfo",
|
||||
"type": "object"
|
||||
},
|
||||
"CreateSkillResourceRequest": {
|
||||
"properties": {
|
||||
"path": {
|
||||
"title": "Path",
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"title": "Content",
|
||||
"type": "string"
|
||||
},
|
||||
"content_type": {
|
||||
"default": "text/plain",
|
||||
"title": "Content Type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path",
|
||||
"content"
|
||||
],
|
||||
"title": "CreateSkillResourceRequest",
|
||||
"type": "object"
|
||||
},
|
||||
"ListSkillResourcesResponse": {
|
||||
"properties": {
|
||||
"resources": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/SkillResourceInfo"
|
||||
},
|
||||
"title": "Resources",
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"resources"
|
||||
],
|
||||
"title": "ListSkillResourcesResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"SkillSummary": {
|
||||
"properties": {
|
||||
"name": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Server API",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.2",
|
||||
"description": "Single-node workstream management, chat interaction, and real-time streaming."
|
||||
},
|
||||
"paths": {
|
||||
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
CreateRoleOptions,
|
||||
CreateScheduleRequest,
|
||||
CreateSkillRequest,
|
||||
CreateSkillResourceRequest,
|
||||
ImportMcpConfigResponse,
|
||||
ListAdminMemoriesResponse,
|
||||
ListMcpServersResponse,
|
||||
@@ -28,6 +29,7 @@ import type {
|
||||
ListSchedulesResponse,
|
||||
ListSettingSchemaResponse,
|
||||
ListSettingsResponse,
|
||||
ListSkillResourcesResponse,
|
||||
ListSkillsResponse,
|
||||
McpServerDetail,
|
||||
RegistryInstallRequest,
|
||||
@@ -35,6 +37,8 @@ import type {
|
||||
SkillDiscoverResponse,
|
||||
SkillInfo,
|
||||
SkillInstallRequest,
|
||||
SkillInstallResponse,
|
||||
SkillResourceInfo,
|
||||
NodeDetailResponse,
|
||||
NodesOptions,
|
||||
OrgInfo,
|
||||
@@ -293,6 +297,30 @@ export class TurnstoneConsole extends BaseClient {
|
||||
await this.request("DELETE", `/v1/api/admin/skills/${skillId}`);
|
||||
}
|
||||
|
||||
async listSkillResources(skillId: string): Promise<SkillResourceInfo[]> {
|
||||
const resp = await this.request<ListSkillResourcesResponse>(
|
||||
"GET",
|
||||
`/v1/api/admin/skills/${skillId}/resources`,
|
||||
);
|
||||
return resp.resources;
|
||||
}
|
||||
|
||||
async createSkillResource(
|
||||
skillId: string,
|
||||
body: CreateSkillResourceRequest,
|
||||
): Promise<SkillResourceInfo> {
|
||||
return this.request("POST", `/v1/api/admin/skills/${skillId}/resources`, {
|
||||
json: body,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSkillResource(skillId: string, path: string): Promise<void> {
|
||||
await this.request(
|
||||
"DELETE",
|
||||
`/v1/api/admin/skills/${skillId}/resources/${path.split("/").map(encodeURIComponent).join("/")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// -- Governance: Usage & Audit ----------------------------------------------
|
||||
|
||||
async getUsage(opts: UsageQueryOptions): Promise<UsageResponse> {
|
||||
@@ -457,7 +485,7 @@ export class TurnstoneConsole extends BaseClient {
|
||||
});
|
||||
}
|
||||
|
||||
async installSkill(body: SkillInstallRequest): Promise<SkillInfo> {
|
||||
async installSkill(body: SkillInstallRequest): Promise<SkillInstallResponse> {
|
||||
return this.request("POST", "/v1/api/admin/skills/install", {
|
||||
json: body,
|
||||
});
|
||||
|
||||
@@ -131,6 +131,9 @@ export type {
|
||||
CreateSkillRequest,
|
||||
UpdateSkillRequest,
|
||||
ListSkillsResponse,
|
||||
SkillResourceInfo,
|
||||
ListSkillResourcesResponse,
|
||||
CreateSkillResourceRequest,
|
||||
UsageBreakdownItem,
|
||||
UsageResponse,
|
||||
UsageQueryOptions,
|
||||
@@ -175,6 +178,8 @@ export type {
|
||||
SkillDiscoverListing,
|
||||
SkillDiscoverResponse,
|
||||
SkillInstallRequest,
|
||||
SkillInstallResponse,
|
||||
SkillInstallSkipped,
|
||||
} from "./types.js";
|
||||
|
||||
// SSE parser (for advanced usage)
|
||||
|
||||
@@ -187,6 +187,9 @@ export interface SkillInfo {
|
||||
notify_on_complete: string;
|
||||
enabled: boolean;
|
||||
allowed_tools: string;
|
||||
license: string;
|
||||
compatibility: string;
|
||||
resource_count: number;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
@@ -213,6 +216,8 @@ export interface CreateSkillRequest {
|
||||
notify_on_complete?: string;
|
||||
enabled?: boolean;
|
||||
allowed_tools?: string;
|
||||
license?: string;
|
||||
compatibility?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSkillRequest {
|
||||
@@ -236,12 +241,34 @@ export interface UpdateSkillRequest {
|
||||
notify_on_complete?: string;
|
||||
enabled?: boolean;
|
||||
allowed_tools?: string;
|
||||
license?: string;
|
||||
compatibility?: string;
|
||||
}
|
||||
|
||||
export interface ListSkillsResponse {
|
||||
skills: SkillInfo[];
|
||||
}
|
||||
|
||||
export interface SkillResourceInfo {
|
||||
resource_id: string;
|
||||
skill_id: string;
|
||||
path: string;
|
||||
content?: string;
|
||||
content_type: string;
|
||||
size: number;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export interface ListSkillResourcesResponse {
|
||||
resources: SkillResourceInfo[];
|
||||
}
|
||||
|
||||
export interface CreateSkillResourceRequest {
|
||||
path: string;
|
||||
content: string;
|
||||
content_type?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server API — Health
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -857,6 +884,17 @@ export interface SkillInstallRequest {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface SkillInstallSkipped {
|
||||
name: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface SkillInstallResponse {
|
||||
installed: SkillInfo[];
|
||||
skipped: SkillInstallSkipped[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// -- Console API: System Settings -------------------------------------------
|
||||
|
||||
export interface SettingInfo {
|
||||
|
||||
@@ -130,7 +130,7 @@ class TestParseScopes:
|
||||
|
||||
|
||||
class TestJWT:
|
||||
SECRET = "test-secret-key-for-jwt"
|
||||
SECRET = "test-secret-key-for-jwt-min-32b!"
|
||||
|
||||
def test_round_trip(self):
|
||||
scopes = frozenset({"read", "write"})
|
||||
@@ -155,7 +155,7 @@ class TestJWT:
|
||||
|
||||
def test_invalid_signature(self):
|
||||
token = create_jwt("user1", frozenset({"read"}), "db", self.SECRET)
|
||||
assert validate_jwt(token, "wrong-secret") is None
|
||||
assert validate_jwt(token, "wrong-secret-key-for-jwt-min-32b") is None
|
||||
|
||||
def test_malformed_token(self):
|
||||
assert validate_jwt("not.a.jwt", self.SECRET) is None
|
||||
@@ -217,7 +217,7 @@ class TestAuthenticateToken:
|
||||
assert result.scopes == frozenset({"read", "write", "approve"})
|
||||
|
||||
def test_jwt_token(self):
|
||||
secret = "test-secret"
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("user1", frozenset({"read", "write"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
result = _authenticate_token(jwt_tok, cfg, jwt_secret=secret)
|
||||
@@ -304,7 +304,7 @@ class TestCheckRequestScopes:
|
||||
assert result.has_scope("approve")
|
||||
|
||||
def test_jwt_with_scopes(self):
|
||||
secret = "test"
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("u1", frozenset({"read", "write"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
allowed, status, msg, result = check_request(
|
||||
@@ -319,7 +319,7 @@ class TestCheckRequestScopes:
|
||||
assert result.user_id == "u1"
|
||||
|
||||
def test_jwt_insufficient_scope(self):
|
||||
secret = "test"
|
||||
secret = "test-secret-key-for-jwt-min-32b!"
|
||||
jwt_tok = create_jwt("u1", frozenset({"read"}), "db", secret)
|
||||
cfg = AuthConfig(enabled=True)
|
||||
allowed, status, msg, _ = check_request(
|
||||
|
||||
@@ -182,7 +182,6 @@ class TestErrorHandling:
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
MagicMock(),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@@ -215,7 +214,6 @@ class TestErrorHandling:
|
||||
result = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
MagicMock(),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@@ -259,7 +257,6 @@ class TestMultiTurnToolUse:
|
||||
verdict = judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
MagicMock(),
|
||||
)
|
||||
assert verdict is not None
|
||||
assert verdict.tier == "llm"
|
||||
@@ -305,7 +302,6 @@ class TestMultiTurnToolUse:
|
||||
judge._evaluate_single(
|
||||
_make_item(),
|
||||
[{"role": "user", "content": "test"}],
|
||||
MagicMock(),
|
||||
)
|
||||
# Should have called create_completion exactly _JUDGE_MAX_TURNS times
|
||||
assert provider.create_completion.call_count == 5
|
||||
|
||||
+143
-46
@@ -1,4 +1,4 @@
|
||||
"""Tests for the load_skill built-in tool."""
|
||||
"""Tests for the skill built-in tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,25 +9,25 @@ from turnstone.core.tools import BUILTIN_TOOL_NAMES, PRIMARY_KEY_MAP
|
||||
|
||||
|
||||
class TestToolRegistration:
|
||||
"""Verify load_skill is registered correctly."""
|
||||
"""Verify skill is registered correctly."""
|
||||
|
||||
def test_in_builtin_tool_names(self) -> None:
|
||||
assert "load_skill" in BUILTIN_TOOL_NAMES
|
||||
assert "skill" in BUILTIN_TOOL_NAMES
|
||||
|
||||
def test_not_agent_tool(self) -> None:
|
||||
from turnstone.core.tools import AGENT_TOOLS
|
||||
|
||||
names = {t["function"]["name"] for t in AGENT_TOOLS}
|
||||
assert "load_skill" not in names
|
||||
assert "skill" not in names
|
||||
|
||||
def test_not_task_agent_tool(self) -> None:
|
||||
from turnstone.core.tools import TASK_AGENT_TOOLS
|
||||
|
||||
names = {t["function"]["name"] for t in TASK_AGENT_TOOLS}
|
||||
assert "load_skill" not in names
|
||||
assert "skill" not in names
|
||||
|
||||
def test_has_primary_key(self) -> None:
|
||||
assert PRIMARY_KEY_MAP.get("load_skill") == "name"
|
||||
assert PRIMARY_KEY_MAP.get("skill") == "name"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -82,12 +82,12 @@ def _make_session(skills: list[dict[str, Any]] | None = None):
|
||||
|
||||
|
||||
class TestPrepareLoadSkill:
|
||||
"""Test _prepare_load_skill validation and item dict shape."""
|
||||
"""Test _prepare_skill validation and item dict shape."""
|
||||
|
||||
def test_load_valid(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "code-review"})
|
||||
assert item["func_name"] == "load_skill"
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "code-review"})
|
||||
assert item["func_name"] == "skill"
|
||||
assert item["action"] == "load"
|
||||
assert item["name"] == "code-review"
|
||||
assert item["needs_approval"] is True
|
||||
@@ -96,19 +96,19 @@ class TestPrepareLoadSkill:
|
||||
|
||||
def test_load_missing_name(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "load"})
|
||||
item = session._prepare_skill("call-1", {"action": "load"})
|
||||
assert "error" in item
|
||||
assert "name" in item["error"].lower()
|
||||
assert item["needs_approval"] is False
|
||||
|
||||
def test_load_empty_name(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": ""})
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": ""})
|
||||
assert "error" in item
|
||||
|
||||
def test_search_with_query(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "code review"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "code review"})
|
||||
assert item["action"] == "search"
|
||||
assert item["query"] == "code review"
|
||||
assert item["needs_approval"] is False
|
||||
@@ -116,30 +116,30 @@ class TestPrepareLoadSkill:
|
||||
|
||||
def test_search_without_query(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search"})
|
||||
item = session._prepare_skill("call-1", {"action": "search"})
|
||||
assert item["action"] == "search"
|
||||
assert item["query"] == ""
|
||||
assert item["needs_approval"] is False
|
||||
|
||||
def test_invalid_action(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "delete"})
|
||||
item = session._prepare_skill("call-1", {"action": "delete"})
|
||||
assert "error" in item
|
||||
assert "delete" in item["error"]
|
||||
|
||||
def test_empty_action(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": ""})
|
||||
item = session._prepare_skill("call-1", {"action": ""})
|
||||
assert "error" in item
|
||||
|
||||
def test_header_for_load(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "my-skill"})
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "my-skill"})
|
||||
assert "my-skill" in item["header"]
|
||||
|
||||
def test_header_for_search(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "testing"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "testing"})
|
||||
assert "testing" in item["header"]
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ class TestPrepareLoadSkill:
|
||||
|
||||
|
||||
class TestExecLoadSkill:
|
||||
"""Test _exec_load_skill execution logic."""
|
||||
"""Test _exec_skill execution logic."""
|
||||
|
||||
def test_load_existing_skill(self) -> None:
|
||||
skills = [
|
||||
@@ -164,8 +164,8 @@ class TestExecLoadSkill:
|
||||
session, _, fake_get = _make_session(skills)
|
||||
|
||||
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "code-review"})
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "code-review"})
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert call_id == "call-1"
|
||||
assert "code-review" in result
|
||||
@@ -177,8 +177,8 @@ class TestExecLoadSkill:
|
||||
session, _, fake_get = _make_session([])
|
||||
|
||||
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "nope"})
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "nope"})
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "not found" in result.lower()
|
||||
assert session._set_skill_called == []
|
||||
@@ -188,8 +188,8 @@ class TestExecLoadSkill:
|
||||
session, _, fake_get = _make_session(skills)
|
||||
|
||||
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "test"})
|
||||
session._exec_load_skill(item)
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "test"})
|
||||
session._exec_skill(item)
|
||||
|
||||
session.ui.on_tool_result.assert_called_once()
|
||||
|
||||
@@ -216,10 +216,10 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = skills
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "code"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "code"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "code-review" in result
|
||||
# docs-writer shouldn't match "code" query
|
||||
@@ -241,10 +241,10 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = skills
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search"})
|
||||
item = session._prepare_skill("call-1", {"action": "search"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
# Should be limited to 10
|
||||
assert result.count("skill-") == 10
|
||||
@@ -254,10 +254,10 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = []
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "nonexistent"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "nonexistent"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "no skills found" in result.lower()
|
||||
|
||||
@@ -276,21 +276,21 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = skills
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "risky"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "risky"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "high" in result
|
||||
|
||||
def test_search_storage_failure_returns_empty(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "test"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "test"})
|
||||
|
||||
with patch(
|
||||
"turnstone.core.storage._registry.get_storage", side_effect=RuntimeError("no storage")
|
||||
):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "no skills found" in result.lower()
|
||||
|
||||
@@ -307,10 +307,8 @@ class TestExecLoadSkill:
|
||||
session, _, fake_get = _make_session(skills)
|
||||
|
||||
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
||||
item = session._prepare_load_skill(
|
||||
"call-1", {"action": "load", "name": "disabled-skill"}
|
||||
)
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "disabled-skill"})
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "not found" in result.lower()
|
||||
assert session._set_skill_called == []
|
||||
@@ -321,8 +319,8 @@ class TestExecLoadSkill:
|
||||
session._skill_name = "active"
|
||||
|
||||
with patch("turnstone.core.session.get_skill_by_name", side_effect=fake_get):
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "active"})
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "active"})
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "already active" in result.lower()
|
||||
assert session._set_skill_called == []
|
||||
@@ -352,10 +350,10 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = skills
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search"})
|
||||
item = session._prepare_skill("call-1", {"action": "search"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "enabled-skill" in result
|
||||
assert "disabled-skill" not in result
|
||||
@@ -375,14 +373,113 @@ class TestExecLoadSkill:
|
||||
mock_storage.list_prompt_templates.return_value = skills
|
||||
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "search", "query": "code review"})
|
||||
item = session._prepare_skill("call-1", {"action": "search", "query": "code review"})
|
||||
|
||||
with patch("turnstone.core.storage._registry.get_storage", return_value=mock_storage):
|
||||
call_id, result = session._exec_load_skill(item)
|
||||
call_id, result = session._exec_skill(item)
|
||||
|
||||
assert "code-review" in result
|
||||
|
||||
def test_preparer_load_has_approval_label(self) -> None:
|
||||
session, _, _ = _make_session()
|
||||
item = session._prepare_load_skill("call-1", {"action": "load", "name": "my-skill"})
|
||||
assert item["approval_label"] == "load_skill__my-skill"
|
||||
item = session._prepare_skill("call-1", {"action": "load", "name": "my-skill"})
|
||||
assert item["approval_label"] == "skill__my-skill"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Skill Catalog Disclosure (Agent Skills standard compliance)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSkillCatalogDisclosure:
|
||||
"""Verify <available-skills> catalog appears in system messages."""
|
||||
|
||||
def _build_session_with_system_messages(
|
||||
self,
|
||||
search_skills: list[dict[str, Any]] | None = None,
|
||||
) -> Any:
|
||||
"""Build a session and call _init_system_messages to get dev_parts."""
|
||||
from turnstone.core.session import ChatSession
|
||||
|
||||
session = ChatSession.__new__(ChatSession)
|
||||
ui = MagicMock()
|
||||
session.ui = ui
|
||||
session.model = "test-model"
|
||||
session._ws_id = "ws-test"
|
||||
session._node_id = "node-1"
|
||||
session._skill_name = None
|
||||
session._skill_content = None
|
||||
session._skill_resources = {}
|
||||
session._applied_skill_content = None
|
||||
session.context_window = 128000
|
||||
session.messages = []
|
||||
session._config = {}
|
||||
session.creative_mode = False
|
||||
session.instructions = ""
|
||||
session.system_messages = []
|
||||
session._agent_system_messages = []
|
||||
session.reasoning_effort = "medium"
|
||||
session._pending_nudge = []
|
||||
session._tool_search = None
|
||||
session._mcp_client = None
|
||||
session._notify_on_complete = "{}"
|
||||
|
||||
# Memory stubs
|
||||
session._memory_config = MagicMock()
|
||||
session._memory_config.fetch_limit = 0
|
||||
session._user_id = ""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.session.list_skills_by_activation",
|
||||
return_value=search_skills or [],
|
||||
),
|
||||
patch.object(session, "_get_visible_memories", return_value=[]),
|
||||
):
|
||||
session._init_system_messages()
|
||||
|
||||
return session
|
||||
|
||||
def test_catalog_present_with_search_skills(self) -> None:
|
||||
skills = [
|
||||
{"name": "pdf-processing", "description": "Extract PDF text and forms."},
|
||||
{"name": "data-analysis", "description": "Analyze datasets."},
|
||||
]
|
||||
session = self._build_session_with_system_messages(search_skills=skills)
|
||||
content = session.system_messages[0]["content"]
|
||||
assert "<available-skills>" in content
|
||||
assert "pdf-processing" in content
|
||||
assert "data-analysis" in content
|
||||
assert "</available-skills>" in content
|
||||
|
||||
def test_catalog_omitted_when_no_search_skills(self) -> None:
|
||||
session = self._build_session_with_system_messages(search_skills=[])
|
||||
content = session.system_messages[0]["content"]
|
||||
assert "<available-skills>" not in content
|
||||
|
||||
def test_catalog_capped_at_30(self) -> None:
|
||||
skills = [{"name": f"skill-{i:03d}", "description": f"Desc {i}"} for i in range(50)]
|
||||
session = self._build_session_with_system_messages(search_skills=skills)
|
||||
content = session.system_messages[0]["content"]
|
||||
# Should include first 30, not all 50
|
||||
assert "skill-029" in content
|
||||
assert "skill-030" not in content
|
||||
|
||||
def test_catalog_escapes_html(self) -> None:
|
||||
skills = [
|
||||
{"name": "xss-test", "description": "Handle <script> & 'quotes'."},
|
||||
]
|
||||
session = self._build_session_with_system_messages(search_skills=skills)
|
||||
content = session.system_messages[0]["content"]
|
||||
assert "<script>" in content
|
||||
assert "<script>" not in content.replace("<available-skills>", "").replace(
|
||||
"</available-skills>", ""
|
||||
).replace("<skill>", "").replace("</skill>", "").replace("<name>", "").replace(
|
||||
"</name>", ""
|
||||
).replace("<description>", "").replace("</description>", "")
|
||||
|
||||
def test_catalog_includes_hint(self) -> None:
|
||||
skills = [{"name": "test", "description": "Test skill."}]
|
||||
session = self._build_session_with_system_messages(search_skills=skills)
|
||||
content = session.system_messages[0]["content"]
|
||||
assert "/skill" in content
|
||||
|
||||
@@ -6,6 +6,7 @@ from turnstone.core.metacognition import (
|
||||
NUDGE_DENIAL,
|
||||
NUDGE_RESUME,
|
||||
NUDGE_START,
|
||||
NUDGE_TOOL_ERROR,
|
||||
detect_completion,
|
||||
detect_correction,
|
||||
format_nudge,
|
||||
@@ -263,5 +264,27 @@ class TestFormatNudge:
|
||||
def test_start(self):
|
||||
assert format_nudge("start") == NUDGE_START
|
||||
|
||||
def test_tool_error(self):
|
||||
assert format_nudge("tool_error") == NUDGE_TOOL_ERROR
|
||||
|
||||
def test_invalid(self):
|
||||
assert format_nudge("invalid") == ""
|
||||
|
||||
|
||||
class TestToolErrorNudge:
|
||||
def test_fires(self):
|
||||
state: dict[str, float] = {}
|
||||
assert should_nudge("tool_error", state, message_count=5, memory_count=3) is True
|
||||
|
||||
def test_cooldown(self):
|
||||
state: dict[str, float] = {}
|
||||
assert should_nudge("tool_error", state, message_count=5, memory_count=3) is True
|
||||
assert should_nudge("tool_error", state, message_count=6, memory_count=3) is False
|
||||
|
||||
def test_not_on_first_message(self):
|
||||
state: dict[str, float] = {}
|
||||
assert should_nudge("tool_error", state, message_count=1, memory_count=3) is False
|
||||
|
||||
def test_not_with_zero_memories(self):
|
||||
state: dict[str, float] = {}
|
||||
assert should_nudge("tool_error", state, message_count=5, memory_count=0) is False
|
||||
|
||||
@@ -131,7 +131,7 @@ def authorize_client(storage: SQLiteBackend, oidc_config: OIDCConfig) -> TestCli
|
||||
)
|
||||
app.state.oidc_config = oidc_config
|
||||
app.state.auth_storage = storage
|
||||
app.state.jwt_secret = "test-jwt-secret"
|
||||
app.state.jwt_secret = "test-jwt-secret-key-padded-32b!!"
|
||||
app.state.jwks_data = {"keys": []}
|
||||
app.state.login_limiter = None
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
@@ -468,7 +468,7 @@ class TestOIDCCallback:
|
||||
)
|
||||
app.state.oidc_config = _make_oidc_config()
|
||||
app.state.auth_storage = backend
|
||||
app.state.jwt_secret = "secret"
|
||||
app.state.jwt_secret = "test-jwt-secret-key-padded-32b!!"
|
||||
app.state.jwks_data = {"keys": []}
|
||||
app.state.login_limiter = None
|
||||
|
||||
@@ -492,7 +492,7 @@ class TestOIDCCallback:
|
||||
)
|
||||
app.state.oidc_config = _make_oidc_config()
|
||||
app.state.auth_storage = storage
|
||||
app.state.jwt_secret = "secret"
|
||||
app.state.jwt_secret = "test-jwt-secret-key-padded-32b!!"
|
||||
app.state.jwks_data = {"keys": []}
|
||||
limiter = LoginRateLimiter(max_attempts=1, window_seconds=300)
|
||||
limiter.record("ip:testclient")
|
||||
|
||||
@@ -184,6 +184,17 @@ class TestEnvSecretFalsePositives:
|
||||
r = evaluate_output("APP_NAME=myapp\nSECRET_KEY=abc123\nAPI_TOKEN=xyz789\nDEBUG=true")
|
||||
assert "env_file_leak" in r.flags
|
||||
|
||||
def test_single_secret_env_line(self) -> None:
|
||||
"""A single AWS_SECRET_ACCESS_KEY=... line should trigger."""
|
||||
r = evaluate_output("AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
|
||||
assert "env_file_leak" in r.flags
|
||||
assert r.risk_level == "high"
|
||||
|
||||
def test_substring_key_no_false_positive(self) -> None:
|
||||
"""MONKEY=banana should not trigger (KEY is a substring, not a segment)."""
|
||||
r = evaluate_output("MONKEY=banana\nTURKEY=gobble\nDONKEY=hee-haw")
|
||||
assert "env_file_leak" not in r.flags
|
||||
|
||||
|
||||
class TestOutputAssessment:
|
||||
"""Verify OutputAssessment structure."""
|
||||
|
||||
@@ -607,3 +607,150 @@ class TestPruneWorkstreams:
|
||||
# Config rows should be cleaned up
|
||||
assert load_workstream_config("orphan_cfg") == {}
|
||||
assert load_workstream_config("stale_cfg") == {}
|
||||
|
||||
|
||||
# ── Parallel tool exception isolation ────────────────────────────────
|
||||
|
||||
|
||||
class TestParallelToolExceptionIsolation:
|
||||
"""Bug #117: one tool raising should not kill the entire batch."""
|
||||
|
||||
def test_exception_in_one_tool_does_not_kill_batch(self, tmp_db, mock_openai_client):
|
||||
from unittest.mock import patch
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="test-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
)
|
||||
|
||||
def succeed(item):
|
||||
return item["call_id"], "ok"
|
||||
|
||||
def fail(item):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
items = [
|
||||
{
|
||||
"call_id": "c1",
|
||||
"func_name": "bash",
|
||||
"execute": succeed,
|
||||
"needs_approval": False,
|
||||
"header": "test",
|
||||
"preview": "",
|
||||
},
|
||||
{
|
||||
"call_id": "c2",
|
||||
"func_name": "math",
|
||||
"execute": fail,
|
||||
"needs_approval": False,
|
||||
"header": "test",
|
||||
"preview": "",
|
||||
},
|
||||
]
|
||||
|
||||
tool_calls = [
|
||||
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
|
||||
{"id": "c2", "function": {"name": "math", "arguments": "{}"}},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(session, "_prepare_tool", side_effect=items),
|
||||
patch.object(session, "_evaluate_intent"),
|
||||
patch.object(session, "_emit_state"),
|
||||
patch.object(session, "_init_system_messages"),
|
||||
patch.object(session, "_check_cancelled"),
|
||||
):
|
||||
session.ui.approve_tools.return_value = (True, None)
|
||||
results, _ = session._execute_tools(tool_calls)
|
||||
|
||||
assert results[0] == ("c1", "ok")
|
||||
assert results[1][0] == "c2"
|
||||
assert "Error executing math" in results[1][1]
|
||||
assert "boom" in results[1][1]
|
||||
|
||||
|
||||
# ── Web search tool gating ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestWebSearchGating:
|
||||
"""Bug #117: web_search should not be offered without a backend."""
|
||||
|
||||
def test_web_search_filtered_when_no_backend(self, tmp_db, mock_openai_client):
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
)
|
||||
|
||||
caps = ModelCapabilities(supports_web_search=False)
|
||||
with (
|
||||
patch.object(session, "_get_capabilities", return_value=caps),
|
||||
patch("turnstone.core.session.get_tavily_key", return_value=None),
|
||||
):
|
||||
tools = session._get_active_tools()
|
||||
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "web_search" not in names
|
||||
|
||||
def test_web_search_kept_when_tavily_available(self, tmp_db, mock_openai_client):
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="local-model",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
)
|
||||
|
||||
caps = ModelCapabilities(supports_web_search=False)
|
||||
with (
|
||||
patch.object(session, "_get_capabilities", return_value=caps),
|
||||
patch("turnstone.core.session.get_tavily_key", return_value="tvly-test-key"),
|
||||
):
|
||||
tools = session._get_active_tools()
|
||||
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "web_search" in names
|
||||
|
||||
def test_web_search_kept_when_native_support(self, tmp_db, mock_openai_client):
|
||||
from unittest.mock import patch
|
||||
|
||||
from turnstone.core.providers._protocol import ModelCapabilities
|
||||
|
||||
session = ChatSession(
|
||||
client=mock_openai_client,
|
||||
model="gpt-5-search-api",
|
||||
ui=MagicMock(),
|
||||
instructions=None,
|
||||
temperature=0.5,
|
||||
max_tokens=1000,
|
||||
tool_timeout=10,
|
||||
)
|
||||
|
||||
caps = ModelCapabilities(supports_web_search=True)
|
||||
with (
|
||||
patch.object(session, "_get_capabilities", return_value=caps),
|
||||
patch("turnstone.core.session.get_tavily_key", return_value=None),
|
||||
):
|
||||
tools = session._get_active_tools()
|
||||
|
||||
names = [t.get("function", {}).get("name") for t in tools]
|
||||
assert "web_search" in names
|
||||
|
||||
+3
-3
@@ -74,7 +74,7 @@ class TestSimEngine:
|
||||
|
||||
def test_llm_response_returns_content(self, engine):
|
||||
async def _test():
|
||||
content, tool_calls = await engine.simulate_llm_response(True, 1)
|
||||
content, tool_calls = await engine.simulate_llm_response(True)
|
||||
assert isinstance(content, str)
|
||||
assert len(content) > 0
|
||||
assert isinstance(tool_calls, list)
|
||||
@@ -85,8 +85,8 @@ class TestSimEngine:
|
||||
async def _test():
|
||||
e1 = SimEngine(fast_config, rng=random.Random(123))
|
||||
e2 = SimEngine(fast_config, rng=random.Random(123))
|
||||
c1, t1 = await e1.simulate_llm_response(True, 1)
|
||||
c2, t2 = await e2.simulate_llm_response(True, 1)
|
||||
c1, t1 = await e1.simulate_llm_response(True)
|
||||
c2, t2 = await e2.simulate_llm_response(True)
|
||||
assert c1 == c2
|
||||
assert len(t1) == len(t2)
|
||||
|
||||
|
||||
@@ -175,11 +175,15 @@ class TestSkillDiscover:
|
||||
instance = mock_cls.return_value
|
||||
instance.search = AsyncMock(return_value=[])
|
||||
|
||||
resp = client.get("/v1/api/admin/skills/discover")
|
||||
resp = client.get("/v1/api/admin/skills/discover", params={"q": "test"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["skills"] == []
|
||||
|
||||
def test_search_empty_query_rejected(self, client: TestClient) -> None:
|
||||
resp = client.get("/v1/api/admin/skills/discover")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_search_permission_denied(self, client_no_perm: TestClient) -> None:
|
||||
resp = client_no_perm.get("/v1/api/admin/skills/discover")
|
||||
assert resp.status_code == 403
|
||||
@@ -241,10 +245,13 @@ class TestSkillInstall:
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "test-skill"
|
||||
assert data["origin"] == "source"
|
||||
assert data["readonly"] is True
|
||||
assert data["source_url"] == "https://github.com/owner/repo"
|
||||
assert data["total"] == 1
|
||||
assert len(data["installed"]) == 1
|
||||
skill = data["installed"][0]
|
||||
assert skill["name"] == "test-skill"
|
||||
assert skill["origin"] == "source"
|
||||
assert skill["readonly"] is True
|
||||
assert skill["source_url"] == "https://github.com/owner/repo"
|
||||
|
||||
def test_install_from_skills_sh(self, client: TestClient) -> None:
|
||||
package = _sample_package()
|
||||
@@ -265,7 +272,7 @@ class TestSkillInstall:
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "test-skill"
|
||||
assert resp.json()["installed"][0]["name"] == "test-skill"
|
||||
|
||||
def test_install_invalid_source(self, client: TestClient) -> None:
|
||||
resp = client.post(
|
||||
@@ -345,10 +352,17 @@ class TestSkillInstall:
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_install_not_found(self, client: TestClient) -> None:
|
||||
with patch(
|
||||
"turnstone.core.skill_sources.fetch_skill_from_github", new_callable=AsyncMock
|
||||
) as mock_fetch:
|
||||
with (
|
||||
patch(
|
||||
"turnstone.core.skill_sources.fetch_skill_from_github", new_callable=AsyncMock
|
||||
) as mock_fetch,
|
||||
patch(
|
||||
"turnstone.core.skill_sources.fetch_skills_from_github_repo",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_batch,
|
||||
):
|
||||
mock_fetch.side_effect = SkillNotFoundError("SKILL.md not found")
|
||||
mock_batch.side_effect = SkillNotFoundError("No SKILL.md files found")
|
||||
|
||||
resp = client.post(
|
||||
"/v1/api/admin/skills/install",
|
||||
@@ -391,7 +405,7 @@ class TestSkillInstall:
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
skill_id = resp.json()["template_id"]
|
||||
skill_id = resp.json()["installed"][0]["template_id"]
|
||||
resources = storage.list_skill_resources(skill_id)
|
||||
assert len(resources) == 1
|
||||
assert resources[0]["path"] == "scripts/setup.sh"
|
||||
|
||||
+316
-6
@@ -18,7 +18,7 @@ description: Automated code review skill
|
||||
author: Test Author
|
||||
version: 2.0.0
|
||||
tags: [python, review, quality]
|
||||
allowed_tools: [read_file, list_directory]
|
||||
allowed-tools: [read_file, list_directory]
|
||||
license: MIT
|
||||
compatibility: ">=0.7"
|
||||
---
|
||||
@@ -187,13 +187,13 @@ Content.
|
||||
|
||||
|
||||
class TestAllowedTools:
|
||||
"""Verify allowed_tools parsing."""
|
||||
"""Verify allowed-tools parsing (Agent Skills standard hyphenated field)."""
|
||||
|
||||
def test_list_format(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: tools-list
|
||||
allowed_tools: [bash, read_file]
|
||||
allowed-tools: [bash, read_file]
|
||||
---
|
||||
|
||||
Content.
|
||||
@@ -201,11 +201,12 @@ Content.
|
||||
result = parse_skill_md(raw)
|
||||
assert result.allowed_tools == ["bash", "read_file"]
|
||||
|
||||
def test_csv_format(self) -> None:
|
||||
def test_space_delimited_format(self) -> None:
|
||||
"""Standard format per Agent Skills spec."""
|
||||
raw = """\
|
||||
---
|
||||
name: tools-csv
|
||||
allowed_tools: "bash, read_file, write_file"
|
||||
name: tools-space
|
||||
allowed-tools: "bash read_file write_file"
|
||||
---
|
||||
|
||||
Content.
|
||||
@@ -219,6 +220,19 @@ Content.
|
||||
name: no-tools
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.allowed_tools == []
|
||||
|
||||
def test_underscore_key_not_read(self) -> None:
|
||||
"""allowed_tools (underscore) is not a SKILL.md field — ignored by parser."""
|
||||
raw = """\
|
||||
---
|
||||
name: legacy-key
|
||||
allowed_tools: [bash, read_file]
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
@@ -247,3 +261,299 @@ class TestValidateSkillName:
|
||||
assert validate_skill_name("HAS-UPPER") is not None
|
||||
assert validate_skill_name("has space") is not None
|
||||
assert validate_skill_name("-leading-hyphen") is not None
|
||||
|
||||
def test_consecutive_hyphens_rejected(self) -> None:
|
||||
"""Agent Skills spec: consecutive hyphens not allowed."""
|
||||
assert validate_skill_name("foo--bar") is not None
|
||||
assert "consecutive hyphens" in (validate_skill_name("a--b") or "")
|
||||
# Single hyphens are fine
|
||||
assert validate_skill_name("foo-bar") is None
|
||||
|
||||
|
||||
# -- Agent Skills Standard Compliance Tests -----------------------------------
|
||||
|
||||
|
||||
class TestStandardAllowedTools:
|
||||
"""Agent Skills spec: 'allowed-tools' (hyphenated), space-delimited."""
|
||||
|
||||
def test_list_format(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: standard-tools
|
||||
allowed-tools: ["Bash(git:*)", "Bash(jq:*)", "Read"]
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.allowed_tools == ["Bash(git:*)", "Bash(jq:*)", "Read"]
|
||||
|
||||
def test_space_delimited(self) -> None:
|
||||
"""Standard format: space-delimited string."""
|
||||
raw = """\
|
||||
---
|
||||
name: space-tools
|
||||
allowed-tools: "Bash(git:*) Bash(jq:*) Read"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.allowed_tools == ["Bash(git:*)", "Bash(jq:*)", "Read"]
|
||||
|
||||
def test_mixed_space_comma_delimiters(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: mixed-delim
|
||||
allowed-tools: "Read, Write Bash"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.allowed_tools == ["Read", "Write", "Bash"]
|
||||
|
||||
|
||||
class TestStandardMetadataNesting:
|
||||
"""Standard puts author/version under metadata map."""
|
||||
|
||||
def test_metadata_author(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: nested-author
|
||||
description: Test skill
|
||||
metadata:
|
||||
author: example-org
|
||||
version: "2.0"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.author == "example-org"
|
||||
assert result.version == "2.0"
|
||||
|
||||
def test_top_level_takes_precedence(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: precedence
|
||||
description: Test skill
|
||||
author: top-level
|
||||
version: 1.0.0
|
||||
metadata:
|
||||
author: nested
|
||||
version: "2.0"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.author == "top-level"
|
||||
assert result.version == "1.0.0"
|
||||
|
||||
def test_metadata_version_only(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: version-only
|
||||
description: Test
|
||||
metadata:
|
||||
version: "3.5.1"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.version == "3.5.1"
|
||||
assert result.author == ""
|
||||
|
||||
def test_null_author_uses_default(self) -> None:
|
||||
"""YAML null/bare key must not produce the string 'None'."""
|
||||
raw = """\
|
||||
---
|
||||
name: null-author
|
||||
description: Test
|
||||
author:
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.author == ""
|
||||
assert result.version == "1.0.0"
|
||||
|
||||
def test_null_version_uses_default(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: null-version
|
||||
description: Test
|
||||
version:
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.version == "1.0.0"
|
||||
|
||||
def test_null_description_falls_back_to_body(self) -> None:
|
||||
"""YAML null description must not produce 'None' string."""
|
||||
raw = """\
|
||||
---
|
||||
name: null-desc
|
||||
description:
|
||||
---
|
||||
|
||||
First paragraph here.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.description == "First paragraph here."
|
||||
assert "None" not in result.description
|
||||
|
||||
def test_null_license_and_compatibility(self) -> None:
|
||||
"""YAML null license/compatibility must not produce 'None' string."""
|
||||
raw = """\
|
||||
---
|
||||
name: null-fields
|
||||
description: Test
|
||||
license:
|
||||
compatibility:
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.license == ""
|
||||
assert result.compatibility == ""
|
||||
|
||||
|
||||
class TestStandardFieldLengths:
|
||||
"""Spec caps: description <= 1024, compatibility <= 500."""
|
||||
|
||||
def test_description_truncated_at_1024(self) -> None:
|
||||
long_desc = "x" * 1200
|
||||
raw = f"""\
|
||||
---
|
||||
name: long-desc
|
||||
description: "{long_desc}"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert len(result.description) == 1024
|
||||
|
||||
def test_compatibility_truncated_at_500(self) -> None:
|
||||
long_compat = "y" * 600
|
||||
raw = f"""\
|
||||
---
|
||||
name: long-compat
|
||||
description: Short
|
||||
compatibility: "{long_compat}"
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert len(result.compatibility) == 500
|
||||
|
||||
def test_short_fields_unchanged(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: short
|
||||
description: Brief
|
||||
compatibility: Requires git
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw)
|
||||
assert result.description == "Brief"
|
||||
assert result.compatibility == "Requires git"
|
||||
|
||||
|
||||
class TestLenientMode:
|
||||
"""Lenient parsing for cross-client skill ingestion."""
|
||||
|
||||
def test_invalid_name_sanitized(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: Invalid_Name!
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw, lenient=True)
|
||||
assert result is not None
|
||||
assert result.name == "invalidname"
|
||||
|
||||
def test_unsalvageable_name_returns_none(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: "!!!"
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
assert parse_skill_md(raw, lenient=True) is None
|
||||
|
||||
def test_missing_description_returns_none(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: no-desc
|
||||
---
|
||||
"""
|
||||
assert parse_skill_md(raw, lenient=True) is None
|
||||
|
||||
def test_broken_yaml_returns_none(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: [broken: yaml: {{{
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
assert parse_skill_md(raw, lenient=True) is None
|
||||
|
||||
def test_malformed_yaml_colon_in_description_recovers(self) -> None:
|
||||
"""Standard recommends retrying unquoted colon values."""
|
||||
raw = """\
|
||||
---
|
||||
name: colon-desc
|
||||
description: Use this skill when: the user asks about PDFs
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw, lenient=True)
|
||||
# The frontmatter library may parse this fine, but if not,
|
||||
# the retry mechanism should recover.
|
||||
assert result is not None
|
||||
assert result.name == "colon-desc"
|
||||
assert "PDF" in result.description
|
||||
|
||||
def test_strict_mode_still_raises(self) -> None:
|
||||
"""Default strict mode unchanged."""
|
||||
raw = """\
|
||||
---
|
||||
name: Invalid_Name!
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
with pytest.raises(ValueError):
|
||||
parse_skill_md(raw)
|
||||
|
||||
def test_consecutive_hyphens_lenient(self) -> None:
|
||||
raw = """\
|
||||
---
|
||||
name: foo--bar
|
||||
description: A test skill
|
||||
---
|
||||
|
||||
Content.
|
||||
"""
|
||||
result = parse_skill_md(raw, lenient=True)
|
||||
assert result is not None
|
||||
assert "--" not in result.name
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Tests for skill resource admin API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from turnstone.console.server import (
|
||||
admin_create_skill_resource,
|
||||
admin_delete_skill_resource,
|
||||
admin_get_skill,
|
||||
admin_get_skill_resource,
|
||||
admin_list_skill_resources,
|
||||
admin_list_skills,
|
||||
)
|
||||
from turnstone.core.auth import AuthResult
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth middleware
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _InjectAuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Inject an admin auth result with admin.skills permission."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next: Any) -> Response:
|
||||
request.state.auth_result = AuthResult(
|
||||
user_id="test-user",
|
||||
scopes=frozenset({"approve"}),
|
||||
token_source="config",
|
||||
permissions=frozenset({"read", "write", "approve", "admin.skills"}),
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ROUTES = [
|
||||
Mount(
|
||||
"/v1",
|
||||
routes=[
|
||||
Route("/api/admin/skills", admin_list_skills),
|
||||
Route("/api/admin/skills/{skill_id}", admin_get_skill),
|
||||
Route(
|
||||
"/api/admin/skills/{skill_id}/resources",
|
||||
admin_list_skill_resources,
|
||||
),
|
||||
Route(
|
||||
"/api/admin/skills/{skill_id}/resources",
|
||||
admin_create_skill_resource,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/skills/{skill_id}/resources/{path:path}",
|
||||
admin_get_skill_resource,
|
||||
),
|
||||
Route(
|
||||
"/api/admin/skills/{skill_id}/resources/{path:path}",
|
||||
admin_delete_skill_resource,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def storage(tmp_path):
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(storage):
|
||||
app = Starlette(
|
||||
routes=_ROUTES,
|
||||
middleware=[Middleware(_InjectAuthMiddleware)],
|
||||
)
|
||||
app.state.auth_storage = storage
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _create_test_skill(storage: SQLiteBackend, *, readonly: bool = False) -> str:
|
||||
"""Create a minimal skill in storage and return its template_id."""
|
||||
skill_id = uuid.uuid4().hex
|
||||
storage.create_prompt_template(
|
||||
template_id=skill_id,
|
||||
name=f"test-skill-{skill_id[:8]}",
|
||||
category="general",
|
||||
content="Test skill content.",
|
||||
variables="[]",
|
||||
is_default=False,
|
||||
org_id="",
|
||||
created_by="test",
|
||||
readonly=readonly,
|
||||
)
|
||||
return skill_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: List resources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestListSkillResources:
|
||||
def test_list_empty(self, client, storage):
|
||||
skill_id = _create_test_skill(storage)
|
||||
resp = client.get(f"/v1/api/admin/skills/{skill_id}/resources")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["resources"] == []
|
||||
|
||||
def test_list_with_resources(self, client, storage):
|
||||
skill_id = _create_test_skill(storage)
|
||||
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "content")
|
||||
resp = client.get(f"/v1/api/admin/skills/{skill_id}/resources")
|
||||
assert resp.status_code == 200
|
||||
resources = resp.json()["resources"]
|
||||
assert len(resources) == 1
|
||||
assert resources[0]["path"] == "scripts/a.sh"
|
||||
assert "content" not in resources[0] # Content NOT in list view
|
||||
|
||||
def test_skill_not_found(self, client):
|
||||
resp = client.get("/v1/api/admin/skills/nonexistent/resources")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Create resource
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCreateSkillResource:
|
||||
def test_create_valid(self, client, storage):
|
||||
skill_id = _create_test_skill(storage)
|
||||
resp = client.post(
|
||||
f"/v1/api/admin/skills/{skill_id}/resources",
|
||||
json={"path": "scripts/setup.sh", "content": "#!/bin/bash\necho hello"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["path"] == "scripts/setup.sh"
|
||||
assert data["size"] > 0
|
||||
|
||||
def test_invalid_path(self, client, storage):
|
||||
skill_id = _create_test_skill(storage)
|
||||
resp = client.post(
|
||||
f"/v1/api/admin/skills/{skill_id}/resources",
|
||||
json={"path": "malicious/file.sh", "content": "x"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_path_traversal_rejected(self, client, storage):
|
||||
skill_id = _create_test_skill(storage)
|
||||
resp = client.post(
|
||||
f"/v1/api/admin/skills/{skill_id}/resources",
|
||||
json={"path": "scripts/../../etc/passwd", "content": "x"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_null_byte_in_path_rejected(self, client, storage):
|
||||
skill_id = _create_test_skill(storage)
|
||||
resp = client.post(
|
||||
f"/v1/api/admin/skills/{skill_id}/resources",
|
||||
json={"path": "scripts/a\x00.sh", "content": "x"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_duplicate_409(self, client, storage):
|
||||
skill_id = _create_test_skill(storage)
|
||||
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "content")
|
||||
resp = client.post(
|
||||
f"/v1/api/admin/skills/{skill_id}/resources",
|
||||
json={"path": "scripts/a.sh", "content": "new"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_size_cap(self, client, storage):
|
||||
skill_id = _create_test_skill(storage)
|
||||
resp = client.post(
|
||||
f"/v1/api/admin/skills/{skill_id}/resources",
|
||||
json={"path": "scripts/big.sh", "content": "x" * (100 * 1024 + 1)},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_max_count(self, client, storage):
|
||||
skill_id = _create_test_skill(storage)
|
||||
for i in range(10):
|
||||
storage.create_skill_resource(uuid.uuid4().hex, skill_id, f"scripts/s{i}.sh", "content")
|
||||
resp = client.post(
|
||||
f"/v1/api/admin/skills/{skill_id}/resources",
|
||||
json={"path": "scripts/extra.sh", "content": "x"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_readonly_skill_blocked(self, client, storage):
|
||||
skill_id = _create_test_skill(storage, readonly=True)
|
||||
resp = client.post(
|
||||
f"/v1/api/admin/skills/{skill_id}/resources",
|
||||
json={"path": "scripts/a.sh", "content": "x"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Get resource
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetSkillResource:
|
||||
def test_get_existing(self, client, storage):
|
||||
skill_id = _create_test_skill(storage)
|
||||
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "hello world")
|
||||
resp = client.get(
|
||||
f"/v1/api/admin/skills/{skill_id}/resources/scripts/a.sh",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["content"] == "hello world"
|
||||
assert data["path"] == "scripts/a.sh"
|
||||
|
||||
def test_not_found(self, client, storage):
|
||||
skill_id = _create_test_skill(storage)
|
||||
resp = client.get(
|
||||
f"/v1/api/admin/skills/{skill_id}/resources/scripts/nope.sh",
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Delete resource
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDeleteSkillResource:
|
||||
def test_delete_existing(self, client, storage):
|
||||
skill_id = _create_test_skill(storage)
|
||||
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "content")
|
||||
resp = client.delete(
|
||||
f"/v1/api/admin/skills/{skill_id}/resources/scripts/a.sh",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert storage.get_skill_resource(skill_id, "scripts/a.sh") is None
|
||||
|
||||
def test_not_found(self, client, storage):
|
||||
skill_id = _create_test_skill(storage)
|
||||
resp = client.delete(
|
||||
f"/v1/api/admin/skills/{skill_id}/resources/scripts/nope.sh",
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_readonly_blocked(self, client, storage):
|
||||
skill_id = _create_test_skill(storage, readonly=True)
|
||||
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "content")
|
||||
resp = client.delete(
|
||||
f"/v1/api/admin/skills/{skill_id}/resources/scripts/a.sh",
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Resource count in skill responses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResourceCountInSkillResponse:
|
||||
def test_list_includes_count(self, client, storage):
|
||||
skill_id = _create_test_skill(storage)
|
||||
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "a")
|
||||
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/b.sh", "b")
|
||||
resp = client.get("/v1/api/admin/skills")
|
||||
skills = resp.json()["skills"]
|
||||
skill = [s for s in skills if s["template_id"] == skill_id][0]
|
||||
assert skill["resource_count"] == 2
|
||||
|
||||
def test_get_includes_count(self, client, storage):
|
||||
skill_id = _create_test_skill(storage)
|
||||
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "a")
|
||||
resp = client.get(f"/v1/api/admin/skills/{skill_id}")
|
||||
assert resp.json()["resource_count"] == 1
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Tests for skill resource storage operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from turnstone.core.storage._sqlite import SQLiteBackend
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def storage(tmp_path):
|
||||
"""Fresh SQLite backend for each test."""
|
||||
return SQLiteBackend(str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
class TestDeleteSkillResourceByPath:
|
||||
def test_delete_existing(self, storage):
|
||||
skill_id = uuid.uuid4().hex
|
||||
rid = uuid.uuid4().hex
|
||||
storage.create_skill_resource(rid, skill_id, "scripts/a.sh", "#!/bin/bash")
|
||||
assert storage.delete_skill_resource_by_path(skill_id, "scripts/a.sh") is True
|
||||
assert storage.get_skill_resource(skill_id, "scripts/a.sh") is None
|
||||
|
||||
def test_delete_not_found(self, storage):
|
||||
assert storage.delete_skill_resource_by_path("nonexistent", "scripts/a.sh") is False
|
||||
|
||||
def test_delete_wrong_path(self, storage):
|
||||
skill_id = uuid.uuid4().hex
|
||||
rid = uuid.uuid4().hex
|
||||
storage.create_skill_resource(rid, skill_id, "scripts/a.sh", "content")
|
||||
assert storage.delete_skill_resource_by_path(skill_id, "scripts/b.sh") is False
|
||||
# Original still exists
|
||||
assert storage.get_skill_resource(skill_id, "scripts/a.sh") is not None
|
||||
|
||||
def test_delete_only_target(self, storage):
|
||||
"""Deleting one resource doesn't affect others for the same skill."""
|
||||
skill_id = uuid.uuid4().hex
|
||||
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", "a")
|
||||
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/b.sh", "b")
|
||||
assert storage.delete_skill_resource_by_path(skill_id, "scripts/a.sh") is True
|
||||
assert storage.get_skill_resource(skill_id, "scripts/b.sh") is not None
|
||||
assert len(storage.list_skill_resources(skill_id)) == 1
|
||||
|
||||
|
||||
class TestListSkillResources:
|
||||
def test_ordering(self, storage):
|
||||
skill_id = uuid.uuid4().hex
|
||||
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/z.sh", "z")
|
||||
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "assets/a.txt", "a")
|
||||
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "references/m.md", "m")
|
||||
rows = storage.list_skill_resources(skill_id)
|
||||
paths = [r["path"] for r in rows]
|
||||
assert paths == sorted(paths)
|
||||
|
||||
def test_empty(self, storage):
|
||||
assert storage.list_skill_resources("nonexistent") == []
|
||||
|
||||
def test_size_from_content(self, storage):
|
||||
skill_id = uuid.uuid4().hex
|
||||
content = "x" * 500
|
||||
storage.create_skill_resource(uuid.uuid4().hex, skill_id, "scripts/a.sh", content)
|
||||
rows = storage.list_skill_resources(skill_id)
|
||||
assert len(rows) == 1
|
||||
assert len(rows[0]["content"]) == 500
|
||||
@@ -20,19 +20,23 @@ class TestParseGitHubUrl:
|
||||
"""GitHub URL parsing."""
|
||||
|
||||
def test_simple_repo(self) -> None:
|
||||
owner, repo, branch, path = _parse_github_url("https://github.com/owner/repo")
|
||||
owner, repo, branch, path, explicit = _parse_github_url("https://github.com/owner/repo")
|
||||
assert owner == "owner"
|
||||
assert repo == "repo"
|
||||
assert branch == "main"
|
||||
assert path == ""
|
||||
assert explicit is False
|
||||
|
||||
def test_repo_with_branch(self) -> None:
|
||||
owner, repo, branch, path = _parse_github_url("https://github.com/owner/repo/tree/develop")
|
||||
owner, repo, branch, path, explicit = _parse_github_url(
|
||||
"https://github.com/owner/repo/tree/develop"
|
||||
)
|
||||
assert branch == "develop"
|
||||
assert path == ""
|
||||
assert explicit is True
|
||||
|
||||
def test_repo_with_path(self) -> None:
|
||||
owner, repo, branch, path = _parse_github_url(
|
||||
owner, repo, branch, path, _explicit = _parse_github_url(
|
||||
"https://github.com/owner/repo/tree/main/skills/code-review"
|
||||
)
|
||||
assert owner == "owner"
|
||||
@@ -41,14 +45,14 @@ class TestParseGitHubUrl:
|
||||
assert path == "skills/code-review"
|
||||
|
||||
def test_blob_url(self) -> None:
|
||||
owner, repo, branch, path = _parse_github_url(
|
||||
owner, repo, branch, path, _explicit = _parse_github_url(
|
||||
"https://github.com/owner/repo/blob/main/SKILL.md"
|
||||
)
|
||||
assert branch == "main"
|
||||
assert path == "SKILL.md"
|
||||
|
||||
def test_invalid_url(self) -> None:
|
||||
owner, repo, branch, path = _parse_github_url("https://gitlab.com/owner/repo")
|
||||
owner, repo, branch, path, _explicit = _parse_github_url("https://gitlab.com/owner/repo")
|
||||
assert owner == ""
|
||||
|
||||
|
||||
|
||||
+106
-7
@@ -802,8 +802,8 @@ class TestSkillAPI:
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_update_skill_readonly_rejected(self, api_client, api_storage):
|
||||
"""Updating a readonly (MCP-sourced) skill returns 403."""
|
||||
def test_update_skill_readonly_spec_fields_rejected(self, api_client, api_storage):
|
||||
"""Updating spec fields on a readonly skill returns 400 (filtered to nothing)."""
|
||||
_create_template(
|
||||
api_storage,
|
||||
"s1",
|
||||
@@ -815,9 +815,65 @@ class TestSkillAPI:
|
||||
)
|
||||
resp = api_client.put(
|
||||
"/v1/api/admin/skills/s1",
|
||||
json={"description": "hacked"},
|
||||
json={"description": "hacked", "content": "evil"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert resp.status_code == 400
|
||||
assert "runtime config" in resp.json()["error"].lower()
|
||||
|
||||
def test_update_skill_readonly_runtime_config_allowed(self, api_client, api_storage):
|
||||
"""Runtime config fields can be updated on a readonly (installed) skill."""
|
||||
_create_template(
|
||||
api_storage,
|
||||
"s1",
|
||||
"installed-skill",
|
||||
"external content",
|
||||
origin="source",
|
||||
readonly=True,
|
||||
)
|
||||
resp = api_client.put(
|
||||
"/v1/api/admin/skills/s1",
|
||||
json={"model": "gpt-5", "temperature": 0.5, "enabled": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["model"] == "gpt-5"
|
||||
assert data["temperature"] == 0.5
|
||||
assert data["enabled"] is False
|
||||
# Spec fields must remain unchanged
|
||||
assert data["content"] == "external content"
|
||||
|
||||
def test_update_skill_readonly_mixed_body_filters_spec(self, api_client, api_storage):
|
||||
"""When JS sends all fields for a readonly skill, spec fields are silently dropped."""
|
||||
_create_template(
|
||||
api_storage,
|
||||
"s1",
|
||||
"installed",
|
||||
"original content",
|
||||
origin="source",
|
||||
readonly=True,
|
||||
)
|
||||
resp = api_client.put(
|
||||
"/v1/api/admin/skills/s1",
|
||||
# Simulate what the browser form submits: every field present
|
||||
json={
|
||||
"name": "hacked",
|
||||
"content": "evil content",
|
||||
"description": "tampered",
|
||||
"model": "gpt-5",
|
||||
"enabled": False,
|
||||
"token_budget": 50000,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# Config fields updated
|
||||
assert data["model"] == "gpt-5"
|
||||
assert data["enabled"] is False
|
||||
assert data["token_budget"] == 50000
|
||||
# Spec fields unchanged
|
||||
assert data["name"] == "installed"
|
||||
assert data["content"] == "original content"
|
||||
assert data["description"] == ""
|
||||
|
||||
def test_update_skill_recomputes_token_estimate(self, api_client, api_storage):
|
||||
"""Updating content recomputes token_estimate."""
|
||||
@@ -846,8 +902,8 @@ class TestSkillAPI:
|
||||
resp = api_client.delete("/v1/api/admin/skills/missing")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_skill_readonly_rejected(self, api_client, api_storage):
|
||||
"""Deleting a readonly skill returns 403."""
|
||||
def test_delete_skill_readonly_allowed(self, api_client, api_storage):
|
||||
"""Deleting a readonly (installed) skill is allowed — uninstall."""
|
||||
_create_template(
|
||||
api_storage,
|
||||
"s1",
|
||||
@@ -858,7 +914,7 @@ class TestSkillAPI:
|
||||
readonly=True,
|
||||
)
|
||||
resp = api_client.delete("/v1/api/admin/skills/s1")
|
||||
assert resp.status_code == 403
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_skill_field_on_workstream_create(self, api_storage):
|
||||
"""Console workstream creation accepts 'skill' field in request body."""
|
||||
@@ -1156,6 +1212,49 @@ class TestSkillSessionConfigApplication:
|
||||
parsed_tools = json.loads(tpl["allowed_tools"])
|
||||
assert parsed_tools == ["bash", "read_file", "write_file"]
|
||||
|
||||
def test_license_compatibility_roundtrip(self, db):
|
||||
"""Agent Skills spec fields license and compatibility round-trip."""
|
||||
db.create_prompt_template(
|
||||
template_id="spec1",
|
||||
name="spec-fields-skill",
|
||||
category="general",
|
||||
content="Spec test.",
|
||||
skill_license="Apache-2.0",
|
||||
compatibility="Requires git, docker, jq",
|
||||
)
|
||||
tpl = db.get_skill_by_name("spec-fields-skill")
|
||||
assert tpl is not None
|
||||
assert tpl["license"] == "Apache-2.0"
|
||||
assert tpl["compatibility"] == "Requires git, docker, jq"
|
||||
|
||||
def test_license_compatibility_default_empty(self, db):
|
||||
"""license and compatibility default to empty string."""
|
||||
db.create_prompt_template(
|
||||
template_id="spec2",
|
||||
name="no-spec-fields",
|
||||
category="general",
|
||||
content="No spec fields.",
|
||||
)
|
||||
tpl = db.get_skill_by_name("no-spec-fields")
|
||||
assert tpl is not None
|
||||
assert tpl["license"] == ""
|
||||
assert tpl["compatibility"] == ""
|
||||
|
||||
def test_update_license_compatibility(self, db):
|
||||
"""license and compatibility can be updated."""
|
||||
db.create_prompt_template(
|
||||
template_id="spec3",
|
||||
name="updatable-spec",
|
||||
category="general",
|
||||
content="Test.",
|
||||
)
|
||||
db.update_prompt_template("spec3", license="MIT")
|
||||
db.update_prompt_template("spec3", compatibility="Python 3.11+")
|
||||
tpl = db.get_prompt_template("spec3")
|
||||
assert tpl is not None
|
||||
assert tpl["license"] == "MIT"
|
||||
assert tpl["compatibility"] == "Python 3.11+"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Migration behavior tests
|
||||
|
||||
@@ -128,17 +128,9 @@ class TestToolSearchManager:
|
||||
return ToolSearchManager(
|
||||
all_tools,
|
||||
always_on_names={"bash", "read_file", "edit_file"},
|
||||
threshold=5,
|
||||
max_results=3,
|
||||
)
|
||||
|
||||
def test_should_activate_above_threshold(self, manager):
|
||||
assert manager.should_activate()
|
||||
|
||||
def test_should_not_activate_below_threshold(self, builtin_tools):
|
||||
mgr = ToolSearchManager(builtin_tools, always_on_names={"bash", "read_file", "edit_file"})
|
||||
assert not mgr.should_activate()
|
||||
|
||||
def test_visible_tools_initially_builtin_only(self, manager):
|
||||
visible = manager.get_visible_tools()
|
||||
names = {_tool_name(t) for t in visible}
|
||||
@@ -201,9 +193,6 @@ class TestToolSearchManager:
|
||||
names = {_tool_name(t) for t in deferred}
|
||||
assert "mcp__github__create_issue" not in names
|
||||
|
||||
def test_get_all_tools_returns_everything(self, manager, builtin_tools, mcp_tools):
|
||||
assert len(manager.get_all_tools()) == len(builtin_tools) + len(mcp_tools)
|
||||
|
||||
def test_search_tool_definition_format(self, manager):
|
||||
defn = manager.get_search_tool_definition()
|
||||
assert defn["type"] == "function"
|
||||
|
||||
@@ -112,7 +112,7 @@ class TestToolsMetadata:
|
||||
"watch": "command",
|
||||
"read_resource": "uri",
|
||||
"use_prompt": "name",
|
||||
"load_skill": "name",
|
||||
"skill": "name",
|
||||
}
|
||||
assert expected == PRIMARY_KEY_MAP
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
|
||||
|
||||
__version__ = "0.7.0"
|
||||
__version__ = "0.8.3"
|
||||
|
||||
@@ -307,9 +307,12 @@ class SkillInfo(BaseModel):
|
||||
notify_on_complete: str = "{}"
|
||||
enabled: bool = True
|
||||
allowed_tools: str = "[]"
|
||||
license: str = ""
|
||||
compatibility: str = ""
|
||||
scan_status: str = ""
|
||||
scan_report: str = "{}"
|
||||
scan_version: str = ""
|
||||
resource_count: int = 0
|
||||
created: str
|
||||
updated: str
|
||||
|
||||
@@ -336,6 +339,8 @@ class CreateSkillRequest(BaseModel):
|
||||
notify_on_complete: str = "{}"
|
||||
enabled: bool = True
|
||||
allowed_tools: str = "[]"
|
||||
license: str = ""
|
||||
compatibility: str = ""
|
||||
|
||||
|
||||
class UpdateSkillRequest(BaseModel):
|
||||
@@ -359,6 +364,8 @@ class UpdateSkillRequest(BaseModel):
|
||||
notify_on_complete: str | None = None
|
||||
enabled: bool | None = None
|
||||
allowed_tools: str | None = None
|
||||
license: str | None = None
|
||||
compatibility: str | None = None
|
||||
|
||||
|
||||
class ListSkillsResponse(BaseModel):
|
||||
@@ -383,6 +390,31 @@ class ListSkillVersionsResponse(BaseModel):
|
||||
versions: list[SkillVersionInfo]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Governance: Skill Resources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SkillResourceInfo(BaseModel):
|
||||
resource_id: str
|
||||
skill_id: str
|
||||
path: str
|
||||
content: str = ""
|
||||
content_type: str = "text/plain"
|
||||
size: int = 0
|
||||
created: str
|
||||
|
||||
|
||||
class ListSkillResourcesResponse(BaseModel):
|
||||
resources: list[SkillResourceInfo]
|
||||
|
||||
|
||||
class CreateSkillResourceRequest(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
content_type: str = "text/plain"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Governance: Usage
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -685,6 +717,17 @@ class SkillInstallRequest(BaseModel):
|
||||
url: str = "" # for github
|
||||
|
||||
|
||||
class SkillInstallSkipped(BaseModel):
|
||||
name: str
|
||||
reason: str
|
||||
|
||||
|
||||
class SkillInstallResponse(BaseModel):
|
||||
installed: list[SkillInfo]
|
||||
skipped: list[SkillInstallSkipped] = []
|
||||
total: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin: MCP Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -23,6 +23,7 @@ from turnstone.api.console_schemas import (
|
||||
CreateMcpServerRequest,
|
||||
CreateRoleRequest,
|
||||
CreateSkillRequest,
|
||||
CreateSkillResourceRequest,
|
||||
CreateToolPolicyRequest,
|
||||
ImportMcpConfigRequest,
|
||||
ImportMcpConfigResponse,
|
||||
@@ -35,6 +36,7 @@ from turnstone.api.console_schemas import (
|
||||
ListRolesResponse,
|
||||
ListSettingSchemaResponse,
|
||||
ListSettingsResponse,
|
||||
ListSkillResourcesResponse,
|
||||
ListSkillsResponse,
|
||||
ListSkillVersionsResponse,
|
||||
ListToolPoliciesResponse,
|
||||
@@ -53,6 +55,8 @@ from turnstone.api.console_schemas import (
|
||||
SkillDiscoverResponse,
|
||||
SkillInfo,
|
||||
SkillInstallRequest,
|
||||
SkillInstallResponse,
|
||||
SkillResourceInfo,
|
||||
SkillVersionInfo,
|
||||
ToolPolicyInfo,
|
||||
UpdateMcpServerRequest,
|
||||
@@ -501,9 +505,9 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/skills/install",
|
||||
"POST",
|
||||
"Install a skill from an external source",
|
||||
"Install skill(s) from an external source",
|
||||
request_model=SkillInstallRequest,
|
||||
response_model=SkillInfo,
|
||||
response_model=SkillInstallResponse,
|
||||
error_codes=[400, 404, 409, 502],
|
||||
tags=["Admin"],
|
||||
),
|
||||
@@ -639,6 +643,39 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
|
||||
"Re-scan a skill for security signals",
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Governance: Skill Resources ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/skills/{skill_id}/resources",
|
||||
"GET",
|
||||
"List resource files for a skill",
|
||||
response_model=ListSkillResourcesResponse,
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/skills/{skill_id}/resources",
|
||||
"POST",
|
||||
"Upload a resource file to a skill",
|
||||
request_model=CreateSkillResourceRequest,
|
||||
response_model=SkillResourceInfo,
|
||||
response_code=201,
|
||||
error_codes=[400, 404, 409],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/skills/{skill_id}/resources/{path}",
|
||||
"GET",
|
||||
"Get a single skill resource by path",
|
||||
response_model=SkillResourceInfo,
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/skills/{skill_id}/resources/{path}",
|
||||
"DELETE",
|
||||
"Delete a skill resource by path",
|
||||
error_codes=[404],
|
||||
tags=["Admin"],
|
||||
),
|
||||
# --- Admin: Memories ---
|
||||
EndpointSpec(
|
||||
"/v1/api/admin/memories",
|
||||
@@ -885,12 +922,16 @@ _ALL_MODELS: list[type[BaseModel]] = [
|
||||
RegistryInstallRequest,
|
||||
SkillDiscoverResponse,
|
||||
SkillInstallRequest,
|
||||
SkillInstallResponse,
|
||||
SkillInfo,
|
||||
SkillVersionInfo,
|
||||
CreateSkillRequest,
|
||||
UpdateSkillRequest,
|
||||
ListSkillsResponse,
|
||||
ListSkillVersionsResponse,
|
||||
SkillResourceInfo,
|
||||
CreateSkillResourceRequest,
|
||||
ListSkillResourcesResponse,
|
||||
SkillSummary,
|
||||
ListSkillSummaryResponse,
|
||||
]
|
||||
|
||||
+348
-72
@@ -2134,6 +2134,24 @@ async def admin_delete_policy(request: Request) -> JSONResponse:
|
||||
|
||||
_VALID_ACTIVATIONS = {"named", "default", "search"}
|
||||
|
||||
# Fields that may be updated on installed (readonly) skills.
|
||||
# These are local runtime configuration — not part of the SKILL.md spec —
|
||||
# so they don't compromise the fidelity of an externally-sourced skill.
|
||||
_SKILL_RUNTIME_CONFIG_FIELDS = frozenset(
|
||||
{
|
||||
"model",
|
||||
"temperature",
|
||||
"reasoning_effort",
|
||||
"max_tokens",
|
||||
"token_budget",
|
||||
"agent_max_turns",
|
||||
"auto_approve",
|
||||
"allowed_tools",
|
||||
"enabled",
|
||||
"notify_on_complete",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _parse_skill_session_config(body: dict[str, Any]) -> tuple[dict[str, Any], JSONResponse | None]:
|
||||
"""Parse and validate session config fields from a skill request body.
|
||||
@@ -2248,7 +2266,7 @@ def _parse_skill_session_config(body: dict[str, Any]) -> tuple[dict[str, Any], J
|
||||
return fields, None
|
||||
|
||||
|
||||
def _skill_to_response(r: dict[str, Any]) -> dict[str, Any]:
|
||||
def _skill_to_response(r: dict[str, Any], resource_count: int = 0) -> dict[str, Any]:
|
||||
"""Convert a storage skill dict to a JSON-safe response dict."""
|
||||
import contextlib
|
||||
import json as _json
|
||||
@@ -2286,9 +2304,12 @@ def _skill_to_response(r: dict[str, Any]) -> dict[str, Any]:
|
||||
"notify_on_complete": r.get("notify_on_complete", "{}"),
|
||||
"enabled": r.get("enabled", True),
|
||||
"allowed_tools": r.get("allowed_tools", "[]"),
|
||||
"license": r.get("license", ""),
|
||||
"compatibility": r.get("compatibility", ""),
|
||||
"scan_status": r.get("scan_status", ""),
|
||||
"scan_report": r.get("scan_report", "{}"),
|
||||
"scan_version": r.get("scan_version", ""),
|
||||
"resource_count": resource_count,
|
||||
"created": r.get("created", ""),
|
||||
"updated": r.get("updated", ""),
|
||||
}
|
||||
@@ -2310,7 +2331,9 @@ async def admin_list_skills(request: Request) -> JSONResponse:
|
||||
offset = _parse_int(params, "offset", 0, minimum=0, maximum=100000)
|
||||
rows = storage.list_prompt_templates(limit=limit, offset=offset)
|
||||
total = storage.count_prompt_templates()
|
||||
skills = [_skill_to_response(r) for r in rows]
|
||||
skill_ids = [r["template_id"] for r in rows]
|
||||
rc_map = storage.count_skill_resources_bulk(skill_ids) if skill_ids else {}
|
||||
skills = [_skill_to_response(r, resource_count=rc_map.get(r["template_id"], 0)) for r in rows]
|
||||
return JSONResponse({"skills": skills, "total": total})
|
||||
|
||||
|
||||
@@ -2330,7 +2353,8 @@ async def admin_get_skill(request: Request) -> JSONResponse:
|
||||
skill = storage.get_prompt_template(skill_id)
|
||||
if skill is None:
|
||||
return JSONResponse({"error": "Skill not found"}, status_code=404)
|
||||
return JSONResponse(_skill_to_response(skill))
|
||||
rc_map = storage.count_skill_resources_bulk([skill_id])
|
||||
return JSONResponse(_skill_to_response(skill, resource_count=rc_map.get(skill_id, 0)))
|
||||
|
||||
|
||||
async def admin_create_skill(request: Request) -> JSONResponse:
|
||||
@@ -2366,6 +2390,8 @@ async def admin_create_skill(request: Request) -> JSONResponse:
|
||||
org_id = str(body.get("org_id", "")).strip()[:64]
|
||||
author = str(body.get("author", "")).strip()[:256]
|
||||
version = str(body.get("version", "1.0.0")).strip()[:64]
|
||||
license_val = str(body.get("license", "")).strip()[:128]
|
||||
compatibility = str(body.get("compatibility", "")).strip()[:500]
|
||||
|
||||
raw_tags = body.get("tags", [])
|
||||
if isinstance(raw_tags, list):
|
||||
@@ -2414,6 +2440,8 @@ async def admin_create_skill(request: Request) -> JSONResponse:
|
||||
tags=tags_str,
|
||||
version=version,
|
||||
author=author,
|
||||
skill_license=license_val,
|
||||
compatibility=compatibility,
|
||||
activation=activation,
|
||||
token_estimate=token_estimate,
|
||||
**session_fields,
|
||||
@@ -2452,8 +2480,7 @@ async def admin_update_skill(request: Request) -> JSONResponse:
|
||||
existing = storage.get_prompt_template(skill_id)
|
||||
if existing is None:
|
||||
return JSONResponse({"error": "Skill not found"}, status_code=404)
|
||||
if existing.get("readonly"):
|
||||
return JSONResponse({"error": "MCP-sourced skills are read-only"}, status_code=403)
|
||||
is_readonly = bool(existing.get("readonly"))
|
||||
|
||||
body = await read_json_or_400(request)
|
||||
if isinstance(body, JSONResponse):
|
||||
@@ -2493,6 +2520,10 @@ async def admin_update_skill(request: Request) -> JSONResponse:
|
||||
updates["author"] = str(body["author"]).strip()[:256]
|
||||
if "version" in body:
|
||||
updates["version"] = str(body["version"]).strip()[:64]
|
||||
if "license" in body:
|
||||
updates["license"] = str(body["license"]).strip()[:128]
|
||||
if "compatibility" in body:
|
||||
updates["compatibility"] = str(body["compatibility"]).strip()[:500]
|
||||
if "tags" in body:
|
||||
raw_tags = body["tags"]
|
||||
if isinstance(raw_tags, list):
|
||||
@@ -2505,6 +2536,13 @@ async def admin_update_skill(request: Request) -> JSONResponse:
|
||||
tag_str = "[]"
|
||||
updates["tags"] = tag_str
|
||||
|
||||
# Installed (readonly) skills: restrict updates to runtime config only.
|
||||
# Spec/content fields are locked to preserve external-source fidelity.
|
||||
if is_readonly:
|
||||
updates = {k: v for k, v in updates.items() if k in _SKILL_RUNTIME_CONFIG_FIELDS}
|
||||
if not updates:
|
||||
return JSONResponse({"error": "No runtime config fields to update"}, status_code=400)
|
||||
|
||||
# Snapshot current state for version history before applying update
|
||||
existing_versions = storage.list_skill_versions(skill_id)
|
||||
version_int = len(existing_versions) + 1
|
||||
@@ -2522,7 +2560,7 @@ async def admin_update_skill(request: Request) -> JSONResponse:
|
||||
record_audit(
|
||||
storage,
|
||||
audit_uid,
|
||||
"skill.update",
|
||||
"skill.update.config" if is_readonly else "skill.update",
|
||||
"skill",
|
||||
skill_id,
|
||||
updates,
|
||||
@@ -2530,7 +2568,8 @@ async def admin_update_skill(request: Request) -> JSONResponse:
|
||||
)
|
||||
|
||||
updated_skill = storage.get_prompt_template(skill_id)
|
||||
return JSONResponse(_skill_to_response(updated_skill))
|
||||
rc_map = storage.count_skill_resources_bulk([skill_id])
|
||||
return JSONResponse(_skill_to_response(updated_skill, resource_count=rc_map.get(skill_id, 0)))
|
||||
|
||||
|
||||
async def admin_delete_skill(request: Request) -> JSONResponse:
|
||||
@@ -2550,8 +2589,6 @@ async def admin_delete_skill(request: Request) -> JSONResponse:
|
||||
existing = storage.get_prompt_template(skill_id)
|
||||
if existing is None:
|
||||
return JSONResponse({"error": "Skill not found"}, status_code=404)
|
||||
if existing.get("readonly"):
|
||||
return JSONResponse({"error": "MCP-sourced skills are read-only"}, status_code=403)
|
||||
|
||||
storage.delete_skill_resources(skill_id)
|
||||
storage.delete_skill_versions(skill_id)
|
||||
@@ -2829,6 +2866,192 @@ async def admin_rescan_skill(request: Request) -> JSONResponse:
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin: Skill Resources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ALLOWED_RESOURCE_DIRS = ("scripts/", "references/", "assets/")
|
||||
_MAX_RESOURCE_SIZE = 100 * 1024 # 100KB
|
||||
_MAX_RESOURCES_PER_SKILL = 10
|
||||
|
||||
|
||||
async def admin_list_skill_resources(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/skills/{skill_id}/resources — list resources."""
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.skills")
|
||||
if err:
|
||||
return err
|
||||
|
||||
skill_id = request.path_params["skill_id"]
|
||||
skill = storage.get_prompt_template(skill_id)
|
||||
if skill is None:
|
||||
return JSONResponse({"error": "Skill not found"}, status_code=404)
|
||||
|
||||
rows = storage.list_skill_resources(skill_id)
|
||||
resources = [
|
||||
{
|
||||
"resource_id": r.get("resource_id", ""),
|
||||
"skill_id": r.get("skill_id", ""),
|
||||
"path": r.get("path", ""),
|
||||
"content_type": r.get("content_type", "text/plain"),
|
||||
"size": len(r.get("content", "")),
|
||||
"created": r.get("created", ""),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return JSONResponse({"resources": resources})
|
||||
|
||||
|
||||
async def admin_get_skill_resource(request: Request) -> JSONResponse:
|
||||
"""GET /v1/api/admin/skills/{skill_id}/resources/{path:path} — get one resource."""
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.skills")
|
||||
if err:
|
||||
return err
|
||||
|
||||
skill_id = request.path_params["skill_id"]
|
||||
path = request.path_params["path"]
|
||||
resource = storage.get_skill_resource(skill_id, path)
|
||||
if resource is None:
|
||||
return JSONResponse({"error": "Resource not found"}, status_code=404)
|
||||
return JSONResponse(
|
||||
{
|
||||
"resource_id": resource.get("resource_id", ""),
|
||||
"skill_id": resource.get("skill_id", ""),
|
||||
"path": resource.get("path", ""),
|
||||
"content": resource.get("content", ""),
|
||||
"content_type": resource.get("content_type", "text/plain"),
|
||||
"size": len(resource.get("content", "")),
|
||||
"created": resource.get("created", ""),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def admin_create_skill_resource(request: Request) -> JSONResponse:
|
||||
"""POST /v1/api/admin/skills/{skill_id}/resources — upload resource."""
|
||||
import uuid
|
||||
|
||||
from turnstone.core.audit import record_audit
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.skills")
|
||||
if err:
|
||||
return err
|
||||
|
||||
skill_id = request.path_params["skill_id"]
|
||||
skill = storage.get_prompt_template(skill_id)
|
||||
if skill is None:
|
||||
return JSONResponse({"error": "Skill not found"}, status_code=404)
|
||||
if skill.get("readonly"):
|
||||
return JSONResponse({"error": "Installed skills are read-only"}, status_code=403)
|
||||
|
||||
body = await read_json_or_400(request)
|
||||
if isinstance(body, JSONResponse):
|
||||
return body
|
||||
|
||||
path = str(body.get("path", "")).strip()
|
||||
content = str(body.get("content", ""))
|
||||
content_type = str(body.get("content_type", "text/plain")).strip()[:64]
|
||||
|
||||
if not path:
|
||||
return JSONResponse({"error": "path is required"}, status_code=400)
|
||||
# Normalize and reject path traversal
|
||||
import posixpath
|
||||
|
||||
path = posixpath.normpath(path)
|
||||
if ".." in path.split("/") or "\x00" in path:
|
||||
return JSONResponse({"error": "Invalid path"}, status_code=400)
|
||||
if not any(path.startswith(d) for d in _ALLOWED_RESOURCE_DIRS):
|
||||
return JSONResponse(
|
||||
{"error": "path must start with scripts/, references/, or assets/"},
|
||||
status_code=400,
|
||||
)
|
||||
if len(content) > _MAX_RESOURCE_SIZE:
|
||||
return JSONResponse(
|
||||
{"error": f"Resource exceeds {_MAX_RESOURCE_SIZE // 1024}KB limit"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
existing = storage.list_skill_resources(skill_id)
|
||||
if len(existing) >= _MAX_RESOURCES_PER_SKILL:
|
||||
return JSONResponse(
|
||||
{"error": f"Maximum {_MAX_RESOURCES_PER_SKILL} resources per skill"},
|
||||
status_code=400,
|
||||
)
|
||||
if storage.get_skill_resource(skill_id, path) is not None:
|
||||
return JSONResponse({"error": "Resource path already exists"}, status_code=409)
|
||||
|
||||
resource_id = uuid.uuid4().hex
|
||||
storage.create_skill_resource(
|
||||
resource_id=resource_id,
|
||||
skill_id=skill_id,
|
||||
path=path,
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
audit_uid, ip = _audit_context(request)
|
||||
record_audit(storage, audit_uid, "skill_resource.create", "skill", skill_id, {"path": path}, ip)
|
||||
|
||||
created = storage.get_skill_resource(skill_id, path)
|
||||
return JSONResponse(
|
||||
{
|
||||
"resource_id": resource_id,
|
||||
"skill_id": skill_id,
|
||||
"path": path,
|
||||
"content_type": content_type,
|
||||
"size": len(content),
|
||||
"created": (created or {}).get("created", ""),
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
|
||||
|
||||
async def admin_delete_skill_resource(request: Request) -> JSONResponse:
|
||||
"""DELETE /v1/api/admin/skills/{skill_id}/resources/{path:path} — delete resource."""
|
||||
from turnstone.core.audit import record_audit
|
||||
from turnstone.core.auth import require_permission
|
||||
from turnstone.core.web_helpers import require_storage_or_503
|
||||
|
||||
storage, err = require_storage_or_503(request)
|
||||
if err:
|
||||
return err
|
||||
err = require_permission(request, "admin.skills")
|
||||
if err:
|
||||
return err
|
||||
|
||||
skill_id = request.path_params["skill_id"]
|
||||
skill = storage.get_prompt_template(skill_id)
|
||||
if skill is None:
|
||||
return JSONResponse({"error": "Skill not found"}, status_code=404)
|
||||
if skill.get("readonly"):
|
||||
return JSONResponse({"error": "Installed skills are read-only"}, status_code=403)
|
||||
|
||||
path = request.path_params["path"]
|
||||
deleted = storage.delete_skill_resource_by_path(skill_id, path)
|
||||
if not deleted:
|
||||
return JSONResponse({"error": "Resource not found"}, status_code=404)
|
||||
|
||||
audit_uid, ip = _audit_context(request)
|
||||
record_audit(storage, audit_uid, "skill_resource.delete", "skill", skill_id, {"path": path}, ip)
|
||||
|
||||
return JSONResponse({"status": "ok"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin: Skill Discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2870,6 +3093,8 @@ async def admin_skill_discover(request: Request) -> JSONResponse:
|
||||
return err
|
||||
|
||||
q = str(request.query_params.get("q", "")).strip()
|
||||
if not q:
|
||||
return JSONResponse({"error": "Search query is required"}, status_code=400)
|
||||
try:
|
||||
limit = max(1, min(int(request.query_params.get("limit", "20")), 100))
|
||||
except (ValueError, TypeError):
|
||||
@@ -2924,6 +3149,7 @@ async def admin_skill_install(request: Request) -> JSONResponse:
|
||||
SkillSourceError,
|
||||
SkillsShClient,
|
||||
fetch_skill_from_github,
|
||||
fetch_skills_from_github_repo,
|
||||
)
|
||||
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
|
||||
|
||||
@@ -2951,12 +3177,16 @@ async def admin_skill_install(request: Request) -> JSONResponse:
|
||||
discovery_url = _get_discovery_url(request)
|
||||
client = SkillsShClient(base_url=discovery_url)
|
||||
github_url = await client.resolve_github_url(skill_id_param)
|
||||
package = await fetch_skill_from_github(github_url)
|
||||
packages = [await fetch_skill_from_github(github_url)]
|
||||
else:
|
||||
url = str(body.get("url", "")).strip()
|
||||
if not url:
|
||||
return JSONResponse({"error": "url is required"}, status_code=400)
|
||||
package = await fetch_skill_from_github(url)
|
||||
try:
|
||||
packages = [await fetch_skill_from_github(url)]
|
||||
except SkillNotFoundError:
|
||||
# No root SKILL.md — try scanning for a multi-skill repo
|
||||
packages = await fetch_skills_from_github_repo(url)
|
||||
except SkillNotFoundError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=404)
|
||||
except SkillSourceError as exc:
|
||||
@@ -2964,75 +3194,102 @@ async def admin_skill_install(request: Request) -> JSONResponse:
|
||||
except ValueError as exc:
|
||||
return JSONResponse({"error": str(exc)}, status_code=400)
|
||||
|
||||
# Check for duplicate by source_url
|
||||
source_url = package.listing.source_url
|
||||
if source_url:
|
||||
existing = storage.get_skill_by_source_url(source_url)
|
||||
if existing:
|
||||
return JSONResponse(
|
||||
{"error": f"Skill from '{source_url}' is already installed"},
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
# Check for duplicate by name
|
||||
if storage.get_prompt_template_by_name(package.parsed.name):
|
||||
return JSONResponse(
|
||||
{"error": f"Skill name '{package.parsed.name}' already exists"},
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
import json as _json
|
||||
|
||||
audit_uid, ip = _audit_context(request)
|
||||
skill_id = uuid.uuid4().hex
|
||||
parsed = package.parsed
|
||||
tags_str = _json.dumps(parsed.tags)
|
||||
allowed_tools_str = _json.dumps(parsed.allowed_tools)
|
||||
content = parsed.content[:32768]
|
||||
token_estimate = len(content) // 4 if content else 0
|
||||
installed: list[dict[str, Any]] = []
|
||||
skipped: list[dict[str, str]] = []
|
||||
|
||||
storage.create_prompt_template(
|
||||
template_id=skill_id,
|
||||
name=parsed.name,
|
||||
category="general",
|
||||
content=content,
|
||||
variables="[]",
|
||||
is_default=False,
|
||||
org_id="",
|
||||
created_by=audit_uid,
|
||||
origin="source",
|
||||
readonly=True,
|
||||
description=parsed.description,
|
||||
tags=tags_str,
|
||||
source_url=source_url,
|
||||
version=parsed.version,
|
||||
author=parsed.author,
|
||||
activation="named",
|
||||
token_estimate=token_estimate,
|
||||
allowed_tools=allowed_tools_str,
|
||||
)
|
||||
for package in packages:
|
||||
pkg_source_url = package.listing.source_url
|
||||
|
||||
# Store bundled resources
|
||||
for path, content in package.resources.items():
|
||||
storage.create_skill_resource(
|
||||
resource_id=uuid.uuid4().hex,
|
||||
skill_id=skill_id,
|
||||
path=path,
|
||||
content=content,
|
||||
# Check for duplicate by source_url
|
||||
if pkg_source_url and storage.get_skill_by_source_url(pkg_source_url):
|
||||
skipped.append({"name": package.parsed.name, "reason": "already installed"})
|
||||
continue
|
||||
|
||||
# Check for duplicate by name
|
||||
if storage.get_prompt_template_by_name(package.parsed.name):
|
||||
skipped.append({"name": package.parsed.name, "reason": "name exists"})
|
||||
continue
|
||||
|
||||
skill_id = uuid.uuid4().hex
|
||||
parsed = package.parsed
|
||||
tags_str = _json.dumps(parsed.tags)
|
||||
allowed_tools_str = _json.dumps(parsed.allowed_tools)
|
||||
content = parsed.content[:32768]
|
||||
token_estimate = len(content) // 4 if content else 0
|
||||
|
||||
try:
|
||||
storage.create_prompt_template(
|
||||
template_id=skill_id,
|
||||
name=parsed.name,
|
||||
category="general",
|
||||
content=content,
|
||||
variables="[]",
|
||||
is_default=False,
|
||||
org_id="",
|
||||
created_by=audit_uid,
|
||||
origin="source",
|
||||
readonly=True,
|
||||
description=parsed.description,
|
||||
tags=tags_str,
|
||||
source_url=pkg_source_url,
|
||||
version=parsed.version,
|
||||
author=parsed.author,
|
||||
skill_license=parsed.license,
|
||||
compatibility=parsed.compatibility,
|
||||
activation="named",
|
||||
token_estimate=token_estimate,
|
||||
allowed_tools=allowed_tools_str,
|
||||
)
|
||||
except Exception:
|
||||
skipped.append({"name": parsed.name, "reason": "conflict"})
|
||||
continue
|
||||
|
||||
# Store bundled resources
|
||||
for res_path, res_content in package.resources.items():
|
||||
storage.create_skill_resource(
|
||||
resource_id=uuid.uuid4().hex,
|
||||
skill_id=skill_id,
|
||||
path=res_path,
|
||||
content=res_content,
|
||||
)
|
||||
|
||||
record_audit(
|
||||
storage,
|
||||
audit_uid,
|
||||
"skill.install",
|
||||
"skill",
|
||||
skill_id,
|
||||
{"name": parsed.name, "source": source, "source_url": pkg_source_url},
|
||||
ip,
|
||||
)
|
||||
|
||||
record_audit(
|
||||
storage,
|
||||
audit_uid,
|
||||
"skill.install",
|
||||
"skill",
|
||||
skill_id,
|
||||
{"name": parsed.name, "source": source, "source_url": source_url},
|
||||
ip,
|
||||
)
|
||||
skill = storage.get_prompt_template(skill_id)
|
||||
if skill:
|
||||
installed.append(_skill_to_response(skill, resource_count=len(package.resources)))
|
||||
|
||||
skill = storage.get_prompt_template(skill_id)
|
||||
return JSONResponse(_skill_to_response(skill))
|
||||
if not installed and skipped:
|
||||
# All skills were duplicates
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "All skills already installed",
|
||||
"installed": [],
|
||||
"skipped": skipped,
|
||||
"total": len(packages),
|
||||
},
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
# Consistent envelope for both single and batch installs
|
||||
return JSONResponse(
|
||||
{
|
||||
"installed": installed,
|
||||
"skipped": skipped,
|
||||
"total": len(packages),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -4361,6 +4618,25 @@ def create_app(
|
||||
"/api/admin/skills/{skill_id}/versions",
|
||||
admin_list_skill_versions,
|
||||
),
|
||||
# Governance: Skill Resources
|
||||
Route(
|
||||
"/api/admin/skills/{skill_id}/resources",
|
||||
admin_list_skill_resources,
|
||||
),
|
||||
Route(
|
||||
"/api/admin/skills/{skill_id}/resources",
|
||||
admin_create_skill_resource,
|
||||
methods=["POST"],
|
||||
),
|
||||
Route(
|
||||
"/api/admin/skills/{skill_id}/resources/{path:path}",
|
||||
admin_get_skill_resource,
|
||||
),
|
||||
Route(
|
||||
"/api/admin/skills/{skill_id}/resources/{path:path}",
|
||||
admin_delete_skill_resource,
|
||||
methods=["DELETE"],
|
||||
),
|
||||
# Governance: Memories
|
||||
Route("/api/admin/memories", admin_list_memories),
|
||||
Route("/api/admin/memories/search", admin_search_memories),
|
||||
|
||||
@@ -14,6 +14,7 @@ var _govAuditOffset = 0;
|
||||
var _skillCurrentView = "installed";
|
||||
var _skillDiscoverResults = [];
|
||||
var _skillDiscoverQuery = "";
|
||||
var _pendingResources = [];
|
||||
var _giTrapHandler = null;
|
||||
var _giTriggerEl = null;
|
||||
|
||||
@@ -687,13 +688,6 @@ function _renderGovSkills(items) {
|
||||
var html = "";
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var t = items[i];
|
||||
var vars = "";
|
||||
try {
|
||||
var vlist = JSON.parse(t.variables || "[]");
|
||||
vars = vlist.join(", ");
|
||||
} catch (e) {
|
||||
vars = t.variables;
|
||||
}
|
||||
var activationBadge = "";
|
||||
var activation = t.activation || "named";
|
||||
if (activation === "default") {
|
||||
@@ -714,7 +708,8 @@ function _renderGovSkills(items) {
|
||||
: "";
|
||||
var catBadge =
|
||||
'<span class="scope-badge">' + escapeHtml(t.category) + "</span>";
|
||||
var scanBadge = "";
|
||||
// Build risk column content with tooltip
|
||||
var riskCell = "";
|
||||
if (t.scan_status) {
|
||||
var scanClass =
|
||||
{
|
||||
@@ -724,42 +719,84 @@ function _renderGovSkills(items) {
|
||||
high: "scope-scan-high",
|
||||
critical: "scope-scan-critical",
|
||||
}[t.scan_status] || "";
|
||||
scanBadge =
|
||||
' <span class="scope-badge ' +
|
||||
var scanIcon =
|
||||
{
|
||||
safe: "\u2713 ",
|
||||
low: "",
|
||||
medium: "\u25B2 ",
|
||||
high: "\u25C6 ",
|
||||
critical: "\u26A0 ",
|
||||
}[t.scan_status] || "";
|
||||
var tipParts = [];
|
||||
try {
|
||||
var report = JSON.parse(t.scan_report || "{}");
|
||||
if (report.composite != null) {
|
||||
tipParts.push("Score: " + report.composite.toFixed(2));
|
||||
}
|
||||
var axes = ["content", "supply_chain", "vulnerability", "capability"];
|
||||
for (var ai = 0; ai < axes.length; ai++) {
|
||||
var d = (report.details || {})[axes[ai]] || {};
|
||||
if (d.flags && d.flags.length) {
|
||||
tipParts.push(
|
||||
axes[ai].replace(/_/g, " ") + ": " + d.flags.join(", "),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
var tipText = tipParts.length ? tipParts.join("\n") : t.scan_status;
|
||||
riskCell =
|
||||
'<span class="scope-badge ' +
|
||||
scanClass +
|
||||
'">' +
|
||||
'" tabindex="0" role="button" aria-label="Risk: ' +
|
||||
escapeHtml(t.scan_status) +
|
||||
(tipParts.length ? ". " + escapeHtml(tipParts.join(". ")) : "") +
|
||||
'" title="' +
|
||||
escapeHtml(tipText) +
|
||||
'">' +
|
||||
escapeHtml(scanIcon + t.scan_status) +
|
||||
"</span>";
|
||||
} else {
|
||||
riskCell =
|
||||
'<span class="scope-badge" style="opacity:0.4" title="Not scanned">\u2014</span>';
|
||||
}
|
||||
var editDisabled = t.readonly ? " disabled" : "";
|
||||
var deleteDisabled = t.readonly ? " disabled" : "";
|
||||
var resBadge = "";
|
||||
if (t.resource_count > 0) {
|
||||
resBadge =
|
||||
' <span class="scope-badge" title="' +
|
||||
t.resource_count +
|
||||
' bundled resource(s)">' +
|
||||
t.resource_count +
|
||||
" res</span>";
|
||||
}
|
||||
var editLabel = t.readonly ? "view" : "edit";
|
||||
var deleteDisabled = "";
|
||||
html +=
|
||||
'<div class="admin-row" role="listitem">' +
|
||||
'<span class="admin-col admin-col-tmcat">' +
|
||||
catBadge +
|
||||
"</span>" +
|
||||
'<span class="admin-col admin-col-tmname">' +
|
||||
escapeHtml(t.name) +
|
||||
" " +
|
||||
activationBadge +
|
||||
defBadge +
|
||||
originBadge +
|
||||
scanBadge +
|
||||
resBadge +
|
||||
(t.description
|
||||
? '<br><span class="admin-col-subtitle">' +
|
||||
escapeHtml(t.description) +
|
||||
"</span>"
|
||||
: "") +
|
||||
"</span>" +
|
||||
'<span class="admin-col admin-col-tmcat">' +
|
||||
catBadge +
|
||||
'<span class="admin-col admin-col-tmrisk">' +
|
||||
riskCell +
|
||||
"</span>" +
|
||||
'<span class="admin-col admin-col-tmvars"><code>' +
|
||||
escapeHtml(vars || "\u2014") +
|
||||
"</code></span>" +
|
||||
'<span class="admin-col admin-col-actions">' +
|
||||
'<button class="admin-btn-action" data-edit-tmpl="' +
|
||||
escapeHtml(t.template_id) +
|
||||
'"' +
|
||||
editDisabled +
|
||||
">edit</button>" +
|
||||
'">' +
|
||||
editLabel +
|
||||
"</button>" +
|
||||
'<button class="admin-btn-danger" data-delete-tmpl="' +
|
||||
escapeHtml(t.template_id) +
|
||||
'" data-tmpl-name="' +
|
||||
@@ -833,6 +870,9 @@ function showCreateTemplateModal() {
|
||||
document.getElementById("skill-description").value = "";
|
||||
document.getElementById("skill-tags").value = "";
|
||||
document.getElementById("skill-author").value = "";
|
||||
document.getElementById("skill-version").value = "";
|
||||
document.getElementById("skill-license").value = "";
|
||||
document.getElementById("skill-compatibility").value = "";
|
||||
document.getElementById("skill-activation").value = "named";
|
||||
document.getElementById("ctm-content").value = "";
|
||||
document.getElementById("ctm-variables").textContent = "(none)";
|
||||
@@ -851,12 +891,13 @@ function showCreateTemplateModal() {
|
||||
document.getElementById("csk-allowed-tools").value = "";
|
||||
document.getElementById("csk-allowed-tools").disabled = false;
|
||||
document.getElementById("csk-enabled").checked = true;
|
||||
document
|
||||
.getElementById("csk-auto-approve")
|
||||
.addEventListener("change", function () {
|
||||
document.getElementById("csk-allowed-tools").disabled = this.checked;
|
||||
});
|
||||
document.getElementById("csk-auto-approve").onchange = function () {
|
||||
document.getElementById("csk-allowed-tools").disabled = this.checked;
|
||||
};
|
||||
document.getElementById("create-template-error").style.display = "none";
|
||||
// Clear resource list
|
||||
_pendingResources = [];
|
||||
_renderPendingResources();
|
||||
document.getElementById("ctm-name").focus();
|
||||
_ctmTrapHandler = _installTrap(
|
||||
"create-template-overlay",
|
||||
@@ -909,31 +950,38 @@ function submitCreateTemplate() {
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
document.getElementById("ctm-submit").disabled = true;
|
||||
var csVersion = (document.getElementById("skill-version").value || "").trim();
|
||||
var createBody = {
|
||||
name: name,
|
||||
category: document.getElementById("ctm-category").value,
|
||||
description: (
|
||||
document.getElementById("skill-description").value || ""
|
||||
).trim(),
|
||||
tags: JSON.stringify(tagsArray),
|
||||
author: (document.getElementById("skill-author").value || "").trim(),
|
||||
license: (document.getElementById("skill-license").value || "").trim(),
|
||||
compatibility: (
|
||||
document.getElementById("skill-compatibility").value || ""
|
||||
).trim(),
|
||||
activation: document.getElementById("skill-activation").value,
|
||||
content: content,
|
||||
variables: JSON.stringify(varList),
|
||||
is_default: document.getElementById("ctm-default").checked,
|
||||
model: document.getElementById("csk-model").value.trim(),
|
||||
auto_approve: document.getElementById("csk-auto-approve").checked,
|
||||
temperature: csTemp ? parseFloat(csTemp) : null,
|
||||
reasoning_effort: document.getElementById("csk-reasoning-effort").value,
|
||||
max_tokens: csMaxTok ? parseInt(csMaxTok, 10) : null,
|
||||
token_budget: csBudget ? parseInt(csBudget, 10) : 0,
|
||||
agent_max_turns: csMaxTurns ? parseInt(csMaxTurns, 10) : null,
|
||||
allowed_tools: JSON.stringify(csAllowedArr),
|
||||
enabled: document.getElementById("csk-enabled").checked,
|
||||
};
|
||||
if (csVersion) createBody.version = csVersion;
|
||||
authFetch("/v1/api/admin/skills", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
category: document.getElementById("ctm-category").value,
|
||||
description: (
|
||||
document.getElementById("skill-description").value || ""
|
||||
).trim(),
|
||||
tags: JSON.stringify(tagsArray),
|
||||
author: (document.getElementById("skill-author").value || "").trim(),
|
||||
activation: document.getElementById("skill-activation").value,
|
||||
content: content,
|
||||
variables: JSON.stringify(varList),
|
||||
is_default: document.getElementById("ctm-default").checked,
|
||||
model: document.getElementById("csk-model").value.trim(),
|
||||
auto_approve: document.getElementById("csk-auto-approve").checked,
|
||||
temperature: csTemp ? parseFloat(csTemp) : null,
|
||||
reasoning_effort: document.getElementById("csk-reasoning-effort").value,
|
||||
max_tokens: csMaxTok ? parseInt(csMaxTok, 10) : null,
|
||||
token_budget: csBudget ? parseInt(csBudget, 10) : 0,
|
||||
agent_max_turns: csMaxTurns ? parseInt(csMaxTurns, 10) : null,
|
||||
allowed_tools: JSON.stringify(csAllowedArr),
|
||||
enabled: document.getElementById("csk-enabled").checked,
|
||||
}),
|
||||
body: JSON.stringify(createBody),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
@@ -942,10 +990,39 @@ function submitCreateTemplate() {
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
hideCreateTemplateModal();
|
||||
showToast("Skill created");
|
||||
loadGovSkills();
|
||||
.then(function (data) {
|
||||
if (_pendingResources.length && data && data.template_id) {
|
||||
var promises = _pendingResources.map(function (res) {
|
||||
return authFetch(
|
||||
"/v1/api/admin/skills/" + data.template_id + "/resources",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(res),
|
||||
},
|
||||
).then(function (r) {
|
||||
if (!r.ok) throw new Error("Upload failed for " + res.path);
|
||||
return r.json();
|
||||
});
|
||||
});
|
||||
Promise.all(promises)
|
||||
.then(function () {
|
||||
hideCreateTemplateModal();
|
||||
showToast(
|
||||
"Skill created with " + _pendingResources.length + " resource(s)",
|
||||
);
|
||||
loadGovSkills();
|
||||
})
|
||||
.catch(function () {
|
||||
hideCreateTemplateModal();
|
||||
showToast("Skill created (some resources failed)");
|
||||
loadGovSkills();
|
||||
});
|
||||
} else {
|
||||
hideCreateTemplateModal();
|
||||
showToast("Skill created");
|
||||
loadGovSkills();
|
||||
}
|
||||
})
|
||||
.catch(function (e) {
|
||||
var el = document.getElementById("create-template-error");
|
||||
@@ -983,6 +1060,9 @@ function showEditTemplateModal(tmplId) {
|
||||
}
|
||||
document.getElementById("etm-tags").value = tagsDisplay;
|
||||
document.getElementById("etm-author").value = tmpl.author || "";
|
||||
document.getElementById("etm-version").value = tmpl.version || "";
|
||||
document.getElementById("etm-license").value = tmpl.license || "";
|
||||
document.getElementById("etm-compatibility").value = tmpl.compatibility || "";
|
||||
document.getElementById("etm-activation").value = tmpl.activation || "named";
|
||||
document.getElementById("etm-content").value = tmpl.content;
|
||||
_updateVarsDisplay("etm-content", "etm-variables");
|
||||
@@ -1017,11 +1097,9 @@ function showEditTemplateModal(tmplId) {
|
||||
document.getElementById("esk-allowed-tools").disabled =
|
||||
tmpl.auto_approve || false;
|
||||
document.getElementById("esk-enabled").checked = tmpl.enabled !== false;
|
||||
document
|
||||
.getElementById("esk-auto-approve")
|
||||
.addEventListener("change", function () {
|
||||
document.getElementById("esk-allowed-tools").disabled = this.checked;
|
||||
});
|
||||
document.getElementById("esk-auto-approve").onchange = function () {
|
||||
document.getElementById("esk-allowed-tools").disabled = this.checked;
|
||||
};
|
||||
document.getElementById("edit-template-error").style.display = "none";
|
||||
// Scan report section
|
||||
var scanSection = document.getElementById("etm-scan-section");
|
||||
@@ -1111,7 +1189,91 @@ function showEditTemplateModal(tmplId) {
|
||||
});
|
||||
};
|
||||
}
|
||||
// Reset collapsible state before applying readonly rules (prevents state leak
|
||||
// when switching between readonly and editable skills in the same session)
|
||||
var allDetails = document.querySelectorAll(
|
||||
"#edit-template-box .admin-details",
|
||||
);
|
||||
for (var d = 0; d < allDetails.length; d++) allDetails[d].open = false;
|
||||
|
||||
// --- Readonly mode for imported skills ---
|
||||
var isReadonly = tmpl.readonly || false;
|
||||
var editTitle = document.getElementById("edit-template-title");
|
||||
if (editTitle)
|
||||
editTitle.textContent = isReadonly ? "View Skill" : "Edit Skill";
|
||||
// Origin badge — show provenance for installed skills
|
||||
var originBadge = document.getElementById("etm-origin-badge");
|
||||
if (originBadge) {
|
||||
if (isReadonly && tmpl.source_url) {
|
||||
originBadge.textContent = "Installed from \u00a0" + tmpl.source_url;
|
||||
originBadge.style.display = "inline-flex";
|
||||
} else if (isReadonly && tmpl.origin && tmpl.origin !== "manual") {
|
||||
originBadge.textContent = "Installed skill";
|
||||
originBadge.style.display = "inline-flex";
|
||||
} else {
|
||||
originBadge.style.display = "none";
|
||||
}
|
||||
}
|
||||
var submitBtn = document.getElementById("etm-submit");
|
||||
if (submitBtn) {
|
||||
submitBtn.style.display = "";
|
||||
submitBtn.textContent = isReadonly ? "Save Config" : "Save";
|
||||
}
|
||||
// Spec/content fields: locked for installed skills (preserve source fidelity)
|
||||
[
|
||||
"etm-name",
|
||||
"etm-category",
|
||||
"etm-description",
|
||||
"etm-tags",
|
||||
"etm-author",
|
||||
"etm-version",
|
||||
"etm-license",
|
||||
"etm-compatibility",
|
||||
"etm-activation",
|
||||
"etm-content",
|
||||
"etm-default",
|
||||
].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.disabled = isReadonly;
|
||||
});
|
||||
// Runtime config fields: always editable (local settings, not part of SKILL.md spec)
|
||||
[
|
||||
"esk-model",
|
||||
"esk-temperature",
|
||||
"esk-reasoning-effort",
|
||||
"esk-max-tokens",
|
||||
"esk-token-budget",
|
||||
"esk-agent-max-turns",
|
||||
"esk-auto-approve",
|
||||
"esk-enabled",
|
||||
].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.disabled = false;
|
||||
});
|
||||
// esk-allowed-tools follows auto_approve state, not readonly state
|
||||
var allowedToolsEl = document.getElementById("esk-allowed-tools");
|
||||
if (allowedToolsEl) allowedToolsEl.disabled = tmpl.auto_approve || false;
|
||||
var cancelBtn = document.querySelector("#edit-template-box .modal-cancel");
|
||||
if (cancelBtn) cancelBtn.textContent = isReadonly ? "Close" : "Cancel";
|
||||
// Auto-expand Runtime Config collapsible for installed skills so config is visible
|
||||
if (isReadonly) {
|
||||
var details = document.querySelectorAll(
|
||||
"#edit-template-box .admin-details",
|
||||
);
|
||||
for (var d = 0; d < details.length; d++) details[d].open = true;
|
||||
}
|
||||
// --- Skill Resources ---
|
||||
var resSection = document.getElementById("etm-resources-section");
|
||||
if (resSection) {
|
||||
_loadSkillResources(tmplId, isReadonly);
|
||||
}
|
||||
_etmTrapHandler = _installTrap("edit-template-overlay", "edit-template-box");
|
||||
// Focus management
|
||||
if (isReadonly) {
|
||||
if (cancelBtn) cancelBtn.focus();
|
||||
} else {
|
||||
document.getElementById("etm-name").focus();
|
||||
}
|
||||
}
|
||||
|
||||
function hideEditTemplateModal() {
|
||||
@@ -1123,6 +1285,245 @@ function hideEditTemplateModal() {
|
||||
_etmTriggerEl = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill Resources
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _loadSkillResources(skillId, readonly) {
|
||||
var container = document.getElementById("etm-resources-list");
|
||||
var addBtn = document.getElementById("etm-add-resource-btn");
|
||||
var addForm = document.getElementById("etm-add-resource-form");
|
||||
if (!container) return;
|
||||
container.innerHTML = '<div class="dashboard-empty">Loading...</div>';
|
||||
if (addBtn) addBtn.style.display = readonly ? "none" : "";
|
||||
if (addForm) addForm.style.display = "none";
|
||||
|
||||
authFetch("/v1/api/admin/skills/" + skillId + "/resources")
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error("Failed");
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
var resources = data.resources || [];
|
||||
if (!resources.length) {
|
||||
container.innerHTML =
|
||||
'<div class="dashboard-empty">No resource files</div>';
|
||||
return;
|
||||
}
|
||||
var html = "";
|
||||
for (var i = 0; i < resources.length; i++) {
|
||||
var res = resources[i];
|
||||
var sizeStr =
|
||||
res.size > 1024
|
||||
? (res.size / 1024).toFixed(1) + " KB"
|
||||
: res.size + " B";
|
||||
html +=
|
||||
'<div role="listitem" style="display:flex;align-items:center;padding:4px 0;gap:8px">' +
|
||||
'<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><code>' +
|
||||
escapeHtml(res.path) +
|
||||
"</code></span>" +
|
||||
'<span style="width:80px;text-align:right;opacity:0.6">' +
|
||||
sizeStr +
|
||||
"</span>" +
|
||||
'<span style="width:60px;text-align:right">' +
|
||||
(readonly
|
||||
? ""
|
||||
: '<button class="admin-btn-danger" data-del-res="' +
|
||||
escapeHtml(res.path) +
|
||||
'" style="font-size:0.85em" aria-label="Delete resource ' +
|
||||
escapeHtml(res.path) +
|
||||
'">delete</button>') +
|
||||
"</span></div>";
|
||||
}
|
||||
container.innerHTML = html;
|
||||
if (!readonly) {
|
||||
container.querySelectorAll("[data-del-res]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var path = this.getAttribute("data-del-res");
|
||||
showConfirmModal(
|
||||
"Delete Resource",
|
||||
'Delete "' + path + '"?',
|
||||
"Delete",
|
||||
function () {
|
||||
authFetch(
|
||||
"/v1/api/admin/skills/" +
|
||||
skillId +
|
||||
"/resources/" +
|
||||
path.split("/").map(encodeURIComponent).join("/"),
|
||||
{ method: "DELETE" },
|
||||
)
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error();
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
showToast("Resource deleted");
|
||||
_loadSkillResources(skillId, readonly);
|
||||
loadGovSkills();
|
||||
var addBtn = document.getElementById(
|
||||
"etm-add-resource-btn",
|
||||
);
|
||||
if (addBtn) addBtn.focus();
|
||||
})
|
||||
.catch(function () {
|
||||
showToast("Failed to delete resource");
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
container.innerHTML =
|
||||
'<div class="dashboard-empty">Failed to load resources</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function _showAddResourceForm(skillId) {
|
||||
var form = document.getElementById("etm-add-resource-form");
|
||||
if (!form) return;
|
||||
form.style.display = "";
|
||||
document.getElementById("etm-res-path").value = "";
|
||||
document.getElementById("etm-res-content").value = "";
|
||||
document.getElementById("etm-res-content-type").value = "text/plain";
|
||||
document.getElementById("etm-res-submit").onclick = function () {
|
||||
var path = (document.getElementById("etm-res-path").value || "").trim();
|
||||
var content = document.getElementById("etm-res-content").value || "";
|
||||
var contentType = document.getElementById("etm-res-content-type").value;
|
||||
if (!path || !content) {
|
||||
showToast("Path and content are required");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!path.startsWith("scripts/") &&
|
||||
!path.startsWith("references/") &&
|
||||
!path.startsWith("assets/")
|
||||
) {
|
||||
showToast("Path must start with scripts/, references/, or assets/");
|
||||
return;
|
||||
}
|
||||
this.disabled = true;
|
||||
this.textContent = "Uploading\u2026";
|
||||
authFetch("/v1/api/admin/skills/" + skillId + "/resources", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
path: path,
|
||||
content: content,
|
||||
content_type: contentType,
|
||||
}),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
return r.json().then(function (d) {
|
||||
throw new Error(d.error || "Failed");
|
||||
});
|
||||
return r.json();
|
||||
})
|
||||
.then(function () {
|
||||
showToast("Resource added");
|
||||
form.style.display = "none";
|
||||
_loadSkillResources(skillId, false);
|
||||
loadGovSkills();
|
||||
})
|
||||
.catch(function (e) {
|
||||
showToast(e.message || "Failed to add resource");
|
||||
})
|
||||
.finally(function () {
|
||||
var btn = document.getElementById("etm-res-submit");
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Upload";
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pending resources (create modal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _renderPendingResources() {
|
||||
var container = document.getElementById("ctm-resources-list");
|
||||
if (!container) return;
|
||||
if (!_pendingResources.length) {
|
||||
container.innerHTML =
|
||||
'<div class="dashboard-empty">No resource files yet</div>';
|
||||
return;
|
||||
}
|
||||
var html = "";
|
||||
for (var i = 0; i < _pendingResources.length; i++) {
|
||||
var r = _pendingResources[i];
|
||||
var sizeStr =
|
||||
r.content.length > 1024
|
||||
? (r.content.length / 1024).toFixed(1) + " KB"
|
||||
: r.content.length + " B";
|
||||
html +=
|
||||
'<div role="listitem" style="display:flex;align-items:center;padding:4px 0;gap:8px">' +
|
||||
'<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><code>' +
|
||||
escapeHtml(r.path) +
|
||||
"</code></span>" +
|
||||
'<span style="width:80px;text-align:right;opacity:0.6">' +
|
||||
sizeStr +
|
||||
"</span>" +
|
||||
'<span style="width:60px;text-align:right">' +
|
||||
'<button class="admin-btn-danger" data-remove-res="' +
|
||||
i +
|
||||
'" style="font-size:0.85em" aria-label="Remove resource ' +
|
||||
escapeHtml(r.path) +
|
||||
'">remove</button>' +
|
||||
"</span></div>";
|
||||
}
|
||||
container.innerHTML = html;
|
||||
container.querySelectorAll("[data-remove-res]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var idx = parseInt(this.getAttribute("data-remove-res"), 10);
|
||||
_pendingResources.splice(idx, 1);
|
||||
_renderPendingResources();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _addPendingResource() {
|
||||
var path = (document.getElementById("ctm-res-path").value || "").trim();
|
||||
var content = document.getElementById("ctm-res-content").value || "";
|
||||
var contentType = document.getElementById("ctm-res-content-type").value;
|
||||
if (!path || !content) {
|
||||
showToast("Path and content are required");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!path.startsWith("scripts/") &&
|
||||
!path.startsWith("references/") &&
|
||||
!path.startsWith("assets/")
|
||||
) {
|
||||
showToast("Path must start with scripts/, references/, or assets/");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
_pendingResources.some(function (r) {
|
||||
return r.path === path;
|
||||
})
|
||||
) {
|
||||
showToast("Resource path already added");
|
||||
return;
|
||||
}
|
||||
if (_pendingResources.length >= 10) {
|
||||
showToast("Maximum 10 resources per skill");
|
||||
return;
|
||||
}
|
||||
_pendingResources.push({
|
||||
path: path,
|
||||
content: content,
|
||||
content_type: contentType,
|
||||
});
|
||||
document.getElementById("ctm-res-path").value = "";
|
||||
document.getElementById("ctm-res-content").value = "";
|
||||
_renderPendingResources();
|
||||
document.getElementById("ctm-res-path").focus();
|
||||
}
|
||||
|
||||
function submitEditTemplate() {
|
||||
var id = document.getElementById("etm-id").value;
|
||||
var content = document.getElementById("etm-content").value;
|
||||
@@ -1153,31 +1554,38 @@ function submitEditTemplate() {
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
document.getElementById("etm-submit").disabled = true;
|
||||
var esVersion = (document.getElementById("etm-version").value || "").trim();
|
||||
var updateBody = {
|
||||
name: document.getElementById("etm-name").value.trim(),
|
||||
category: document.getElementById("etm-category").value,
|
||||
description: (
|
||||
document.getElementById("etm-description").value || ""
|
||||
).trim(),
|
||||
tags: JSON.stringify(tagsArray),
|
||||
author: (document.getElementById("etm-author").value || "").trim(),
|
||||
license: (document.getElementById("etm-license").value || "").trim(),
|
||||
compatibility: (
|
||||
document.getElementById("etm-compatibility").value || ""
|
||||
).trim(),
|
||||
activation: document.getElementById("etm-activation").value,
|
||||
content: content,
|
||||
variables: JSON.stringify(varList),
|
||||
is_default: document.getElementById("etm-default").checked,
|
||||
model: document.getElementById("esk-model").value.trim(),
|
||||
auto_approve: document.getElementById("esk-auto-approve").checked,
|
||||
temperature: esTemp ? parseFloat(esTemp) : null,
|
||||
reasoning_effort: document.getElementById("esk-reasoning-effort").value,
|
||||
max_tokens: esMaxTok ? parseInt(esMaxTok, 10) : null,
|
||||
token_budget: esBudget ? parseInt(esBudget, 10) : 0,
|
||||
agent_max_turns: esMaxTurns ? parseInt(esMaxTurns, 10) : null,
|
||||
allowed_tools: JSON.stringify(esAllowedArr),
|
||||
enabled: document.getElementById("esk-enabled").checked,
|
||||
};
|
||||
if (esVersion) updateBody.version = esVersion;
|
||||
authFetch("/v1/api/admin/skills/" + id, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: document.getElementById("etm-name").value.trim(),
|
||||
category: document.getElementById("etm-category").value,
|
||||
description: (
|
||||
document.getElementById("etm-description").value || ""
|
||||
).trim(),
|
||||
tags: JSON.stringify(tagsArray),
|
||||
author: (document.getElementById("etm-author").value || "").trim(),
|
||||
activation: document.getElementById("etm-activation").value,
|
||||
content: content,
|
||||
variables: JSON.stringify(varList),
|
||||
is_default: document.getElementById("etm-default").checked,
|
||||
model: document.getElementById("esk-model").value.trim(),
|
||||
auto_approve: document.getElementById("esk-auto-approve").checked,
|
||||
temperature: esTemp ? parseFloat(esTemp) : null,
|
||||
reasoning_effort: document.getElementById("esk-reasoning-effort").value,
|
||||
max_tokens: esMaxTok ? parseInt(esMaxTok, 10) : null,
|
||||
token_budget: esBudget ? parseInt(esBudget, 10) : 0,
|
||||
agent_max_turns: esMaxTurns ? parseInt(esMaxTurns, 10) : null,
|
||||
allowed_tools: JSON.stringify(esAllowedArr),
|
||||
enabled: document.getElementById("esk-enabled").checked,
|
||||
}),
|
||||
body: JSON.stringify(updateBody),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (!r.ok)
|
||||
@@ -1762,6 +2170,10 @@ function switchSkillView(view) {
|
||||
|
||||
function searchSkillDiscover() {
|
||||
var q = (document.getElementById("skill-discover-q").value || "").trim();
|
||||
if (!q) {
|
||||
showToast("Enter a search query");
|
||||
return;
|
||||
}
|
||||
_skillDiscoverResults = [];
|
||||
_skillDiscoverQuery = q;
|
||||
|
||||
@@ -1931,13 +2343,14 @@ function installDiscoveredSkill(skill) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
var tierMsg = data.scan_status ? " [" + data.scan_status + "]" : "";
|
||||
var first = (data.installed && data.installed[0]) || {};
|
||||
var tierMsg = first.scan_status ? " [" + first.scan_status + "]" : "";
|
||||
showToast("Skill installed: " + (skill.name || skill.id) + tierMsg);
|
||||
// Mark as installed in results with scan data
|
||||
for (var j = 0; j < _skillDiscoverResults.length; j++) {
|
||||
if (_skillDiscoverResults[j].id === skill.id) {
|
||||
_skillDiscoverResults[j].installed = true;
|
||||
_skillDiscoverResults[j].scan_status = data.scan_status || "";
|
||||
_skillDiscoverResults[j].scan_status = first.scan_status || "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -2003,12 +2416,28 @@ function submitGitHubImport() {
|
||||
})
|
||||
.then(function (data) {
|
||||
hideGitHubImportModal();
|
||||
var tierMsg = data.scan_status ? " [" + data.scan_status + "]" : "";
|
||||
showToast("Skill installed: " + (data.name || "") + tierMsg);
|
||||
// Refresh if we're on discover view
|
||||
if (_skillCurrentView === "discover") {
|
||||
searchSkillDiscover();
|
||||
var count = data.installed.length;
|
||||
var skipCount = (data.skipped || []).length;
|
||||
var msg;
|
||||
if (count === 1 && !skipCount) {
|
||||
var name = data.installed[0].name || "";
|
||||
var tierMsg = data.installed[0].scan_status
|
||||
? " [" + data.installed[0].scan_status + "]"
|
||||
: "";
|
||||
msg = "Skill installed: " + name + tierMsg;
|
||||
} else if (count === 0 && skipCount) {
|
||||
msg =
|
||||
"All " +
|
||||
skipCount +
|
||||
" skill" +
|
||||
(skipCount !== 1 ? "s" : "") +
|
||||
" already installed";
|
||||
} else {
|
||||
msg = count + " skill" + (count !== 1 ? "s" : "") + " installed";
|
||||
if (skipCount) msg += " (" + skipCount + " already installed)";
|
||||
}
|
||||
showToast(msg);
|
||||
loadGovSkills();
|
||||
})
|
||||
.catch(function (e) {
|
||||
errEl.textContent = e.message;
|
||||
|
||||
@@ -95,7 +95,11 @@
|
||||
<div class="admin-sidebar-group-label" aria-hidden="true">Governance</div>
|
||||
<button id="tab-roles" class="admin-nav" data-tab="roles" role="tab" aria-selected="false" aria-controls="admin-roles" tabindex="-1" onclick="switchAdminTab('roles')">Roles</button>
|
||||
<button id="tab-policies" class="admin-nav" data-tab="policies" role="tab" aria-selected="false" aria-controls="admin-policies" tabindex="-1" onclick="switchAdminTab('policies')">Policies</button>
|
||||
</div>
|
||||
<div class="admin-sidebar-group" data-group="extensions" role="group" aria-label="Extensions">
|
||||
<div class="admin-sidebar-group-label" aria-hidden="true">Extensions</div>
|
||||
<button id="tab-skills" class="admin-nav" data-tab="skills" role="tab" aria-selected="false" aria-controls="admin-skills" tabindex="-1" onclick="switchAdminTab('skills')">Skills</button>
|
||||
<button id="tab-mcp" class="admin-nav" data-tab="mcp" role="tab" aria-selected="false" aria-controls="admin-mcp" tabindex="-1" onclick="switchAdminTab('mcp')">MCP Servers</button>
|
||||
</div>
|
||||
<div class="admin-sidebar-group" data-group="observe" role="group" aria-label="Observe">
|
||||
<div class="admin-sidebar-group-label" aria-hidden="true">Observe</div>
|
||||
@@ -106,7 +110,6 @@
|
||||
<div class="admin-sidebar-group" data-group="system" role="group" aria-label="System">
|
||||
<div class="admin-sidebar-group-label" aria-hidden="true">System</div>
|
||||
<button id="tab-settings" class="admin-nav" data-tab="settings" role="tab" aria-selected="false" aria-controls="admin-settings" tabindex="-1" onclick="switchAdminTab('settings')">Settings</button>
|
||||
<button id="tab-mcp" class="admin-nav" data-tab="mcp" role="tab" aria-selected="false" aria-controls="admin-mcp" tabindex="-1" onclick="switchAdminTab('mcp')">MCP Servers</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div id="admin-sidebar-backdrop" class="admin-sidebar-backdrop" aria-hidden="true"></div>
|
||||
@@ -269,9 +272,9 @@
|
||||
<!-- Installed view -->
|
||||
<div id="skill-view-installed" role="tabpanel" aria-labelledby="skill-tab-installed">
|
||||
<div class="admin-colheaders" aria-hidden="true">
|
||||
<span class="admin-col admin-col-tmname">NAME</span>
|
||||
<span class="admin-col admin-col-tmcat">CATEGORY</span>
|
||||
<span class="admin-col admin-col-tmvars">VARIABLES</span>
|
||||
<span class="admin-col admin-col-tmname">NAME</span>
|
||||
<span class="admin-col admin-col-tmrisk">RISK</span>
|
||||
<span class="admin-col admin-col-actions">ACTIONS</span>
|
||||
</div>
|
||||
<div id="admin-skills-table" role="list" aria-label="Skills" aria-live="polite">
|
||||
@@ -406,7 +409,7 @@
|
||||
<span class="section-header">MCP</span>
|
||||
<div class="mcp-view-toggle" role="tablist" aria-label="MCP view">
|
||||
<button class="mcp-view-btn active" data-mcp-view="servers" role="tab" aria-selected="true" aria-controls="mcp-view-servers" tabindex="0" onclick="switchMcpView('servers')">Servers</button>
|
||||
<button class="mcp-view-btn" data-mcp-view="registry" role="tab" aria-selected="false" aria-controls="mcp-view-registry" tabindex="-1" onclick="switchMcpView('registry')">Registry</button>
|
||||
<button class="mcp-view-btn" data-mcp-view="registry" role="tab" aria-selected="false" aria-controls="mcp-view-registry" tabindex="-1" onclick="switchMcpView('registry')">Discover</button>
|
||||
</div>
|
||||
<span id="mcp-servers-toolbar">
|
||||
<button id="mcp-sync-btn" class="admin-action-btn admin-action-btn-ghost" onclick="reloadMcpNodes()" title="Push MCP server config to all cluster nodes and reconnect">Sync to Nodes</button>
|
||||
@@ -845,59 +848,111 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
|
||||
<!-- Create Skill Modal -->
|
||||
<div id="create-template-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-template-title">
|
||||
<div id="create-template-box" class="admin-modal admin-modal-wide">
|
||||
<div id="create-template-box" class="admin-modal admin-modal-wide admin-modal-skill">
|
||||
<h2 id="create-template-title">Create Skill</h2>
|
||||
<div id="create-template-error" role="alert" aria-live="assertive"></div>
|
||||
<label for="ctm-name">Name</label>
|
||||
<input id="ctm-name" type="text" placeholder="e.g. Code Review Agent" autocomplete="off">
|
||||
<label for="ctm-category">Category</label>
|
||||
<select id="ctm-category">
|
||||
<option value="general">General</option>
|
||||
<option value="engineering">Engineering</option>
|
||||
<option value="support">Support</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<label for="skill-description">Description</label>
|
||||
<textarea id="skill-description" rows="2" placeholder="Brief description for discovery"></textarea>
|
||||
<label for="skill-tags">Tags</label>
|
||||
<input id="skill-tags" type="text" placeholder="Comma-separated tags">
|
||||
<label for="skill-author">Author</label>
|
||||
<input id="skill-author" type="text" placeholder="Author name">
|
||||
<label for="skill-activation">Activation</label>
|
||||
<select id="skill-activation">
|
||||
<option value="named">Named</option>
|
||||
<option value="default">Default (auto-apply)</option>
|
||||
<option value="search">Search (BM25 discoverable)</option>
|
||||
</select>
|
||||
<label for="ctm-content">Content <span class="label-hint">system message text, use {{model}}, {{ws_id}}, {{node_id}} for placeholders</span></label>
|
||||
<textarea id="ctm-content" rows="6" placeholder="You are a code reviewer using {{model}}..."></textarea>
|
||||
<label>Variables <span class="label-hint">auto-detected from content — available: model, ws_id, node_id</span></label>
|
||||
<div id="ctm-variables" class="label-hint" style="padding:4px 0;min-height:1.2em"></div>
|
||||
<label class="admin-checkbox"><input id="ctm-default" type="checkbox"> Set as default for new workstreams</label>
|
||||
<div class="skill-spec-body">
|
||||
<div class="skill-spec-col skill-spec-col-meta">
|
||||
<div class="skill-spec-section">
|
||||
<h3 class="skill-spec-heading">Identity</h3>
|
||||
<label for="ctm-name">Name</label>
|
||||
<input id="ctm-name" type="text" placeholder="e.g. code-review" autocomplete="off">
|
||||
<label for="ctm-category">Category</label>
|
||||
<select id="ctm-category">
|
||||
<option value="general">General</option>
|
||||
<option value="engineering">Engineering</option>
|
||||
<option value="support">Support</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<label for="skill-description">Description</label>
|
||||
<textarea id="skill-description" rows="2" placeholder="Brief description for skill discovery"></textarea>
|
||||
</div>
|
||||
<div class="skill-spec-section">
|
||||
<h3 class="skill-spec-heading">Manifest</h3>
|
||||
<label for="skill-tags">Tags</label>
|
||||
<input id="skill-tags" type="text" placeholder="python, review, quality">
|
||||
<label for="skill-author">Author</label>
|
||||
<input id="skill-author" type="text" placeholder="Author name">
|
||||
<label for="skill-version">Version</label>
|
||||
<input id="skill-version" type="text" placeholder="1.0.0">
|
||||
<label for="skill-license">License</label>
|
||||
<select id="skill-license">
|
||||
<option value="">— not specified —</option>
|
||||
<option value="MIT">MIT</option>
|
||||
<option value="Apache-2.0">Apache-2.0</option>
|
||||
<option value="GPL-2.0">GPL-2.0</option>
|
||||
<option value="GPL-3.0">GPL-3.0</option>
|
||||
<option value="LGPL-2.1">LGPL-2.1</option>
|
||||
<option value="LGPL-3.0">LGPL-3.0</option>
|
||||
<option value="AGPL-3.0">AGPL-3.0</option>
|
||||
<option value="BSD-2-Clause">BSD-2-Clause</option>
|
||||
<option value="BSD-3-Clause">BSD-3-Clause</option>
|
||||
<option value="ISC">ISC</option>
|
||||
<option value="MPL-2.0">MPL-2.0</option>
|
||||
<option value="Unlicense">Unlicense</option>
|
||||
<option value="Proprietary">Proprietary</option>
|
||||
</select>
|
||||
<label for="skill-compatibility">Compatibility <span class="label-hint">environment requirements, max 500 chars</span></label>
|
||||
<input id="skill-compatibility" type="text" placeholder="Requires git, docker, etc." maxlength="500">
|
||||
</div>
|
||||
<div class="skill-spec-section">
|
||||
<h3 class="skill-spec-heading">Deployment</h3>
|
||||
<label for="skill-activation">Activation <span class="label-hint">how models discover this skill</span></label>
|
||||
<select id="skill-activation">
|
||||
<option value="named">Named — explicit /skill invocation</option>
|
||||
<option value="default">Default — auto-applied to every session</option>
|
||||
<option value="search">Search — BM25 discoverable</option>
|
||||
</select>
|
||||
<label class="admin-checkbox"><input id="ctm-default" type="checkbox"> Apply to new workstreams by default</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skill-spec-col skill-spec-col-content">
|
||||
<div class="skill-spec-section skill-spec-section-content">
|
||||
<h3 class="skill-spec-heading">Skill Content <span class="label-hint">system message — {{model}}, {{ws_id}}, {{node_id}}</span></h3>
|
||||
<textarea id="ctm-content" class="skill-content-area" placeholder="You are a code reviewer using {{model}}..."></textarea>
|
||||
<div class="skill-vars-row">
|
||||
<span class="skill-vars-label">Variables</span>
|
||||
<div id="ctm-variables" class="skill-vars-display label-hint"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="admin-details">
|
||||
<summary>Session Config <span class="label-hint">optional — applied when skill is selected for a workstream</span></summary>
|
||||
<label for="csk-model">Model</label>
|
||||
<input id="csk-model" type="text" placeholder="Default model">
|
||||
<label for="csk-temperature">Temperature</label>
|
||||
<input id="csk-temperature" type="number" step="0.1" min="0" max="2" placeholder="System default">
|
||||
<label for="csk-reasoning-effort">Reasoning Effort</label>
|
||||
<select id="csk-reasoning-effort">
|
||||
<option value="">System default</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<label for="csk-max-tokens">Max Tokens</label>
|
||||
<input id="csk-max-tokens" type="number" min="1" placeholder="System default">
|
||||
<label for="csk-token-budget">Token Budget</label>
|
||||
<input id="csk-token-budget" type="number" min="0" placeholder="0 = unlimited">
|
||||
<label for="csk-agent-max-turns">Agent Max Turns</label>
|
||||
<input id="csk-agent-max-turns" type="number" min="1" placeholder="System default">
|
||||
<summary>Runtime Config <span class="label-hint">model, temperature, token limits</span></summary>
|
||||
<div class="skill-config-grid">
|
||||
<div><label for="csk-model">Model</label><input id="csk-model" type="text" placeholder="Default model"></div>
|
||||
<div><label for="csk-temperature">Temperature</label><input id="csk-temperature" type="number" step="0.1" min="0" max="2" placeholder="System default"></div>
|
||||
<div>
|
||||
<label for="csk-reasoning-effort">Reasoning Effort</label>
|
||||
<select id="csk-reasoning-effort">
|
||||
<option value="">System default</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><label for="csk-max-tokens">Max Tokens</label><input id="csk-max-tokens" type="number" min="1" placeholder="System default"></div>
|
||||
<div><label for="csk-token-budget">Token Budget</label><input id="csk-token-budget" type="number" min="0" placeholder="0 = unlimited"></div>
|
||||
<div><label for="csk-agent-max-turns">Agent Max Turns</label><input id="csk-agent-max-turns" type="number" min="1" placeholder="System default"></div>
|
||||
</div>
|
||||
<label class="admin-checkbox"><input id="csk-auto-approve" type="checkbox"> Auto-approve all tools</label>
|
||||
<label for="csk-allowed-tools">Allowed Tools <span class="label-hint">comma-separated tool names for auto-approve</span></label>
|
||||
<input id="csk-allowed-tools" type="text" placeholder="bash, read_file, write_file">
|
||||
<label class="admin-checkbox"><input id="csk-enabled" type="checkbox" checked> Enabled</label>
|
||||
</details>
|
||||
<details class="admin-details">
|
||||
<summary>Resources <span class="label-hint">bundled files (scripts, references, assets)</span></summary>
|
||||
<div id="ctm-resources-list" role="list" aria-live="polite" aria-label="Pending resources"></div>
|
||||
<div style="margin-top:8px;display:flex;flex-direction:column;gap:6px">
|
||||
<label for="ctm-res-path">Path</label>
|
||||
<input id="ctm-res-path" type="text" placeholder="scripts/setup.sh or references/guide.md">
|
||||
<label for="ctm-res-content-type">Content Type</label>
|
||||
<input id="ctm-res-content-type" type="text" value="text/plain">
|
||||
<label for="ctm-res-content">Content</label>
|
||||
<textarea id="ctm-res-content" rows="4" placeholder="Resource file content"></textarea>
|
||||
<button type="button" class="admin-btn-action" onclick="_addPendingResource()">Add Resource</button>
|
||||
</div>
|
||||
</details>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideCreateTemplateModal()">Cancel</button>
|
||||
<button id="ctm-submit" class="modal-submit" onclick="submitCreateTemplate()">Create</button>
|
||||
@@ -907,55 +962,95 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
|
||||
<!-- Edit Skill Modal -->
|
||||
<div id="edit-template-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="edit-template-title">
|
||||
<div id="edit-template-box" class="admin-modal admin-modal-wide">
|
||||
<div id="edit-template-box" class="admin-modal admin-modal-wide admin-modal-skill">
|
||||
<h2 id="edit-template-title">Edit Skill</h2>
|
||||
<div id="etm-origin-badge" class="skill-origin-badge" style="display:none"></div>
|
||||
<div id="edit-template-error" role="alert" aria-live="assertive"></div>
|
||||
<input id="etm-id" type="hidden">
|
||||
<label for="etm-name">Name</label>
|
||||
<input id="etm-name" type="text" autocomplete="off">
|
||||
<label for="etm-category">Category</label>
|
||||
<select id="etm-category">
|
||||
<option value="general">General</option>
|
||||
<option value="engineering">Engineering</option>
|
||||
<option value="support">Support</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<label for="etm-description">Description</label>
|
||||
<textarea id="etm-description" rows="2" placeholder="Brief description for discovery"></textarea>
|
||||
<label for="etm-tags">Tags</label>
|
||||
<input id="etm-tags" type="text" placeholder="Comma-separated tags">
|
||||
<label for="etm-author">Author</label>
|
||||
<input id="etm-author" type="text" placeholder="Author name">
|
||||
<label for="etm-activation">Activation</label>
|
||||
<select id="etm-activation">
|
||||
<option value="named">Named</option>
|
||||
<option value="default">Default (auto-apply)</option>
|
||||
<option value="search">Search (BM25 discoverable)</option>
|
||||
</select>
|
||||
<label for="etm-content">Content</label>
|
||||
<textarea id="etm-content" rows="6"></textarea>
|
||||
<label>Variables <span class="label-hint">auto-detected from content — available: model, ws_id, node_id</span></label>
|
||||
<div id="etm-variables" class="label-hint" style="padding:4px 0;min-height:1.2em"></div>
|
||||
<label class="admin-checkbox"><input id="etm-default" type="checkbox"> Set as default</label>
|
||||
<div class="skill-spec-body">
|
||||
<div class="skill-spec-col skill-spec-col-meta">
|
||||
<div class="skill-spec-section">
|
||||
<h3 class="skill-spec-heading">Identity</h3>
|
||||
<label for="etm-name">Name</label>
|
||||
<input id="etm-name" type="text" autocomplete="off">
|
||||
<label for="etm-category">Category</label>
|
||||
<select id="etm-category">
|
||||
<option value="general">General</option>
|
||||
<option value="engineering">Engineering</option>
|
||||
<option value="support">Support</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<label for="etm-description">Description</label>
|
||||
<textarea id="etm-description" rows="2" placeholder="Brief description for skill discovery"></textarea>
|
||||
</div>
|
||||
<div class="skill-spec-section">
|
||||
<h3 class="skill-spec-heading">Manifest</h3>
|
||||
<label for="etm-tags">Tags</label>
|
||||
<input id="etm-tags" type="text" placeholder="python, review, quality">
|
||||
<label for="etm-author">Author</label>
|
||||
<input id="etm-author" type="text" placeholder="Author name">
|
||||
<label for="etm-version">Version</label>
|
||||
<input id="etm-version" type="text" placeholder="1.0.0">
|
||||
<label for="etm-license">License</label>
|
||||
<select id="etm-license">
|
||||
<option value="">— not specified —</option>
|
||||
<option value="MIT">MIT</option>
|
||||
<option value="Apache-2.0">Apache-2.0</option>
|
||||
<option value="GPL-2.0">GPL-2.0</option>
|
||||
<option value="GPL-3.0">GPL-3.0</option>
|
||||
<option value="LGPL-2.1">LGPL-2.1</option>
|
||||
<option value="LGPL-3.0">LGPL-3.0</option>
|
||||
<option value="AGPL-3.0">AGPL-3.0</option>
|
||||
<option value="BSD-2-Clause">BSD-2-Clause</option>
|
||||
<option value="BSD-3-Clause">BSD-3-Clause</option>
|
||||
<option value="ISC">ISC</option>
|
||||
<option value="MPL-2.0">MPL-2.0</option>
|
||||
<option value="Unlicense">Unlicense</option>
|
||||
<option value="Proprietary">Proprietary</option>
|
||||
</select>
|
||||
<label for="etm-compatibility">Compatibility <span class="label-hint">environment requirements, max 500 chars</span></label>
|
||||
<input id="etm-compatibility" type="text" placeholder="Requires git, docker, etc." maxlength="500">
|
||||
</div>
|
||||
<div class="skill-spec-section">
|
||||
<h3 class="skill-spec-heading">Deployment</h3>
|
||||
<label for="etm-activation">Activation <span class="label-hint">how models discover this skill</span></label>
|
||||
<select id="etm-activation">
|
||||
<option value="named">Named — explicit /skill invocation</option>
|
||||
<option value="default">Default — auto-applied to every session</option>
|
||||
<option value="search">Search — BM25 discoverable</option>
|
||||
</select>
|
||||
<label class="admin-checkbox"><input id="etm-default" type="checkbox"> Apply to new workstreams by default</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skill-spec-col skill-spec-col-content">
|
||||
<div class="skill-spec-section skill-spec-section-content">
|
||||
<h3 class="skill-spec-heading">Skill Content <span class="label-hint">{{model}}, {{ws_id}}, {{node_id}}</span></h3>
|
||||
<textarea id="etm-content" class="skill-content-area"></textarea>
|
||||
<div class="skill-vars-row">
|
||||
<span class="skill-vars-label">Variables</span>
|
||||
<div id="etm-variables" class="skill-vars-display label-hint"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="admin-details">
|
||||
<summary>Session Config <span class="label-hint">applied when skill is selected for a workstream</span></summary>
|
||||
<label for="esk-model">Model</label>
|
||||
<input id="esk-model" type="text" placeholder="Default model">
|
||||
<label for="esk-temperature">Temperature</label>
|
||||
<input id="esk-temperature" type="number" step="0.1" min="0" max="2" placeholder="System default">
|
||||
<label for="esk-reasoning-effort">Reasoning Effort</label>
|
||||
<select id="esk-reasoning-effort">
|
||||
<option value="">System default</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
<label for="esk-max-tokens">Max Tokens</label>
|
||||
<input id="esk-max-tokens" type="number" min="1" placeholder="System default">
|
||||
<label for="esk-token-budget">Token Budget</label>
|
||||
<input id="esk-token-budget" type="number" min="0" placeholder="0 = unlimited">
|
||||
<label for="esk-agent-max-turns">Agent Max Turns</label>
|
||||
<input id="esk-agent-max-turns" type="number" min="1" placeholder="System default">
|
||||
<summary>Runtime Config <span class="label-hint">model, temperature, token limits</span></summary>
|
||||
<div class="skill-config-grid">
|
||||
<div><label for="esk-model">Model</label><input id="esk-model" type="text" placeholder="Default model"></div>
|
||||
<div><label for="esk-temperature">Temperature</label><input id="esk-temperature" type="number" step="0.1" min="0" max="2" placeholder="System default"></div>
|
||||
<div>
|
||||
<label for="esk-reasoning-effort">Reasoning Effort</label>
|
||||
<select id="esk-reasoning-effort">
|
||||
<option value="">System default</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><label for="esk-max-tokens">Max Tokens</label><input id="esk-max-tokens" type="number" min="1" placeholder="System default"></div>
|
||||
<div><label for="esk-token-budget">Token Budget</label><input id="esk-token-budget" type="number" min="0" placeholder="0 = unlimited"></div>
|
||||
<div><label for="esk-agent-max-turns">Agent Max Turns</label><input id="esk-agent-max-turns" type="number" min="1" placeholder="System default"></div>
|
||||
</div>
|
||||
<label class="admin-checkbox"><input id="esk-auto-approve" type="checkbox"> Auto-approve all tools</label>
|
||||
<label for="esk-allowed-tools">Allowed Tools <span class="label-hint">comma-separated tool names for auto-approve</span></label>
|
||||
<input id="esk-allowed-tools" type="text" placeholder="bash, read_file, write_file">
|
||||
@@ -966,6 +1061,22 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
<div id="etm-scan-report" aria-labelledby="etm-scan-heading"></div>
|
||||
<button type="button" id="etm-rescan-btn" class="admin-btn-action" style="margin-top:8px">Re-scan</button>
|
||||
</div>
|
||||
<details id="etm-resources-section" class="admin-details">
|
||||
<summary>Resources <span class="label-hint">bundled files for this skill</span></summary>
|
||||
<div id="etm-resources-list" role="list" aria-live="polite" aria-label="Skill resources"></div>
|
||||
<button type="button" id="etm-add-resource-btn" class="admin-btn-action" style="margin-top:8px" onclick="_showAddResourceForm(document.getElementById('etm-id').value)">Add Resource</button>
|
||||
<div id="etm-add-resource-form" style="display:none">
|
||||
<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">
|
||||
<label for="etm-res-path">Path</label>
|
||||
<input id="etm-res-path" type="text" placeholder="scripts/setup.sh or references/guide.md">
|
||||
<label for="etm-res-content-type">Content Type</label>
|
||||
<input id="etm-res-content-type" type="text" value="text/plain">
|
||||
<label for="etm-res-content">Content</label>
|
||||
<textarea id="etm-res-content" rows="4" placeholder="Resource file content"></textarea>
|
||||
<button type="button" id="etm-res-submit" class="admin-btn-action">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="modal-buttons">
|
||||
<button class="modal-cancel" onclick="hideEditTemplateModal()">Cancel</button>
|
||||
<button id="etm-submit" class="modal-submit" onclick="submitEditTemplate()">Save</button>
|
||||
|
||||
@@ -1166,6 +1166,14 @@
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--accent-dim);
|
||||
}
|
||||
.admin-modal input:disabled, .admin-modal select:disabled, .admin-modal textarea:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
background: var(--bg-highlight);
|
||||
border-color: var(--border);
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.admin-modal label.admin-checkbox input:disabled { opacity: 0.4; }
|
||||
.admin-modal input::placeholder, .admin-modal textarea::placeholder { color: var(--fg-dim); opacity: 0.6; }
|
||||
.admin-modal textarea { resize: vertical; min-height: 40px; }
|
||||
.admin-modal [role="alert"] { color: var(--red); font-size: 12px; margin-bottom: 8px; display: none; }
|
||||
@@ -1199,6 +1207,138 @@
|
||||
.admin-details summary .label-hint { font-weight: 400; }
|
||||
.admin-details label:first-of-type { margin-top: 4px; }
|
||||
|
||||
/* ==========================================================================
|
||||
Skill Spec Modal — two-column manifest layout
|
||||
Left: Identity / Manifest / Deployment | Right: Skill Content
|
||||
========================================================================== */
|
||||
.admin-modal-skill { padding: 28px 28px 24px; }
|
||||
|
||||
.skill-spec-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.55fr;
|
||||
gap: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skill-spec-col-meta {
|
||||
border-right: 1px solid var(--border);
|
||||
padding-right: 22px;
|
||||
}
|
||||
|
||||
.skill-spec-col-content {
|
||||
padding-left: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.skill-spec-section { margin-bottom: 14px; }
|
||||
.skill-spec-section:last-child { margin-bottom: 0; }
|
||||
|
||||
/* h3 used for screen-reader heading structure; reset UA defaults */
|
||||
h3.skill-spec-heading { font-size: inherit; margin-block: 0; }
|
||||
.skill-spec-heading {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent);
|
||||
padding-bottom: 5px;
|
||||
margin: 14px 0 6px;
|
||||
border-bottom: 1px solid var(--accent-dim);
|
||||
}
|
||||
.skill-spec-section:first-child .skill-spec-heading { margin-top: 0; }
|
||||
.skill-spec-heading .label-hint {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 400;
|
||||
font-size: 10px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.skill-spec-section-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.skill-content-area {
|
||||
flex: 1;
|
||||
min-height: 220px;
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 11.5px !important;
|
||||
line-height: 1.65 !important;
|
||||
}
|
||||
|
||||
.skill-vars-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.skill-vars-label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--fg-dim);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skill-vars-display { font-size: 11px; }
|
||||
|
||||
.skill-config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 8px 14px;
|
||||
margin: 4px 0 2px;
|
||||
}
|
||||
.skill-config-grid > div { min-width: 0; }
|
||||
|
||||
/* Origin badge — shown for remotely installed (readonly) skills */
|
||||
.skill-origin-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--cyan);
|
||||
background: rgba(103, 232, 249, 0.07);
|
||||
border: 1px solid rgba(103, 232, 249, 0.18);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 5px 10px;
|
||||
margin-bottom: 14px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.skill-origin-badge::before {
|
||||
content: "\2193";
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.skill-spec-body { grid-template-columns: 1fr; }
|
||||
.skill-spec-col-meta {
|
||||
border-right: none;
|
||||
padding-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.skill-spec-col-content { padding-left: 0; }
|
||||
.skill-config-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
.modal-buttons { display: flex; gap: 10px; margin-top: 20px; }
|
||||
.modal-cancel {
|
||||
flex: 1;
|
||||
@@ -1367,7 +1507,7 @@
|
||||
========================================================================== */
|
||||
#admin-skills .admin-colheaders,
|
||||
#admin-skills .admin-row {
|
||||
grid-template-columns: 1.5fr 100px 1fr 140px;
|
||||
grid-template-columns: 80px 1.5fr 80px 120px;
|
||||
}
|
||||
/* ==========================================================================
|
||||
Governance: Audit grid
|
||||
@@ -1652,7 +1792,7 @@
|
||||
#admin-skills .admin-colheaders, #admin-skills .admin-row {
|
||||
grid-template-columns: 1fr 100px;
|
||||
}
|
||||
.admin-col-tmcat, .admin-col-tmvars { display: none; }
|
||||
.admin-col-tmcat, .admin-col-tmrisk { display: none; }
|
||||
#admin-audit .admin-colheaders, #admin-audit .admin-row {
|
||||
grid-template-columns: 60px 1fr 100px;
|
||||
}
|
||||
|
||||
@@ -975,7 +975,7 @@ class IntentJudge:
|
||||
"""Daemon thread: run LLM judge for each item and invoke callback."""
|
||||
for item, h_verdict in zip(items, heuristic_verdicts, strict=True):
|
||||
try:
|
||||
llm_verdict = self._evaluate_single(item, messages, h_verdict)
|
||||
llm_verdict = self._evaluate_single(item, messages)
|
||||
# Arbitrate: only callback when LLM upgrades the heuristic
|
||||
if llm_verdict and llm_verdict.confidence > h_verdict.confidence:
|
||||
callback(llm_verdict)
|
||||
@@ -990,7 +990,6 @@ class IntentJudge:
|
||||
self,
|
||||
item: dict[str, Any],
|
||||
messages: list[dict[str, Any]],
|
||||
heuristic: IntentVerdict,
|
||||
) -> IntentVerdict | None:
|
||||
"""Run LLM judge for a single tool call. Returns verdict or None."""
|
||||
start = time.monotonic()
|
||||
|
||||
@@ -179,10 +179,17 @@ def list_default_skills(org_id: str = "") -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
def list_skills_by_activation(activation: str) -> list[dict[str, Any]]:
|
||||
def list_skills_by_activation(
|
||||
activation: str,
|
||||
*,
|
||||
enabled_only: bool = False,
|
||||
limit: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return skills filtered by activation value, ordered by name."""
|
||||
try:
|
||||
return get_storage().list_skills_by_activation(activation)
|
||||
return get_storage().list_skills_by_activation(
|
||||
activation, enabled_only=enabled_only, limit=limit
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@@ -44,12 +44,19 @@ NUDGE_START = (
|
||||
"user's request to find applicable context, preferences, or guidance."
|
||||
)
|
||||
|
||||
NUDGE_TOOL_ERROR = (
|
||||
"A tool just returned an error. Before retrying, check your memories — "
|
||||
"the user may have given feedback about this tool or error pattern in a "
|
||||
"previous session. Use memory(action='search') to find relevant guidance."
|
||||
)
|
||||
|
||||
_NUDGE_MAP: dict[str, str] = {
|
||||
"correction": NUDGE_CORRECTION,
|
||||
"denial": NUDGE_DENIAL,
|
||||
"resume": NUDGE_RESUME,
|
||||
"completion": NUDGE_COMPLETION,
|
||||
"start": NUDGE_START,
|
||||
"tool_error": NUDGE_TOOL_ERROR,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -153,6 +160,9 @@ def should_nudge(
|
||||
# Start nudge only on first message
|
||||
if nudge_type == "start" and message_count != 1:
|
||||
return False
|
||||
# Tool error nudge only if there are memories to search
|
||||
if nudge_type == "tool_error" and memory_count == 0:
|
||||
return False
|
||||
# Resume/start nudge only if there are memories to recall
|
||||
if nudge_type in ("resume", "start") and memory_count == 0:
|
||||
return False
|
||||
|
||||
@@ -54,7 +54,10 @@ _RE_CONNECTION_STRING = re.compile(
|
||||
r"(?:postgresql|mysql|mongodb|redis|amqp)://[^:@\s]+:[^@\s]+@",
|
||||
)
|
||||
_RE_ENV_SECRET_LINE = re.compile(r"[A-Z][A-Z_0-9]+=\S+")
|
||||
_RE_ENV_SECRET_KEY = re.compile(r"SECRET|KEY|TOKEN|PASSWORD|CREDENTIAL", re.IGNORECASE)
|
||||
_RE_ENV_SECRET_KEY = re.compile(
|
||||
r"(?:^|_)(?:SECRET|TOKEN|PASSWORD|CREDENTIAL)(?:_|$)|(?:^|_)KEY(?:_|$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# (pattern, redact_label) — ordered most-specific first for redaction.
|
||||
_CREDENTIAL_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
||||
@@ -218,9 +221,7 @@ def _check_credentials(
|
||||
risk = "high"
|
||||
|
||||
env_lines = _RE_ENV_SECRET_LINE.findall(text)
|
||||
if len(env_lines) >= 3 and any(
|
||||
_RE_ENV_SECRET_KEY.search(ln.split("=", 1)[0]) for ln in env_lines
|
||||
):
|
||||
if any(_RE_ENV_SECRET_KEY.search(ln.split("=", 1)[0]) for ln in env_lines):
|
||||
_add_flag(flags, "credential_leak")
|
||||
flags.append("env_file_leak")
|
||||
ann.append("Output contains .env-style assignments with secret-bearing keys.")
|
||||
|
||||
+174
-40
@@ -39,6 +39,7 @@ from turnstone.core.memory import (
|
||||
get_skill_by_name,
|
||||
get_workstream_display_name,
|
||||
list_default_skills,
|
||||
list_skills_by_activation,
|
||||
list_structured_memories,
|
||||
list_workstreams_with_history,
|
||||
load_messages,
|
||||
@@ -128,6 +129,11 @@ _MAX_SKILL_CONTENT: int = 32768
|
||||
_TEMPLATE_VAR_RE = re.compile(r"\{\{(\w+)\}\}")
|
||||
|
||||
|
||||
def _without_tool(tools: list[dict[str, Any]], name: str) -> list[dict[str, Any]]:
|
||||
"""Return *tools* with the named tool removed."""
|
||||
return [t for t in tools if t.get("function", {}).get("name") != name]
|
||||
|
||||
|
||||
def _render_template(content: str, context: dict[str, str]) -> str:
|
||||
"""Replace ``{{variable}}`` placeholders in a single pass.
|
||||
|
||||
@@ -341,12 +347,12 @@ class ChatSession:
|
||||
self._tool_search = ToolSearchManager(
|
||||
self._tools,
|
||||
always_on_names=set(BUILTIN_TOOL_NAMES),
|
||||
threshold=tool_search_threshold,
|
||||
max_results=tool_search_max_results,
|
||||
)
|
||||
# Skill: explicit name overrides is_default skills
|
||||
self._skill_name: str | None = skill
|
||||
self._skill_content: str | None = None
|
||||
self._skill_resources: dict[str, str] = {}
|
||||
self._load_skills()
|
||||
self._init_system_messages()
|
||||
self._save_config()
|
||||
@@ -359,11 +365,16 @@ class ChatSession:
|
||||
def model_alias(self) -> str | None:
|
||||
return self._model_alias
|
||||
|
||||
def _get_capabilities(self) -> ModelCapabilities:
|
||||
def _resolve_capabilities(
|
||||
self,
|
||||
provider: LLMProvider,
|
||||
model: str,
|
||||
alias: str | None = None,
|
||||
) -> ModelCapabilities:
|
||||
"""Get model capabilities, applying config.toml overrides if present."""
|
||||
caps = self._provider.get_capabilities(self.model)
|
||||
if self._registry and self._model_alias:
|
||||
cfg: ModelConfig = self._registry.get_config(self._model_alias)
|
||||
caps = provider.get_capabilities(model)
|
||||
if self._registry and alias:
|
||||
cfg: ModelConfig = self._registry.get_config(alias)
|
||||
if cfg.capabilities:
|
||||
fields = {f.name for f in dataclasses.fields(type(caps))}
|
||||
overrides = {k: v for k, v in cfg.capabilities.items() if k in fields}
|
||||
@@ -371,6 +382,10 @@ class ChatSession:
|
||||
caps = dataclasses.replace(caps, **overrides)
|
||||
return caps
|
||||
|
||||
def _get_capabilities(self) -> ModelCapabilities:
|
||||
"""Get capabilities for the current model."""
|
||||
return self._resolve_capabilities(self._provider, self.model, self._model_alias)
|
||||
|
||||
def _save_config(self) -> None:
|
||||
"""Persist LLM-affecting config so resumed workstreams behave identically."""
|
||||
save_workstream_config(
|
||||
@@ -405,6 +420,9 @@ class ChatSession:
|
||||
if skill_data:
|
||||
self._skill_content = _render_template(skill_data["content"], context)
|
||||
self._check_skill_budget(skill_data)
|
||||
self._skill_resources = self._load_skill_resources(
|
||||
skill_data.get("template_id", "")
|
||||
)
|
||||
if skill_data.get("scan_status") in ("high", "critical"):
|
||||
scan_tier = skill_data["scan_status"]
|
||||
log.warning(
|
||||
@@ -419,6 +437,7 @@ class ChatSession:
|
||||
else:
|
||||
log.warning("skill.not_found", name=self._skill_name)
|
||||
self._skill_content = None
|
||||
self._skill_resources = {}
|
||||
else:
|
||||
defaults = list_default_skills()
|
||||
if defaults:
|
||||
@@ -426,6 +445,7 @@ class ChatSession:
|
||||
self._skill_content = "\n\n".join(parts)
|
||||
else:
|
||||
self._skill_content = None
|
||||
self._skill_resources = {}
|
||||
|
||||
def set_skill(self, name: str | None) -> None:
|
||||
"""Set or clear the active skill."""
|
||||
@@ -444,6 +464,18 @@ class ChatSession:
|
||||
context_window=self.context_window,
|
||||
)
|
||||
|
||||
def _load_skill_resources(self, skill_id: str) -> dict[str, str]:
|
||||
"""Load bundled resources for a skill and return {path: content}."""
|
||||
if not skill_id:
|
||||
return {}
|
||||
try:
|
||||
storage = get_storage()
|
||||
rows = storage.list_skill_resources(skill_id)
|
||||
return {r["path"]: r.get("content", "") for r in rows}
|
||||
except Exception:
|
||||
log.warning("skill_resources.load_failed", skill_id=skill_id, exc_info=True)
|
||||
return {}
|
||||
|
||||
# -- MCP tool refresh ----------------------------------------------------
|
||||
|
||||
def _on_mcp_tools_changed(self) -> None:
|
||||
@@ -494,7 +526,6 @@ class ChatSession:
|
||||
self._tool_search = ToolSearchManager(
|
||||
self._tools,
|
||||
always_on_names=set(BUILTIN_TOOL_NAMES),
|
||||
threshold=self._tool_search_threshold,
|
||||
max_results=self._tool_search_max_results,
|
||||
)
|
||||
# Restore previously expanded tools that still exist
|
||||
@@ -820,6 +851,46 @@ class ChatSession:
|
||||
tpl = tpl[:_MAX_SKILL_CONTENT]
|
||||
dev_parts.append("")
|
||||
dev_parts.append(tpl)
|
||||
if self._skill_resources:
|
||||
lines = ["<skill-resources>"]
|
||||
total_size = 0
|
||||
for rpath, rcontent in sorted(self._skill_resources.items()):
|
||||
size_kb = f"{len(rcontent) / 1024:.1f}KB"
|
||||
total_size += len(rcontent)
|
||||
lines.append(f"- {rpath} ({size_kb})")
|
||||
if total_size <= 8192:
|
||||
for rpath, rcontent in sorted(self._skill_resources.items()):
|
||||
lines.append(f"\n--- {rpath} ---")
|
||||
lines.append(rcontent)
|
||||
else:
|
||||
lines.append(
|
||||
"Resource content omitted (total exceeds 8KB). "
|
||||
"Resource files are listed above by path and size."
|
||||
)
|
||||
lines.append("</skill-resources>")
|
||||
dev_parts.append("\n".join(lines))
|
||||
# Skill catalog: disclose search-activated skills so the model
|
||||
# knows they exist (Agent Skills standard progressive disclosure).
|
||||
try:
|
||||
search_skills = list_skills_by_activation("search", enabled_only=True, limit=30)
|
||||
except Exception:
|
||||
log.warning("session.skill_catalog_failed", exc_info=True)
|
||||
search_skills = []
|
||||
if search_skills:
|
||||
catalog_lines = ["<available-skills>"]
|
||||
for sk in search_skills[:30]:
|
||||
sk_name = _html_escape(sk.get("name", ""))
|
||||
sk_desc = _html_escape(sk.get("description", "")[:200])
|
||||
catalog_lines.append(
|
||||
f" <skill><name>{sk_name}</name><description>{sk_desc}</description></skill>"
|
||||
)
|
||||
catalog_lines.append("</available-skills>")
|
||||
catalog_lines.append(
|
||||
"Additional skills are available. When a task matches a skill "
|
||||
"description, ask the user to activate it with `/skill <name>`, "
|
||||
"or use `/skill search <query>` to find relevant skills."
|
||||
)
|
||||
dev_parts.append("\n".join(catalog_lines))
|
||||
if self.instructions:
|
||||
dev_parts.append("")
|
||||
dev_parts.append(self.instructions)
|
||||
@@ -879,19 +950,29 @@ class ChatSession:
|
||||
- Client-side fallback: send visible tools + synthetic tool_search.
|
||||
|
||||
Without tool search: return self._tools unchanged.
|
||||
|
||||
Web search gating: ``web_search`` is removed when the model has
|
||||
no native search support and no Tavily API key is configured.
|
||||
"""
|
||||
if self.creative_mode:
|
||||
return None
|
||||
if not self._tool_search:
|
||||
return self._tools
|
||||
# Check if provider supports native tool search
|
||||
caps = self._get_capabilities()
|
||||
if caps.supports_tool_search:
|
||||
# Provider handles defer_loading — send all tools
|
||||
return self._tools
|
||||
# Client-side fallback: visible tools + search tool
|
||||
visible = self._tool_search.get_visible_tools()
|
||||
return visible + [self._tool_search.get_search_tool_definition()]
|
||||
if not self._tool_search:
|
||||
tools = self._tools
|
||||
else:
|
||||
if caps.supports_tool_search:
|
||||
# Provider handles defer_loading — send all tools
|
||||
tools = self._tools
|
||||
else:
|
||||
# Client-side fallback: visible tools + search tool
|
||||
visible = self._tool_search.get_visible_tools()
|
||||
tools = visible + [self._tool_search.get_search_tool_definition()]
|
||||
|
||||
# Gate web_search: only include when a backend exists
|
||||
if not caps.supports_web_search and not get_tavily_key():
|
||||
tools = _without_tool(tools, "web_search")
|
||||
|
||||
return tools
|
||||
|
||||
def _get_deferred_names(self) -> frozenset[str] | None:
|
||||
"""Return names of deferred tools for native provider search, or None."""
|
||||
@@ -1176,6 +1257,29 @@ class ChatSession:
|
||||
_tname,
|
||||
tool_call_id=tc_id,
|
||||
)
|
||||
# Metacognitive nudge: check memories on tool error
|
||||
if (
|
||||
self._memory_config.nudges
|
||||
and any(
|
||||
isinstance(out, str)
|
||||
and (
|
||||
out.startswith("Error")
|
||||
or " error: " in out[:50]
|
||||
or out.startswith("Command timed out")
|
||||
or out.startswith("Unknown tool:")
|
||||
)
|
||||
for _, out in results
|
||||
)
|
||||
and should_nudge(
|
||||
"tool_error",
|
||||
self._metacog_state,
|
||||
message_count=len(self.messages),
|
||||
memory_count=self._visible_memory_count(),
|
||||
cooldown_secs=self._memory_config.nudge_cooldown,
|
||||
)
|
||||
):
|
||||
self._pending_nudge.append(format_nudge("tool_error"))
|
||||
self._init_system_messages()
|
||||
# Inject user feedback from approval prompt (e.g. "y, use full path")
|
||||
if user_feedback:
|
||||
self.messages.append({"role": "user", "content": user_feedback})
|
||||
@@ -1873,9 +1977,24 @@ class ChatSession:
|
||||
it["func_args"] = {"command": it.get("command", "")}
|
||||
elif name in ("write_file", "edit_file", "read_file"):
|
||||
it["func_args"] = {"path": it.get("path", "")}
|
||||
elif name == "web_fetch":
|
||||
it["func_args"] = {"url": it.get("url", ""), "question": it.get("question", "")}
|
||||
elif name == "web_search":
|
||||
it["func_args"] = {"query": it.get("query", ""), "topic": it.get("topic", "")}
|
||||
elif name == "skill":
|
||||
it["func_args"] = {"action": it.get("action", ""), "name": it.get("name", "")}
|
||||
elif name == "watch":
|
||||
it["func_args"] = {
|
||||
"action": it.get("action", ""),
|
||||
"command": it.get("command", ""),
|
||||
"name": it.get("watch_name", ""),
|
||||
}
|
||||
elif name == "notify":
|
||||
it["func_args"] = {"message": it.get("message", "")[:200]}
|
||||
elif name == "task":
|
||||
it["func_args"] = {"prompt": it.get("prompt", "")[:200]}
|
||||
elif it.get("mcp_args"):
|
||||
it["func_args"] = it["mcp_args"]
|
||||
# Other tools: func_args stays absent → judge defaults to {}
|
||||
|
||||
def _on_verdict(verdict: object) -> None:
|
||||
"""Callback from the daemon judge thread."""
|
||||
@@ -1983,8 +2102,17 @@ class ChatSession:
|
||||
return item["call_id"], item["error"]
|
||||
if item.get("denied"):
|
||||
return item["call_id"], item.get("denial_msg", "Denied by user")
|
||||
result: tuple[str, str | list[dict[str, Any]]] = item["execute"](item)
|
||||
return result
|
||||
try:
|
||||
result: tuple[str, str | list[dict[str, Any]]] = item["execute"](item)
|
||||
return result
|
||||
except (KeyboardInterrupt, GenerationCancelled):
|
||||
raise
|
||||
except Exception as e:
|
||||
func = item.get("func_name", "unknown")
|
||||
msg = f"Error executing {func}: {e}"
|
||||
log.warning("tool_exec.failed", tool=func, error=str(e), exc_info=True)
|
||||
self.ui.on_error(msg)
|
||||
return item["call_id"], msg
|
||||
|
||||
if len(items) == 1:
|
||||
results = [run_one(items[0])]
|
||||
@@ -2129,7 +2257,7 @@ class ChatSession:
|
||||
"watch": self._prepare_watch,
|
||||
"read_resource": self._prepare_read_resource,
|
||||
"use_prompt": self._prepare_use_prompt,
|
||||
"load_skill": self._prepare_load_skill,
|
||||
"skill": self._prepare_skill,
|
||||
}
|
||||
preparer = preparers.get(func_name)
|
||||
if not preparer:
|
||||
@@ -2953,10 +3081,10 @@ class ChatSession:
|
||||
"limit": max(1, min(limit, 50)),
|
||||
}
|
||||
|
||||
# -- load_skill prepare/execute --------------------------------------------
|
||||
# -- skill prepare/execute -------------------------------------------------
|
||||
|
||||
def _prepare_load_skill(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Prepare a load_skill action (load or search)."""
|
||||
def _prepare_skill(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Prepare a skill action (load or search)."""
|
||||
action = (args.get("action") or "").strip().lower()
|
||||
|
||||
if action == "load":
|
||||
@@ -2964,20 +3092,20 @@ class ChatSession:
|
||||
if not name:
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "load_skill",
|
||||
"header": "\u2717 load_skill: name is required",
|
||||
"func_name": "skill",
|
||||
"header": "\u2717 skill: name is required",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": "Error: 'name' is required for load action",
|
||||
}
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "load_skill",
|
||||
"header": f"\u2699 load_skill: {name}",
|
||||
"func_name": "skill",
|
||||
"header": f"\u2699 skill: {name}",
|
||||
"preview": "",
|
||||
"needs_approval": True,
|
||||
"approval_label": f"load_skill__{name}",
|
||||
"execute": self._exec_load_skill,
|
||||
"approval_label": f"skill__{name}",
|
||||
"execute": self._exec_skill,
|
||||
"action": "load",
|
||||
"name": name,
|
||||
}
|
||||
@@ -2986,26 +3114,26 @@ class ChatSession:
|
||||
query = (args.get("query") or "").strip()
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "load_skill",
|
||||
"func_name": "skill",
|
||||
"header": f"\u2699 skill search{': ' + query[:80] if query else ''}",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"execute": self._exec_load_skill,
|
||||
"execute": self._exec_skill,
|
||||
"action": "search",
|
||||
"query": query,
|
||||
}
|
||||
|
||||
return {
|
||||
"call_id": call_id,
|
||||
"func_name": "load_skill",
|
||||
"header": "\u2717 load_skill: invalid action",
|
||||
"func_name": "skill",
|
||||
"header": "\u2717 skill: invalid action",
|
||||
"preview": "",
|
||||
"needs_approval": False,
|
||||
"error": f"Error: action must be 'load' or 'search', got '{action}'",
|
||||
}
|
||||
|
||||
def _exec_load_skill(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Execute a load_skill action."""
|
||||
def _exec_skill(self, item: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Execute a skill action."""
|
||||
call_id = item["call_id"]
|
||||
action = item["action"]
|
||||
|
||||
@@ -3014,12 +3142,12 @@ class ChatSession:
|
||||
skill_data = get_skill_by_name(name)
|
||||
if not skill_data or not skill_data.get("enabled", True):
|
||||
msg = f"Error: skill '{name}' not found"
|
||||
self.ui.on_tool_result(call_id, "load_skill", msg)
|
||||
self.ui.on_tool_result(call_id, "skill", msg)
|
||||
return call_id, msg
|
||||
|
||||
if self._skill_name == name:
|
||||
msg = f"Skill '{name}' is already active"
|
||||
self.ui.on_tool_result(call_id, "load_skill", msg)
|
||||
self.ui.on_tool_result(call_id, "skill", msg)
|
||||
return call_id, msg
|
||||
|
||||
self.set_skill(name)
|
||||
@@ -3032,7 +3160,7 @@ class ChatSession:
|
||||
if scan:
|
||||
parts.append(f"Security tier: {scan}")
|
||||
msg = "\n".join(parts)
|
||||
self.ui.on_tool_result(call_id, "load_skill", msg)
|
||||
self.ui.on_tool_result(call_id, "skill", msg)
|
||||
return call_id, msg
|
||||
|
||||
# action == "search"
|
||||
@@ -3042,7 +3170,7 @@ class ChatSession:
|
||||
|
||||
rows = get_storage().list_prompt_templates(limit=50)
|
||||
except Exception:
|
||||
log.warning("load_skill.search_storage_error", exc_info=True)
|
||||
log.warning("skill.search_storage_error", exc_info=True)
|
||||
rows = []
|
||||
|
||||
# Filter out disabled skills
|
||||
@@ -3086,7 +3214,7 @@ class ChatSession:
|
||||
|
||||
if not rows:
|
||||
msg = "No skills found" + (f" matching '{query}'" if query else "")
|
||||
self.ui.on_tool_result(call_id, "load_skill", msg)
|
||||
self.ui.on_tool_result(call_id, "skill", msg)
|
||||
return call_id, msg
|
||||
|
||||
lines = [f"Found {len(rows)} skill(s):", ""]
|
||||
@@ -3108,7 +3236,7 @@ class ChatSession:
|
||||
lines.append(line)
|
||||
|
||||
msg = "\n".join(lines)
|
||||
self.ui.on_tool_result(call_id, "load_skill", msg)
|
||||
self.ui.on_tool_result(call_id, "skill", msg)
|
||||
return call_id, msg
|
||||
|
||||
# -- MCP tool prepare/execute ----------------------------------------------
|
||||
@@ -3597,6 +3725,12 @@ class ChatSession:
|
||||
agent_client, agent_model, _ = self._registry.resolve(self._registry.agent_model)
|
||||
agent_provider = self._registry.get_provider(self._registry.agent_model)
|
||||
|
||||
# Gate web_search: remove when no backend exists for the agent model
|
||||
agent_alias = self._registry.agent_model if self._registry else None
|
||||
agent_caps = self._resolve_capabilities(agent_provider, agent_model, agent_alias)
|
||||
if not agent_caps.supports_web_search and not get_tavily_key():
|
||||
tools = _without_tool(tools, "web_search")
|
||||
|
||||
# Build extra params for agent calls
|
||||
agent_extra: dict[str, Any] | None = None
|
||||
if agent_provider.provider_name == "openai":
|
||||
|
||||
+148
-23
@@ -2,19 +2,40 @@
|
||||
|
||||
Pure functions, no I/O. Accepts raw SKILL.md text and returns a
|
||||
:class:`ParsedSkill` dataclass.
|
||||
|
||||
Compliant with the Agent Skills specification (https://agentskills.io/specification).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import Any, Literal, overload
|
||||
|
||||
import frontmatter
|
||||
|
||||
# Name validation: lowercase letters, digits, hyphens, max 64 chars
|
||||
from turnstone.core.log import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# Name validation: lowercase letters, digits, hyphens, max 64 chars.
|
||||
# Note: consecutive hyphens checked separately (not expressible in a
|
||||
# single character-class regex without a lookahead).
|
||||
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{0,62}[a-z0-9]$|^[a-z0-9]$")
|
||||
|
||||
# Split allowed-tools on whitespace or commas (standard uses spaces,
|
||||
# legacy turnstone format uses commas). Tool expressions must not
|
||||
# contain internal whitespace (e.g. "Bash(git:*)" not "Bash(git: *)").
|
||||
_LIST_SPLIT_RE = re.compile(r"[\s,]+")
|
||||
|
||||
# Malformed YAML recovery: match a bare ``description:`` line whose
|
||||
# value contains an unquoted colon (the most common cross-client issue).
|
||||
_BARE_DESC_RE = re.compile(r"^(description:\s*)(.+)$", re.MULTILINE)
|
||||
|
||||
# Field length caps from the Agent Skills specification.
|
||||
_MAX_DESCRIPTION_LEN = 1024
|
||||
_MAX_COMPATIBILITY_LEN = 500
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParsedSkill:
|
||||
@@ -55,13 +76,39 @@ def _extract_tags(meta: dict[str, Any]) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _extract_list(meta: dict[str, Any], key: str) -> list[str]:
|
||||
"""Extract a list of strings from frontmatter, with fallback."""
|
||||
val = meta.get(key)
|
||||
if isinstance(val, list):
|
||||
return [str(v) for v in val if v]
|
||||
if isinstance(val, str) and val:
|
||||
return [v.strip() for v in val.split(",") if v.strip()]
|
||||
def _extract_str(meta: dict[str, Any], key: str, default: str = "") -> str:
|
||||
"""Extract a string field, checking top-level then ``metadata.*`` fallback.
|
||||
|
||||
Handles YAML ``null`` / bare keys gracefully (returns *default*
|
||||
rather than the string ``"None"``).
|
||||
"""
|
||||
raw = meta.get(key)
|
||||
val = str(raw).strip() if raw is not None else ""
|
||||
if val:
|
||||
return val
|
||||
# Standard puts author/version under metadata map
|
||||
nested = meta.get("metadata")
|
||||
if isinstance(nested, dict):
|
||||
raw = nested.get(key)
|
||||
val = str(raw).strip() if raw is not None else ""
|
||||
if val:
|
||||
return val
|
||||
return default
|
||||
|
||||
|
||||
def _extract_list(meta: dict[str, Any], *keys: str) -> list[str]:
|
||||
"""Extract a list of strings from frontmatter.
|
||||
|
||||
Tries each *key* in order (first match wins). String values are
|
||||
split on whitespace or commas to handle both the Agent Skills
|
||||
standard (space-delimited) and legacy comma-delimited formats.
|
||||
"""
|
||||
for key in keys:
|
||||
val = meta.get(key)
|
||||
if isinstance(val, list):
|
||||
return [str(v) for v in val if v]
|
||||
if isinstance(val, str) and val:
|
||||
return [v for v in _LIST_SPLIT_RE.split(val) if v]
|
||||
return []
|
||||
|
||||
|
||||
@@ -71,22 +118,66 @@ def validate_skill_name(name: str) -> str | None:
|
||||
return "name is required"
|
||||
if len(name) > 64:
|
||||
return f"name exceeds 64 characters ({len(name)})"
|
||||
if "--" in name:
|
||||
return "name must not contain consecutive hyphens"
|
||||
if not _NAME_RE.match(name):
|
||||
return "name must be lowercase alphanumeric with hyphens (e.g. 'code-review')"
|
||||
return None
|
||||
|
||||
|
||||
def parse_skill_md(raw: str) -> ParsedSkill:
|
||||
"""Parse SKILL.md (YAML frontmatter + markdown body).
|
||||
def _try_parse_frontmatter(raw: str) -> frontmatter.Post:
|
||||
"""Parse YAML frontmatter with a single malformed-YAML retry.
|
||||
|
||||
Handles missing or malformed frontmatter gracefully — returns a
|
||||
``ParsedSkill`` with defaults for any missing fields.
|
||||
|
||||
Raises ``ValueError`` if ``name`` is missing or invalid.
|
||||
The most common cross-client issue is unquoted description values
|
||||
containing colons (e.g. ``description: Use when: the user asks``).
|
||||
On initial failure, wrap the description value in quotes and retry.
|
||||
"""
|
||||
try:
|
||||
post = frontmatter.loads(raw)
|
||||
return frontmatter.loads(raw)
|
||||
except Exception:
|
||||
pass # fall through to retry
|
||||
|
||||
# Retry: quote the description line
|
||||
def _quote_desc(m: re.Match[str]) -> str:
|
||||
prefix = m.group(1)
|
||||
value = m.group(2).strip()
|
||||
escaped = value.replace('"', '\\"')
|
||||
return f'{prefix}"{escaped}"'
|
||||
|
||||
fixed = _BARE_DESC_RE.sub(_quote_desc, raw)
|
||||
if fixed != raw:
|
||||
try:
|
||||
return frontmatter.loads(fixed)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise ValueError("Failed to parse SKILL.md YAML frontmatter")
|
||||
|
||||
|
||||
@overload
|
||||
def parse_skill_md(raw: str, *, lenient: Literal[False] = ...) -> ParsedSkill: ...
|
||||
|
||||
|
||||
@overload
|
||||
def parse_skill_md(raw: str, *, lenient: Literal[True]) -> ParsedSkill | None: ...
|
||||
|
||||
|
||||
def parse_skill_md(raw: str, *, lenient: bool = False) -> ParsedSkill | None:
|
||||
"""Parse SKILL.md (YAML frontmatter + markdown body).
|
||||
|
||||
When *lenient* is ``False`` (default — strict mode), raises
|
||||
``ValueError`` on missing/invalid name or unparseable YAML.
|
||||
|
||||
When *lenient* is ``True`` (for external import / cross-client
|
||||
ingestion), logs warnings and returns ``None`` for unskippable
|
||||
failures instead of raising.
|
||||
"""
|
||||
try:
|
||||
post = _try_parse_frontmatter(raw)
|
||||
except Exception as exc:
|
||||
if lenient:
|
||||
log.warning("skill_parser.yaml_failed", error=str(exc))
|
||||
return None
|
||||
raise ValueError(f"Failed to parse SKILL.md frontmatter: {exc}") from exc
|
||||
|
||||
meta: dict[str, Any] = dict(post.metadata)
|
||||
@@ -96,10 +187,20 @@ def parse_skill_md(raw: str) -> ParsedSkill:
|
||||
name = str(meta.get("name", "")).strip().lower()
|
||||
name_err = validate_skill_name(name)
|
||||
if name_err:
|
||||
raise ValueError(name_err)
|
||||
if lenient:
|
||||
log.warning("skill_parser.name_invalid", name=name, error=name_err)
|
||||
# Try to salvage: strip invalid chars, truncate
|
||||
sanitized = re.sub(r"[^a-z0-9-]", "", name).strip("-")
|
||||
sanitized = re.sub(r"-{2,}", "-", sanitized)[:64].strip("-")
|
||||
if not sanitized or validate_skill_name(sanitized):
|
||||
return None
|
||||
name = sanitized
|
||||
else:
|
||||
raise ValueError(name_err)
|
||||
|
||||
# Description — frontmatter or first paragraph of body
|
||||
description = str(meta.get("description", "")).strip()
|
||||
raw_desc = meta.get("description")
|
||||
description = str(raw_desc).strip() if raw_desc is not None else ""
|
||||
if not description and body:
|
||||
first_line = body.split("\n")[0].strip()
|
||||
# Skip markdown headings
|
||||
@@ -107,15 +208,39 @@ def parse_skill_md(raw: str) -> ParsedSkill:
|
||||
first_line = first_line.lstrip("# ").strip()
|
||||
description = first_line[:256]
|
||||
|
||||
if not description and lenient:
|
||||
log.warning("skill_parser.no_description", name=name)
|
||||
return None
|
||||
|
||||
# Spec caps
|
||||
if len(description) > _MAX_DESCRIPTION_LEN:
|
||||
log.warning(
|
||||
"skill_parser.description_truncated",
|
||||
name=name,
|
||||
length=len(description),
|
||||
)
|
||||
description = description[:_MAX_DESCRIPTION_LEN]
|
||||
|
||||
raw_compat = meta.get("compatibility")
|
||||
compatibility = str(raw_compat).strip() if raw_compat is not None else ""
|
||||
if len(compatibility) > _MAX_COMPATIBILITY_LEN:
|
||||
log.warning(
|
||||
"skill_parser.compatibility_truncated",
|
||||
name=name,
|
||||
length=len(compatibility),
|
||||
)
|
||||
compatibility = compatibility[:_MAX_COMPATIBILITY_LEN]
|
||||
|
||||
return ParsedSkill(
|
||||
name=name,
|
||||
description=description,
|
||||
content=body,
|
||||
tags=_extract_tags(meta),
|
||||
author=str(meta.get("author", "")).strip(),
|
||||
version=str(meta.get("version", "1.0.0")).strip(),
|
||||
allowed_tools=_extract_list(meta, "allowed_tools"),
|
||||
license=str(meta.get("license", "")).strip(),
|
||||
compatibility=str(meta.get("compatibility", "")).strip(),
|
||||
author=_extract_str(meta, "author"),
|
||||
version=_extract_str(meta, "version", default="1.0.0"),
|
||||
# Standard uses "allowed-tools" (hyphenated); stored internally as allowed_tools
|
||||
allowed_tools=_extract_list(meta, "allowed-tools"),
|
||||
license=_extract_str(meta, "license"),
|
||||
compatibility=compatibility,
|
||||
raw_frontmatter=meta,
|
||||
)
|
||||
|
||||
+213
-43
@@ -6,6 +6,7 @@ and :func:`fetch_skill_from_github` for fetching SKILL.md from GitHub repos.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -120,22 +121,91 @@ class SkillsShClient:
|
||||
return url
|
||||
|
||||
|
||||
def _parse_github_url(url: str) -> tuple[str, str, str, str]:
|
||||
"""Parse a GitHub URL into (owner, repo, branch, path).
|
||||
def _parse_github_url(url: str) -> tuple[str, str, str, str, bool]:
|
||||
"""Parse a GitHub URL into (owner, repo, branch, path, branch_explicit).
|
||||
|
||||
Returns ("", "", "", "") if URL doesn't match.
|
||||
Returns ("", "", "", "", False) if URL doesn't match.
|
||||
"""
|
||||
m = _GITHUB_URL_RE.match(url)
|
||||
if not m:
|
||||
return ("", "", "", "")
|
||||
return ("", "", "", "", False)
|
||||
return (
|
||||
m.group("owner"),
|
||||
m.group("repo"),
|
||||
m.group("branch") or "main",
|
||||
m.group("path") or "",
|
||||
bool(m.group("branch")),
|
||||
)
|
||||
|
||||
|
||||
def _find_resource_files(
|
||||
tree_items: list[dict[str, Any]], skill_md_dir: str
|
||||
) -> list[dict[str, str]]:
|
||||
"""Filter tree items to resource files relative to a SKILL.md directory."""
|
||||
resource_files: list[dict[str, str]] = []
|
||||
for item in tree_items:
|
||||
if item.get("type") != "blob":
|
||||
continue
|
||||
item_path: str = item.get("path", "")
|
||||
rel_path = item_path
|
||||
if skill_md_dir:
|
||||
if not item_path.startswith(f"{skill_md_dir}/"):
|
||||
continue
|
||||
rel_path = item_path[len(skill_md_dir) + 1 :]
|
||||
first_seg = rel_path.split("/")[0] if "/" in rel_path else ""
|
||||
if first_seg not in _RESOURCE_DIRS:
|
||||
continue
|
||||
ext = os.path.splitext(rel_path)[1].lower()
|
||||
if ext not in _TEXT_EXTENSIONS:
|
||||
continue
|
||||
size = item.get("size", 0)
|
||||
if size > _MAX_RESOURCE_SIZE:
|
||||
continue
|
||||
resource_files.append({"path": rel_path, "full_path": item_path})
|
||||
return resource_files[:_MAX_RESOURCE_FILES]
|
||||
|
||||
|
||||
def _check_rate_limit(resp: httpx.Response) -> None:
|
||||
"""Raise SkillSourceError with guidance if GitHub rate limit is hit."""
|
||||
if resp.status_code == 403:
|
||||
remaining = resp.headers.get("x-ratelimit-remaining", "")
|
||||
if remaining == "0":
|
||||
raise SkillSourceError(
|
||||
"GitHub API rate limit exceeded. "
|
||||
"Set TURNSTONE_GITHUB_TOKEN env var for higher limits (5000 req/hr)."
|
||||
)
|
||||
remaining = resp.headers.get("x-ratelimit-remaining", "")
|
||||
if remaining and remaining.isdigit() and int(remaining) < 10:
|
||||
logger.warning("GitHub API rate limit low: %s remaining", remaining)
|
||||
|
||||
|
||||
_FETCH_CONCURRENCY = 5
|
||||
|
||||
|
||||
async def _fetch_resource_contents(
|
||||
client: httpx.AsyncClient,
|
||||
raw_base: str,
|
||||
resource_files: list[dict[str, str]],
|
||||
) -> dict[str, str]:
|
||||
"""Fetch content for a list of resource files (concurrent)."""
|
||||
if not resource_files:
|
||||
return {}
|
||||
sem = asyncio.Semaphore(_FETCH_CONCURRENCY)
|
||||
|
||||
async def _fetch_one(rf: dict[str, str]) -> tuple[str, str] | None:
|
||||
async with sem:
|
||||
try:
|
||||
resp = await client.get(f"{raw_base}/{rf['full_path']}")
|
||||
if resp.status_code == 200:
|
||||
return rf["path"], resp.text
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
return None
|
||||
|
||||
results = await asyncio.gather(*[_fetch_one(rf) for rf in resource_files])
|
||||
return {path: content for r in results if r is not None for path, content in [r]}
|
||||
|
||||
|
||||
async def fetch_skill_from_github(url: str) -> SkillPackage:
|
||||
"""Fetch a SKILL.md and bundled resources from a GitHub repository.
|
||||
|
||||
@@ -147,7 +217,7 @@ async def fetch_skill_from_github(url: str) -> SkillPackage:
|
||||
Uses ``TURNSTONE_GITHUB_TOKEN`` env var for authenticated requests
|
||||
(60 → 5000 req/hr rate limit headroom).
|
||||
"""
|
||||
owner, repo, branch, path = _parse_github_url(url)
|
||||
owner, repo, branch, path, branch_explicit = _parse_github_url(url)
|
||||
if not owner:
|
||||
raise SkillSourceError(f"Could not parse GitHub URL: {url}")
|
||||
|
||||
@@ -157,7 +227,6 @@ async def fetch_skill_from_github(url: str) -> SkillPackage:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
# When branch isn't specified in URL, try main then master
|
||||
branch_explicit = bool(_GITHUB_URL_RE.match(url) and _GITHUB_URL_RE.match(url).group("branch")) # type: ignore[union-attr]
|
||||
branches_to_try = [branch] if branch_explicit else ["main", "master"]
|
||||
|
||||
api_base = f"https://api.github.com/repos/{owner}/{repo}"
|
||||
@@ -187,7 +256,10 @@ async def fetch_skill_from_github(url: str) -> SkillPackage:
|
||||
skill_md_content = ""
|
||||
skill_md_dir = ""
|
||||
resolved_branch = branch
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=15.0, headers=headers) as client:
|
||||
_timeout = httpx.Timeout(10.0, connect=5.0)
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=True, timeout=_timeout, headers=headers
|
||||
) as client:
|
||||
# Try each branch × candidate combination
|
||||
for try_branch in branches_to_try:
|
||||
raw_base = f"https://raw.githubusercontent.com/{owner}/{repo}/{try_branch}"
|
||||
@@ -195,10 +267,9 @@ async def fetch_skill_from_github(url: str) -> SkillPackage:
|
||||
try:
|
||||
resp = await client.get(f"{raw_base}/{candidate}")
|
||||
if resp.status_code == 200:
|
||||
content_len = int(resp.headers.get("content-length", "0"))
|
||||
if content_len > _MAX_SKILL_MD_SIZE:
|
||||
if len(resp.content) > _MAX_SKILL_MD_SIZE:
|
||||
continue
|
||||
skill_md_content = resp.text[:_MAX_SKILL_MD_SIZE]
|
||||
skill_md_content = resp.text
|
||||
# Directory containing the SKILL.md
|
||||
parts = candidate.rsplit("/", 1)
|
||||
skill_md_dir = parts[0] if len(parts) > 1 else ""
|
||||
@@ -224,51 +295,150 @@ async def fetch_skill_from_github(url: str) -> SkillPackage:
|
||||
f"{api_base}/git/trees/{resolved_branch}",
|
||||
params={"recursive": "1"},
|
||||
)
|
||||
_check_rate_limit(tree_resp)
|
||||
if tree_resp.status_code == 200 and len(tree_resp.content) < 2 * 1024 * 1024:
|
||||
tree_data = tree_resp.json()
|
||||
resource_files: list[dict[str, Any]] = []
|
||||
for item in tree_data.get("tree", []):
|
||||
if item.get("type") != "blob":
|
||||
continue
|
||||
item_path: str = item.get("path", "")
|
||||
# Filter to resource dirs relative to SKILL.md location
|
||||
rel_path = item_path
|
||||
if skill_md_dir:
|
||||
if not item_path.startswith(f"{skill_md_dir}/"):
|
||||
continue
|
||||
rel_path = item_path[len(skill_md_dir) + 1 :]
|
||||
# Check if it's in a resource directory
|
||||
first_seg = rel_path.split("/")[0] if "/" in rel_path else ""
|
||||
if first_seg not in _RESOURCE_DIRS:
|
||||
continue
|
||||
# Filter to text-safe extensions only
|
||||
ext = os.path.splitext(rel_path)[1].lower()
|
||||
if ext not in _TEXT_EXTENSIONS:
|
||||
continue
|
||||
size = item.get("size", 0)
|
||||
if size > _MAX_RESOURCE_SIZE:
|
||||
continue
|
||||
resource_files.append({"path": rel_path, "full_path": item_path})
|
||||
|
||||
# Fetch up to MAX files
|
||||
for rf in resource_files[:_MAX_RESOURCE_FILES]:
|
||||
try:
|
||||
content_resp = await client.get(f"{raw_base}/{rf['full_path']}")
|
||||
if content_resp.status_code == 200:
|
||||
resources[rf["path"]] = content_resp.text
|
||||
except httpx.HTTPError:
|
||||
continue
|
||||
rf = _find_resource_files(tree_data.get("tree", []), skill_md_dir)
|
||||
resources = await _fetch_resource_contents(client, raw_base, rf)
|
||||
except httpx.HTTPError:
|
||||
logger.debug("Failed to fetch resource tree for %s/%s", owner, repo)
|
||||
|
||||
# Build a per-skill source URL pointing to the specific subdirectory
|
||||
if skill_md_dir:
|
||||
specific_url = f"https://github.com/{owner}/{repo}/tree/{resolved_branch}/{skill_md_dir}"
|
||||
else:
|
||||
specific_url = url
|
||||
|
||||
listing = SkillListing(
|
||||
id=f"{owner}/{repo}/{parsed.name}",
|
||||
name=parsed.name,
|
||||
description=parsed.description,
|
||||
author=parsed.author,
|
||||
source="github",
|
||||
source_url=url,
|
||||
source_url=specific_url,
|
||||
tags=parsed.tags,
|
||||
)
|
||||
|
||||
return SkillPackage(listing=listing, parsed=parsed, resources=resources)
|
||||
|
||||
|
||||
_MAX_SKILLS_PER_REPO = 50
|
||||
|
||||
|
||||
async def fetch_skills_from_github_repo(url: str) -> list[SkillPackage]:
|
||||
"""Scan a GitHub repo for all SKILL.md files and return each as a package.
|
||||
|
||||
Used when a repo-level URL has no root SKILL.md (monorepo pattern).
|
||||
"""
|
||||
owner, repo, branch, url_path, branch_explicit = _parse_github_url(url)
|
||||
if not owner:
|
||||
raise SkillSourceError(f"Could not parse GitHub URL: {url}")
|
||||
url_path = url_path.rstrip("/")
|
||||
|
||||
headers: dict[str, str] = {"Accept": "application/vnd.github.v3+json"}
|
||||
token = os.environ.get("TURNSTONE_GITHUB_TOKEN", "")
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
branches_to_try = [branch] if branch_explicit else ["main", "master"]
|
||||
|
||||
api_base = f"https://api.github.com/repos/{owner}/{repo}"
|
||||
|
||||
_timeout = httpx.Timeout(10.0, connect=5.0)
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=True, timeout=_timeout, headers=headers
|
||||
) as client:
|
||||
# Find the tree with all SKILL.md files
|
||||
tree_data: dict[str, Any] = {}
|
||||
resolved_branch = branch
|
||||
for try_branch in branches_to_try:
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{api_base}/git/trees/{try_branch}",
|
||||
params={"recursive": "1"},
|
||||
)
|
||||
_check_rate_limit(resp)
|
||||
if resp.status_code == 200 and len(resp.content) < 2 * 1024 * 1024:
|
||||
tree_data = resp.json()
|
||||
resolved_branch = try_branch
|
||||
break
|
||||
except httpx.HTTPError:
|
||||
continue
|
||||
|
||||
if not tree_data:
|
||||
raise SkillSourceError(f"Could not fetch repo tree for {owner}/{repo}")
|
||||
|
||||
# Find all SKILL.md files in the tree (filtered to URL path if provided)
|
||||
skill_md_paths: list[str] = []
|
||||
tree_items = tree_data.get("tree", [])
|
||||
for item in tree_items:
|
||||
if item.get("type") != "blob":
|
||||
continue
|
||||
p: str = item.get("path", "")
|
||||
if not (p.endswith("/SKILL.md") or p == "SKILL.md"):
|
||||
continue
|
||||
if url_path and not p.startswith(f"{url_path}/") and p != url_path:
|
||||
continue
|
||||
skill_md_paths.append(p)
|
||||
|
||||
if not skill_md_paths:
|
||||
raise SkillNotFoundError(f"No SKILL.md files found in {owner}/{repo}")
|
||||
|
||||
# Cap to prevent abuse
|
||||
skill_md_paths = skill_md_paths[:_MAX_SKILLS_PER_REPO]
|
||||
|
||||
raw_base = f"https://raw.githubusercontent.com/{owner}/{repo}/{resolved_branch}"
|
||||
|
||||
# Fetch all SKILL.md files concurrently
|
||||
sem = asyncio.Semaphore(_FETCH_CONCURRENCY)
|
||||
|
||||
async def _fetch_skill_md(p: str) -> tuple[str, str] | None:
|
||||
async with sem:
|
||||
try:
|
||||
r = await client.get(f"{raw_base}/{p}")
|
||||
if r.status_code == 200 and len(r.content) <= _MAX_SKILL_MD_SIZE:
|
||||
return p, r.text
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
return None
|
||||
|
||||
md_results = await asyncio.gather(*[_fetch_skill_md(p) for p in skill_md_paths])
|
||||
|
||||
packages: list[SkillPackage] = []
|
||||
for result in md_results:
|
||||
if result is None:
|
||||
continue
|
||||
skill_md_path, content = result
|
||||
|
||||
# Determine directory containing this SKILL.md
|
||||
parts = skill_md_path.rsplit("/", 1)
|
||||
skill_md_dir = parts[0] if len(parts) > 1 else ""
|
||||
|
||||
# Parse — skip if invalid
|
||||
try:
|
||||
parsed = parse_skill_md(content)
|
||||
except ValueError:
|
||||
logger.debug("Skipping invalid SKILL.md at %s", skill_md_path)
|
||||
continue
|
||||
|
||||
# Collect resources for this skill (concurrent via helper)
|
||||
rf = _find_resource_files(tree_items, skill_md_dir)
|
||||
resources = await _fetch_resource_contents(client, raw_base, rf)
|
||||
|
||||
specific_url = (
|
||||
f"https://github.com/{owner}/{repo}/tree/{resolved_branch}/{skill_md_dir}"
|
||||
if skill_md_dir
|
||||
else url
|
||||
)
|
||||
listing = SkillListing(
|
||||
id=f"{owner}/{repo}/{parsed.name}",
|
||||
name=parsed.name,
|
||||
description=parsed.description,
|
||||
author=parsed.author,
|
||||
source="github",
|
||||
source_url=specific_url,
|
||||
tags=parsed.tags,
|
||||
)
|
||||
packages.append(SkillPackage(listing=listing, parsed=parsed, resources=resources))
|
||||
|
||||
return packages
|
||||
|
||||
@@ -1508,6 +1508,8 @@ class PostgreSQLBackend:
|
||||
notify_on_complete: str = "{}",
|
||||
enabled: bool = True,
|
||||
allowed_tools: str = "[]",
|
||||
skill_license: str = "",
|
||||
compatibility: str = "",
|
||||
) -> None:
|
||||
# Sync is_default from activation when activation is explicitly set
|
||||
if activation == "default":
|
||||
@@ -1540,6 +1542,8 @@ class PostgreSQLBackend:
|
||||
"activation": activation,
|
||||
"token_estimate": token_estimate,
|
||||
"allowed_tools": allowed_tools,
|
||||
"license": skill_license,
|
||||
"compatibility": compatibility,
|
||||
"scan_status": scan_status,
|
||||
"scan_report": scan_report,
|
||||
"scan_version": scan_version,
|
||||
@@ -1679,13 +1683,24 @@ class PostgreSQLBackend:
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def list_skills_by_activation(self, activation: str) -> list[dict[str, Any]]:
|
||||
def list_skills_by_activation(
|
||||
self,
|
||||
activation: str,
|
||||
*,
|
||||
enabled_only: bool = False,
|
||||
limit: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
q = (
|
||||
sa.select(prompt_templates)
|
||||
.where(prompt_templates.c.activation == activation)
|
||||
.order_by(prompt_templates.c.name)
|
||||
).fetchall()
|
||||
)
|
||||
if enabled_only:
|
||||
q = q.where(prompt_templates.c.enabled == 1)
|
||||
if limit > 0:
|
||||
q = q.limit(limit)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [
|
||||
_row_to_dict(r, "is_default", "readonly", "auto_approve", "enabled") for r in rows
|
||||
]
|
||||
@@ -1773,6 +1788,33 @@ class PostgreSQLBackend:
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
def delete_skill_resource_by_path(self, skill_id: str, path: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(skill_resources).where(
|
||||
sa.and_(
|
||||
skill_resources.c.skill_id == skill_id,
|
||||
skill_resources.c.path == path,
|
||||
)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def count_skill_resources_bulk(self, skill_ids: list[str]) -> dict[str, int]:
|
||||
if not skill_ids:
|
||||
return {}
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
skill_resources.c.skill_id,
|
||||
sa.func.count().label("cnt"),
|
||||
)
|
||||
.where(skill_resources.c.skill_id.in_(skill_ids))
|
||||
.group_by(skill_resources.c.skill_id)
|
||||
).fetchall()
|
||||
return {r[0]: r[1] for r in rows}
|
||||
|
||||
# -- Skill versions --------------------------------------------------------
|
||||
|
||||
def create_skill_version(
|
||||
|
||||
@@ -579,6 +579,8 @@ class StorageBackend(Protocol):
|
||||
notify_on_complete: str = "{}",
|
||||
enabled: bool = True,
|
||||
allowed_tools: str = "[]",
|
||||
skill_license: str = "",
|
||||
compatibility: str = "",
|
||||
) -> None:
|
||||
"""Create a prompt template (skill)."""
|
||||
...
|
||||
@@ -617,7 +619,13 @@ class StorageBackend(Protocol):
|
||||
"""Count prompt templates, optionally filtered by org_id."""
|
||||
...
|
||||
|
||||
def list_skills_by_activation(self, activation: str) -> list[dict[str, Any]]:
|
||||
def list_skills_by_activation(
|
||||
self,
|
||||
activation: str,
|
||||
*,
|
||||
enabled_only: bool = False,
|
||||
limit: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return prompt templates filtered by activation value, ordered by name."""
|
||||
...
|
||||
|
||||
@@ -658,6 +666,14 @@ class StorageBackend(Protocol):
|
||||
"""Delete all resource files for a skill. Returns count deleted."""
|
||||
...
|
||||
|
||||
def delete_skill_resource_by_path(self, skill_id: str, path: str) -> bool:
|
||||
"""Delete a single resource file by skill_id and path. Returns True if found."""
|
||||
...
|
||||
|
||||
def count_skill_resources_bulk(self, skill_ids: list[str]) -> dict[str, int]:
|
||||
"""Count resources per skill in a single query. Returns {skill_id: count}."""
|
||||
...
|
||||
|
||||
# -- Skill versions --------------------------------------------------------
|
||||
|
||||
def create_skill_version(
|
||||
|
||||
@@ -311,6 +311,8 @@ prompt_templates = sa.Table(
|
||||
sa.Column("activation", sa.Text, nullable=False, server_default="named"),
|
||||
sa.Column("token_estimate", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("allowed_tools", sa.Text, nullable=False, server_default="[]"), # JSON array
|
||||
sa.Column("license", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("compatibility", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("scan_status", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("scan_report", sa.Text, nullable=False, server_default="{}"), # JSON
|
||||
sa.Column("installed_at", sa.Text, nullable=False, server_default=""),
|
||||
|
||||
@@ -1532,6 +1532,8 @@ class SQLiteBackend:
|
||||
notify_on_complete: str = "{}",
|
||||
enabled: bool = True,
|
||||
allowed_tools: str = "[]",
|
||||
skill_license: str = "",
|
||||
compatibility: str = "",
|
||||
) -> None:
|
||||
# Sync is_default from activation when activation is explicitly set
|
||||
if activation == "default":
|
||||
@@ -1564,6 +1566,8 @@ class SQLiteBackend:
|
||||
"activation": activation,
|
||||
"token_estimate": token_estimate,
|
||||
"allowed_tools": allowed_tools,
|
||||
"license": skill_license,
|
||||
"compatibility": compatibility,
|
||||
"scan_status": scan_status,
|
||||
"scan_report": scan_report,
|
||||
"scan_version": scan_version,
|
||||
@@ -1703,13 +1707,24 @@ class SQLiteBackend:
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def list_skills_by_activation(self, activation: str) -> list[dict[str, Any]]:
|
||||
def list_skills_by_activation(
|
||||
self,
|
||||
activation: str,
|
||||
*,
|
||||
enabled_only: bool = False,
|
||||
limit: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._engine.connect() as conn:
|
||||
rows = conn.execute(
|
||||
q = (
|
||||
sa.select(prompt_templates)
|
||||
.where(prompt_templates.c.activation == activation)
|
||||
.order_by(prompt_templates.c.name)
|
||||
).fetchall()
|
||||
)
|
||||
if enabled_only:
|
||||
q = q.where(prompt_templates.c.enabled == 1)
|
||||
if limit > 0:
|
||||
q = q.limit(limit)
|
||||
rows = conn.execute(q).fetchall()
|
||||
return [
|
||||
_row_to_dict(r, "is_default", "readonly", "auto_approve", "enabled") for r in rows
|
||||
]
|
||||
@@ -1797,6 +1812,40 @@ class SQLiteBackend:
|
||||
conn.commit()
|
||||
return result.rowcount
|
||||
|
||||
def delete_skill_resource_by_path(self, skill_id: str, path: str) -> bool:
|
||||
with self._engine.connect() as conn:
|
||||
result = conn.execute(
|
||||
sa.delete(skill_resources).where(
|
||||
sa.and_(
|
||||
skill_resources.c.skill_id == skill_id,
|
||||
skill_resources.c.path == path,
|
||||
)
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
def count_skill_resources_bulk(self, skill_ids: list[str]) -> dict[str, int]:
|
||||
if not skill_ids:
|
||||
return {}
|
||||
result: dict[str, int] = {}
|
||||
# Chunk to stay under SQLite's max variable limit (999)
|
||||
chunk_size = 900
|
||||
with self._engine.connect() as conn:
|
||||
for i in range(0, len(skill_ids), chunk_size):
|
||||
chunk = skill_ids[i : i + chunk_size]
|
||||
rows = conn.execute(
|
||||
sa.select(
|
||||
skill_resources.c.skill_id,
|
||||
sa.func.count().label("cnt"),
|
||||
)
|
||||
.where(skill_resources.c.skill_id.in_(chunk))
|
||||
.group_by(skill_resources.c.skill_id)
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
result[r[0]] = r[1]
|
||||
return result
|
||||
|
||||
# -- Skill versions --------------------------------------------------------
|
||||
|
||||
def create_skill_version(
|
||||
|
||||
@@ -54,6 +54,8 @@ SKILL_MUTABLE = frozenset(
|
||||
"notify_on_complete",
|
||||
"enabled",
|
||||
"allowed_tools",
|
||||
"license",
|
||||
"compatibility",
|
||||
"scan_version",
|
||||
"scan_status",
|
||||
"scan_report",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Add license and compatibility columns to prompt_templates.
|
||||
|
||||
Agent Skills standard (agentskills.io) defines license and compatibility
|
||||
as optional SKILL.md frontmatter fields. These were parsed but discarded
|
||||
prior to this migration.
|
||||
|
||||
Revision ID: 023
|
||||
Revises: 022
|
||||
Create Date: 2026-03-17
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "023"
|
||||
down_revision = "022"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"prompt_templates",
|
||||
sa.Column("license", sa.Text, nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"prompt_templates",
|
||||
sa.Column("compatibility", sa.Text, nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("prompt_templates", "compatibility")
|
||||
op.drop_column("prompt_templates", "license")
|
||||
@@ -64,15 +64,12 @@ class ToolSearchManager:
|
||||
all_tools: list[dict[str, Any]],
|
||||
always_on_names: set[str],
|
||||
*,
|
||||
threshold: int = 20,
|
||||
max_results: int = 5,
|
||||
) -> None:
|
||||
self._all_tools = all_tools
|
||||
self._always_on: list[dict[str, Any]] = []
|
||||
self._deferred: list[dict[str, Any]] = []
|
||||
self._deferred_by_name: dict[str, dict[str, Any]] = {}
|
||||
self._expanded: dict[str, None] = {} # ordered set (preserves discovery order)
|
||||
self._threshold = threshold
|
||||
self._max_results = max_results
|
||||
|
||||
for tool in all_tools:
|
||||
@@ -90,10 +87,6 @@ class ToolSearchManager:
|
||||
# Pre-compute server summary for the search tool description
|
||||
self._server_hint = _mcp_server_summary(self._deferred)
|
||||
|
||||
def should_activate(self) -> bool:
|
||||
"""Return True if tool search should be active (enough tools)."""
|
||||
return len(self._all_tools) > self._threshold
|
||||
|
||||
def get_visible_tools(self) -> list[dict[str, Any]]:
|
||||
"""Return always-on tools + any expanded (discovered) tools."""
|
||||
result = list(self._always_on)
|
||||
@@ -107,10 +100,6 @@ class ToolSearchManager:
|
||||
"""Return tools that are currently deferred (not yet discovered)."""
|
||||
return [t for t in self._deferred if _tool_name(t) not in self._expanded]
|
||||
|
||||
def get_all_tools(self) -> list[dict[str, Any]]:
|
||||
"""Return the full tool list (for native provider modes)."""
|
||||
return list(self._all_tools)
|
||||
|
||||
def search(self, query: str) -> list[dict[str, Any]]:
|
||||
"""Search deferred tools by query, return top-k matches.
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ MAX_WATCHES_PER_WS = 5
|
||||
MIN_INTERVAL = 10 # seconds
|
||||
MAX_INTERVAL = 86_400 # 24 hours
|
||||
DEFAULT_MAX_POLLS = 100
|
||||
DEFAULT_INTERVAL = 300 # 5 minutes
|
||||
MAX_OUTPUT_SIZE = 65_536 # truncate stored/dispatched output at 64 KB
|
||||
|
||||
# Safe builtins exposed to condition expressions.
|
||||
|
||||
@@ -38,7 +38,6 @@ from turnstone.api.console_schemas import (
|
||||
RoleInfo,
|
||||
SettingInfo,
|
||||
SkillDiscoverResponse,
|
||||
SkillInfo,
|
||||
ToolPolicyInfo,
|
||||
UsageResponse,
|
||||
)
|
||||
@@ -454,6 +453,36 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
"DELETE", f"/v1/api/admin/skills/{skill_id}", response_model=StatusResponse
|
||||
)
|
||||
|
||||
async def list_skill_resources(self, skill_id: str) -> list[dict[str, Any]]:
|
||||
"""List resource files for a skill."""
|
||||
resp = await self._request("GET", f"/v1/api/admin/skills/{skill_id}/resources")
|
||||
resources: list[dict[str, Any]] = resp.get("resources", [])
|
||||
return resources
|
||||
|
||||
async def create_skill_resource(
|
||||
self,
|
||||
skill_id: str,
|
||||
path: str,
|
||||
content: str,
|
||||
content_type: str = "text/plain",
|
||||
) -> dict[str, Any]:
|
||||
"""Upload a resource file to a skill."""
|
||||
body: dict[str, Any] = {"path": path, "content": content, "content_type": content_type}
|
||||
return await self._request(
|
||||
"POST", f"/v1/api/admin/skills/{skill_id}/resources", json_body=body
|
||||
)
|
||||
|
||||
async def delete_skill_resource(self, skill_id: str, path: str) -> StatusResponse:
|
||||
"""Delete a skill resource by path."""
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded = quote(path, safe="/")
|
||||
return await self._request(
|
||||
"DELETE",
|
||||
f"/v1/api/admin/skills/{skill_id}/resources/{encoded}",
|
||||
response_model=StatusResponse,
|
||||
)
|
||||
|
||||
# -- governance: usage & audit -------------------------------------------
|
||||
|
||||
async def get_usage(
|
||||
@@ -769,8 +798,11 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
*,
|
||||
skill_id: str = "",
|
||||
url: str = "",
|
||||
) -> SkillInfo:
|
||||
"""Install a skill from an external source."""
|
||||
) -> dict[str, Any]:
|
||||
"""Install skill(s) from an external source.
|
||||
|
||||
Returns ``{installed: [...], skipped: [...], total: int}``.
|
||||
"""
|
||||
body: dict[str, Any] = {"source": source}
|
||||
if skill_id:
|
||||
body["skill_id"] = skill_id
|
||||
@@ -780,7 +812,6 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
"POST",
|
||||
"/v1/api/admin/skills/install",
|
||||
json_body=body,
|
||||
response_model=SkillInfo,
|
||||
)
|
||||
|
||||
|
||||
@@ -1040,6 +1071,23 @@ class TurnstoneConsole:
|
||||
def delete_skill(self, skill_id: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.delete_skill(skill_id))
|
||||
|
||||
def list_skill_resources(self, skill_id: str) -> list[dict[str, Any]]:
|
||||
return self._runner.run(self._async.list_skill_resources(skill_id))
|
||||
|
||||
def create_skill_resource(
|
||||
self,
|
||||
skill_id: str,
|
||||
path: str,
|
||||
content: str,
|
||||
content_type: str = "text/plain",
|
||||
) -> dict[str, Any]:
|
||||
return self._runner.run(
|
||||
self._async.create_skill_resource(skill_id, path, content, content_type)
|
||||
)
|
||||
|
||||
def delete_skill_resource(self, skill_id: str, path: str) -> StatusResponse:
|
||||
return self._runner.run(self._async.delete_skill_resource(skill_id, path))
|
||||
|
||||
# -- governance: usage & audit -------------------------------------------
|
||||
|
||||
def get_usage(
|
||||
@@ -1219,7 +1267,7 @@ class TurnstoneConsole:
|
||||
*,
|
||||
skill_id: str = "",
|
||||
url: str = "",
|
||||
) -> SkillInfo:
|
||||
) -> dict[str, Any]:
|
||||
return self._runner.run(self._async.install_skill(source, skill_id=skill_id, url=url))
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
@@ -66,9 +66,7 @@ class SimEngine:
|
||||
self._config = config
|
||||
self._rng = rng or random.Random(config.seed)
|
||||
|
||||
async def simulate_llm_response(
|
||||
self, first_round: bool, turn_number: int
|
||||
) -> tuple[str, list[dict[str, Any]]]:
|
||||
async def simulate_llm_response(self, first_round: bool) -> tuple[str, list[dict[str, Any]]]:
|
||||
"""Simulate an LLM response.
|
||||
|
||||
Returns ``(content_text, tool_calls)`` where *tool_calls* may be
|
||||
|
||||
@@ -75,7 +75,6 @@ class SimWorkstream:
|
||||
self._set_state("thinking", correlation_id)
|
||||
content, tool_calls = await self._engine.simulate_llm_response(
|
||||
rounds == 0,
|
||||
self._turn_count,
|
||||
)
|
||||
await self._stream_content(content, correlation_id)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Protocol
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from turnstone.mq.broker import RedisBroker
|
||||
from turnstone.mq.protocol import SendMessage
|
||||
@@ -18,15 +18,6 @@ if TYPE_CHECKING:
|
||||
log = logging.getLogger("turnstone.sim.scenario")
|
||||
|
||||
|
||||
class Scenario(Protocol):
|
||||
async def run(
|
||||
self,
|
||||
cluster: SimCluster,
|
||||
config: SimConfig,
|
||||
metrics: MetricsCollector,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class SteadyStateScenario:
|
||||
"""Inject messages at a constant rate for the configured duration."""
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "load_skill",
|
||||
"name": "skill",
|
||||
"description": "Load or search for skills. Actions: 'load' activates a skill by name (replaces current skill), 'search' finds available skills by query.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
Reference in New Issue
Block a user