mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 15:32:24 -06:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86b404177b | |||
| 10165bb8a1 | |||
| 5d478573cc | |||
| 1b24e4717f | |||
| 9a2db63c07 | |||
| e159837b74 | |||
| ec3454ee2e | |||
| 5cbb832162 | |||
| 4e4ae2a91d | |||
| c7d0bac638 | |||
| e86305c143 | |||
| d0fc42195a | |||
| 760321f7ee | |||
| 693e51f782 | |||
| ba07409724 | |||
| c76a61841e | |||
| 341d2f604f | |||
| 3f7f8495d6 | |||
| dc464ac313 |
@@ -51,7 +51,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
uv-version: "0.9.18"
|
||||
- run: uv lock --check
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
||||
with:
|
||||
uv-version: "0.9.18"
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ FROM python:3.14-slim
|
||||
LABEL org.opencontainers.image.title="turnstone" \
|
||||
org.opencontainers.image.description="Multi-node AI orchestration platform"
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.10 /uv /usr/local/bin/uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.11 /uv /usr/local/bin/uv
|
||||
|
||||
# System dependencies for psycopg (PostgreSQL client library)
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends libpq5 \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# OpenShell inference routing for Turnstone.
|
||||
#
|
||||
# When using inference routing, the sandbox process connects to
|
||||
# https://inference.local instead of the real LLM API. The OpenShell
|
||||
# proxy intercepts, rewrites credentials, and forwards to the backend.
|
||||
#
|
||||
# This keeps real API keys out of the sandbox entirely — the process
|
||||
# only sees opaque placeholder tokens in its environment.
|
||||
#
|
||||
# Usage:
|
||||
# openshell sandbox run \
|
||||
# --inference-routes deploy/openshell/routes.yaml \
|
||||
# ...
|
||||
#
|
||||
# Then start turnstone with:
|
||||
# python3 -m turnstone.server --base-url https://inference.local
|
||||
#
|
||||
# CUSTOMIZE: uncomment one of the provider blocks below.
|
||||
|
||||
routes:
|
||||
|
||||
# --- OpenAI ---
|
||||
# - name: inference.local
|
||||
# endpoint: https://api.openai.com/v1
|
||||
# model: gpt-5
|
||||
# provider_type: openai
|
||||
# protocols:
|
||||
# - openai_chat_completions
|
||||
# - model_discovery
|
||||
# api_key_env: OPENAI_API_KEY
|
||||
|
||||
# --- Anthropic ---
|
||||
# - name: inference.local
|
||||
# endpoint: https://api.anthropic.com
|
||||
# model: claude-sonnet-4-6
|
||||
# provider_type: anthropic
|
||||
# protocols:
|
||||
# - anthropic_messages
|
||||
# api_key_env: ANTHROPIC_API_KEY
|
||||
|
||||
# --- Local model server (vLLM / llama.cpp) ---
|
||||
# No secret resolution needed — local servers typically have no auth.
|
||||
# Omit both api_key and api_key_env to skip credential injection.
|
||||
# - name: inference.local
|
||||
# endpoint: http://localhost:8000/v1
|
||||
# model: meta-llama/Llama-3.1-70B-Instruct
|
||||
# protocols:
|
||||
# - openai_chat_completions
|
||||
# - model_discovery
|
||||
@@ -0,0 +1,333 @@
|
||||
# OpenShell sandbox policy for Turnstone AI orchestration platform.
|
||||
#
|
||||
# This policy wraps a turnstone-server process (the primary sandbox target).
|
||||
# The bridge, console, and channel gateway are separate processes that would
|
||||
# each need their own sandbox with a tailored policy variant.
|
||||
#
|
||||
# Usage:
|
||||
# openshell sandbox run \
|
||||
# --policy deploy/openshell/turnstone-policy.yaml \
|
||||
# --workdir /project \
|
||||
# -- python3 -m turnstone.server --host 0.0.0.0 --port 8080
|
||||
#
|
||||
# For inference routing (keeps real API keys out of the sandbox):
|
||||
# openshell sandbox run \
|
||||
# --policy deploy/openshell/turnstone-policy.yaml \
|
||||
# --inference-routes deploy/openshell/routes.yaml \
|
||||
# --workdir /project \
|
||||
# -- python3 -m turnstone.server --host 0.0.0.0 --port 8080 \
|
||||
# --base-url https://inference.local
|
||||
#
|
||||
# Note: inference.local is intercepted by the OpenShell proxy before
|
||||
# network policy evaluation — no network_policies entry is needed for it.
|
||||
#
|
||||
# Customization points (search for "CUSTOMIZE"):
|
||||
# - OIDC issuer endpoint
|
||||
# - Redis host/port (if not localhost)
|
||||
# - MCP HTTP server endpoints
|
||||
# - Additional tool binaries
|
||||
# - web_fetch domain allowlist
|
||||
|
||||
version: 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filesystem: Landlock kernel enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
# Static — cannot be changed after sandbox creation.
|
||||
# include_workdir adds the --workdir path to read_write automatically.
|
||||
|
||||
filesystem_policy:
|
||||
include_workdir: true
|
||||
|
||||
read_only:
|
||||
# Python runtime + installed packages (includes turnstone package)
|
||||
- /usr
|
||||
- /lib
|
||||
- /lib64
|
||||
# System essentials
|
||||
- /etc
|
||||
- /proc
|
||||
- /dev/urandom
|
||||
# Turnstone config (read-only — writes go to database)
|
||||
# CUSTOMIZE: adjust if config lives elsewhere
|
||||
- /home/sandbox/.config/turnstone
|
||||
|
||||
read_write:
|
||||
# Working directory is added via include_workdir
|
||||
# Temp files (bash tool scripts, eval workdirs)
|
||||
- /tmp
|
||||
# Shell redirections (2>/dev/null)
|
||||
- /dev/null
|
||||
# SQLite database (default location is workdir, covered by include_workdir)
|
||||
# Logs
|
||||
- /var/log
|
||||
|
||||
landlock:
|
||||
# best_effort: degrade gracefully on kernels without Landlock (< 5.13)
|
||||
# Change to hard_requirement for production hardened deployments
|
||||
compatibility: best_effort
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process: privilege separation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
process:
|
||||
run_as_user: sandbox
|
||||
run_as_group: sandbox
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Network: per-endpoint, per-binary allowlisting
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default-deny. Only listed host:port pairs are reachable.
|
||||
# Child processes (MCP servers, bash subcommands) inherit the network
|
||||
# namespace — they cannot bypass the proxy.
|
||||
|
||||
network_policies:
|
||||
|
||||
# --- LLM API providers ---
|
||||
|
||||
openai_api:
|
||||
name: openai-api
|
||||
endpoints:
|
||||
- host: api.openai.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
anthropic_api:
|
||||
name: anthropic-api
|
||||
endpoints:
|
||||
- host: api.anthropic.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- Web search fallback (Tavily) ---
|
||||
|
||||
tavily_api:
|
||||
name: tavily-search
|
||||
endpoints:
|
||||
- host: api.tavily.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- Skill discovery ---
|
||||
|
||||
skills_registry:
|
||||
name: skills-registry
|
||||
endpoints:
|
||||
- host: skills.sh
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
github_api:
|
||||
name: github-api
|
||||
endpoints:
|
||||
- host: api.github.com
|
||||
port: 443
|
||||
protocol: rest
|
||||
tls: terminate
|
||||
enforcement: enforce
|
||||
access: read-only
|
||||
- host: raw.githubusercontent.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
mcp_registry:
|
||||
name: mcp-registry
|
||||
endpoints:
|
||||
- host: registry.modelcontextprotocol.io
|
||||
port: 443
|
||||
protocol: rest
|
||||
tls: terminate
|
||||
enforcement: enforce
|
||||
access: read-only
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- OIDC SSO ---
|
||||
# CUSTOMIZE: replace with your identity provider's hostname
|
||||
|
||||
# oidc_provider:
|
||||
# name: oidc-provider
|
||||
# endpoints:
|
||||
# - host: login.example.com
|
||||
# port: 443
|
||||
# binaries:
|
||||
# - path: /usr/bin/python3*
|
||||
# - path: /usr/local/bin/python3*
|
||||
|
||||
# --- Redis (MQ) ---
|
||||
# CUSTOMIZE: if Redis is not on localhost, add host + allowed_ips.
|
||||
# localhost is blocked by default SSRF protection, so we need allowed_ips.
|
||||
|
||||
redis:
|
||||
name: redis-mq
|
||||
endpoints:
|
||||
- port: 6379
|
||||
allowed_ips:
|
||||
- "127.0.0.1"
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- Discord (channel integration) ---
|
||||
# Uncomment if using turnstone-channel with Discord adapter.
|
||||
|
||||
# discord:
|
||||
# name: discord
|
||||
# endpoints:
|
||||
# - host: discord.com
|
||||
# port: 443
|
||||
# - host: gateway.discord.gg
|
||||
# port: 443
|
||||
# - host: cdn.discordapp.com
|
||||
# port: 443
|
||||
# binaries:
|
||||
# - path: /usr/bin/python3*
|
||||
# - path: /usr/local/bin/python3*
|
||||
|
||||
# --- web_fetch tool: curated domain allowlist ---
|
||||
#
|
||||
# This is the hard tradeoff. Turnstone's web_fetch tool lets the LLM
|
||||
# fetch arbitrary public URLs. OpenShell cannot allow "all HTTPS" —
|
||||
# every domain must be enumerated.
|
||||
#
|
||||
# Strategy: allowlist the domains your workloads actually need.
|
||||
# The web_fetch tool will return a connection error for unlisted domains,
|
||||
# which the LLM handles gracefully (it tells the user it can't reach
|
||||
# that site).
|
||||
#
|
||||
# CUSTOMIZE: add domains your workstreams need to fetch from.
|
||||
|
||||
web_fetch_common:
|
||||
name: web-fetch-common
|
||||
endpoints:
|
||||
# Documentation sites
|
||||
- host: "**.readthedocs.io"
|
||||
port: 443
|
||||
- host: docs.python.org
|
||||
port: 443
|
||||
- host: "**.github.io"
|
||||
port: 443
|
||||
# Package registries (metadata lookups)
|
||||
- host: pypi.org
|
||||
port: 443
|
||||
- host: www.npmjs.com
|
||||
port: 443
|
||||
# Stack Overflow / reference
|
||||
- host: stackoverflow.com
|
||||
port: 443
|
||||
- host: "**.stackexchange.com"
|
||||
port: 443
|
||||
# Wikipedia
|
||||
- host: "**.wikipedia.org"
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- MCP HTTP servers ---
|
||||
# CUSTOMIZE: add endpoints for any MCP servers using streamable-http
|
||||
# transport. stdio-transport MCP servers need no network entry (they
|
||||
# communicate via stdin/stdout pipes within the sandbox).
|
||||
|
||||
# mcp_http_servers:
|
||||
# name: mcp-http
|
||||
# endpoints:
|
||||
# - host: mcp.internal.example.com
|
||||
# port: 443
|
||||
# binaries:
|
||||
# - path: /usr/bin/python3*
|
||||
# - path: /usr/local/bin/python3*
|
||||
|
||||
# --- Bash tool: curl/wget ---
|
||||
# The bash tool can run curl/wget. These inherit the network namespace
|
||||
# so they can only reach allowed endpoints. But they need binary entries
|
||||
# to pass the proxy's identity check.
|
||||
|
||||
bash_network_tools:
|
||||
name: bash-network-tools
|
||||
endpoints:
|
||||
# Mirrors web_fetch_common — curl/wget should have the same reach.
|
||||
- host: "**.readthedocs.io"
|
||||
port: 443
|
||||
- host: docs.python.org
|
||||
port: 443
|
||||
- host: "**.github.io"
|
||||
port: 443
|
||||
- host: pypi.org
|
||||
port: 443
|
||||
- host: www.npmjs.com
|
||||
port: 443
|
||||
- host: stackoverflow.com
|
||||
port: 443
|
||||
- host: "**.stackexchange.com"
|
||||
port: 443
|
||||
- host: "**.wikipedia.org"
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/curl
|
||||
- path: /usr/bin/wget
|
||||
|
||||
# --- Package installation ---
|
||||
# pip install / uv add from the bash tool.
|
||||
|
||||
package_registries:
|
||||
name: package-install
|
||||
endpoints:
|
||||
- host: pypi.org
|
||||
port: 443
|
||||
- host: files.pythonhosted.org
|
||||
port: 443
|
||||
- host: "**.pypi.org"
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/pip*
|
||||
- path: /usr/local/bin/pip*
|
||||
- path: /usr/bin/uv
|
||||
- path: /usr/local/bin/uv
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
|
||||
# --- Git operations ---
|
||||
# read-only: clone, fetch, pull. No push (L7 enforcement).
|
||||
|
||||
git_operations:
|
||||
name: git-read-only
|
||||
endpoints:
|
||||
- host: github.com
|
||||
port: 443
|
||||
protocol: rest
|
||||
tls: terminate
|
||||
enforcement: enforce
|
||||
rules:
|
||||
- allow:
|
||||
method: GET
|
||||
path: "/**/info/refs*"
|
||||
- allow:
|
||||
method: POST
|
||||
path: "/**/git-upload-pack"
|
||||
- host: gitlab.com
|
||||
port: 443
|
||||
protocol: rest
|
||||
tls: terminate
|
||||
enforcement: enforce
|
||||
rules:
|
||||
- allow:
|
||||
method: GET
|
||||
path: "/**/info/refs*"
|
||||
- allow:
|
||||
method: POST
|
||||
path: "/**/git-upload-pack"
|
||||
binaries:
|
||||
- path: /usr/bin/git
|
||||
+15
-5
@@ -100,7 +100,7 @@ turnstone/
|
||||
index.html Single-page app shell (links to CSS and JS)
|
||||
style.css Page-specific UI styles (dashboard, markdown elements, approval blocks)
|
||||
renderer.js Markdown + LaTeX renderer (tables, nested lists, blockquotes, KaTeX math)
|
||||
app.js Page-specific client-side JavaScript (SSE, workstreams, tool approval)
|
||||
app.js Split-pane UI (Pane class, binary layout tree, SSE, tool approval)
|
||||
tools/
|
||||
*.json 15 tool schemas (OpenAI function-calling format + turnstone metadata)
|
||||
```
|
||||
@@ -375,12 +375,19 @@ non-idle background workstreams above the input prompt.
|
||||
### Web Workstreams
|
||||
|
||||
- **Tab bar**: Each workstream renders as a tab with a colored state indicator
|
||||
(CSS `@keyframes pulse` animation per state).
|
||||
- **Per-tab SSE**: `connectContentSSE(wsId)` opens
|
||||
`/v1/api/events?ws_id=<id>` for the active tab's event stream.
|
||||
(CSS `@keyframes pulse` animation per state). Clicking a tab switches the
|
||||
focused pane's workstream (or focuses an existing pane showing that ws).
|
||||
- **Split panes**: The UI supports tiling multiple workstreams side-by-side or
|
||||
stacked via a binary layout tree. Each `Pane` instance encapsulates its own
|
||||
SSE connection, message area, input, and state (busy, approval, streaming).
|
||||
Split via right-click context menu, pane header buttons, or keyboard
|
||||
(`Ctrl+\`, `Ctrl+Shift+\`). Max 6 panes; no duplicate workstreams across panes.
|
||||
Layout persisted to `localStorage`.
|
||||
- **Per-pane SSE**: `Pane.connectSSE(wsId)` opens
|
||||
`/v1/api/events?ws_id=<id>` for each pane's event stream independently.
|
||||
- **Global SSE**: `connectGlobalSSE()` opens `/v1/api/events/global` which
|
||||
receives `ws_state` broadcasts from all workstreams, used to update tab
|
||||
indicators without switching.
|
||||
indicators and pane headers without switching.
|
||||
- **New tab / close**: POST `/v1/api/workstreams/new`, POST `/v1/api/workstreams/close`.
|
||||
|
||||
### Thread Safety
|
||||
@@ -953,6 +960,9 @@ warns if the summary was truncated.
|
||||
seeded with `history.replaceState({turnstone: 'dashboard'})` on load. The
|
||||
`popstate` listener restores the correct tab or shows the dashboard,
|
||||
guarded by `_historyNavigation = true` to prevent re-entrant pushState.
|
||||
- **Pane focus**: `mousedown` and `focusin` events on pane containers update
|
||||
`focusedPaneId`. Approval shortcuts (y/n/a) apply to the focused pane.
|
||||
`Ctrl+Alt+Arrow` cycles focus between panes.
|
||||
|
||||
### Eval Resilience
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
# OpenShell Sandbox Integration
|
||||
|
||||
Turnstone can run inside an [OpenShell](https://github.com/NVIDIA/OpenShell)
|
||||
sandbox for kernel-enforced security boundaries around tool execution. OpenShell
|
||||
provides four layers of defense that Turnstone's application-level safety model
|
||||
does not cover:
|
||||
|
||||
| Layer | Mechanism | What it prevents |
|
||||
|-------|-----------|------------------|
|
||||
| Filesystem | Landlock | Writes to `/etc`, `~/.ssh`, system paths |
|
||||
| Network | Network namespace + seccomp + HTTP CONNECT proxy | Connections to unlisted hosts |
|
||||
| Process | `setuid` drop + verification | Privilege escalation to root |
|
||||
| Credentials | Proxy-level secret resolution | API keys in sandbox memory |
|
||||
|
||||
Turnstone's own safety layers (human approval, intent judge, tool policies,
|
||||
output guard) remain active inside the sandbox and handle threats at the semantic
|
||||
level -- what the LLM *means* to do with its legitimate access.
|
||||
|
||||
> See also: [Security and Authentication](security.md),
|
||||
> [Intent Validation](judge.md), [Governance](governance.md)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Run turnstone-server in an OpenShell sandbox
|
||||
openshell sandbox run \
|
||||
--policy deploy/openshell/turnstone-policy.yaml \
|
||||
--workdir /path/to/project \
|
||||
-- python3 -m turnstone.server --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
With inference routing (API keys never enter the sandbox):
|
||||
|
||||
```bash
|
||||
openshell sandbox run \
|
||||
--policy deploy/openshell/turnstone-policy.yaml \
|
||||
--inference-routes deploy/openshell/routes.yaml \
|
||||
--workdir /path/to/project \
|
||||
-- python3 -m turnstone.server --host 0.0.0.0 --port 8080 \
|
||||
--base-url https://inference.local
|
||||
```
|
||||
|
||||
The `inference.local` hostname is intercepted by the OpenShell proxy before
|
||||
network policy evaluation -- no network policy entry is needed for it.
|
||||
|
||||
---
|
||||
|
||||
## Policy Files
|
||||
|
||||
### `deploy/openshell/turnstone-policy.yaml`
|
||||
|
||||
The main sandbox policy. Covers filesystem, process, and network rules.
|
||||
|
||||
### `deploy/openshell/routes.yaml`
|
||||
|
||||
Inference routing configuration. Maps `inference.local` to real LLM API
|
||||
backends. Uncomment and configure the provider(s) you use.
|
||||
|
||||
---
|
||||
|
||||
## Filesystem Policy
|
||||
|
||||
The policy uses Landlock (Linux 5.13+) for kernel-enforced filesystem access
|
||||
control. Paths are locked at sandbox creation and cannot be changed at runtime.
|
||||
|
||||
| Path | Access | Purpose |
|
||||
|------|--------|---------|
|
||||
| `--workdir` | read-write | Project files (auto-added via `include_workdir`) |
|
||||
| `/tmp` | read-write | Bash tool temp scripts, eval workdirs |
|
||||
| `/dev/null` | read-write | Shell redirections (`2>/dev/null`) |
|
||||
| `/var/log` | read-write | Log files |
|
||||
| `/usr`, `/lib`, `/lib64` | read-only | Python runtime, installed packages |
|
||||
| `/etc` | read-only | System config, SSL certificates |
|
||||
| `/proc`, `/dev/urandom` | read-only | Process info, entropy |
|
||||
| `~/.config/turnstone` | read-only | Config file (writes go to database) |
|
||||
|
||||
Landlock runs in `best_effort` mode by default -- degrades gracefully on kernels
|
||||
without Landlock support. Set `compatibility: hard_requirement` for production
|
||||
hardened deployments.
|
||||
|
||||
---
|
||||
|
||||
## Network Policy
|
||||
|
||||
Default-deny. Only explicitly listed host:port pairs are reachable. All child
|
||||
processes (MCP servers, bash commands, grep) inherit the network namespace and
|
||||
cannot bypass the proxy.
|
||||
|
||||
### Included endpoints
|
||||
|
||||
| Policy | Hosts | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `openai_api` | `api.openai.com` | OpenAI LLM API |
|
||||
| `anthropic_api` | `api.anthropic.com` | Anthropic LLM API |
|
||||
| `tavily_api` | `api.tavily.com` | Web search fallback |
|
||||
| `skills_registry` | `skills.sh` | Skill discovery |
|
||||
| `github_api` | `api.github.com` (read-only L7), `raw.githubusercontent.com` | Skill fetch, GitHub API |
|
||||
| `mcp_registry` | `registry.modelcontextprotocol.io` (read-only L7) | MCP server discovery |
|
||||
| `redis` | `127.0.0.1:6379` | Message queue |
|
||||
| `web_fetch_common` | readthedocs, python docs, GitHub Pages, PyPI, npm, Stack Overflow, Wikipedia | Curated web_fetch domains |
|
||||
| `bash_network_tools` | Same as `web_fetch_common` | curl/wget from bash tool |
|
||||
| `package_registries` | `pypi.org`, `files.pythonhosted.org` | pip/uv package installs |
|
||||
| `git_operations` | `github.com`, `gitlab.com` (L7: clone/fetch only, no push) | Git read-only operations |
|
||||
|
||||
### L7 enforcement
|
||||
|
||||
Endpoints marked with `protocol: rest` and `tls: terminate` get HTTP-level
|
||||
inspection. The proxy TLS-terminates using an ephemeral per-sandbox CA, parses
|
||||
each request, and evaluates method + path against the rules.
|
||||
|
||||
The `github_api`, `mcp_registry`, and `git_operations` policies use L7
|
||||
enforcement:
|
||||
|
||||
- **GitHub API / MCP Registry**: `access: read-only` -- only GET, HEAD, OPTIONS
|
||||
allowed
|
||||
- **Git operations**: explicit rules allowing only `info/refs` (GET) and
|
||||
`git-upload-pack` (POST) -- clone and fetch work, push is blocked
|
||||
|
||||
### Commented-out sections
|
||||
|
||||
The policy includes commented blocks for optional integrations. Uncomment and
|
||||
configure as needed:
|
||||
|
||||
- **OIDC** -- add your identity provider's hostname
|
||||
- **Discord** -- `discord.com`, `gateway.discord.gg`, `cdn.discordapp.com`
|
||||
- **MCP HTTP servers** -- any MCP servers using streamable-http transport
|
||||
|
||||
---
|
||||
|
||||
## Customizing the Domain Allowlist
|
||||
|
||||
The `web_fetch` tool lets the LLM fetch arbitrary public URLs, but OpenShell
|
||||
cannot allow "all HTTPS" -- bare wildcard hosts are rejected by policy
|
||||
validation. Instead, the policy ships with a curated set of common reference
|
||||
domains.
|
||||
|
||||
To add domains your workloads need:
|
||||
|
||||
```yaml
|
||||
# In turnstone-policy.yaml, under web_fetch_common.endpoints:
|
||||
- host: docs.example.com
|
||||
port: 443
|
||||
|
||||
# Also add to bash_network_tools.endpoints if curl/wget should reach it:
|
||||
- host: docs.example.com
|
||||
port: 443
|
||||
```
|
||||
|
||||
Wildcard patterns are supported:
|
||||
|
||||
- `*.example.com` -- matches one subdomain level (e.g. `api.example.com`)
|
||||
- `**.example.com` -- matches any depth (e.g. `deep.sub.example.com`)
|
||||
|
||||
Unlisted domains return connection errors, which the LLM handles gracefully by
|
||||
telling the user it cannot reach that site.
|
||||
|
||||
---
|
||||
|
||||
## Inference Routing
|
||||
|
||||
Inference routing keeps real API keys completely outside the sandbox. The
|
||||
sandbox process only sees opaque placeholder tokens in its environment
|
||||
(`openshell:resolve:env:ANTHROPIC_API_KEY`). The proxy rewrites these to real
|
||||
credentials on the wire before forwarding to the upstream API.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Edit `deploy/openshell/routes.yaml` -- uncomment your provider:
|
||||
|
||||
```yaml
|
||||
routes:
|
||||
# OpenAI
|
||||
- name: inference.local
|
||||
endpoint: https://api.openai.com/v1
|
||||
model: gpt-5
|
||||
provider_type: openai
|
||||
protocols:
|
||||
- openai_chat_completions
|
||||
- model_discovery
|
||||
api_key_env: OPENAI_API_KEY
|
||||
|
||||
# Or Anthropic
|
||||
- name: inference.local
|
||||
endpoint: https://api.anthropic.com
|
||||
model: claude-sonnet-4-6
|
||||
provider_type: anthropic
|
||||
protocols:
|
||||
- anthropic_messages
|
||||
api_key_env: ANTHROPIC_API_KEY
|
||||
```
|
||||
|
||||
2. Start with `--inference-routes` and point turnstone at `inference.local`:
|
||||
|
||||
```bash
|
||||
openshell sandbox run \
|
||||
--inference-routes deploy/openshell/routes.yaml \
|
||||
--base-url https://inference.local \
|
||||
...
|
||||
```
|
||||
|
||||
3. When inference routing is active, the `openai_api` and `anthropic_api`
|
||||
network policies can be removed from the sandbox policy -- the proxy handles
|
||||
LLM traffic on a separate code path that bypasses OPA entirely.
|
||||
|
||||
### Local model servers
|
||||
|
||||
For local servers (vLLM, llama.cpp) with no authentication, omit both
|
||||
`api_key` and `api_key_env` from the route config. No credential resolution
|
||||
is needed.
|
||||
|
||||
---
|
||||
|
||||
## MCP Server Subprocesses
|
||||
|
||||
MCP servers using stdio transport are spawned as child processes of turnstone.
|
||||
They automatically inherit all sandbox constraints:
|
||||
|
||||
- **Network namespace** -- kernel-level, cannot be bypassed
|
||||
- **Landlock filesystem** -- kernel-level, cannot be relaxed
|
||||
- **Seccomp socket filter** -- kernel-level, inherited on fork
|
||||
|
||||
No per-subprocess policy entries are needed for these constraints. However, if
|
||||
an MCP server makes outbound network requests (through the proxy), its binary
|
||||
must appear in a `binaries[]` entry for the relevant network policy. The proxy
|
||||
identifies the requesting process via `/proc/<pid>/exe` (not `argv[0]`, which
|
||||
is spoofable).
|
||||
|
||||
Example for a Python-based MCP server that calls an external API:
|
||||
|
||||
```yaml
|
||||
mcp_external_api:
|
||||
name: mcp-external
|
||||
endpoints:
|
||||
- host: api.example.com
|
||||
port: 443
|
||||
binaries:
|
||||
- path: /usr/bin/python3*
|
||||
- path: /usr/local/bin/python3*
|
||||
```
|
||||
|
||||
MCP servers using streamable-http transport are remote -- they need a network
|
||||
policy entry for their host:port but no binary entry (the Python process making
|
||||
the HTTP call is already covered by the standard `python3*` binary entries).
|
||||
|
||||
---
|
||||
|
||||
## Security Model: Which Layer Enforces What
|
||||
|
||||
```
|
||||
OpenShell (infrastructure) Turnstone (application)
|
||||
───────────────────────────── ──────────────────────────────
|
||||
Filesystem access Landlock kernel enforcement (no enforcement)
|
||||
Network egress Netns + seccomp + proxy + OPA SSRF check on web_fetch
|
||||
Credentials Placeholder injection + proxy Output guard redaction
|
||||
Privilege level setuid drop + verification (no enforcement)
|
||||
Tool semantics (no visibility) Heuristic + LLM judge
|
||||
Tool policies (no visibility) fnmatch admin policies
|
||||
Prompt injection (no visibility) Output guard detection
|
||||
Human approval (no visibility) Approval gate + "always"
|
||||
```
|
||||
|
||||
OpenShell constrains what the process can physically reach. Turnstone constrains
|
||||
what the LLM does with its legitimate access. Neither layer is sufficient alone:
|
||||
|
||||
- Without OpenShell: a bash command can `curl` secrets to any endpoint, write to
|
||||
`/etc/crontab`, or read `~/.ssh/id_rsa` -- all gated only by human approval
|
||||
- Without Turnstone: the LLM can `rm -rf` the entire workdir, run destructive
|
||||
commands, or consume prompt injection payloads -- all within the sandbox's
|
||||
allowed scope
|
||||
|
||||
---
|
||||
|
||||
## Hardening Checklist
|
||||
|
||||
For production deployments:
|
||||
|
||||
- [ ] Set `landlock.compatibility: hard_requirement`
|
||||
- [ ] Enable inference routing (removes API keys from sandbox)
|
||||
- [ ] Remove `openai_api`/`anthropic_api` network policies when using inference
|
||||
routing (traffic goes through the router, not direct)
|
||||
- [ ] Review and trim `web_fetch_common` domains to your actual needs
|
||||
- [ ] Remove `package_registries` policy if pip/uv installs are not needed
|
||||
- [ ] Add your OIDC provider endpoint if using SSO
|
||||
- [ ] Set Redis `allowed_ips` to your actual Redis host if not localhost
|
||||
- [ ] Consider removing `bash_network_tools` entirely if bash should not have
|
||||
network access
|
||||
+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.2"
|
||||
version = "0.8.4"
|
||||
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.4",
|
||||
"description": "Cluster-wide visibility and control across all turnstone nodes."
|
||||
},
|
||||
"paths": {
|
||||
@@ -4378,6 +4378,12 @@
|
||||
"description": "Skill name (replaces default skills)",
|
||||
"title": "Skill",
|
||||
"type": "string"
|
||||
},
|
||||
"resume_ws": {
|
||||
"default": "",
|
||||
"description": "Workstream ID to resume (loads previous conversation)",
|
||||
"title": "Resume Ws",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "ConsoleCreateWsRequest",
|
||||
@@ -6883,6 +6889,16 @@
|
||||
"title": "Allowed Tools",
|
||||
"type": "string"
|
||||
},
|
||||
"license": {
|
||||
"default": "",
|
||||
"title": "License",
|
||||
"type": "string"
|
||||
},
|
||||
"compatibility": {
|
||||
"default": "",
|
||||
"title": "Compatibility",
|
||||
"type": "string"
|
||||
},
|
||||
"scan_status": {
|
||||
"default": "",
|
||||
"title": "Scan Status",
|
||||
@@ -7107,6 +7123,16 @@
|
||||
"default": "[]",
|
||||
"title": "Allowed Tools",
|
||||
"type": "string"
|
||||
},
|
||||
"license": {
|
||||
"default": "",
|
||||
"title": "License",
|
||||
"type": "string"
|
||||
},
|
||||
"compatibility": {
|
||||
"default": "",
|
||||
"title": "Compatibility",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -7357,6 +7383,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",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "turnstone Server API",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.4",
|
||||
"description": "Single-node workstream management, chat interaction, and real-time streaming."
|
||||
},
|
||||
"paths": {
|
||||
@@ -1559,6 +1559,11 @@
|
||||
"title": "Version",
|
||||
"type": "string"
|
||||
},
|
||||
"node_id": {
|
||||
"default": "",
|
||||
"title": "Node Id",
|
||||
"type": "string"
|
||||
},
|
||||
"uptime_seconds": {
|
||||
"default": 0.0,
|
||||
"title": "Uptime Seconds",
|
||||
@@ -1569,6 +1574,12 @@
|
||||
"title": "Model",
|
||||
"type": "string"
|
||||
},
|
||||
"max_ws": {
|
||||
"default": 10,
|
||||
"description": "Maximum concurrent workstreams",
|
||||
"title": "Max Ws",
|
||||
"type": "integer"
|
||||
},
|
||||
"workstreams": {
|
||||
"$ref": "#/components/schemas/WorkstreamCounts",
|
||||
"default": {
|
||||
|
||||
@@ -187,6 +187,8 @@ export interface SkillInfo {
|
||||
notify_on_complete: string;
|
||||
enabled: boolean;
|
||||
allowed_tools: string;
|
||||
license: string;
|
||||
compatibility: string;
|
||||
resource_count: number;
|
||||
created: string;
|
||||
updated: string;
|
||||
@@ -214,6 +216,8 @@ export interface CreateSkillRequest {
|
||||
notify_on_complete?: string;
|
||||
enabled?: boolean;
|
||||
allowed_tools?: string;
|
||||
license?: string;
|
||||
compatibility?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSkillRequest {
|
||||
@@ -237,6 +241,8 @@ export interface UpdateSkillRequest {
|
||||
notify_on_complete?: string;
|
||||
enabled?: boolean;
|
||||
allowed_tools?: string;
|
||||
license?: string;
|
||||
compatibility?: string;
|
||||
}
|
||||
|
||||
export interface ListSkillsResponse {
|
||||
@@ -396,6 +402,7 @@ export interface ConsoleCreateWsRequest {
|
||||
model?: string;
|
||||
initial_message?: string;
|
||||
skill?: string;
|
||||
resume_ws?: string;
|
||||
}
|
||||
|
||||
export interface ConsoleCreateWsResponse {
|
||||
|
||||
@@ -19,6 +19,7 @@ class TestServerVersioning:
|
||||
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_all.return_value = []
|
||||
mock_mgr.max_workstreams = 10
|
||||
app = create_app(
|
||||
workstreams=mock_mgr,
|
||||
global_queue=queue.Queue(),
|
||||
|
||||
+10
-2
@@ -783,6 +783,7 @@ class TestServerAuth:
|
||||
mock_ws.session = mock_session
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_all.return_value = [mock_ws]
|
||||
mock_mgr.max_workstreams = 10
|
||||
|
||||
app = srv_mod.create_app(
|
||||
workstreams=mock_mgr,
|
||||
@@ -1001,6 +1002,7 @@ class TestServerLogin:
|
||||
mock_ws.session = mock_session
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_all.return_value = [mock_ws]
|
||||
mock_mgr.max_workstreams = 10
|
||||
|
||||
app = srv_mod.create_app(
|
||||
workstreams=mock_mgr,
|
||||
@@ -1369,8 +1371,11 @@ class TestCorsConfigurable:
|
||||
|
||||
import turnstone.server as srv_mod
|
||||
|
||||
mgr = MagicMock()
|
||||
mgr.list_all.return_value = []
|
||||
mgr.max_workstreams = 10
|
||||
app = srv_mod.create_app(
|
||||
workstreams=MagicMock(),
|
||||
workstreams=mgr,
|
||||
global_queue=queue.Queue(),
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
@@ -1388,8 +1393,11 @@ class TestCorsConfigurable:
|
||||
|
||||
import turnstone.server as srv_mod
|
||||
|
||||
mgr = MagicMock()
|
||||
mgr.list_all.return_value = []
|
||||
mgr.max_workstreams = 10
|
||||
app = srv_mod.create_app(
|
||||
workstreams=MagicMock(),
|
||||
workstreams=mgr,
|
||||
global_queue=queue.Queue(),
|
||||
global_listeners=[],
|
||||
global_listeners_lock=threading.Lock(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
+85
-1
@@ -3,7 +3,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import queue
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -264,6 +264,57 @@ class TestCollectorPolling:
|
||||
assert q.empty()
|
||||
assert len(c._nodes["node-a"].workstreams) == 0
|
||||
|
||||
def test_poll_401_preserves_workstreams_and_marks_unreachable(self):
|
||||
"""A 401 from the server must NOT wipe workstream data."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
reachable=True,
|
||||
workstreams={"ws1": {"id": "ws1", "name": "existing", "state": "idle"}},
|
||||
)
|
||||
|
||||
# Mock httpx to return 401
|
||||
import httpx as _httpx
|
||||
|
||||
mock_response = _httpx.Response(
|
||||
401,
|
||||
json={"error": "Unauthorized"},
|
||||
request=_httpx.Request("GET", "http://a:8080/v1/api/dashboard"),
|
||||
)
|
||||
|
||||
with patch.object(c._http_client, "get", return_value=mock_response):
|
||||
c._poll_all_nodes()
|
||||
|
||||
# Workstream data must be preserved, node marked unreachable
|
||||
assert c._nodes["node-a"].reachable is False
|
||||
assert "ws1" in c._nodes["node-a"].workstreams
|
||||
assert c._nodes["node-a"].workstreams["ws1"]["name"] == "existing"
|
||||
|
||||
def test_poll_403_preserves_workstreams(self):
|
||||
"""A 403 should also preserve state and mark unreachable."""
|
||||
c = _make_collector()
|
||||
c._nodes["node-a"] = NodeSnapshot(
|
||||
node_id="node-a",
|
||||
server_url="http://a:8080",
|
||||
reachable=True,
|
||||
workstreams={"ws1": {"id": "ws1", "name": "keep-me", "state": "running"}},
|
||||
)
|
||||
|
||||
import httpx as _httpx
|
||||
|
||||
mock_response = _httpx.Response(
|
||||
403,
|
||||
json={"error": "Forbidden"},
|
||||
request=_httpx.Request("GET", "http://a:8080/v1/api/dashboard"),
|
||||
)
|
||||
|
||||
with patch.object(c._http_client, "get", return_value=mock_response):
|
||||
c._poll_all_nodes()
|
||||
|
||||
assert c._nodes["node-a"].reachable is False
|
||||
assert "ws1" in c._nodes["node-a"].workstreams
|
||||
|
||||
|
||||
class TestCollectorEvents:
|
||||
"""Real-time event handling from cluster channel."""
|
||||
@@ -1050,6 +1101,39 @@ class TestConsoleWorkstreamCreation:
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["target_node"] == "pool"
|
||||
|
||||
def test_create_with_resume_ws_directed(self, client_and_broker, mock_collector):
|
||||
"""resume_ws is forwarded in directed dispatch."""
|
||||
client, broker = client_and_broker
|
||||
resp = client.post(
|
||||
"/v1/api/cluster/workstreams/new",
|
||||
json={"node_id": "node-a", "resume_ws": "old-ws-id-123"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
msg = json.loads(broker.push_inbound.call_args[0][0])
|
||||
assert msg["resume_ws"] == "old-ws-id-123"
|
||||
|
||||
def test_create_with_resume_ws_pool(self, client_and_broker, mock_collector):
|
||||
"""resume_ws is forwarded in pool dispatch."""
|
||||
client, broker = client_and_broker
|
||||
resp = client.post(
|
||||
"/v1/api/cluster/workstreams/new",
|
||||
json={"node_id": "pool", "resume_ws": "old-ws-id-456"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
msg = json.loads(broker.push_inbound.call_args[0][0])
|
||||
assert msg["resume_ws"] == "old-ws-id-456"
|
||||
|
||||
def test_create_with_resume_ws_auto(self, client_and_broker, mock_collector):
|
||||
"""resume_ws is forwarded in auto-select dispatch."""
|
||||
client, broker = client_and_broker
|
||||
resp = client.post(
|
||||
"/v1/api/cluster/workstreams/new",
|
||||
json={"resume_ws": "old-ws-id-789"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
msg = json.loads(broker.push_inbound.call_args[0][0])
|
||||
assert msg["resume_ws"] == "old-ws-id-789"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proxy tests
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -623,6 +623,7 @@ class TestServerHealthMetrics:
|
||||
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_all.return_value = [mock_ws]
|
||||
mock_mgr.max_workstreams = 10
|
||||
|
||||
app = srv_mod.create_app(
|
||||
workstreams=mock_mgr,
|
||||
@@ -799,6 +800,7 @@ class TestServerRateLimiting:
|
||||
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.list_all.return_value = [mock_ws]
|
||||
mock_mgr.max_workstreams = 10
|
||||
|
||||
app = srv_mod.create_app(
|
||||
workstreams=mock_mgr,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+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
|
||||
|
||||
+103
-4
@@ -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."""
|
||||
@@ -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.8.2"
|
||||
__version__ = "0.8.4"
|
||||
|
||||
@@ -137,6 +137,9 @@ class ConsoleCreateWsRequest(BaseModel):
|
||||
default="", description="Optional first message sent after creation"
|
||||
)
|
||||
skill: str = Field(default="", description="Skill name (replaces default skills)")
|
||||
resume_ws: str = Field(
|
||||
default="", description="Workstream ID to resume (loads previous conversation)"
|
||||
)
|
||||
|
||||
|
||||
class ConsoleCreateWsResponse(BaseModel):
|
||||
@@ -307,6 +310,8 @@ 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 = ""
|
||||
@@ -337,6 +342,8 @@ class CreateSkillRequest(BaseModel):
|
||||
notify_on_complete: str = "{}"
|
||||
enabled: bool = True
|
||||
allowed_tools: str = "[]"
|
||||
license: str = ""
|
||||
compatibility: str = ""
|
||||
|
||||
|
||||
class UpdateSkillRequest(BaseModel):
|
||||
@@ -360,6 +367,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):
|
||||
|
||||
@@ -157,8 +157,10 @@ class McpStatus(BaseModel):
|
||||
class HealthResponse(BaseModel):
|
||||
status: str = Field(examples=["ok", "degraded"])
|
||||
version: str = ""
|
||||
node_id: str = ""
|
||||
uptime_seconds: float = 0.0
|
||||
model: str = ""
|
||||
max_ws: int = Field(default=10, description="Maximum concurrent workstreams")
|
||||
workstreams: WorkstreamCounts = WorkstreamCounts()
|
||||
backend: BackendStatus | None = None
|
||||
mcp: McpStatus | None = None
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import TYPE_CHECKING, Any
|
||||
import httpx
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from turnstone.core.auth import ServiceTokenManager
|
||||
from turnstone.mq.broker import RedisBroker
|
||||
|
||||
log = logging.getLogger("turnstone.console.collector")
|
||||
@@ -58,6 +59,7 @@ class ClusterCollector:
|
||||
max_poll_workers: int = 50,
|
||||
http_timeout: float = 5.0,
|
||||
auth_token: str = "",
|
||||
token_manager: ServiceTokenManager | None = None,
|
||||
):
|
||||
self._broker = broker
|
||||
self._prefix = prefix
|
||||
@@ -65,16 +67,20 @@ class ClusterCollector:
|
||||
self._discovery_interval = discovery_interval
|
||||
self._max_poll_workers = max_poll_workers
|
||||
self._http_timeout = http_timeout
|
||||
self._token_manager = token_manager
|
||||
# Static auth header — only used when no token_manager is present.
|
||||
# When a token_manager exists, auth is injected per-request via
|
||||
# extra_headers in _poll_all_nodes to avoid stale JWT expiry.
|
||||
self._static_auth: dict[str, str] | None = None
|
||||
if auth_token and token_manager is None:
|
||||
self._static_auth = {"Authorization": f"Bearer {auth_token}"}
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._nodes: dict[str, NodeSnapshot] = {}
|
||||
self._running = False
|
||||
self._threads: list[threading.Thread] = []
|
||||
self._poll_pool = ThreadPoolExecutor(max_workers=max_poll_workers)
|
||||
headers = {}
|
||||
if auth_token:
|
||||
headers["Authorization"] = f"Bearer {auth_token}"
|
||||
self._http_client = httpx.Client(timeout=http_timeout, headers=headers)
|
||||
self._http_client = httpx.Client(timeout=http_timeout)
|
||||
|
||||
# SSE fan-out to browser clients
|
||||
self._listeners: list[queue.Queue[dict[str, Any]]] = []
|
||||
@@ -212,6 +218,7 @@ class ClusterCollector:
|
||||
self._nodes[nid].server_url = meta.get(
|
||||
"server_url", self._nodes[nid].server_url
|
||||
)
|
||||
self._nodes[nid].max_ws = meta.get("max_ws", self._nodes[nid].max_ws)
|
||||
|
||||
# Remove nodes whose heartbeats expired
|
||||
lost = [nid for nid in self._nodes if nid not in active_ids]
|
||||
@@ -235,6 +242,14 @@ class ClusterCollector:
|
||||
|
||||
def _poll_all_nodes(self) -> None:
|
||||
"""Fetch dashboard data from all known nodes in parallel."""
|
||||
# Snapshot current auth header for this poll cycle. Per-request
|
||||
# headers avoid mutating shared client state (thread-safe).
|
||||
if self._token_manager is not None:
|
||||
poll_headers: dict[str, str] | None = {
|
||||
"Authorization": f"Bearer {self._token_manager.token}"
|
||||
}
|
||||
else:
|
||||
poll_headers = self._static_auth
|
||||
with self._lock:
|
||||
targets = [
|
||||
(n.node_id, n.server_url)
|
||||
@@ -245,25 +260,44 @@ class ClusterCollector:
|
||||
if not targets:
|
||||
return
|
||||
|
||||
futures = {self._poll_pool.submit(self._fetch_node, nid, url): nid for nid, url in targets}
|
||||
futures = {
|
||||
self._poll_pool.submit(self._fetch_node, nid, url, poll_headers): nid
|
||||
for nid, url in targets
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
nid = futures[future]
|
||||
try:
|
||||
dashboard, health = future.result()
|
||||
self._apply_poll(nid, dashboard, health)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code in (401, 403):
|
||||
log.warning(
|
||||
"Auth failure polling node %s: HTTP %d", nid, exc.response.status_code
|
||||
)
|
||||
else:
|
||||
log.debug("Failed to poll node %s: HTTP %d", nid, exc.response.status_code)
|
||||
with self._lock:
|
||||
if nid in self._nodes:
|
||||
self._nodes[nid].reachable = False
|
||||
except Exception:
|
||||
log.debug("Failed to poll node %s", nid)
|
||||
with self._lock:
|
||||
if nid in self._nodes:
|
||||
self._nodes[nid].reachable = False
|
||||
|
||||
def _fetch_node(self, node_id: str, server_url: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
def _fetch_node(
|
||||
self,
|
||||
node_id: str,
|
||||
server_url: str,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Fetch /v1/api/dashboard and /health from a single node."""
|
||||
base = server_url.rstrip("/")
|
||||
dash_resp = self._http_client.get(f"{base}/v1/api/dashboard")
|
||||
dash_resp = self._http_client.get(f"{base}/v1/api/dashboard", headers=extra_headers)
|
||||
dash_resp.raise_for_status()
|
||||
dash_data: dict[str, Any] = dash_resp.json()
|
||||
try:
|
||||
health_resp = self._http_client.get(f"{base}/health")
|
||||
health_resp = self._http_client.get(f"{base}/health", headers=extra_headers)
|
||||
health_data: dict[str, Any] = health_resp.json()
|
||||
except Exception:
|
||||
health_data = {}
|
||||
|
||||
+58
-18
@@ -371,6 +371,7 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
raw_model = body.get("model", "")
|
||||
raw_initial_message = body.get("initial_message", "")
|
||||
raw_skill = body.get("skill", "")
|
||||
raw_resume_ws = body.get("resume_ws", "")
|
||||
if not isinstance(raw_node_id, str):
|
||||
raw_node_id = "" if raw_node_id is None else None
|
||||
if not isinstance(raw_name, str):
|
||||
@@ -381,15 +382,20 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
raw_initial_message = "" if raw_initial_message is None else None
|
||||
if not isinstance(raw_skill, str):
|
||||
raw_skill = "" if raw_skill is None else None
|
||||
if not isinstance(raw_resume_ws, str):
|
||||
raw_resume_ws = "" if raw_resume_ws is None else None
|
||||
if (
|
||||
raw_node_id is None
|
||||
or raw_name is None
|
||||
or raw_model is None
|
||||
or raw_initial_message is None
|
||||
or raw_skill is None
|
||||
or raw_resume_ws is None
|
||||
):
|
||||
return JSONResponse(
|
||||
{"error": "node_id, name, model, initial_message, and skill must be strings"},
|
||||
{
|
||||
"error": "node_id, name, model, initial_message, skill, and resume_ws must be strings"
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
node_id = raw_node_id
|
||||
@@ -397,6 +403,7 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
model = raw_model[:128]
|
||||
initial_message = raw_initial_message[:4096]
|
||||
skill = raw_skill[:256]
|
||||
resume_ws = raw_resume_ws[:64]
|
||||
|
||||
from turnstone.mq.protocol import CreateWorkstreamMessage
|
||||
|
||||
@@ -407,6 +414,7 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
model=model,
|
||||
initial_message=initial_message,
|
||||
skill=skill,
|
||||
resume_ws=resume_ws,
|
||||
)
|
||||
broker.push_inbound(msg.to_json())
|
||||
log.debug("Pool dispatch: correlation_id=%s name=%r", msg.correlation_id, name)
|
||||
@@ -435,6 +443,7 @@ async def create_workstream(request: Request) -> JSONResponse:
|
||||
target_node=node_id,
|
||||
initial_message=initial_message,
|
||||
skill=skill,
|
||||
resume_ws=resume_ws,
|
||||
)
|
||||
broker.push_inbound(msg.to_json(), node_id=node_id)
|
||||
|
||||
@@ -670,17 +679,13 @@ async def _proxy_sse(
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
# Create async HTTP client for proxy routes
|
||||
headers: dict[str, str] = {}
|
||||
token = app.state.proxy_auth_token
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
app.state.proxy_client = httpx.AsyncClient(timeout=30, headers=headers)
|
||||
# Separate client for SSE streams — longer read timeout, shared connection pool
|
||||
# Create async HTTP clients for proxy routes. Auth headers are NOT baked
|
||||
# in — _proxy_auth_headers() injects a fresh token per-request so JWTs
|
||||
# auto-rotate via ServiceTokenManager instead of expiring after 1 hour.
|
||||
app.state.proxy_client = httpx.AsyncClient(timeout=30)
|
||||
app.state.proxy_sse_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=5, read=30, write=5, pool=5),
|
||||
limits=httpx.Limits(keepalive_expiry=30),
|
||||
headers=headers,
|
||||
)
|
||||
# Start scheduler if configured
|
||||
scheduler = getattr(app.state, "scheduler", None)
|
||||
@@ -2134,6 +2139,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.
|
||||
@@ -2286,6 +2309,8 @@ def _skill_to_response(r: dict[str, Any], resource_count: int = 0) -> dict[str,
|
||||
"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", ""),
|
||||
@@ -2370,6 +2395,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):
|
||||
@@ -2418,6 +2445,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,
|
||||
@@ -2456,8 +2485,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):
|
||||
@@ -2497,6 +2525,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):
|
||||
@@ -2509,6 +2541,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
|
||||
@@ -2526,7 +2565,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,
|
||||
@@ -3203,6 +3242,8 @@ async def admin_skill_install(request: Request) -> JSONResponse:
|
||||
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,
|
||||
@@ -4818,13 +4859,13 @@ def main() -> None:
|
||||
audience=JWT_AUD_SERVER,
|
||||
expiry_hours=1,
|
||||
)
|
||||
collector_token = collector_token_mgr.token
|
||||
log.info("console.collector_jwt_minted")
|
||||
log.info("console.collector_token_manager_created")
|
||||
|
||||
collector = ClusterCollector(
|
||||
broker=broker,
|
||||
poll_interval=args.poll_interval,
|
||||
auth_token=collector_token,
|
||||
auth_token=collector_token if collector_token_mgr is None else "",
|
||||
token_manager=collector_token_mgr,
|
||||
)
|
||||
collector.start()
|
||||
|
||||
@@ -4862,8 +4903,7 @@ def main() -> None:
|
||||
audience=JWT_AUD_SERVER,
|
||||
expiry_hours=1,
|
||||
)
|
||||
proxy_token = proxy_token_mgr.token
|
||||
log.info("console.proxy_jwt_minted")
|
||||
log.info("console.proxy_token_manager_created")
|
||||
|
||||
from turnstone.core.web_helpers import parse_cors_origins
|
||||
|
||||
@@ -4875,7 +4915,7 @@ def main() -> None:
|
||||
auth_config=auth_config,
|
||||
jwt_secret=jwt_secret,
|
||||
auth_storage=auth_storage,
|
||||
proxy_auth_token=proxy_token,
|
||||
proxy_auth_token=proxy_token if proxy_token_mgr is None else "",
|
||||
proxy_token_mgr=proxy_token_mgr,
|
||||
cors_origins=cors_origins,
|
||||
)
|
||||
|
||||
@@ -768,7 +768,7 @@ function _renderGovSkills(items) {
|
||||
t.resource_count +
|
||||
" res</span>";
|
||||
}
|
||||
var editDisabled = t.readonly ? " disabled" : "";
|
||||
var editLabel = t.readonly ? "view" : "edit";
|
||||
var deleteDisabled = "";
|
||||
html +=
|
||||
'<div class="admin-row" role="listitem">' +
|
||||
@@ -794,9 +794,9 @@ function _renderGovSkills(items) {
|
||||
'<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="' +
|
||||
@@ -870,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)";
|
||||
@@ -888,11 +891,9 @@ 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 = [];
|
||||
@@ -949,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)
|
||||
@@ -1052,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");
|
||||
@@ -1086,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");
|
||||
@@ -1180,12 +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, tmpl.readonly || false);
|
||||
_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() {
|
||||
@@ -1466,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)
|
||||
|
||||
@@ -848,61 +848,100 @@ 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">optional bundled files (scripts, references, assets)</span></summary>
|
||||
<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>
|
||||
@@ -923,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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
+108
-43
@@ -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,7 +347,6 @@ 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
|
||||
@@ -360,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}
|
||||
@@ -372,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(
|
||||
@@ -512,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
|
||||
@@ -615,10 +628,14 @@ class ChatSession:
|
||||
user_msg = ""
|
||||
asst_msg = ""
|
||||
for m in self.messages:
|
||||
content = m.get("content") or ""
|
||||
# Handle multi-part content (vision messages)
|
||||
if isinstance(content, list):
|
||||
content = " ".join(p.get("text", "") for p in content if isinstance(p, dict))
|
||||
if m["role"] == "user" and not user_msg:
|
||||
user_msg = (m.get("content") or "")[:300]
|
||||
user_msg = content[:300]
|
||||
elif m["role"] == "assistant" and not asst_msg:
|
||||
asst_msg = (m.get("content") or "")[:200]
|
||||
asst_msg = content[:200]
|
||||
if user_msg and asst_msg:
|
||||
break
|
||||
if not user_msg:
|
||||
@@ -654,8 +671,9 @@ class ChatSession:
|
||||
title = raw.split("\n")[0].strip().strip('"').strip("'")
|
||||
if title:
|
||||
update_workstream_title(self._ws_id, title[:80])
|
||||
self.ui.on_rename(title[:80])
|
||||
except Exception:
|
||||
pass # Title generation is non-critical
|
||||
log.debug("Title generation failed for ws=%s", self._ws_id, exc_info=True)
|
||||
|
||||
def resume(self, ws_id: str) -> bool:
|
||||
"""Load messages from a previous workstream and resume it.
|
||||
@@ -856,6 +874,28 @@ class ChatSession:
|
||||
)
|
||||
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)
|
||||
@@ -915,19 +955,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."""
|
||||
@@ -1936,7 +1986,7 @@ class ChatSession:
|
||||
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 == "load_skill":
|
||||
elif name == "skill":
|
||||
it["func_args"] = {"action": it.get("action", ""), "name": it.get("name", "")}
|
||||
elif name == "watch":
|
||||
it["func_args"] = {
|
||||
@@ -2057,8 +2107,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])]
|
||||
@@ -2203,7 +2262,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:
|
||||
@@ -3027,10 +3086,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":
|
||||
@@ -3038,20 +3097,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,
|
||||
}
|
||||
@@ -3060,26 +3119,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"]
|
||||
|
||||
@@ -3088,12 +3147,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)
|
||||
@@ -3106,7 +3165,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"
|
||||
@@ -3116,7 +3175,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
|
||||
@@ -3160,7 +3219,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):", ""]
|
||||
@@ -3182,7 +3241,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 ----------------------------------------------
|
||||
@@ -3671,6 +3730,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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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."""
|
||||
...
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -107,6 +107,11 @@ class WorkstreamManager:
|
||||
self._evictions: int = 0
|
||||
self._last_evicted: Workstream | None = None
|
||||
|
||||
@property
|
||||
def max_workstreams(self) -> int:
|
||||
"""Configured maximum concurrent workstreams."""
|
||||
return self._max_workstreams
|
||||
|
||||
@property
|
||||
def eviction_count(self) -> int:
|
||||
"""Number of workstreams auto-evicted by ``create()``."""
|
||||
|
||||
+25
-2
@@ -85,6 +85,7 @@ class Bridge:
|
||||
self._approval_timeout = approval_timeout
|
||||
self._prefix = prefix
|
||||
self._node_id = node_id # resolved in run() from server /health
|
||||
self._server_max_ws: int = 10 # resolved in _fetch_node_id() from server /health
|
||||
self._heartbeat_ttl = heartbeat_ttl
|
||||
self._started_at = time.time()
|
||||
self._auth_token = auth_token
|
||||
@@ -140,6 +141,15 @@ class Bridge:
|
||||
|
||||
# -- public entry point --------------------------------------------------
|
||||
|
||||
def _fetch_server_metadata(self) -> None:
|
||||
"""Fetch max_ws from server /health (best-effort, non-blocking)."""
|
||||
try:
|
||||
resp = self._http.get("/health")
|
||||
if resp.status_code == 200:
|
||||
self._server_max_ws = resp.json().get("max_ws", 10)
|
||||
except Exception:
|
||||
log.debug("Failed to fetch server metadata", exc_info=True)
|
||||
|
||||
def _fetch_node_id(self) -> str:
|
||||
"""Retrieve node_id from server /health with capped exponential backoff.
|
||||
|
||||
@@ -161,6 +171,7 @@ class Bridge:
|
||||
data = resp.json()
|
||||
nid = data.get("node_id", "")
|
||||
if nid:
|
||||
self._server_max_ws = data.get("max_ws", 10)
|
||||
return str(nid)
|
||||
log.warning("Server /health missing node_id (attempt %d)", attempt)
|
||||
except SystemExit:
|
||||
@@ -178,10 +189,18 @@ class Bridge:
|
||||
"""Block until shutdown (KeyboardInterrupt)."""
|
||||
if not self._node_id:
|
||||
self._node_id = self._fetch_node_id()
|
||||
else:
|
||||
# node_id was pre-set — still need to fetch max_ws from server
|
||||
self._fetch_server_metadata()
|
||||
from turnstone.core.log import ctx_node_id
|
||||
|
||||
ctx_node_id.set(self._node_id)
|
||||
log.info("Bridge starting — node=%s server=%s", self._node_id, self._server_url)
|
||||
log.info(
|
||||
"Bridge starting — node=%s server=%s max_ws=%d",
|
||||
self._node_id,
|
||||
self._server_url,
|
||||
self._server_max_ws,
|
||||
)
|
||||
self._recover_workstreams()
|
||||
|
||||
heartbeat_t = threading.Thread(
|
||||
@@ -879,7 +898,11 @@ class Bridge:
|
||||
while self._running:
|
||||
self._broker.register_node(
|
||||
self._node_id,
|
||||
{"server_url": self._server_url, "started": self._started_at},
|
||||
{
|
||||
"server_url": self._server_url,
|
||||
"started": self._started_at,
|
||||
"max_ws": self._server_max_ws,
|
||||
},
|
||||
ttl=self._heartbeat_ttl,
|
||||
)
|
||||
time.sleep(self._heartbeat_ttl / 2)
|
||||
|
||||
@@ -135,6 +135,7 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
model: str = "",
|
||||
initial_message: str = "",
|
||||
skill: str = "",
|
||||
resume_ws: str = "",
|
||||
) -> ConsoleCreateWsResponse:
|
||||
body: dict[str, Any] = {}
|
||||
if node_id:
|
||||
@@ -147,6 +148,8 @@ class AsyncTurnstoneConsole(_BaseClient):
|
||||
body["initial_message"] = initial_message
|
||||
if skill:
|
||||
body["skill"] = skill
|
||||
if resume_ws:
|
||||
body["resume_ws"] = resume_ws
|
||||
return await self._request(
|
||||
"POST",
|
||||
"/v1/api/cluster/workstreams/new",
|
||||
@@ -880,6 +883,7 @@ class TurnstoneConsole:
|
||||
model: str = "",
|
||||
initial_message: str = "",
|
||||
skill: str = "",
|
||||
resume_ws: str = "",
|
||||
) -> ConsoleCreateWsResponse:
|
||||
return self._runner.run(
|
||||
self._async.create_workstream(
|
||||
@@ -888,6 +892,7 @@ class TurnstoneConsole:
|
||||
model=model,
|
||||
initial_message=initial_message,
|
||||
skill=skill,
|
||||
resume_ws=resume_ws,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -990,6 +990,7 @@ async def health(request: Request) -> JSONResponse:
|
||||
"node_id": getattr(request.app.state, "node_id", ""),
|
||||
"uptime_seconds": round(time.monotonic() - _metrics.start_time, 2),
|
||||
"model": _metrics.model,
|
||||
"max_ws": mgr.max_workstreams,
|
||||
"workstreams": {"total": len(wss), **states},
|
||||
"backend": {
|
||||
"status": "up" if backend_ok else "down",
|
||||
|
||||
@@ -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",
|
||||
+2004
-1045
File diff suppressed because it is too large
Load Diff
@@ -71,7 +71,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="messages" role="log" aria-live="polite" aria-label="Chat messages"></div>
|
||||
<div id="split-root"></div>
|
||||
|
||||
<!-- New workstream modal -->
|
||||
<div id="new-ws-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="new-ws-title">
|
||||
@@ -104,12 +104,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="input-area">
|
||||
<textarea id="input" rows="1" placeholder="Type a message... (Shift+Enter for newline)" aria-label="Message input"></textarea>
|
||||
<button id="send-btn" onclick="sendMessage()">Send</button>
|
||||
<button id="stop-btn" onclick="cancelGeneration()" style="display:none" aria-label="Stop generation">■ Stop</button>
|
||||
</div>
|
||||
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
<script>
|
||||
window.TURNSTONE_AUTH_TITLE = "turnstone";
|
||||
@@ -120,6 +114,12 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
{ desc: "Close workstream", badge: '<span class="kb-key">Ctrl+W</span>' },
|
||||
{ desc: "Switch to tab 1\u20139", badge: '<span class="kb-key">Ctrl+1</span>\u2026<span class="kb-key">9</span>' }
|
||||
]},
|
||||
{ title: "Split panes", keys: [
|
||||
{ desc: "Split right", badge: '<span class="kb-key">Ctrl+\\</span>' },
|
||||
{ desc: "Split down", badge: '<span class="kb-key">Ctrl+Shift+\\</span>' },
|
||||
{ desc: "Close pane", badge: '<span class="kb-key">Ctrl+Shift+W</span>' },
|
||||
{ desc: "Cycle pane focus", badge: '<span class="kb-key">Ctrl+Alt+\u2190</span> <span class="kb-key">\u2192</span>' }
|
||||
]},
|
||||
{ title: "Tool approval", keys: [
|
||||
{ desc: "Approve", badge: '<span class="kb-key">y</span> / <span class="kb-key">Enter</span>' },
|
||||
{ desc: "Deny", badge: '<span class="kb-key">n</span> / <span class="kb-key">Esc</span>' },
|
||||
|
||||
+160
-13
@@ -190,10 +190,155 @@
|
||||
}
|
||||
#new-tab-btn:hover { background: var(--bg-highlight); color: var(--accent); border-color: var(--accent); }
|
||||
|
||||
/* ==========================================================================
|
||||
Split panes
|
||||
========================================================================== */
|
||||
#split-root {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.split-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.split-horizontal { flex-direction: row; }
|
||||
.split-vertical { flex-direction: column; }
|
||||
.split-child {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.split-handle {
|
||||
flex: 0 0 4px;
|
||||
background: var(--border);
|
||||
cursor: col-resize;
|
||||
transition: background 0.15s;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
touch-action: none;
|
||||
}
|
||||
.split-handle:hover,
|
||||
.split-handle:active,
|
||||
.split-handle.dragging { background: var(--accent); }
|
||||
.split-vertical > .split-handle { cursor: row-resize; }
|
||||
/* Expand hit area to 12px via pseudoelement */
|
||||
.split-handle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
.split-horizontal > .split-handle::before {
|
||||
top: 0; bottom: 0; left: -4px; right: -4px;
|
||||
}
|
||||
.split-vertical > .split-handle::before {
|
||||
left: 0; right: 0; top: -4px; bottom: -4px;
|
||||
}
|
||||
|
||||
/* Pane */
|
||||
.pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
min-height: 150px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pane.focused { outline: 1px solid var(--accent-dim); outline-offset: -1px; }
|
||||
.multi-pane .pane.focused .pane-header {
|
||||
border-bottom-color: var(--accent);
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
/* Pane header — only visible in multi-pane mode */
|
||||
.pane-header { display: none; }
|
||||
.multi-pane .pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 8px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
min-height: 24px;
|
||||
}
|
||||
.pane-ws-name {
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pane.focused .pane-ws-name { color: var(--accent); }
|
||||
.pane-actions { display: flex; gap: 2px; flex-shrink: 0; }
|
||||
.pane-action-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg-dim);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.15s, color 0.1s, background 0.1s;
|
||||
}
|
||||
.pane.focused .pane-action-btn { opacity: 0.5; }
|
||||
.pane-header:hover .pane-action-btn,
|
||||
.pane-action-btn:focus-visible { opacity: 1; }
|
||||
.pane-action-btn:hover { color: var(--fg-bright); background: var(--bg-highlight); }
|
||||
.pane-close-btn:hover { color: var(--red); }
|
||||
|
||||
/* Pane context menu */
|
||||
.pane-ctx-menu {
|
||||
position: fixed;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
min-width: 200px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 300;
|
||||
overflow: hidden;
|
||||
padding: 4px 0;
|
||||
}
|
||||
[data-theme="light"] .pane-ctx-menu { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); }
|
||||
.pane-ctx-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
padding: 7px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.pane-ctx-item:hover:not(:disabled) { background: var(--bg-highlight); }
|
||||
.pane-ctx-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||
.pane-ctx-item:disabled { color: var(--fg-dim); opacity: 0.4; cursor: default; }
|
||||
.pane-ctx-label { flex: 1; }
|
||||
.pane-ctx-key {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--fg-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pane-ctx-sep { height: 1px; background: var(--border-strong); margin: 4px 0; }
|
||||
|
||||
/* ==========================================================================
|
||||
Messages
|
||||
========================================================================== */
|
||||
#messages {
|
||||
.pane-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -461,7 +606,7 @@ body { position: static; }
|
||||
/* ==========================================================================
|
||||
Input area
|
||||
========================================================================== */
|
||||
#input-area {
|
||||
.pane-input-area {
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-strong);
|
||||
@@ -469,7 +614,7 @@ body { position: static; }
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#input-area textarea {
|
||||
.pane-input {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
@@ -484,8 +629,8 @@ body { position: static; }
|
||||
max-height: 200px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
#input-area textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
|
||||
#input-area button {
|
||||
.pane-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
|
||||
.pane-input-area button {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
@@ -498,11 +643,11 @@ body { position: static; }
|
||||
letter-spacing: 0.02em;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
#input-area button:hover { filter: brightness(1.1); }
|
||||
#input-area button:disabled { opacity: 0.35; cursor: not-allowed; filter: none; }
|
||||
#stop-btn { background: var(--red, #c94040); }
|
||||
#stop-btn:focus-visible { outline: 2px solid var(--fg-bright, #e8ecf4); outline-offset: 2px; }
|
||||
[data-theme="light"] #stop-btn { color: #fff; }
|
||||
.pane-input-area button:hover { filter: brightness(1.1); }
|
||||
.pane-input-area button:disabled { opacity: 0.35; cursor: not-allowed; filter: none; }
|
||||
.pane-stop { background: var(--red, #c94040); }
|
||||
.pane-stop:focus-visible { outline: 2px solid var(--fg-bright, #e8ecf4); outline-offset: 2px; }
|
||||
[data-theme="light"] .pane-stop { color: #fff; }
|
||||
|
||||
/* ==========================================================================
|
||||
Inline approval blocks
|
||||
@@ -768,7 +913,7 @@ body { position: static; }
|
||||
/* ==========================================================================
|
||||
Focus indicators — server-specific overrides
|
||||
========================================================================== */
|
||||
#input-area textarea:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
|
||||
.pane-input:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
|
||||
.approval-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
/* ==========================================================================
|
||||
@@ -1117,11 +1262,13 @@ body { position: static; }
|
||||
.ws-tab, .ws-tab .tab-close, #new-tab-btn,
|
||||
.hmenu-item, .dashboard-card,
|
||||
.approval-btn, .approval-feedback-input,
|
||||
#plan-buttons button, #input-area button,
|
||||
#plan-buttons button, .pane-input-area button,
|
||||
.dashboard-new-btn, .dashboard-input,
|
||||
#health-indicator, #hamburger-btn,
|
||||
#mcp-status, .msg-assistant tbody tr,
|
||||
.msg-assistant .img-placeholder,
|
||||
#new-ws-cancel, #new-ws-submit,
|
||||
#new-ws-box input, #new-ws-box select { transition: none; }
|
||||
#new-ws-box input, #new-ws-box select,
|
||||
.split-handle, .pane-action-btn,
|
||||
.pane-ctx-item { transition: none; }
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.84.0"
|
||||
version = "0.86.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -167,9 +167,9 @@ dependencies = [
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/ea/0869d6df9ef83dcf393aeefc12dd81677d091c6ffc86f783e51cf44062f2/anthropic-0.84.0.tar.gz", hash = "sha256:72f5f90e5aebe62dca316cb013629cfa24996b0f5a4593b8c3d712bc03c43c37", size = 539457, upload-time = "2026-02-25T05:22:38.54Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/7a/8b390dc47945d3169875d342847431e5f7d5fa716b2e37494d57cfc1db10/anthropic-0.86.0.tar.gz", hash = "sha256:60023a7e879aa4fbb1fed99d487fe407b2ebf6569603e5047cfe304cebdaa0e5", size = 583820, upload-time = "2026-03-18T18:43:08.017Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl", hash = "sha256:861c4c50f91ca45f942e091d83b60530ad6d4f98733bfe648065364da05d29e7", size = 455156, upload-time = "2026-02-25T05:22:40.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/5f/67db29c6e5d16c8c9c4652d3efb934d89cb750cad201539141781d8eae14/anthropic-0.86.0-py3-none-any.whl", hash = "sha256:9d2bbd339446acce98858c5627d33056efe01f70435b22b63546fe7edae0cd57", size = 469400, upload-time = "2026-03-18T18:43:06.526Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -431,101 +431,101 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.4"
|
||||
version = "7.13.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/ad/b59e5b451cf7172b8d1043dc0fa718f23aab379bc1521ee13d4bd9bfa960/coverage-7.13.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d490ba50c3f35dd7c17953c68f3270e7ccd1c6642e2d2afe2d8e720b98f5a053", size = 219278, upload-time = "2026-02-09T12:56:31.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/17/0cb7ca3de72e5f4ef2ec2fa0089beafbcaaaead1844e8b8a63d35173d77d/coverage-7.13.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19bc3c88078789f8ef36acb014d7241961dbf883fd2533d18cb1e7a5b4e28b11", size = 219783, upload-time = "2026-02-09T12:56:33.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/63/325d8e5b11e0eaf6d0f6a44fad444ae58820929a9b0de943fa377fe73e85/coverage-7.13.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3998e5a32e62fdf410c0dbd3115df86297995d6e3429af80b8798aad894ca7aa", size = 250200, upload-time = "2026-02-09T12:56:34.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/53/c16972708cbb79f2942922571a687c52bd109a7bd51175aeb7558dff2236/coverage-7.13.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e264226ec98e01a8e1054314af91ee6cde0eacac4f465cc93b03dbe0bce2fd7", size = 252114, upload-time = "2026-02-09T12:56:35.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/c2/7ab36d8b8cc412bec9ea2d07c83c48930eb4ba649634ba00cb7e4e0f9017/coverage-7.13.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3aa4e7b9e416774b21797365b358a6e827ffadaaca81b69ee02946852449f00", size = 254220, upload-time = "2026-02-09T12:56:37.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/4d/cf52c9a3322c89a0e6febdfbc83bb45c0ed3c64ad14081b9503adee702e7/coverage-7.13.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71ca20079dd8f27fcf808817e281e90220475cd75115162218d0e27549f95fef", size = 256164, upload-time = "2026-02-09T12:56:39.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/e9/eb1dd17bd6de8289df3580e967e78294f352a5df8a57ff4671ee5fc3dcd0/coverage-7.13.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e2f25215f1a359ab17320b47bcdaca3e6e6356652e8256f2441e4ef972052903", size = 250325, upload-time = "2026-02-09T12:56:40.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/07/8c1542aa873728f72267c07278c5cc0ec91356daf974df21335ccdb46368/coverage-7.13.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d65b2d373032411e86960604dc4edac91fdfb5dca539461cf2cbe78327d1e64f", size = 251913, upload-time = "2026-02-09T12:56:41.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/d7/c62e2c5e4483a748e27868e4c32ad3daa9bdddbba58e1bc7a15e252baa74/coverage-7.13.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94eb63f9b363180aff17de3e7c8760c3ba94664ea2695c52f10111244d16a299", size = 249974, upload-time = "2026-02-09T12:56:43.323Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/9f/4c5c015a6e98ced54efd0f5cf8d31b88e5504ecb6857585fc0161bb1e600/coverage-7.13.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e856bf6616714c3a9fbc270ab54103f4e685ba236fa98c054e8f87f266c93505", size = 253741, upload-time = "2026-02-09T12:56:45.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/59/0f4eef89b9f0fcd9633b5d350016f54126ab49426a70ff4c4e87446cabdc/coverage-7.13.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:65dfcbe305c3dfe658492df2d85259e0d79ead4177f9ae724b6fb245198f55d6", size = 249695, upload-time = "2026-02-09T12:56:46.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2c/b7476f938deb07166f3eb281a385c262675d688ff4659ad56c6c6b8e2e70/coverage-7.13.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b507778ae8a4c915436ed5c2e05b4a6cecfa70f734e19c22a005152a11c7b6a9", size = 250599, upload-time = "2026-02-09T12:56:48.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/34/c3420709d9846ee3785b9f2831b4d94f276f38884032dca1457fa83f7476/coverage-7.13.4-cp311-cp311-win32.whl", hash = "sha256:784fc3cf8be001197b652d51d3fd259b1e2262888693a4636e18879f613a62a9", size = 221780, upload-time = "2026-02-09T12:56:50.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/08/3d9c8613079d2b11c185b865de9a4c1a68850cfda2b357fae365cf609f29/coverage-7.13.4-cp311-cp311-win_amd64.whl", hash = "sha256:2421d591f8ca05b308cf0092807308b2facbefe54af7c02ac22548b88b95c98f", size = 222715, upload-time = "2026-02-09T12:56:51.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/1a/54c3c80b2f056164cc0a6cdcb040733760c7c4be9d780fe655f356f433e4/coverage-7.13.4-cp311-cp311-win_arm64.whl", hash = "sha256:79e73a76b854d9c6088fe5d8b2ebe745f8681c55f7397c3c0a016192d681045f", size = 221385, upload-time = "2026-02-09T12:56:53.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -1319,7 +1319,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.28.0"
|
||||
version = "2.29.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -1331,9 +1331,9 @@ dependencies = [
|
||||
{ name = "tqdm" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/56/87/eb0abb4ef88ddb95b3c13149384c4c288f584f3be17d6a4f63f8c3e3c226/openai-2.28.0.tar.gz", hash = "sha256:bb7fdff384d2a787fa82e8822d1dd3c02e8cf901d60f1df523b7da03cbb6d48d", size = 670334, upload-time = "2026-03-13T19:56:27.306Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/15/203d537e58986b5673e7f232453a2a2f110f22757b15921cbdeea392e520/openai-2.29.0.tar.gz", hash = "sha256:32d09eb2f661b38d3edd7d7e1a2943d1633f572596febe64c0cd370c86d52bec", size = 671128, upload-time = "2026-03-17T17:53:49.599Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/5a/df122348638885526e53140e9c6b0d844af7312682b3bde9587eebc28b47/openai-2.28.0-py3-none-any.whl", hash = "sha256:79aa5c45dba7fef84085701c235cf13ba88485e1ef4f8dfcedc44fc2a698fc1d", size = 1141218, upload-time = "2026-03-13T19:56:25.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl", hash = "sha256:b7c5de513c3286d17c5e29b92c4c98ceaf0d775244ac8159aeb1bddf840eb42a", size = 1141533, upload-time = "2026-03-17T17:53:47.348Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2067,15 +2067,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.3.2"
|
||||
version = "3.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "starlette" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2168,7 +2168,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "turnstone"
|
||||
version = "0.8.2"
|
||||
version = "0.8.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
|
||||
Reference in New Issue
Block a user