mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
dc464ac313
Brings skills implementation into full compliance with agentskills.io: Parser: - Read `allowed-tools` (hyphenated, standard) only; stored as `allowed_tools` internally — no underscore fallback - Reject consecutive hyphens in skill names - Extract author/version from standard `metadata:` map with top-level fallback; null-safe (no "None" string for bare YAML keys) - Truncate description at 1024 chars, compatibility at 500 chars (spec caps) with log warnings - Lenient parsing mode (lenient=True) for cross-client import: sanitizes names, returns None on skip, malformed-YAML colon-value retry - Type overloads: strict mode returns ParsedSkill, lenient returns ParsedSkill | None Session: - `<available-skills>` XML catalog in system messages for activation="search" skills (disabled ones filtered out, capped at 30) Tool rename: - `load_skill` tool → `skill` (JSON, session preparers/executors, approval labels, tests, docs) Storage (migration 023): - Add `license` and `compatibility` columns to prompt_templates - skill_license / compatibility params on create_prompt_template across protocol, SQLite, PostgreSQL backends - Add to SKILL_MUTABLE for update_prompt_template API + server: - SkillInfo, CreateSkillRequest, UpdateSkillRequest: license + compatibility fields - Create/update/install endpoints extract and persist both fields - Install endpoint maps parsed.license + parsed.compatibility from imported SKILL.md (previously discarded) - _skill_to_response() includes both fields Admin UI: - Create + edit modals: version, license, compatibility fields - Readonly (imported) skills: "edit" → "view" button, modal title "View Skill", all fields disabled, Save hidden, Cancel → "Close", collapsibles auto-expand, focus on Close button - :disabled CSS for dark-theme modal inputs (bg-highlight, cursor not-allowed, dimmed text) - Fix addEventListener stacking on auto-approve checkboxes → .onchange SDK: license + compatibility on SkillInfo, CreateSkillRequest, UpdateSkillRequest TypeScript interfaces Docs: governance.md, judge.md, tools.md, README, diagram updated
190 lines
5.8 KiB
Python
190 lines
5.8 KiB
Python
"""Shared utilities for storage backends."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Row helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def row_to_dict(row: Any, *bool_fields: str) -> dict[str, Any]:
|
|
"""Convert a SQLAlchemy row to a dict, casting named fields to bool."""
|
|
d = dict(row._mapping)
|
|
for key in bool_fields:
|
|
if key in d:
|
|
d[key] = bool(d[key])
|
|
return d
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Field allowlists for governance update methods
|
|
# ---------------------------------------------------------------------------
|
|
|
|
ROLE_MUTABLE = frozenset({"display_name", "permissions"})
|
|
ORG_MUTABLE = frozenset({"display_name", "settings"})
|
|
POLICY_MUTABLE = frozenset({"name", "tool_pattern", "action", "priority", "enabled"})
|
|
SKILL_MUTABLE = frozenset(
|
|
{
|
|
"name",
|
|
"content",
|
|
"category",
|
|
"variables",
|
|
"is_default",
|
|
"description",
|
|
"tags",
|
|
"source_url",
|
|
"version",
|
|
"author",
|
|
"activation",
|
|
"token_estimate",
|
|
"model",
|
|
"auto_approve",
|
|
"temperature",
|
|
"reasoning_effort",
|
|
"max_tokens",
|
|
"token_budget",
|
|
"agent_max_turns",
|
|
"notify_on_complete",
|
|
"enabled",
|
|
"allowed_tools",
|
|
"license",
|
|
"compatibility",
|
|
"scan_version",
|
|
"scan_status",
|
|
"scan_report",
|
|
}
|
|
)
|
|
STRUCTURED_MEMORY_MUTABLE = frozenset({"content", "description", "type"})
|
|
MCP_SERVER_MUTABLE = frozenset(
|
|
{
|
|
"name",
|
|
"transport",
|
|
"command",
|
|
"args",
|
|
"url",
|
|
"headers",
|
|
"env",
|
|
"auto_approve",
|
|
"enabled",
|
|
"registry_name",
|
|
"registry_version",
|
|
"registry_meta",
|
|
}
|
|
)
|
|
VERDICT_MUTABLE = frozenset(
|
|
{
|
|
"user_decision",
|
|
"intent_summary",
|
|
"risk_level",
|
|
"confidence",
|
|
"recommendation",
|
|
"reasoning",
|
|
"evidence",
|
|
"tier",
|
|
"judge_model",
|
|
"latency_ms",
|
|
}
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Skill scanning helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def scan_skill_content(content: str, allowed_tools: str) -> tuple[str, str, str]:
|
|
"""Run the skill scanner and return ``(scan_status, scan_report_json, scanner_version)``.
|
|
|
|
Uses a lazy import to avoid circular dependencies. Silently returns
|
|
empty results on import or scan errors so skill creation is never
|
|
blocked by a scanner bug.
|
|
"""
|
|
try:
|
|
from turnstone.core.skill_scanner import SCANNER_VERSION, scan_skill
|
|
|
|
tools: list[str] | None = None
|
|
if allowed_tools and allowed_tools.strip() != "[]":
|
|
try:
|
|
parsed = json.loads(allowed_tools)
|
|
if isinstance(parsed, list):
|
|
tools = [str(x) for x in parsed if isinstance(x, str)]
|
|
if not tools:
|
|
tools = None
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
result = scan_skill(content, tools)
|
|
return result.tier, json.dumps(result.to_dict(), ensure_ascii=False), SCANNER_VERSION
|
|
except Exception:
|
|
log.debug("skill_scanner: scan failed", exc_info=True)
|
|
return "", "{}", ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Message reconstruction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def reconstruct_messages(rows: list[Any], ws_id: str) -> list[dict[str, Any]]:
|
|
"""Reconstruct OpenAI message format from stored conversation rows.
|
|
|
|
Each *row* is a 7-element tuple of ``(role, content, tool_name,
|
|
tool_args, tool_call_id, provider_data, tool_calls_json)`` ordered
|
|
chronologically by row ID.
|
|
|
|
Post-migration 013 the only roles are ``user``, ``assistant``, and
|
|
``tool``. Assistant messages carry their ``tool_calls`` as a JSON
|
|
column, so no heuristic merging is needed.
|
|
"""
|
|
messages: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
role, content, _tool_name, _tool_args, tc_id, provider_data, tool_calls_json = row
|
|
|
|
if role == "user":
|
|
messages.append({"role": "user", "content": content or ""})
|
|
|
|
elif role == "assistant":
|
|
msg: dict[str, Any] = {"role": "assistant", "content": content}
|
|
if provider_data:
|
|
with contextlib.suppress(json.JSONDecodeError, TypeError):
|
|
msg["_provider_content"] = json.loads(provider_data)
|
|
if tool_calls_json:
|
|
with contextlib.suppress(json.JSONDecodeError, TypeError):
|
|
msg["tool_calls"] = json.loads(tool_calls_json)
|
|
messages.append(msg)
|
|
|
|
elif role == "tool":
|
|
messages.append(
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": tc_id or "",
|
|
"content": content or "",
|
|
}
|
|
)
|
|
|
|
# Repair: strip trailing incomplete tool call turns
|
|
while messages:
|
|
tail_tools = 0
|
|
for j in range(len(messages) - 1, -1, -1):
|
|
if messages[j].get("role") == "tool":
|
|
tail_tools += 1
|
|
else:
|
|
break
|
|
asst_idx = len(messages) - 1 - tail_tools
|
|
if asst_idx < 0:
|
|
break
|
|
asst = messages[asst_idx]
|
|
if asst.get("role") != "assistant" or not asst.get("tool_calls"):
|
|
break
|
|
if tail_tools >= len(asst["tool_calls"]):
|
|
break
|
|
del messages[asst_idx:]
|
|
|
|
return messages
|