1295 lines
48 KiB
Python
1295 lines
48 KiB
Python
"""Discovery, validation, and Pi-style execution for local Skill packages."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import html
|
|
import json
|
|
import os
|
|
import re
|
|
import threading
|
|
from copy import deepcopy
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from .config_utils import get_config_section
|
|
from .request_utils import FunctionToolsRejected, ToolChoiceRejected
|
|
|
|
|
|
SKILL_OPTIONS_TYPE = "ycyy.openai_text_skill_options"
|
|
SKILL_OPTIONS_SCHEMA_VERSION = 1
|
|
REFERENCE_EXTENSIONS = {".md", ".txt", ".json", ".yaml", ".yml"}
|
|
DEFAULT_LIMITS = {
|
|
"max_skill_md_bytes": 128 * 1024,
|
|
"max_reference_file_bytes": 256 * 1024,
|
|
"max_reference_total_bytes": 1024 * 1024,
|
|
"max_disclosed_bytes_per_execution": 512 * 1024,
|
|
"max_tool_rounds": 8,
|
|
"max_tool_calls_per_execution": 16,
|
|
}
|
|
DEFAULT_ALLOW_CALL = False
|
|
PLUGIN_ROOT = Path(__file__).resolve().parent.parent
|
|
SKILL_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
|
|
|
|
def get_skill_config():
|
|
raw = get_config_section("skills")
|
|
raw_config = dict(raw) if isinstance(raw, dict) else {}
|
|
paths = raw_config.get("paths", ["skills"])
|
|
if not isinstance(paths, list) or not all(isinstance(path, str) for path in paths):
|
|
raise ValueError("skills.paths must be an array of strings")
|
|
config = {"paths": paths}
|
|
allow_call = raw_config.get("allow_call", DEFAULT_ALLOW_CALL)
|
|
if not isinstance(allow_call, bool):
|
|
raise ValueError("skills.allow_call must be a boolean")
|
|
config["allow_call"] = allow_call
|
|
# Resource and tool-loop limits are implementation safety constants, not
|
|
# public configuration. Ignore legacy max_* keys in config files.
|
|
config.update(DEFAULT_LIMITS)
|
|
return config
|
|
|
|
|
|
def _safe_decode(data, label):
|
|
try:
|
|
return data.decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
raise ValueError(f"Skill text file is not valid UTF-8: {label}") from exc
|
|
|
|
|
|
def _parse_scalar(value):
|
|
value = value.strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
return value[1:-1]
|
|
return value
|
|
|
|
|
|
def parse_skill_frontmatter(text):
|
|
"""Parse the small, string-only YAML subset used by standard skills."""
|
|
lines = text.splitlines()
|
|
if not lines or lines[0].strip() != "---":
|
|
raise ValueError("SKILL.md must start with YAML front matter")
|
|
end = next((index for index in range(1, len(lines)) if lines[index].strip() == "---"), None)
|
|
if end is None:
|
|
raise ValueError("SKILL.md front matter is not terminated")
|
|
|
|
result = {}
|
|
index = 1
|
|
while index < end:
|
|
line = lines[index]
|
|
if not line.strip() or line.lstrip().startswith("#"):
|
|
index += 1
|
|
continue
|
|
if line[:1].isspace():
|
|
# Nested values for extension metadata are intentionally ignored;
|
|
# the loader only consumes the standard top-level string fields.
|
|
index += 1
|
|
continue
|
|
if ":" not in line:
|
|
raise ValueError(f"Unsupported SKILL.md front matter at line {index + 1}")
|
|
key, raw_value = line.split(":", 1)
|
|
key = key.strip()
|
|
raw_value = raw_value.strip()
|
|
if not key:
|
|
raise ValueError(f"Invalid SKILL.md front matter key at line {index + 1}")
|
|
if raw_value in {"|", "|-", "|+", ">", ">-", ">+"}:
|
|
block = []
|
|
index += 1
|
|
while index < end and (not lines[index].strip() or lines[index][:1].isspace()):
|
|
block.append(lines[index].lstrip())
|
|
index += 1
|
|
result[key] = "\n".join(block).strip() if raw_value.startswith("|") else " ".join(
|
|
part.strip() for part in block if part.strip()
|
|
)
|
|
continue
|
|
result[key] = _parse_scalar(raw_value)
|
|
index += 1
|
|
|
|
for required in ("name", "description"):
|
|
if not isinstance(result.get(required), str) or not result[required].strip():
|
|
raise ValueError(f"SKILL.md front matter requires non-empty '{required}'")
|
|
result[required] = result[required].strip()
|
|
if len(result["name"]) > 64 or not SKILL_NAME_PATTERN.fullmatch(result["name"]):
|
|
raise ValueError(
|
|
"SKILL.md 'name' must be 1-64 lowercase letters, digits, or single hyphens"
|
|
)
|
|
if len(result["description"]) > 1024:
|
|
raise ValueError("SKILL.md 'description' must not exceed 1024 characters")
|
|
compatibility = result.get("compatibility", "")
|
|
if compatibility is not None and not isinstance(compatibility, str):
|
|
raise ValueError("SKILL.md 'compatibility' must be a string")
|
|
result["compatibility"] = str(compatibility or "").strip()
|
|
if len(result["compatibility"]) > 500:
|
|
raise ValueError("SKILL.md 'compatibility' must not exceed 500 characters")
|
|
return result
|
|
|
|
|
|
def _is_within(path, root):
|
|
try:
|
|
path.relative_to(root)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def _configured_roots(config):
|
|
roots = []
|
|
for raw in config["paths"]:
|
|
raw = raw.strip()
|
|
if not raw:
|
|
continue
|
|
path = Path(os.path.expandvars(raw)).expanduser()
|
|
if not path.is_absolute():
|
|
path = PLUGIN_ROOT / path
|
|
roots.append(path.resolve())
|
|
return roots
|
|
|
|
|
|
def _find_skill_manifest(directory):
|
|
try:
|
|
matches = [
|
|
item
|
|
for item in directory.iterdir()
|
|
if item.is_file() and item.name.casefold() == "skill.md"
|
|
]
|
|
except OSError as exc:
|
|
raise ValueError(f"Unable to inspect Skill directory: {directory.name}") from exc
|
|
if len(matches) > 1:
|
|
raise ValueError(f"Skill directory contains multiple SKILL.md files: {directory.name}")
|
|
return matches[0] if matches else None
|
|
|
|
|
|
def _candidate_skill_dirs(config):
|
|
for configured in _configured_roots(config):
|
|
try:
|
|
if not configured.exists() or not configured.is_dir():
|
|
continue
|
|
if _find_skill_manifest(configured) is not None:
|
|
yield configured
|
|
continue
|
|
children = sorted(configured.iterdir(), key=lambda item: item.name.casefold())
|
|
except OSError as exc:
|
|
raise ValueError("Unable to scan a configured Skill root") from exc
|
|
for child in children:
|
|
try:
|
|
is_skill = child.is_dir() and _find_skill_manifest(child) is not None
|
|
resolved = child.resolve() if is_skill else None
|
|
except OSError as exc:
|
|
raise ValueError(f"Unable to inspect Skill directory: {child.name}") from exc
|
|
if is_skill:
|
|
if not _is_within(resolved, configured):
|
|
raise ValueError(f"Skill directory escapes configured root: {child.name}")
|
|
yield resolved
|
|
|
|
|
|
def _snapshot_skill(skill_root, config):
|
|
root = skill_root.resolve()
|
|
manifest = _find_skill_manifest(root)
|
|
if manifest is None:
|
|
raise ValueError("Skill directory does not contain SKILL.md")
|
|
skill_file = manifest.resolve()
|
|
if not _is_within(skill_file, root):
|
|
raise ValueError("SKILL.md escapes its Skill directory")
|
|
try:
|
|
skill_bytes = skill_file.read_bytes()
|
|
except OSError as exc:
|
|
raise ValueError("Unable to read SKILL.md") from exc
|
|
if len(skill_bytes) > config["max_skill_md_bytes"]:
|
|
raise ValueError(f"SKILL.md exceeds {config['max_skill_md_bytes']} bytes")
|
|
skill_text = _safe_decode(skill_bytes, "SKILL.md")
|
|
metadata = parse_skill_frontmatter(skill_text)
|
|
|
|
manifest = []
|
|
total = 0
|
|
references_dir = root / "references"
|
|
if references_dir.exists():
|
|
resolved_refs = references_dir.resolve()
|
|
if not _is_within(resolved_refs, root):
|
|
raise ValueError("references directory escapes its Skill directory")
|
|
try:
|
|
candidates = sorted(
|
|
(
|
|
item for item in references_dir.rglob("*")
|
|
if item.is_file() and item.suffix.lower() in REFERENCE_EXTENSIONS
|
|
),
|
|
key=lambda item: item.relative_to(root).as_posix().casefold(),
|
|
)
|
|
except OSError as exc:
|
|
raise ValueError("Unable to scan Skill references") from exc
|
|
for item in candidates:
|
|
relative = item.relative_to(root).as_posix()
|
|
try:
|
|
resolved = item.resolve()
|
|
except OSError as exc:
|
|
raise ValueError(f"Unable to resolve Skill reference: {relative}") from exc
|
|
if not _is_within(resolved, root):
|
|
raise ValueError(f"Reference escapes its Skill directory: {relative}")
|
|
try:
|
|
data = resolved.read_bytes()
|
|
except OSError as exc:
|
|
raise ValueError(f"Unable to read Skill reference: {relative}") from exc
|
|
if len(data) > config["max_reference_file_bytes"]:
|
|
raise ValueError(f"Reference exceeds {config['max_reference_file_bytes']} bytes: {relative}")
|
|
_safe_decode(data, relative)
|
|
total += len(data)
|
|
if total > config["max_reference_total_bytes"]:
|
|
raise ValueError(f"Skill references exceed {config['max_reference_total_bytes']} bytes")
|
|
manifest.append({
|
|
"path": relative,
|
|
"size": len(data),
|
|
"sha256": hashlib.sha256(data).hexdigest(),
|
|
})
|
|
|
|
digest_source = {
|
|
"skill_md_sha256": hashlib.sha256(skill_bytes).hexdigest(),
|
|
"references": manifest,
|
|
}
|
|
skill_hash = hashlib.sha256(
|
|
json.dumps(digest_source, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
|
).hexdigest()
|
|
return {
|
|
"name": metadata["name"],
|
|
"description": metadata["description"],
|
|
"compatibility": metadata["compatibility"],
|
|
"skill_instructions": skill_text,
|
|
"skill_md_sha256": digest_source["skill_md_sha256"],
|
|
"skill_hash": skill_hash,
|
|
"reference_manifest": manifest,
|
|
"_root": root,
|
|
"_skill_file": skill_file,
|
|
}
|
|
|
|
|
|
def discover_skills(strict=True):
|
|
config = get_skill_config()
|
|
result = []
|
|
names = set()
|
|
errors = []
|
|
for root in _candidate_skill_dirs(config):
|
|
try:
|
|
snapshot = _snapshot_skill(root, config)
|
|
if snapshot["name"] in names:
|
|
raise ValueError(f"Duplicate Skill name: {snapshot['name']}")
|
|
names.add(snapshot["name"])
|
|
result.append(snapshot)
|
|
except Exception as exc:
|
|
if strict or str(exc).startswith("Duplicate Skill name:"):
|
|
raise
|
|
errors.append(str(exc))
|
|
result.sort(key=lambda item: item["name"].casefold())
|
|
return result, errors
|
|
|
|
|
|
def get_skill_summaries():
|
|
"""Return only the metadata needed to render the Skill selector."""
|
|
skills, _ = discover_skills(strict=True)
|
|
return [
|
|
{"name": skill["name"], "description": skill["description"]}
|
|
for skill in skills
|
|
]
|
|
|
|
|
|
|
|
def get_skill_snapshot(skill_name):
|
|
skills, _ = discover_skills(strict=True)
|
|
for snapshot in skills:
|
|
if snapshot["name"] == skill_name:
|
|
return snapshot
|
|
raise ValueError(f"Unknown Skill name: {skill_name}")
|
|
|
|
|
|
def create_skill_options(skill_name):
|
|
snapshot = get_skill_snapshot(skill_name)
|
|
return _options_from_snapshot(snapshot)
|
|
|
|
|
|
def _options_from_snapshot(snapshot):
|
|
return {
|
|
"type": SKILL_OPTIONS_TYPE,
|
|
"schema_version": SKILL_OPTIONS_SCHEMA_VERSION,
|
|
"skill_name": snapshot["name"],
|
|
"description": snapshot["description"],
|
|
"compatibility": snapshot["compatibility"],
|
|
"skill_instructions": snapshot["skill_instructions"],
|
|
"skill_hash": snapshot["skill_hash"],
|
|
"reference_manifest": snapshot["reference_manifest"],
|
|
}
|
|
|
|
|
|
def validate_skill_options(options):
|
|
if not isinstance(options, dict):
|
|
raise ValueError("skill_options must come from OpenAI Text Skill Options")
|
|
if options.get("type") != SKILL_OPTIONS_TYPE:
|
|
raise ValueError(f"Unsupported skill_options type: {options.get('type')!r}")
|
|
if options.get("schema_version") != SKILL_OPTIONS_SCHEMA_VERSION:
|
|
raise ValueError(f"Unsupported skill_options schema_version: {options.get('schema_version')!r}")
|
|
name = options.get("skill_name")
|
|
if not isinstance(name, str) or not name.strip():
|
|
raise ValueError("skill_options is missing skill_name")
|
|
snapshot = get_skill_snapshot(name)
|
|
expected = _options_from_snapshot(snapshot)
|
|
for key in ("skill_name", "description", "compatibility", "skill_instructions", "skill_hash", "reference_manifest"):
|
|
if options.get(key) != expected[key]:
|
|
raise ValueError(f"Skill changed or skill_options field is invalid: {key}")
|
|
return snapshot
|
|
|
|
|
|
|
|
def read_skill_reference(snapshot, relative_path, allow_skill_md=False):
|
|
if not isinstance(relative_path, str):
|
|
raise ValueError("Reference path must be a string")
|
|
if allow_skill_md and relative_path == "SKILL.md":
|
|
skill_file = snapshot["_skill_file"]
|
|
data = skill_file.read_bytes()
|
|
if hashlib.sha256(data).hexdigest() != snapshot["skill_md_sha256"]:
|
|
raise ValueError("Skill reference changed during execution: SKILL.md")
|
|
return {
|
|
"skill_name": snapshot["name"],
|
|
"path": "SKILL.md",
|
|
"sha256": hashlib.sha256(data).hexdigest(),
|
|
"content": _safe_decode(data, "SKILL.md"),
|
|
}
|
|
entry = next((item for item in snapshot["reference_manifest"] if item["path"] == relative_path), None)
|
|
if entry is None:
|
|
return {"error": "reference_not_found", "path": relative_path}
|
|
root = snapshot["_root"].resolve()
|
|
resolved = (root / Path(relative_path)).resolve()
|
|
if not _is_within(resolved, root):
|
|
raise ValueError("Reference path escapes its Skill directory")
|
|
try:
|
|
data = resolved.read_bytes()
|
|
except OSError as exc:
|
|
raise ValueError(f"Unable to read Skill reference: {relative_path}") from exc
|
|
digest = hashlib.sha256(data).hexdigest()
|
|
if len(data) != entry["size"] or digest != entry["sha256"]:
|
|
raise ValueError(f"Skill reference changed during execution: {relative_path}")
|
|
return {
|
|
"skill_name": snapshot["name"],
|
|
"path": relative_path,
|
|
"sha256": digest,
|
|
"content": _safe_decode(data, relative_path),
|
|
}
|
|
|
|
|
|
def load_skill(snapshot):
|
|
"""Load and revalidate the complete selected Skill for tool disclosure."""
|
|
result = read_skill_reference(snapshot, "SKILL.md", allow_skill_md=True)
|
|
return {
|
|
"skill_name": snapshot["name"],
|
|
"skill_hash": snapshot["skill_hash"],
|
|
"instructions": result["content"],
|
|
"references": [
|
|
{"path": item["path"], "size": item["size"]}
|
|
for item in snapshot["reference_manifest"]
|
|
],
|
|
}
|
|
|
|
|
|
LOAD_SKILL_TOOL = "load_skill"
|
|
READ_SKILL_FILE_TOOL = "read_skill_file"
|
|
INVOCATION_POLICY = "required_once_per_session"
|
|
EXECUTION_MODE = "pi_skill_agent"
|
|
|
|
|
|
class SkillExecutionError(ValueError):
|
|
"""Stable, traceable failure raised by the Skill execution layer."""
|
|
|
|
def __init__(self, code, message, retryable=False):
|
|
self.code = str(code)
|
|
self.message = str(message)
|
|
self.retryable = bool(retryable)
|
|
super().__init__(f"{self.code}: {self.message}")
|
|
|
|
|
|
|
|
def _identity(snapshot):
|
|
return {"name": snapshot["name"], "hash": snapshot["skill_hash"]}
|
|
|
|
|
|
def _trace(snapshot=None, protocol=None):
|
|
return {
|
|
"schema_version": 1,
|
|
"execution_mode": EXECUTION_MODE if snapshot else None,
|
|
"protocol": protocol,
|
|
"skill_identity": _identity(snapshot) if snapshot else {},
|
|
"skill_loaded": False,
|
|
"load_source": "none",
|
|
"invocation_policy": INVOCATION_POLICY,
|
|
"tool_choice_mode": None,
|
|
"compat_retry_count": 0,
|
|
"tool_rounds": 0,
|
|
"tool_calls": 0,
|
|
"files_read": [],
|
|
"errors": [],
|
|
}
|
|
|
|
|
|
def _append_trace_error(trace, code, message, **details):
|
|
error = {"code": str(code), "message": str(message)}
|
|
error.update({key: value for key, value in details.items() if value is not None})
|
|
if error not in trace["errors"]:
|
|
trace["errors"].append(error)
|
|
|
|
|
|
def _function_definition(name):
|
|
if name == LOAD_SKILL_TOOL:
|
|
return {
|
|
"name": name,
|
|
"description": "Load the complete selected SKILL.md.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {},
|
|
"required": [],
|
|
"additionalProperties": False,
|
|
},
|
|
}
|
|
if name == READ_SKILL_FILE_TOOL:
|
|
return {
|
|
"name": name,
|
|
"description": "Read one exact references/... file from the loaded Skill manifest.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"path": {
|
|
"type": "string",
|
|
"description": "Exact references/... path from the manifest.",
|
|
}
|
|
},
|
|
"required": ["path"],
|
|
"additionalProperties": False,
|
|
},
|
|
}
|
|
raise ValueError(f"Unknown Skill tool definition: {name}")
|
|
|
|
|
|
def skill_tool_definitions(protocol="openai-completions", loaded=False):
|
|
"""Return the provider schema for the only tool registered in this phase."""
|
|
definition = _function_definition(
|
|
READ_SKILL_FILE_TOOL if loaded else LOAD_SKILL_TOOL
|
|
)
|
|
if protocol == "openai-completions":
|
|
return [{"type": "function", "function": definition}]
|
|
if protocol == "openai-responses":
|
|
return [{"type": "function", **definition}]
|
|
raise SkillExecutionError(
|
|
"skill_protocol_not_supported",
|
|
f"Skill execution does not support protocol: {protocol}",
|
|
)
|
|
|
|
|
|
def _parse_args(raw):
|
|
if isinstance(raw, dict):
|
|
value = raw
|
|
else:
|
|
try:
|
|
value = json.loads(raw or "{}")
|
|
except (TypeError, ValueError) as exc:
|
|
raise SkillExecutionError(
|
|
"pi_skill_tool_call_invalid",
|
|
"Skill tool arguments must be valid JSON",
|
|
) from exc
|
|
if not isinstance(value, dict):
|
|
raise SkillExecutionError(
|
|
"pi_skill_tool_call_invalid",
|
|
"Skill tool arguments must be a JSON object",
|
|
)
|
|
return value
|
|
|
|
|
|
def _sanitize(value, snapshot):
|
|
"""Deterministically redact secrets and real local Skill paths."""
|
|
sensitive_keys = {
|
|
"api_key", "authorization", "proxy-authorization", "access_token",
|
|
"secret", "password",
|
|
}
|
|
real_paths = {
|
|
str(snapshot.get("_root", "")),
|
|
str(snapshot.get("_skill_file", "")),
|
|
}
|
|
real_paths.discard("")
|
|
|
|
def visit(current):
|
|
if isinstance(current, dict):
|
|
return {
|
|
key: "[REDACTED]" if str(key).casefold() in sensitive_keys else visit(item)
|
|
for key, item in current.items()
|
|
}
|
|
if isinstance(current, list):
|
|
return [visit(item) for item in current]
|
|
if isinstance(current, tuple):
|
|
return [visit(item) for item in current]
|
|
if isinstance(current, str):
|
|
result = current
|
|
for path in sorted(real_paths, key=len, reverse=True):
|
|
result = result.replace(path, "[SKILL_ROOT]")
|
|
result = result.replace(path.replace("\\", "/"), "[SKILL_ROOT]")
|
|
return result
|
|
return current
|
|
|
|
return visit(deepcopy(value))
|
|
|
|
|
|
@dataclass
|
|
class SkillSession:
|
|
"""Append-only ledger used by continuation, replay, and UI output."""
|
|
|
|
events: list[dict] = field(default_factory=list)
|
|
version: int = 0
|
|
skill_loaded: bool = False
|
|
skill_identity: dict = field(default_factory=dict)
|
|
load_version: int | None = None
|
|
lock: threading.RLock = field(
|
|
default_factory=threading.RLock, repr=False, compare=False
|
|
)
|
|
|
|
def append(self, event_type, **data):
|
|
self.events.append(deepcopy({
|
|
"type": event_type,
|
|
"context_version": self.version,
|
|
**data,
|
|
}))
|
|
|
|
def matches_loaded(self, snapshot):
|
|
return self.skill_loaded and self.skill_identity == _identity(snapshot)
|
|
|
|
def reset_for(self, snapshot):
|
|
identity = _identity(snapshot)
|
|
if self.skill_identity and self.skill_identity != identity:
|
|
self.skill_loaded = False
|
|
self.load_version = None
|
|
self.append("skill_reset", skill_identity=identity)
|
|
self.skill_identity = identity
|
|
|
|
def commit(self, context, snapshot, protocol):
|
|
was_loaded = self.matches_loaded(snapshot)
|
|
self.version += 1
|
|
self.skill_identity = _identity(snapshot)
|
|
self.skill_loaded = True
|
|
if not was_loaded:
|
|
self.load_version = self.version
|
|
self.append(
|
|
"turn_commit",
|
|
protocol=protocol,
|
|
skill_identity=self.skill_identity,
|
|
skill_loaded=True,
|
|
provider_context=context,
|
|
)
|
|
|
|
def abort(self, exc):
|
|
self.append(
|
|
"turn_abort",
|
|
error={
|
|
"code": getattr(exc, "code", "pi_skill_execution_aborted"),
|
|
"message": getattr(exc, "message", str(exc)),
|
|
},
|
|
)
|
|
|
|
def derive_context(self):
|
|
for event in reversed(self.events):
|
|
if event.get("type") == "skill_reset":
|
|
return []
|
|
if (
|
|
event.get("type") == "turn_commit"
|
|
and event.get("skill_identity") == self.skill_identity
|
|
):
|
|
return deepcopy(event.get("provider_context") or [])
|
|
return []
|
|
|
|
def conversation(self, snapshot):
|
|
status = (
|
|
"committed"
|
|
if self.events and self.events[-1]["type"] == "turn_commit"
|
|
else "aborted"
|
|
)
|
|
return {
|
|
"schema_version": 1,
|
|
"execution_mode": EXECUTION_MODE,
|
|
"context_version": self.version,
|
|
"turn_status": status,
|
|
"skill_identity": dict(self.skill_identity),
|
|
"skill_loaded": self.skill_loaded,
|
|
"load_version": self.load_version,
|
|
"provider_context": _sanitize(self.derive_context(), snapshot),
|
|
"events": _sanitize(self.events, snapshot),
|
|
}
|
|
|
|
|
|
class SkillSessionStore:
|
|
_sessions = {}
|
|
_lock = threading.RLock()
|
|
|
|
@classmethod
|
|
def get(cls, key):
|
|
with cls._lock:
|
|
if key not in cls._sessions:
|
|
session = SkillSession()
|
|
session.append("session_open", execution_mode=EXECUTION_MODE)
|
|
cls._sessions[key] = session
|
|
return cls._sessions[key]
|
|
|
|
@classmethod
|
|
def clear(cls, key):
|
|
with cls._lock:
|
|
cls._sessions.pop(key, None)
|
|
|
|
|
|
class ReadOnlySkillRuntime:
|
|
"""Execute the two registered tools through host file APIs only."""
|
|
|
|
def __init__(self, snapshot, limits, trace):
|
|
self.snapshot = snapshot
|
|
self.limits = limits
|
|
self.trace = trace
|
|
self.cache = {}
|
|
self.disclosed = 0
|
|
self.loaded = False
|
|
|
|
def _account(self, result):
|
|
added = len(result.get("content", "").encode("utf-8"))
|
|
self.disclosed += added
|
|
if self.disclosed > self.limits["max_disclosed_bytes_per_execution"]:
|
|
raise SkillExecutionError(
|
|
"pi_skill_tool_limit_exceeded", "Skill disclosure byte limit exceeded"
|
|
)
|
|
path = result.get("path")
|
|
if result.get("content") is not None and path not in self.trace["files_read"]:
|
|
self.trace["files_read"].append(path)
|
|
|
|
def execute(self, name, arguments):
|
|
if name == LOAD_SKILL_TOOL:
|
|
if arguments:
|
|
raise SkillExecutionError(
|
|
"pi_skill_tool_call_invalid", "load_skill accepts no arguments"
|
|
)
|
|
if name in self.cache:
|
|
return self.cache[name]
|
|
try:
|
|
output = load_skill(self.snapshot)
|
|
except ValueError as exc:
|
|
raise SkillExecutionError("skill_snapshot_changed", str(exc)) from exc
|
|
self._account({"path": "SKILL.md", "content": output["instructions"]})
|
|
output = _sanitize(output, self.snapshot)
|
|
self.cache[name] = output
|
|
self.loaded = True
|
|
return output
|
|
|
|
if name != READ_SKILL_FILE_TOOL:
|
|
raise SkillExecutionError(
|
|
"pi_skill_tool_call_invalid", f"Unknown Skill tool: {name}"
|
|
)
|
|
if not self.loaded:
|
|
raise SkillExecutionError(
|
|
"skill_not_loaded", "read_skill_file requires load_skill first"
|
|
)
|
|
if set(arguments) != {"path"} or not isinstance(arguments.get("path"), str):
|
|
raise SkillExecutionError(
|
|
"pi_skill_tool_call_invalid",
|
|
"read_skill_file requires only the string 'path' argument",
|
|
)
|
|
path = arguments["path"]
|
|
if path == "SKILL.md":
|
|
raise SkillExecutionError(
|
|
"pi_skill_tool_call_invalid", "SKILL.md can only be loaded with load_skill"
|
|
)
|
|
if path in self.cache:
|
|
return self.cache[path]
|
|
try:
|
|
result = read_skill_reference(self.snapshot, path)
|
|
except ValueError as exc:
|
|
raise SkillExecutionError("skill_snapshot_changed", str(exc)) from exc
|
|
if result.get("error"):
|
|
raise SkillExecutionError(
|
|
"pi_skill_tool_call_invalid", f"Reference is not in the manifest: {path}"
|
|
)
|
|
self._account(result)
|
|
self.cache[path] = result
|
|
return result
|
|
|
|
|
|
@dataclass
|
|
class NormalizedResponse:
|
|
native_items: list[dict]
|
|
tool_calls: list[dict]
|
|
final_text: str | None
|
|
|
|
|
|
class ProviderAdapter:
|
|
"""Wire conversion only. Skill policy remains in PiSkillAgentLoop."""
|
|
|
|
protocol = None
|
|
history_field = None
|
|
|
|
def __init__(self, post_json, endpoint, headers, timeout, proxies, session):
|
|
self.post_json = post_json
|
|
self.endpoint = endpoint
|
|
self.headers = headers
|
|
self.timeout = timeout
|
|
self.proxies = proxies
|
|
self.session = session
|
|
|
|
def _post(self, payload):
|
|
return self.post_json(
|
|
self.endpoint, self.headers, payload, self.timeout, self.proxies
|
|
)
|
|
|
|
def request(
|
|
self, base_payload, history, tools, tool_choice=None,
|
|
):
|
|
raise NotImplementedError
|
|
|
|
def normalize(self, data):
|
|
raise NotImplementedError
|
|
|
|
def append_native_items(self, history, normalized):
|
|
raise NotImplementedError
|
|
|
|
def serialize_tool_result(self, call_id, output):
|
|
raise NotImplementedError
|
|
|
|
|
|
class ResponsesProviderAdapter(ProviderAdapter):
|
|
protocol = "openai-responses"
|
|
history_field = "input"
|
|
|
|
def request(
|
|
self, base_payload, history, tools, tool_choice=None,
|
|
):
|
|
payload = {
|
|
**base_payload,
|
|
"input": deepcopy(history),
|
|
"tools": deepcopy(tools),
|
|
"parallel_tool_calls": False,
|
|
}
|
|
if tool_choice is not None:
|
|
payload["tool_choice"] = deepcopy(tool_choice)
|
|
self.session.append("model_request", protocol=self.protocol, payload=payload)
|
|
try:
|
|
data = self._post(payload)
|
|
except (ToolChoiceRejected, FunctionToolsRejected) as exc:
|
|
raise SkillExecutionError(
|
|
"pi_skill_provider_not_supported",
|
|
"The Responses provider rejected function tool execution",
|
|
) from exc
|
|
self.session.append("model_response", protocol=self.protocol, response=data)
|
|
return data
|
|
|
|
def normalize(self, data):
|
|
if not isinstance(data, dict) or data.get("status") != "completed":
|
|
detail = (
|
|
data.get("incomplete_details") or data.get("error") or data.get("status")
|
|
if isinstance(data, dict)
|
|
else "non-object response"
|
|
)
|
|
raise SkillExecutionError(
|
|
"pi_skill_response_invalid",
|
|
f"Responses request did not complete: {detail or 'unknown'}",
|
|
)
|
|
output = data.get("output")
|
|
if not isinstance(output, list):
|
|
raise SkillExecutionError(
|
|
"pi_skill_response_invalid", "Responses response is missing output"
|
|
)
|
|
calls = []
|
|
chunks = []
|
|
for index, item in enumerate(output):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
if item.get("type") == "function_call":
|
|
calls.append({
|
|
"type": "tool_call",
|
|
"id": item.get("id") or item.get("call_id"),
|
|
"call_id": item.get("call_id"),
|
|
"name": item.get("name"),
|
|
"arguments": item.get("arguments"),
|
|
"native_index": index,
|
|
"native_item": deepcopy(item),
|
|
})
|
|
elif item.get("type") == "message":
|
|
for block in item.get("content") or []:
|
|
if (
|
|
isinstance(block, dict)
|
|
and block.get("type") == "output_text"
|
|
and block.get("text")
|
|
):
|
|
chunks.append(block["text"])
|
|
return NormalizedResponse(
|
|
output, calls, "\n".join(chunks) if chunks else None
|
|
)
|
|
|
|
def append_native_items(self, history, normalized):
|
|
history.extend(deepcopy(normalized.native_items))
|
|
|
|
def serialize_tool_result(self, call_id, output):
|
|
return {
|
|
"type": "function_call_output",
|
|
"call_id": call_id,
|
|
"output": json.dumps(output, ensure_ascii=False),
|
|
}
|
|
|
|
|
|
class CompletionsProviderAdapter(ProviderAdapter):
|
|
protocol = "openai-completions"
|
|
history_field = "messages"
|
|
|
|
def request(
|
|
self, base_payload, history, tools, tool_choice=None,
|
|
):
|
|
payload = {
|
|
**base_payload,
|
|
"messages": deepcopy(history),
|
|
"tools": deepcopy(tools),
|
|
"parallel_tool_calls": False,
|
|
}
|
|
if tool_choice is not None:
|
|
payload["tool_choice"] = deepcopy(tool_choice)
|
|
self.session.append("model_request", protocol=self.protocol, payload=payload)
|
|
try:
|
|
data = self._post(payload)
|
|
except ToolChoiceRejected as exc:
|
|
raise SkillExecutionError(
|
|
"compat_tool_choice_not_supported",
|
|
"The compatible API rejected tool_choice",
|
|
) from exc
|
|
except FunctionToolsRejected as exc:
|
|
raise SkillExecutionError(
|
|
"compat_function_tools_not_supported",
|
|
"The compatible API rejected Skill function tools",
|
|
) from exc
|
|
self.session.append("model_response", protocol=self.protocol, response=data)
|
|
return data
|
|
|
|
def normalize(self, data):
|
|
choices = data.get("choices") if isinstance(data, dict) else None
|
|
message = (
|
|
choices[0].get("message")
|
|
if isinstance(choices, list)
|
|
and choices
|
|
and isinstance(choices[0], dict)
|
|
else None
|
|
)
|
|
if not isinstance(message, dict):
|
|
raise SkillExecutionError(
|
|
"pi_skill_response_invalid", "Completions response is missing message"
|
|
)
|
|
calls = []
|
|
for index, call in enumerate(message.get("tool_calls") or []):
|
|
function = call.get("function") if isinstance(call, dict) else None
|
|
calls.append({
|
|
"type": "tool_call",
|
|
"id": call.get("id") if isinstance(call, dict) else None,
|
|
"call_id": call.get("id") if isinstance(call, dict) else None,
|
|
"name": function.get("name") if isinstance(function, dict) else None,
|
|
"arguments": function.get("arguments") if isinstance(function, dict) else None,
|
|
"native_index": index,
|
|
"native_item": deepcopy(call),
|
|
})
|
|
content = message.get("content")
|
|
final_text = content if isinstance(content, str) and content.strip() else None
|
|
return NormalizedResponse([deepcopy(message)], calls, final_text)
|
|
|
|
def append_native_items(self, history, normalized):
|
|
history.extend(deepcopy(normalized.native_items))
|
|
|
|
def serialize_tool_result(self, call_id, output):
|
|
return {
|
|
"role": "tool",
|
|
"tool_call_id": call_id,
|
|
"content": json.dumps(output, ensure_ascii=False),
|
|
}
|
|
|
|
|
|
class SkillToolRegistry:
|
|
"""Expose exactly one Skill tool for the current load phase."""
|
|
|
|
@staticmethod
|
|
def allowed_name(loaded):
|
|
return READ_SKILL_FILE_TOOL if loaded else LOAD_SKILL_TOOL
|
|
|
|
@classmethod
|
|
def validate(cls, call, loaded, seen):
|
|
call_id = call.get("call_id")
|
|
name = call.get("name")
|
|
if not isinstance(call_id, str) or not call_id or call_id in seen:
|
|
raise SkillExecutionError(
|
|
"pi_skill_tool_call_invalid", "Tool call_id is missing or duplicated"
|
|
)
|
|
if name != cls.allowed_name(loaded):
|
|
raise SkillExecutionError(
|
|
"pi_skill_tool_call_invalid",
|
|
f"Tool is not registered in the current phase: {name!r}",
|
|
)
|
|
return call_id, name, _parse_args(call.get("arguments"))
|
|
|
|
|
|
class PiSkillAgentLoop:
|
|
"""Protocol-neutral Skill policy, lifecycle, limits, and load gate."""
|
|
|
|
def __init__(self, adapter, snapshot, session, limits):
|
|
self.adapter = adapter
|
|
self.snapshot = snapshot
|
|
self.session = session
|
|
self.limits = limits
|
|
|
|
def run(self, payload, trace):
|
|
field = self.adapter.history_field
|
|
history = deepcopy(payload[field])
|
|
base_payload = {
|
|
key: deepcopy(value)
|
|
for key, value in payload.items()
|
|
if key not in {field, "tools", "tool_choice", "parallel_tool_calls"}
|
|
}
|
|
loaded = self.session.matches_loaded(self.snapshot)
|
|
runtime = ReadOnlySkillRuntime(self.snapshot, self.limits, trace)
|
|
runtime.loaded = loaded
|
|
if loaded:
|
|
trace["skill_loaded"] = True
|
|
trace["load_source"] = "session"
|
|
trace["tool_choice_mode"] = "provider_default"
|
|
seen = set()
|
|
|
|
for round_index in range(self.limits["max_tool_rounds"] + 1):
|
|
tools = skill_tool_definitions(self.adapter.protocol, loaded=loaded)
|
|
data = self.adapter.request(
|
|
base_payload,
|
|
history,
|
|
tools,
|
|
)
|
|
normalized = self.adapter.normalize(data)
|
|
self.adapter.append_native_items(history, normalized)
|
|
self.session.append(
|
|
"model_output_normalized",
|
|
items=[
|
|
{key: value for key, value in item.items() if key != "native_item"}
|
|
for item in normalized.tool_calls
|
|
],
|
|
final_text_present=normalized.final_text is not None,
|
|
)
|
|
|
|
if not normalized.tool_calls:
|
|
if not loaded:
|
|
raise SkillExecutionError(
|
|
"skill_not_loaded",
|
|
"The model returned final output before calling load_skill",
|
|
)
|
|
if normalized.final_text is None:
|
|
raise SkillExecutionError(
|
|
"pi_skill_response_invalid",
|
|
"Provider response contains no final text",
|
|
)
|
|
trace["skill_loaded"] = True
|
|
if trace["load_source"] == "none":
|
|
trace["load_source"] = "tool_call"
|
|
self.session.commit(
|
|
history, self.snapshot, self.adapter.protocol
|
|
)
|
|
return normalized.final_text
|
|
|
|
if round_index >= self.limits["max_tool_rounds"]:
|
|
raise SkillExecutionError(
|
|
"pi_skill_tool_limit_exceeded", "Skill tool round limit exceeded"
|
|
)
|
|
trace["tool_rounds"] += 1
|
|
phase_loaded = loaded
|
|
planned = []
|
|
for call in normalized.tool_calls:
|
|
self.session.append("tool_call_planned", call=call)
|
|
try:
|
|
call_id, name, arguments = SkillToolRegistry.validate(
|
|
call, phase_loaded, seen
|
|
)
|
|
except SkillExecutionError as exc:
|
|
self.session.append(
|
|
"tool_call_validated",
|
|
call_id=call.get("call_id"),
|
|
name=call.get("name"),
|
|
accepted=False,
|
|
error={"code": exc.code, "message": exc.message},
|
|
)
|
|
raise
|
|
seen.add(call_id)
|
|
planned.append((call_id, name, arguments))
|
|
self.session.append(
|
|
"tool_call_validated", call_id=call_id, name=name,
|
|
arguments=arguments, accepted=True,
|
|
)
|
|
|
|
for call_id, name, arguments in planned:
|
|
trace["tool_calls"] += 1
|
|
if trace["tool_calls"] > self.limits["max_tool_calls_per_execution"]:
|
|
raise SkillExecutionError(
|
|
"pi_skill_tool_limit_exceeded", "Skill tool call limit exceeded"
|
|
)
|
|
self.session.append(
|
|
"tool_call_started", call_id=call_id, name=name,
|
|
path=arguments.get("path"),
|
|
)
|
|
try:
|
|
output = runtime.execute(name, arguments)
|
|
except SkillExecutionError as exc:
|
|
self.session.append(
|
|
"tool_call_settled", call_id=call_id, name=name,
|
|
outcome="error", path=arguments.get("path"),
|
|
error={"code": exc.code, "message": exc.message},
|
|
)
|
|
raise
|
|
self.session.append(
|
|
"tool_call_settled", call_id=call_id, name=name,
|
|
outcome="success", path=output.get("path"), output=output,
|
|
)
|
|
wire_result = self.adapter.serialize_tool_result(call_id, output)
|
|
history.append(wire_result)
|
|
self.session.append(
|
|
"tool_result_appended", call_id=call_id,
|
|
protocol=self.adapter.protocol, item=wire_result,
|
|
)
|
|
if name == LOAD_SKILL_TOOL and runtime.loaded:
|
|
loaded = True
|
|
trace["skill_loaded"] = True
|
|
trace["load_source"] = "tool_call"
|
|
|
|
raise SkillExecutionError(
|
|
"pi_skill_tool_limit_exceeded", "Skill tool round limit exceeded"
|
|
)
|
|
|
|
|
|
class SkillExecutionRouter:
|
|
"""Activate the Pi Skill Agent only for an explicitly selected Skill."""
|
|
|
|
@staticmethod
|
|
def _merge_context(protocol, previous, incoming):
|
|
if not previous:
|
|
return list(incoming)
|
|
current = list(incoming)
|
|
if protocol == "openai-completions":
|
|
while (
|
|
current
|
|
and isinstance(current[0], dict)
|
|
and current[0].get("role") in {"system", "developer"}
|
|
):
|
|
current.pop(0)
|
|
return list(previous) + current
|
|
|
|
@staticmethod
|
|
def _selected_skill_text(snapshot, already_loaded):
|
|
action = (
|
|
"The complete SKILL.md is already present in this Skill session. "
|
|
"Use it for this request and read only needed reference files."
|
|
if already_loaded
|
|
else "Use the selected Skill for this request. Before answering, load the complete SKILL.md."
|
|
)
|
|
return (
|
|
"<selected_skill>\n"
|
|
f" <name>{html.escape(snapshot['name'])}</name>\n"
|
|
f" <description>{html.escape(snapshot['description'])}</description>\n"
|
|
" <location>SKILL.md</location>\n"
|
|
"</selected_skill>\n"
|
|
f"{action}\n"
|
|
"Scripts, network access, shell execution, and file writes are unavailable.\n\n"
|
|
)
|
|
|
|
@staticmethod
|
|
def _inject_user_context(protocol, items, snapshot, already_loaded):
|
|
result = deepcopy(items)
|
|
prefix = SkillExecutionRouter._selected_skill_text(snapshot, already_loaded)
|
|
for index in range(len(result) - 1, -1, -1):
|
|
item = result[index]
|
|
if not isinstance(item, dict) or item.get("role") != "user":
|
|
continue
|
|
content = item.get("content")
|
|
if isinstance(content, str):
|
|
item["content"] = prefix + content
|
|
elif isinstance(content, list):
|
|
expected = "text" if protocol == "openai-completions" else "input_text"
|
|
block = next(
|
|
(
|
|
entry
|
|
for entry in content
|
|
if isinstance(entry, dict) and entry.get("type") == expected
|
|
),
|
|
None,
|
|
)
|
|
if block is None:
|
|
content.insert(0, {"type": expected, "text": prefix})
|
|
else:
|
|
block["text"] = prefix + str(block.get("text") or "")
|
|
else:
|
|
raise SkillExecutionError(
|
|
"pi_skill_response_invalid", "Skill request has no user text content"
|
|
)
|
|
return result
|
|
raise SkillExecutionError(
|
|
"pi_skill_response_invalid", "Skill request has no user message"
|
|
)
|
|
|
|
@staticmethod
|
|
def _check_tool_conflicts(payload):
|
|
for tool in payload.get("tools") or []:
|
|
if not isinstance(tool, dict):
|
|
continue
|
|
function = (
|
|
tool.get("function")
|
|
if tool.get("type") == "function" and "function" in tool
|
|
else tool
|
|
)
|
|
name = function.get("name") if isinstance(function, dict) else None
|
|
if name in {LOAD_SKILL_TOOL, READ_SKILL_FILE_TOOL}:
|
|
raise SkillExecutionError(
|
|
"skill_tool_name_conflict",
|
|
f"The private Skill tool name is already present: {name}",
|
|
)
|
|
|
|
@staticmethod
|
|
def run(
|
|
protocol, post_json, endpoint, headers, timeout, proxies, payload,
|
|
snapshot, session_key, persist_context=True, trace=None,
|
|
):
|
|
config = get_skill_config()
|
|
if not config.get("allow_call", False):
|
|
raise SkillExecutionError(
|
|
"skill_call_disabled", "Skill calls are disabled by configuration"
|
|
)
|
|
if protocol not in {"openai-completions", "openai-responses"}:
|
|
raise SkillExecutionError(
|
|
"skill_protocol_not_supported",
|
|
f"Skill execution does not support protocol: {protocol}",
|
|
)
|
|
active_trace = trace if isinstance(trace, dict) else _trace(snapshot, protocol)
|
|
active_trace["execution_mode"] = EXECUTION_MODE
|
|
session = SkillSessionStore.get(session_key) if persist_context else SkillSession()
|
|
if not persist_context:
|
|
session.append("session_open", execution_mode=EXECUTION_MODE)
|
|
|
|
with session.lock:
|
|
session.reset_for(snapshot)
|
|
loaded_from_session = persist_context and session.matches_loaded(snapshot)
|
|
session.append(
|
|
"skill_manifest", skill_identity=_identity(snapshot),
|
|
metadata={
|
|
"name": snapshot["name"],
|
|
"description": snapshot["description"],
|
|
"location": "SKILL.md",
|
|
},
|
|
references=[
|
|
{"path": item["path"], "size": item["size"]}
|
|
for item in snapshot["reference_manifest"]
|
|
],
|
|
)
|
|
try:
|
|
SkillExecutionRouter._check_tool_conflicts(payload)
|
|
field = "messages" if protocol == "openai-completions" else "input"
|
|
incoming = list(payload[field])
|
|
previous_context = session.derive_context() if persist_context else []
|
|
if previous_context:
|
|
incoming = SkillExecutionRouter._merge_context(
|
|
protocol, previous_context, incoming
|
|
)
|
|
incoming = SkillExecutionRouter._inject_user_context(
|
|
protocol, incoming, snapshot, loaded_from_session
|
|
)
|
|
session.append(
|
|
"request_input", protocol=protocol, items=incoming,
|
|
persist_context=bool(persist_context),
|
|
)
|
|
routed_payload = {**payload, field: incoming}
|
|
adapter_type = (
|
|
ResponsesProviderAdapter
|
|
if protocol == "openai-responses"
|
|
else CompletionsProviderAdapter
|
|
)
|
|
adapter = adapter_type(
|
|
post_json, endpoint, headers, timeout, proxies, session
|
|
)
|
|
result = PiSkillAgentLoop(
|
|
adapter, snapshot, session, config
|
|
).run(routed_payload, active_trace)
|
|
return result, active_trace, session.conversation(snapshot)
|
|
except Exception as exc:
|
|
session.abort(exc)
|
|
code = getattr(exc, "code", "pi_skill_execution_aborted")
|
|
message = getattr(exc, "message", str(exc))
|
|
_append_trace_error(
|
|
active_trace, code, _sanitize(message, snapshot)
|
|
)
|
|
raise
|
|
|
|
@dataclass
|
|
class SkillRequestContext:
|
|
"""Small node-facing facade for the complete Skill request lifecycle."""
|
|
|
|
snapshot: dict | None
|
|
protocol: str
|
|
trace: dict
|
|
|
|
@classmethod
|
|
def create(cls, options, protocol):
|
|
snapshot = validate_skill_options(options) if options is not None else None
|
|
return cls(snapshot, protocol, _trace(snapshot, protocol if snapshot else None))
|
|
|
|
@property
|
|
def enabled(self):
|
|
return self.snapshot is not None
|
|
|
|
def session_discriminator(self, system_prompt):
|
|
if not self.enabled:
|
|
return system_prompt
|
|
return json.dumps(
|
|
[system_prompt or "", self.snapshot["skill_hash"], EXECUTION_MODE],
|
|
ensure_ascii=False,
|
|
)
|
|
|
|
def validate_advanced_options(self, options):
|
|
if not self.enabled or not isinstance(options, dict):
|
|
return
|
|
forbidden = {
|
|
"tools", "tool_choice", "parallel_tool_calls",
|
|
"previous_response_id", "conversation", "container", "containers",
|
|
"skills",
|
|
}.intersection(options)
|
|
if forbidden:
|
|
raise ValueError(
|
|
"Advanced options cannot override Skill tool fields: "
|
|
+ ", ".join(sorted(forbidden))
|
|
)
|
|
|
|
def clear_session(self, session_key):
|
|
if self.enabled:
|
|
SkillSessionStore.clear(session_key)
|
|
|
|
def execute(
|
|
self, post_json, endpoint, headers, timeout, proxies, payload,
|
|
session_key, persist_context=True,
|
|
):
|
|
if not self.enabled:
|
|
raise RuntimeError("Skill execution requires selected skill_options")
|
|
try:
|
|
result, _, conversation = SkillExecutionRouter.run(
|
|
self.protocol,
|
|
post_json,
|
|
endpoint,
|
|
headers,
|
|
timeout,
|
|
proxies,
|
|
payload,
|
|
self.snapshot,
|
|
session_key,
|
|
persist_context=persist_context,
|
|
trace=self.trace,
|
|
)
|
|
return result, conversation
|
|
except Exception as exc:
|
|
detail = str(exc)
|
|
if not self.trace["errors"]:
|
|
_append_trace_error(
|
|
self.trace,
|
|
getattr(exc, "code", "execution_error"),
|
|
getattr(exc, "message", detail),
|
|
)
|
|
trace_json = self.trace_json()
|
|
if isinstance(exc, ValueError):
|
|
raise ValueError(f"{detail}\nSkill Trace: {trace_json}") from exc
|
|
raise ValueError(
|
|
f"The API request failed: {detail}\nSkill Trace: {trace_json}"
|
|
) from exc
|
|
|
|
def trace_json(self):
|
|
return json.dumps(self.trace, ensure_ascii=False)
|