mirror of
https://github.com/katanemo/plano.git
synced 2026-06-08 14:55:14 +02:00
feat(skills): add Agent Skills support with orchestrator-driven activation
This commit is contained in:
parent
5a4487fc6e
commit
7f5bf641bb
24 changed files with 2777 additions and 97 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
from planoai.utils import convert_legacy_listeners
|
from planoai.utils import convert_legacy_listeners
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
import yaml
|
import yaml
|
||||||
|
|
@ -8,6 +9,14 @@ from jsonschema import validate, ValidationError
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from planoai.consts import DEFAULT_OTEL_TRACING_GRPC_ENDPOINT
|
from planoai.consts import DEFAULT_OTEL_TRACING_GRPC_ENDPOINT
|
||||||
|
from planoai.skills import (
|
||||||
|
MAX_CATALOG_BYTES,
|
||||||
|
Skill,
|
||||||
|
discover_skills,
|
||||||
|
find_project_root,
|
||||||
|
is_project_trusted,
|
||||||
|
total_catalog_size,
|
||||||
|
)
|
||||||
|
|
||||||
SUPPORTED_PROVIDERS_WITH_BASE_URL = [
|
SUPPORTED_PROVIDERS_WITH_BASE_URL = [
|
||||||
"azure_openai",
|
"azure_openai",
|
||||||
|
|
@ -162,6 +171,127 @@ def _version_tuple(version_string):
|
||||||
return tuple(out)
|
return tuple(out)
|
||||||
|
|
||||||
|
|
||||||
|
def materialize_skills_in_config(config_yaml: dict, project_root: Path) -> None:
|
||||||
|
"""Discover and inline Agent Skills referenced by `config_yaml`.
|
||||||
|
|
||||||
|
Mutates `config_yaml` in place. The user's source config may declare
|
||||||
|
`skills:` as a list of strings (skill names) or omit it entirely. After
|
||||||
|
this call, `config_yaml["skills"]` is either absent or a list of fully
|
||||||
|
materialized objects with `name`, `description`, `path`, `body`, etc.
|
||||||
|
|
||||||
|
Project-scope skills under `<project_root>/.plano/skills/` are only loaded
|
||||||
|
when the project has been marked trusted via `planoai skills trust`.
|
||||||
|
|
||||||
|
Per-route `routing_preferences[].skills` allow-lists are preserved as-is
|
||||||
|
so brightstaff can scope the catalog when that route is selected.
|
||||||
|
"""
|
||||||
|
requested = config_yaml.get("skills")
|
||||||
|
user_only = not is_project_trusted(project_root)
|
||||||
|
|
||||||
|
discovered, diagnostics = discover_skills(
|
||||||
|
project_root=project_root, include_user_scope=True
|
||||||
|
)
|
||||||
|
for diag in diagnostics:
|
||||||
|
prefix = "error" if diag.severity == "error" else "warning"
|
||||||
|
print(f"[skills] {prefix}: {diag.path}: {diag.message}")
|
||||||
|
|
||||||
|
if user_only:
|
||||||
|
project_skills = [s for s in discovered if s.scope == "project"]
|
||||||
|
if project_skills:
|
||||||
|
print(
|
||||||
|
"[skills] note: project-scope skills are present but the project is "
|
||||||
|
"not trusted yet; run `planoai skills trust` to enable them."
|
||||||
|
)
|
||||||
|
# Keep all non-project scopes (user + agents) — both are user-tier and
|
||||||
|
# auto-trusted, so they always load regardless of project trust state.
|
||||||
|
discovered = [s for s in discovered if s.scope != "project"]
|
||||||
|
|
||||||
|
skills_by_name: dict[str, Skill] = {s.name: s for s in discovered}
|
||||||
|
|
||||||
|
if requested is None:
|
||||||
|
# Default: auto-include every discovered skill.
|
||||||
|
selected: list[Skill] = list(discovered)
|
||||||
|
else:
|
||||||
|
if not isinstance(requested, list):
|
||||||
|
raise Exception("`skills:` must be a list of strings or skill objects")
|
||||||
|
selected = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for entry in requested:
|
||||||
|
if isinstance(entry, str):
|
||||||
|
name = entry
|
||||||
|
elif isinstance(entry, dict):
|
||||||
|
name = entry.get("name")
|
||||||
|
if not isinstance(name, str):
|
||||||
|
raise Exception(
|
||||||
|
"skill entries with object form must include a string `name`"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise Exception(
|
||||||
|
f"unsupported entry in `skills:` (expected str or mapping, got {type(entry).__name__})"
|
||||||
|
)
|
||||||
|
if name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(name)
|
||||||
|
skill = skills_by_name.get(name)
|
||||||
|
if skill is None:
|
||||||
|
print(
|
||||||
|
f"[skills] warning: skill '{name}' is declared in config but no "
|
||||||
|
f"SKILL.md was discovered under .plano/skills/ or ~/.plano/skills/"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
selected.append(skill)
|
||||||
|
|
||||||
|
if not selected:
|
||||||
|
config_yaml.pop("skills", None)
|
||||||
|
_strip_unknown_route_skills(config_yaml, set())
|
||||||
|
return
|
||||||
|
|
||||||
|
catalog_bytes = total_catalog_size(selected)
|
||||||
|
if catalog_bytes > MAX_CATALOG_BYTES:
|
||||||
|
print(
|
||||||
|
f"[skills] warning: skill catalog size is {catalog_bytes} bytes, "
|
||||||
|
f"above the recommended cap of {MAX_CATALOG_BYTES}. Consider trimming "
|
||||||
|
f"`routing_preferences[].skills` to the smallest useful set per route."
|
||||||
|
)
|
||||||
|
|
||||||
|
config_yaml["skills"] = [s.to_dict() for s in selected]
|
||||||
|
_strip_unknown_route_skills(config_yaml, {s.name for s in selected})
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_unknown_route_skills(config_yaml: dict, known: set) -> None:
|
||||||
|
"""Drop unknown skill names from `routing_preferences[*].skills` allow-lists.
|
||||||
|
|
||||||
|
The orchestrator only ever sees skills referenced under some
|
||||||
|
`routing_preferences[].skills`; an unknown name there would render the
|
||||||
|
`<skills>` block with a stale entry the runtime can't resolve, so filter
|
||||||
|
them out here with a warning instead.
|
||||||
|
"""
|
||||||
|
routes = config_yaml.get("routing_preferences")
|
||||||
|
if not isinstance(routes, list):
|
||||||
|
return
|
||||||
|
for route in routes:
|
||||||
|
if not isinstance(route, dict):
|
||||||
|
continue
|
||||||
|
allow = route.get("skills")
|
||||||
|
if not isinstance(allow, list):
|
||||||
|
continue
|
||||||
|
filtered = []
|
||||||
|
for name in allow:
|
||||||
|
if not isinstance(name, str):
|
||||||
|
continue
|
||||||
|
if name in known:
|
||||||
|
filtered.append(name)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"[skills] warning: routing_preference '{route.get('name')}' "
|
||||||
|
f"references unknown skill '{name}'; dropping from allow-list."
|
||||||
|
)
|
||||||
|
if filtered:
|
||||||
|
route["skills"] = filtered
|
||||||
|
else:
|
||||||
|
route.pop("skills", None)
|
||||||
|
|
||||||
|
|
||||||
def validate_and_render_schema():
|
def validate_and_render_schema():
|
||||||
ENVOY_CONFIG_TEMPLATE_FILE = os.getenv(
|
ENVOY_CONFIG_TEMPLATE_FILE = os.getenv(
|
||||||
"ENVOY_CONFIG_TEMPLATE_FILE", "envoy.template.yaml"
|
"ENVOY_CONFIG_TEMPLATE_FILE", "envoy.template.yaml"
|
||||||
|
|
@ -196,6 +326,13 @@ def validate_and_render_schema():
|
||||||
_ = yaml.safe_load(plano_config_schema)
|
_ = yaml.safe_load(plano_config_schema)
|
||||||
inferred_clusters = {}
|
inferred_clusters = {}
|
||||||
|
|
||||||
|
# Materialize Agent Skills before further processing so the rest of the
|
||||||
|
# pipeline (Jinja2 envoy template, dump to plano_config_rendered.yaml) sees
|
||||||
|
# the inlined body / description / path.
|
||||||
|
plano_config_path = Path(PLANO_CONFIG_FILE).resolve()
|
||||||
|
project_root = find_project_root(plano_config_path.parent)
|
||||||
|
materialize_skills_in_config(config_yaml, project_root)
|
||||||
|
|
||||||
# Convert legacy llm_providers to model_providers
|
# Convert legacy llm_providers to model_providers
|
||||||
if "llm_providers" in config_yaml:
|
if "llm_providers" in config_yaml:
|
||||||
if "model_providers" in config_yaml:
|
if "model_providers" in config_yaml:
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,12 @@ BUILTIN_TEMPLATES: list[Template] = [
|
||||||
description="stateful responses with memory-backed storage",
|
description="stateful responses with memory-backed storage",
|
||||||
yaml_text=_load_template_yaml("conversational_state_v1_responses.yaml"),
|
yaml_text=_load_template_yaml("conversational_state_v1_responses.yaml"),
|
||||||
),
|
),
|
||||||
|
Template(
|
||||||
|
id="skills_routing",
|
||||||
|
title="Agent Skills Routing",
|
||||||
|
description="install Agent Skills (agentskills.io) and let Plano-Orchestrator route to them",
|
||||||
|
yaml_text=_load_template_yaml("skills_routing.yaml"),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ from planoai.init_cmd import init as init_cmd
|
||||||
from planoai.trace_cmd import trace as trace_cmd, start_trace_listener_background
|
from planoai.trace_cmd import trace as trace_cmd, start_trace_listener_background
|
||||||
from planoai.chatgpt_cmd import chatgpt as chatgpt_cmd
|
from planoai.chatgpt_cmd import chatgpt as chatgpt_cmd
|
||||||
from planoai.obs_cmd import obs as obs_cmd
|
from planoai.obs_cmd import obs as obs_cmd
|
||||||
|
from planoai.skills_cmd import skills as skills_cmd
|
||||||
from planoai.consts import (
|
from planoai.consts import (
|
||||||
DEFAULT_OTEL_TRACING_GRPC_ENDPOINT,
|
DEFAULT_OTEL_TRACING_GRPC_ENDPOINT,
|
||||||
DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT,
|
DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT,
|
||||||
|
|
@ -746,6 +747,7 @@ main.add_command(init_cmd, name="init")
|
||||||
main.add_command(trace_cmd, name="trace")
|
main.add_command(trace_cmd, name="trace")
|
||||||
main.add_command(chatgpt_cmd, name="chatgpt")
|
main.add_command(chatgpt_cmd, name="chatgpt")
|
||||||
main.add_command(obs_cmd, name="obs")
|
main.add_command(obs_cmd, name="obs")
|
||||||
|
main.add_command(skills_cmd, name="skills")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
||||||
420
cli/planoai/skills.py
Normal file
420
cli/planoai/skills.py
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
"""Agent Skills discovery for Plano.
|
||||||
|
|
||||||
|
Parses SKILL.md files from .plano/skills/ (project scope) and ~/.plano/skills/
|
||||||
|
(user scope) following the Agent Skills specification:
|
||||||
|
https://agentskills.io/specification.md
|
||||||
|
|
||||||
|
The parser is intentionally lenient (per the "Adding skills support" guide):
|
||||||
|
warn on cosmetic issues but only skip a skill when its YAML is unparseable or
|
||||||
|
its required `description` field is missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from planoai.utils import getLogger
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
PROJECT_SKILLS_DIR = Path(".plano") / "skills"
|
||||||
|
USER_SKILLS_DIR = Path(os.path.expanduser("~/.plano/skills"))
|
||||||
|
# Universal Agent Skills install location used by `npx skills add` (vercel-labs/add-skill).
|
||||||
|
# Auto-trusted: same security posture as ~/.plano/skills, no project trust needed.
|
||||||
|
AGENTS_SKILLS_DIR = Path(os.path.expanduser("~/.agents/skills"))
|
||||||
|
|
||||||
|
MAX_CATALOG_BYTES = 5 * 1024
|
||||||
|
|
||||||
|
MAX_DIRS_SCANNED = 2000
|
||||||
|
|
||||||
|
_NAME_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||||
|
|
||||||
|
|
||||||
|
def trusted_projects_file() -> Path:
|
||||||
|
"""Resolve `~/.plano/trusted_projects.json` at call time.
|
||||||
|
|
||||||
|
Lazy so tests can override $HOME and have the new path picked up; module
|
||||||
|
import time would freeze it to the developer's actual home directory.
|
||||||
|
"""
|
||||||
|
return Path(os.path.expanduser("~/.plano/trusted_projects.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def is_project_trusted(project_root: Path) -> bool:
|
||||||
|
"""Return True if `project_root` is listed in `~/.plano/trusted_projects.json`.
|
||||||
|
|
||||||
|
Project-scope skills come from arbitrary repos and are gated on this trust
|
||||||
|
decision (set with `planoai skills trust`). Single source of truth, shared
|
||||||
|
between the `skills_cmd` CLI surface and the render pipeline.
|
||||||
|
"""
|
||||||
|
path = trusted_projects_file()
|
||||||
|
if not path.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
trusted = data.get("trusted_projects", []) if isinstance(data, dict) else []
|
||||||
|
resolved = str(project_root.resolve())
|
||||||
|
return resolved in {str(Path(p).resolve()) for p in trusted}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SkillDiagnostic:
|
||||||
|
severity: str # "warn" or "error"
|
||||||
|
message: str
|
||||||
|
path: Path
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Skill:
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
location: Path
|
||||||
|
base_dir: Path
|
||||||
|
body: str
|
||||||
|
scope: str
|
||||||
|
compatibility: str | None = None
|
||||||
|
license: str | None = None
|
||||||
|
metadata: dict = field(default_factory=dict)
|
||||||
|
allowed_tools: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize to a YAML-friendly dict for embedding in rendered config."""
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"path": str(self.location),
|
||||||
|
"base_dir": str(self.base_dir),
|
||||||
|
"scope": self.scope,
|
||||||
|
"body": self.body,
|
||||||
|
"compatibility": self.compatibility,
|
||||||
|
"license": self.license,
|
||||||
|
"metadata": dict(self.metadata) if self.metadata else None,
|
||||||
|
"allowed_tools": self.allowed_tools,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_project_root(start: Path | None = None) -> Path:
|
||||||
|
"""Walk up from `start` looking for `.plano/`, then `.git/`.
|
||||||
|
|
||||||
|
Falls back to `start` (or cwd) if nothing is found. This matches how
|
||||||
|
`npx skills add` chooses a project root.
|
||||||
|
"""
|
||||||
|
base = Path(start or Path.cwd()).resolve()
|
||||||
|
cur = base
|
||||||
|
while cur != cur.parent:
|
||||||
|
if (cur / ".plano").exists():
|
||||||
|
return cur
|
||||||
|
cur = cur.parent
|
||||||
|
|
||||||
|
cur = base
|
||||||
|
while cur != cur.parent:
|
||||||
|
if (cur / ".git").exists():
|
||||||
|
return cur
|
||||||
|
cur = cur.parent
|
||||||
|
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def parse_skill_md(path: Path) -> tuple[Skill | None, list[SkillDiagnostic]]:
|
||||||
|
"""Parse a single SKILL.md file leniently."""
|
||||||
|
diagnostics: list[SkillDiagnostic] = []
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
diagnostics.append(
|
||||||
|
SkillDiagnostic("error", f"failed to read SKILL.md: {exc}", path)
|
||||||
|
)
|
||||||
|
return None, diagnostics
|
||||||
|
|
||||||
|
frontmatter, body = _split_frontmatter(text)
|
||||||
|
if frontmatter is None:
|
||||||
|
diagnostics.append(SkillDiagnostic("error", "missing YAML frontmatter", path))
|
||||||
|
return None, diagnostics
|
||||||
|
|
||||||
|
data = _parse_yaml_lenient(frontmatter, path, diagnostics)
|
||||||
|
if data is None:
|
||||||
|
return None, diagnostics
|
||||||
|
|
||||||
|
description = data.get("description")
|
||||||
|
if not isinstance(description, str) or not description.strip():
|
||||||
|
diagnostics.append(
|
||||||
|
SkillDiagnostic(
|
||||||
|
"error", "skill is missing required 'description' field", path
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return None, diagnostics
|
||||||
|
|
||||||
|
parent_name = path.parent.name
|
||||||
|
name = data.get("name")
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
diagnostics.append(
|
||||||
|
SkillDiagnostic(
|
||||||
|
"warn",
|
||||||
|
f"missing 'name' field; falling back to parent directory '{parent_name}'",
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
name = parent_name
|
||||||
|
|
||||||
|
name = name.strip()
|
||||||
|
|
||||||
|
if len(name) > 64:
|
||||||
|
diagnostics.append(
|
||||||
|
SkillDiagnostic("warn", "skill name exceeds 64 characters", path)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _NAME_PATTERN.match(name):
|
||||||
|
diagnostics.append(
|
||||||
|
SkillDiagnostic(
|
||||||
|
"warn",
|
||||||
|
f"skill name '{name}' violates spec naming rules "
|
||||||
|
"(lowercase alphanumeric + hyphens, no leading/trailing/double hyphens)",
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if name != parent_name:
|
||||||
|
diagnostics.append(
|
||||||
|
SkillDiagnostic(
|
||||||
|
"warn",
|
||||||
|
f"skill name '{name}' does not match parent directory '{parent_name}'",
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
metadata_raw = data.get("metadata")
|
||||||
|
metadata = {}
|
||||||
|
if isinstance(metadata_raw, dict):
|
||||||
|
metadata = {str(k): str(v) for k, v in metadata_raw.items()}
|
||||||
|
|
||||||
|
skill = Skill(
|
||||||
|
name=name,
|
||||||
|
description=description.strip(),
|
||||||
|
location=path.resolve(),
|
||||||
|
base_dir=path.parent.resolve(),
|
||||||
|
body=body,
|
||||||
|
scope="project", # may be overridden by caller
|
||||||
|
compatibility=_string_field(data.get("compatibility")),
|
||||||
|
license=_string_field(data.get("license")),
|
||||||
|
metadata=metadata,
|
||||||
|
allowed_tools=_string_field(data.get("allowed-tools")),
|
||||||
|
)
|
||||||
|
return skill, diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _split_frontmatter(text: str) -> tuple[str | None, str]:
|
||||||
|
if not text.startswith("---"):
|
||||||
|
return None, text
|
||||||
|
|
||||||
|
m = re.match(r"^---\s*\r?\n(.*?)\r?\n---\s*(?:\r?\n)?(.*)$", text, re.DOTALL)
|
||||||
|
if not m:
|
||||||
|
return None, text
|
||||||
|
return m.group(1), m.group(2).strip("\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_yaml_lenient(
|
||||||
|
frontmatter: str, path: Path, diagnostics: list[SkillDiagnostic]
|
||||||
|
) -> dict | None:
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(frontmatter)
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
retried = _retry_quote_problem_fields(frontmatter)
|
||||||
|
if retried is None:
|
||||||
|
diagnostics.append(
|
||||||
|
SkillDiagnostic("error", f"YAML parse error: {exc}", path)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(retried)
|
||||||
|
except yaml.YAMLError as exc2:
|
||||||
|
diagnostics.append(
|
||||||
|
SkillDiagnostic(
|
||||||
|
"error", f"YAML parse error (after retry): {exc2}", path
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
diagnostics.append(
|
||||||
|
SkillDiagnostic("error", "frontmatter is not a YAML mapping", path)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
_PROBLEM_FIELDS = ("description", "compatibility")
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_quote_problem_fields(frontmatter: str) -> str | None:
|
||||||
|
"""Wrap unquoted values for fields prone to YAML colon-collisions in quotes."""
|
||||||
|
lines = frontmatter.splitlines()
|
||||||
|
out: list[str] = []
|
||||||
|
changed = False
|
||||||
|
for line in lines:
|
||||||
|
m = re.match(r"^(\w[\w-]*)\s*:\s*(.*)$", line)
|
||||||
|
if m and m.group(1) in _PROBLEM_FIELDS:
|
||||||
|
key = m.group(1)
|
||||||
|
value = m.group(2).rstrip()
|
||||||
|
if value and not (
|
||||||
|
(value.startswith("'") and value.endswith("'"))
|
||||||
|
or (value.startswith('"') and value.endswith('"'))
|
||||||
|
):
|
||||||
|
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
out.append(f'{key}: "{escaped}"')
|
||||||
|
changed = True
|
||||||
|
continue
|
||||||
|
out.append(line)
|
||||||
|
if not changed:
|
||||||
|
return None
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _string_field(value) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
s = str(value).strip()
|
||||||
|
return s or None
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_skill_dirs(root: Path) -> Iterable[Path]:
|
||||||
|
if not root.exists() or not root.is_dir():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
children = sorted(root.iterdir(), key=lambda p: p.name)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for child in children:
|
||||||
|
count += 1
|
||||||
|
if count > MAX_DIRS_SCANNED:
|
||||||
|
log.warning(
|
||||||
|
"exceeded max scan budget (%d) while looking for skills in %s",
|
||||||
|
MAX_DIRS_SCANNED,
|
||||||
|
root,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
if not child.is_dir():
|
||||||
|
continue
|
||||||
|
if child.name.startswith("."):
|
||||||
|
continue
|
||||||
|
yield child
|
||||||
|
|
||||||
|
|
||||||
|
def discover_skills(
|
||||||
|
project_root: Path | None = None,
|
||||||
|
include_user_scope: bool = True,
|
||||||
|
) -> tuple[list[Skill], list[SkillDiagnostic]]:
|
||||||
|
"""Discover all skills available to the current project.
|
||||||
|
|
||||||
|
Precedence (highest first): project > user > agents. Project-scope
|
||||||
|
skills shadow lower tiers with the same name; user-scope shadows
|
||||||
|
agents-scope. Both ``~/.plano/skills/`` (Plano-native) and
|
||||||
|
``~/.agents/skills/`` (the universal Agent Skills install location used
|
||||||
|
by ``npx skills add``) are treated as auto-trusted user-tier scopes.
|
||||||
|
|
||||||
|
Returns ``(skills, diagnostics)`` sorted by name.
|
||||||
|
"""
|
||||||
|
project_root = find_project_root(project_root)
|
||||||
|
project_dir = project_root / PROJECT_SKILLS_DIR
|
||||||
|
|
||||||
|
skills_by_name: dict[str, Skill] = {}
|
||||||
|
diagnostics: list[SkillDiagnostic] = []
|
||||||
|
|
||||||
|
if include_user_scope:
|
||||||
|
# Load lowest precedence first so higher tiers shadow.
|
||||||
|
for skill_dir in _iter_skill_dirs(AGENTS_SKILLS_DIR):
|
||||||
|
skill_md = skill_dir / "SKILL.md"
|
||||||
|
if not skill_md.exists():
|
||||||
|
continue
|
||||||
|
skill, diags = parse_skill_md(skill_md)
|
||||||
|
diagnostics.extend(diags)
|
||||||
|
if skill is not None:
|
||||||
|
skill = _set_scope(skill, "agents")
|
||||||
|
skills_by_name[skill.name] = skill
|
||||||
|
|
||||||
|
for skill_dir in _iter_skill_dirs(USER_SKILLS_DIR):
|
||||||
|
skill_md = skill_dir / "SKILL.md"
|
||||||
|
if not skill_md.exists():
|
||||||
|
continue
|
||||||
|
skill, diags = parse_skill_md(skill_md)
|
||||||
|
diagnostics.extend(diags)
|
||||||
|
if skill is None:
|
||||||
|
continue
|
||||||
|
skill = _set_scope(skill, "user")
|
||||||
|
existing = skills_by_name.get(skill.name)
|
||||||
|
if existing is not None and existing.scope == "agents":
|
||||||
|
diagnostics.append(
|
||||||
|
SkillDiagnostic(
|
||||||
|
"warn",
|
||||||
|
f"user-scope skill '{skill.name}' shadows ~/.agents/skills entry at {existing.location}",
|
||||||
|
skill.location,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
skills_by_name[skill.name] = skill
|
||||||
|
|
||||||
|
for skill_dir in _iter_skill_dirs(project_dir):
|
||||||
|
skill_md = skill_dir / "SKILL.md"
|
||||||
|
if not skill_md.exists():
|
||||||
|
continue
|
||||||
|
skill, diags = parse_skill_md(skill_md)
|
||||||
|
diagnostics.extend(diags)
|
||||||
|
if skill is None:
|
||||||
|
continue
|
||||||
|
skill = _set_scope(skill, "project")
|
||||||
|
existing = skills_by_name.get(skill.name)
|
||||||
|
if existing is not None and existing.scope in ("user", "agents"):
|
||||||
|
diagnostics.append(
|
||||||
|
SkillDiagnostic(
|
||||||
|
"warn",
|
||||||
|
f"project-scope skill '{skill.name}' shadows {existing.scope}-scope skill at {existing.location}",
|
||||||
|
skill.location,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
skills_by_name[skill.name] = skill
|
||||||
|
|
||||||
|
return sorted(skills_by_name.values(), key=lambda s: s.name), diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def _set_scope(skill: Skill, scope: str) -> Skill:
|
||||||
|
return Skill(
|
||||||
|
name=skill.name,
|
||||||
|
description=skill.description,
|
||||||
|
location=skill.location,
|
||||||
|
base_dir=skill.base_dir,
|
||||||
|
body=skill.body,
|
||||||
|
scope=scope,
|
||||||
|
compatibility=skill.compatibility,
|
||||||
|
license=skill.license,
|
||||||
|
metadata=skill.metadata,
|
||||||
|
allowed_tools=skill.allowed_tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def total_catalog_size(skills: Iterable[Skill]) -> int:
|
||||||
|
"""Approximate byte size of the catalog the orchestrator will receive."""
|
||||||
|
return sum(len(s.name) + len(s.description) for s in skills)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_skills_by_allow_list(
|
||||||
|
skills: Iterable[Skill], allow_list: Iterable[str] | None
|
||||||
|
) -> list[Skill]:
|
||||||
|
"""Filter skills to those whose `name` appears in `allow_list`.
|
||||||
|
|
||||||
|
If `allow_list` is None, returns all skills. Unknown names are silently
|
||||||
|
dropped — callers warn at config-validation time.
|
||||||
|
"""
|
||||||
|
if allow_list is None:
|
||||||
|
return list(skills)
|
||||||
|
allowed = set(allow_list)
|
||||||
|
return [s for s in skills if s.name in allowed]
|
||||||
471
cli/planoai/skills_cmd.py
Normal file
471
cli/planoai/skills_cmd.py
Normal file
|
|
@ -0,0 +1,471 @@
|
||||||
|
"""`planoai skills` command group.
|
||||||
|
|
||||||
|
Installs Agent Skills (https://agentskills.io) and surfaces them to Plano.
|
||||||
|
|
||||||
|
Three discovery scopes are supported, in descending precedence:
|
||||||
|
|
||||||
|
* ``<project>/.plano/skills/`` -- repo-pinned skills. Loaded only when the
|
||||||
|
project has been marked trusted via ``planoai skills trust`` (skill content
|
||||||
|
is injected into the orchestrator prompt, so we gate on trust).
|
||||||
|
* ``~/.plano/skills/`` -- Plano-native user-scope. Always trusted.
|
||||||
|
* ``~/.agents/skills/`` -- universal Agent Skills location used by
|
||||||
|
``npx skills add``. Always trusted; lets the upstream skills CLI work
|
||||||
|
out of the box without any Plano-specific awareness.
|
||||||
|
|
||||||
|
``planoai skills add`` tries ``npx skills add`` first (the upstream CLI from
|
||||||
|
https://github.com/vercel-labs/add-skill), which writes to
|
||||||
|
``~/.agents/skills/<name>`` and is picked up automatically thanks to the
|
||||||
|
agents-scope above. Falls back to ``git clone`` into ``.plano/skills/`` when
|
||||||
|
``npx`` is unavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import rich_click as click
|
||||||
|
from rich.console import Console
|
||||||
|
from rich.table import Table
|
||||||
|
|
||||||
|
from planoai.consts import PLANO_COLOR
|
||||||
|
from planoai.skills import (
|
||||||
|
PROJECT_SKILLS_DIR,
|
||||||
|
Skill,
|
||||||
|
discover_skills,
|
||||||
|
find_project_root,
|
||||||
|
is_project_trusted,
|
||||||
|
trusted_projects_file,
|
||||||
|
)
|
||||||
|
from planoai.utils import getLogger
|
||||||
|
|
||||||
|
log = getLogger(__name__)
|
||||||
|
|
||||||
|
_OWNER_REPO_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _InstallTarget:
|
||||||
|
owner: str
|
||||||
|
repo: str
|
||||||
|
ref: str | None = None # optional branch / tag / commit (e.g. "owner/repo@v1")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def slug(self) -> str:
|
||||||
|
return f"{self.owner}/{self.repo}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self) -> str:
|
||||||
|
return f"https://github.com/{self.owner}/{self.repo}.git"
|
||||||
|
|
||||||
|
|
||||||
|
def _console() -> Console:
|
||||||
|
return Console()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_skills_dir(project_root: Path) -> Path:
|
||||||
|
skills_dir = project_root / PROJECT_SKILLS_DIR
|
||||||
|
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return skills_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_install_target(raw: str) -> _InstallTarget:
|
||||||
|
spec = raw.strip()
|
||||||
|
ref: str | None = None
|
||||||
|
if "@" in spec:
|
||||||
|
spec, _, ref_value = spec.partition("@")
|
||||||
|
ref = ref_value.strip() or None
|
||||||
|
if not _OWNER_REPO_PATTERN.match(spec):
|
||||||
|
raise click.BadParameter(
|
||||||
|
f"expected '<owner>/<repo>' (optionally suffixed with '@<ref>'), got '{raw}'"
|
||||||
|
)
|
||||||
|
owner, repo = spec.split("/", 1)
|
||||||
|
return _InstallTarget(owner=owner, repo=repo, ref=ref)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_npx() -> bool:
|
||||||
|
return shutil.which("npx") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_git() -> bool:
|
||||||
|
return shutil.which("git") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_project_trusted(project_root: Path) -> None:
|
||||||
|
path = trusted_projects_file()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
existing: dict = {}
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as fh:
|
||||||
|
existing = json.load(fh) or {}
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
existing = {}
|
||||||
|
trusted = set(existing.get("trusted_projects", []) or [])
|
||||||
|
trusted.add(str(project_root.resolve()))
|
||||||
|
existing["trusted_projects"] = sorted(trusted)
|
||||||
|
with path.open("w", encoding="utf-8") as fh:
|
||||||
|
json.dump(existing, fh, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_manifest(skills_dir: Path) -> dict:
|
||||||
|
manifest_path = skills_dir / ".skills.json"
|
||||||
|
if not manifest_path.exists():
|
||||||
|
return {"skills": {}}
|
||||||
|
try:
|
||||||
|
with manifest_path.open("r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {"skills": {}}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {"skills": {}}
|
||||||
|
data.setdefault("skills", {})
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _write_manifest(skills_dir: Path, manifest: dict) -> None:
|
||||||
|
manifest_path = skills_dir / ".skills.json"
|
||||||
|
with manifest_path.open("w", encoding="utf-8") as fh:
|
||||||
|
json.dump(manifest, fh, indent=2, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_install(
|
||||||
|
skills_dir: Path, name: str, target: _InstallTarget, source: str
|
||||||
|
) -> None:
|
||||||
|
manifest = _read_manifest(skills_dir)
|
||||||
|
manifest["skills"][name] = {
|
||||||
|
"source": source,
|
||||||
|
"repo": target.slug,
|
||||||
|
"ref": target.ref,
|
||||||
|
"installed_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
_write_manifest(skills_dir, manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_from_manifest(skills_dir: Path, name: str) -> None:
|
||||||
|
manifest = _read_manifest(skills_dir)
|
||||||
|
manifest["skills"].pop(name, None)
|
||||||
|
_write_manifest(skills_dir, manifest)
|
||||||
|
|
||||||
|
|
||||||
|
def _install_via_npx(
|
||||||
|
target: _InstallTarget, project_root: Path, console: Console
|
||||||
|
) -> bool:
|
||||||
|
"""Try to install with `npx skills add`. Returns True on success."""
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.setdefault("SKILLS_NO_TELEMETRY", "1")
|
||||||
|
arg = target.slug if target.ref is None else f"{target.slug}@{target.ref}"
|
||||||
|
cmd = ["npx", "--yes", "skills", "add", arg]
|
||||||
|
console.print(
|
||||||
|
f"[dim]Running:[/dim] [cyan]{' '.join(cmd)}[/cyan] [dim](cwd={project_root})[/dim]"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=project_root,
|
||||||
|
env=env,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def _install_via_git(
|
||||||
|
target: _InstallTarget,
|
||||||
|
project_root: Path,
|
||||||
|
skills_dir: Path,
|
||||||
|
console: Console,
|
||||||
|
) -> bool:
|
||||||
|
if not _has_git():
|
||||||
|
console.print("[red]X[/red] git is not installed; cannot fall back from npx")
|
||||||
|
return False
|
||||||
|
|
||||||
|
dest = skills_dir / target.repo
|
||||||
|
if dest.exists():
|
||||||
|
console.print(
|
||||||
|
f"[yellow]![/yellow] {dest} already exists. "
|
||||||
|
"Remove it first with [cyan]planoai skills remove[/cyan] before reinstalling."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
cmd = ["git", "clone", "--depth", "1"]
|
||||||
|
if target.ref:
|
||||||
|
cmd.extend(["--branch", target.ref])
|
||||||
|
cmd.extend([target.url, str(dest)])
|
||||||
|
console.print(
|
||||||
|
f"[dim]Running:[/dim] [cyan]{' '.join(cmd)}[/cyan] [dim](cwd={project_root})[/dim]"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, cwd=project_root, check=False)
|
||||||
|
except FileNotFoundError:
|
||||||
|
console.print("[red]X[/red] git binary not found")
|
||||||
|
return False
|
||||||
|
if result.returncode != 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
shutil.rmtree(dest / ".git", ignore_errors=True)
|
||||||
|
|
||||||
|
if not (dest / "SKILL.md").exists():
|
||||||
|
console.print(
|
||||||
|
f"[red]X[/red] {target.slug} does not contain a SKILL.md at its repo root; "
|
||||||
|
"this does not appear to be a valid Agent Skill."
|
||||||
|
)
|
||||||
|
shutil.rmtree(dest, ignore_errors=True)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _print_skills_table(console: Console, skills: list[Skill]) -> None:
|
||||||
|
if not skills:
|
||||||
|
console.print(
|
||||||
|
f"[dim]No skills installed.[/dim] Try [cyan]planoai skills add owner/repo[/cyan]."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
table = Table(title="Installed Agent Skills", border_style="dim")
|
||||||
|
table.add_column("Name", style=f"bold {PLANO_COLOR}")
|
||||||
|
table.add_column("Scope")
|
||||||
|
table.add_column("Description")
|
||||||
|
table.add_column("Path", style="dim")
|
||||||
|
for s in skills:
|
||||||
|
desc = s.description.splitlines()[0]
|
||||||
|
if len(desc) > 80:
|
||||||
|
desc = desc[:77] + "..."
|
||||||
|
table.add_row(s.name, s.scope, desc, str(s.location))
|
||||||
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(name="skills")
|
||||||
|
def skills():
|
||||||
|
"""Manage Agent Skills (agentskills.io) for this Plano project."""
|
||||||
|
|
||||||
|
|
||||||
|
@skills.command(name="add")
|
||||||
|
@click.argument("target", required=True)
|
||||||
|
@click.option(
|
||||||
|
"--path",
|
||||||
|
default=".",
|
||||||
|
help="Project directory (defaults to the directory containing .plano/ or .git/).",
|
||||||
|
)
|
||||||
|
def add_cmd(target: str, path: str):
|
||||||
|
"""Install an Agent Skill from a GitHub repo into .plano/skills/.
|
||||||
|
|
||||||
|
TARGET should be `owner/repo` (optionally suffixed with `@ref` for a branch
|
||||||
|
or tag).
|
||||||
|
"""
|
||||||
|
console = _console()
|
||||||
|
install_target = _parse_install_target(target)
|
||||||
|
project_root = find_project_root(Path(path).resolve())
|
||||||
|
skills_dir = _ensure_skills_dir(project_root)
|
||||||
|
|
||||||
|
console.print(
|
||||||
|
f"[bold {PLANO_COLOR}]Installing skill[/bold {PLANO_COLOR}] "
|
||||||
|
f"[cyan]{install_target.slug}[/cyan] -> [dim]{skills_dir}[/dim]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Snapshot what's already discoverable so we can diff after install and
|
||||||
|
# surface every newly-added skill regardless of which scope it landed in
|
||||||
|
# (project for git fallback, agents for `npx skills add`, etc.) and
|
||||||
|
# regardless of how the installed skill name maps to the repo name (e.g.
|
||||||
|
# multi-skill repos like openai/skills -> ~/.agents/skills/pdf).
|
||||||
|
before, _ = discover_skills(project_root=project_root, include_user_scope=True)
|
||||||
|
before_keys = {(s.name, str(s.base_dir)) for s in before}
|
||||||
|
|
||||||
|
used_source: str
|
||||||
|
success = False
|
||||||
|
if _has_npx():
|
||||||
|
success = _install_via_npx(install_target, project_root, console)
|
||||||
|
used_source = "npx-skills"
|
||||||
|
if not success:
|
||||||
|
console.print(
|
||||||
|
"[yellow]![/yellow] npx skills add did not succeed; "
|
||||||
|
"falling back to direct git clone."
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
success = _install_via_git(install_target, project_root, skills_dir, console)
|
||||||
|
used_source = "git"
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
console.print(
|
||||||
|
f"[red]X[/red] Failed to install [cyan]{install_target.slug}[/cyan]"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
discovered, diagnostics = discover_skills(
|
||||||
|
project_root=project_root, include_user_scope=True
|
||||||
|
)
|
||||||
|
for diag in diagnostics:
|
||||||
|
if diag.severity == "error":
|
||||||
|
console.print(f"[red]X[/red] {diag.path}: {diag.message}")
|
||||||
|
else:
|
||||||
|
console.print(f"[yellow]![/yellow] {diag.path}: {diag.message}")
|
||||||
|
|
||||||
|
newly_installed = [
|
||||||
|
s for s in discovered if (s.name, str(s.base_dir)) not in before_keys
|
||||||
|
]
|
||||||
|
if newly_installed:
|
||||||
|
for s in newly_installed:
|
||||||
|
if s.scope == "project":
|
||||||
|
_record_install(skills_dir, s.name, install_target, used_source)
|
||||||
|
try:
|
||||||
|
display_path = s.location.relative_to(project_root)
|
||||||
|
except ValueError:
|
||||||
|
display_path = s.location
|
||||||
|
console.print(
|
||||||
|
f"[green]+[/green] Installed [bold]{s.name}[/bold] "
|
||||||
|
f"[dim]({display_path}, scope={s.scope})[/dim]"
|
||||||
|
)
|
||||||
|
if any(
|
||||||
|
s.scope == "project" for s in newly_installed
|
||||||
|
) and not is_project_trusted(project_root):
|
||||||
|
console.print(
|
||||||
|
"\n[dim]Project-scope skills are not auto-loaded until this project is "
|
||||||
|
"trusted. Run[/dim] [cyan]planoai skills trust[/cyan] [dim]to enable them.[/dim]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
console.print(
|
||||||
|
"[yellow]![/yellow] Install reported success but no new SKILL.md "
|
||||||
|
"was discovered under .plano/skills, ~/.plano/skills, or "
|
||||||
|
"~/.agents/skills. Check the repo structure or pass a "
|
||||||
|
"single-skill repo."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@skills.command(name="list")
|
||||||
|
@click.option(
|
||||||
|
"--path",
|
||||||
|
default=".",
|
||||||
|
help="Project directory (defaults to the directory containing .plano/ or .git/).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--no-user-scope",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Skip user-scope skills under ~/.plano/skills and ~/.agents/skills.",
|
||||||
|
)
|
||||||
|
def list_cmd(path: str, no_user_scope: bool):
|
||||||
|
"""List discovered Agent Skills across project / user / agents scopes."""
|
||||||
|
console = _console()
|
||||||
|
project_root = find_project_root(Path(path).resolve())
|
||||||
|
discovered, diagnostics = discover_skills(
|
||||||
|
project_root=project_root, include_user_scope=not no_user_scope
|
||||||
|
)
|
||||||
|
_print_skills_table(console, discovered)
|
||||||
|
|
||||||
|
if diagnostics:
|
||||||
|
console.print()
|
||||||
|
for diag in diagnostics:
|
||||||
|
color = "red" if diag.severity == "error" else "yellow"
|
||||||
|
marker = "X" if diag.severity == "error" else "!"
|
||||||
|
console.print(f"[{color}]{marker}[/{color}] {diag.path}: {diag.message}")
|
||||||
|
|
||||||
|
|
||||||
|
@skills.command(name="remove")
|
||||||
|
@click.argument("name", required=True)
|
||||||
|
@click.option(
|
||||||
|
"--path",
|
||||||
|
default=".",
|
||||||
|
help="Project directory (defaults to the directory containing .plano/ or .git/).",
|
||||||
|
)
|
||||||
|
def remove_cmd(name: str, path: str):
|
||||||
|
"""Remove a project-scope skill from .plano/skills/.
|
||||||
|
|
||||||
|
User-scope skills under ~/.plano/skills or ~/.agents/skills must be
|
||||||
|
removed with their respective installer (`npx skills remove <name>` for
|
||||||
|
the latter); planoai will not touch directories outside the project.
|
||||||
|
"""
|
||||||
|
console = _console()
|
||||||
|
project_root = find_project_root(Path(path).resolve())
|
||||||
|
skills_dir = project_root / PROJECT_SKILLS_DIR
|
||||||
|
if not skills_dir.exists():
|
||||||
|
console.print(f"[red]X[/red] no skills directory at {skills_dir}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
target_dir = skills_dir / name
|
||||||
|
if not target_dir.exists():
|
||||||
|
discovered, _ = discover_skills(
|
||||||
|
project_root=project_root, include_user_scope=True
|
||||||
|
)
|
||||||
|
project_match = next(
|
||||||
|
(s for s in discovered if s.name == name and s.scope == "project"), None
|
||||||
|
)
|
||||||
|
if project_match is None:
|
||||||
|
other = next((s for s in discovered if s.name == name), None)
|
||||||
|
if other is not None:
|
||||||
|
console.print(
|
||||||
|
f"[red]X[/red] '{name}' is installed in {other.scope} scope at "
|
||||||
|
f"{other.base_dir}; planoai only removes project-scope skills. "
|
||||||
|
"Use the upstream installer (e.g. `npx skills remove`) for that one."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
console.print(
|
||||||
|
f"[red]X[/red] no project-scope skill named '{name}' under {skills_dir}"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
target_dir = project_match.base_dir
|
||||||
|
|
||||||
|
if not target_dir.resolve().is_relative_to(skills_dir.resolve()):
|
||||||
|
console.print(
|
||||||
|
f"[red]X[/red] refusing to delete {target_dir} (outside {skills_dir})"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
shutil.rmtree(target_dir, ignore_errors=False)
|
||||||
|
_remove_from_manifest(skills_dir, name)
|
||||||
|
console.print(f"[green]+[/green] Removed [bold]{name}[/bold]")
|
||||||
|
|
||||||
|
|
||||||
|
@skills.command(name="trust")
|
||||||
|
@click.option(
|
||||||
|
"--path",
|
||||||
|
default=".",
|
||||||
|
help="Project directory to mark as trusted.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--revoke",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Revoke trust instead of granting it.",
|
||||||
|
)
|
||||||
|
def trust_cmd(path: str, revoke: bool):
|
||||||
|
"""Mark this project's .plano/skills/ as trusted for auto-loading.
|
||||||
|
|
||||||
|
Project-scope skills come from the working directory's repo, which may
|
||||||
|
be untrusted. Plano refuses to inject their contents into the
|
||||||
|
orchestrator prompt until you trust the project.
|
||||||
|
"""
|
||||||
|
console = _console()
|
||||||
|
project_root = find_project_root(Path(path).resolve())
|
||||||
|
|
||||||
|
if revoke:
|
||||||
|
path = trusted_projects_file()
|
||||||
|
if not path.exists():
|
||||||
|
console.print("[dim]No trusted projects to revoke.[/dim]")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as fh:
|
||||||
|
data = json.load(fh) or {}
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
data = {}
|
||||||
|
trusted = {
|
||||||
|
str(Path(p).resolve()) for p in data.get("trusted_projects", []) or []
|
||||||
|
}
|
||||||
|
trusted.discard(str(project_root.resolve()))
|
||||||
|
data["trusted_projects"] = sorted(trusted)
|
||||||
|
with path.open("w", encoding="utf-8") as fh:
|
||||||
|
json.dump(data, fh, indent=2)
|
||||||
|
console.print(f"[green]+[/green] Revoked trust for [bold]{project_root}[/bold]")
|
||||||
|
return
|
||||||
|
|
||||||
|
_mark_project_trusted(project_root)
|
||||||
|
console.print(
|
||||||
|
f"[green]+[/green] Trusted [bold]{project_root}[/bold].\n"
|
||||||
|
f"[dim]Project-scope skills under .plano/skills/ will now be loaded at startup.[/dim]"
|
||||||
|
)
|
||||||
71
cli/planoai/templates/skills_routing.yaml
Normal file
71
cli/planoai/templates/skills_routing.yaml
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
version: v0.3.0
|
||||||
|
|
||||||
|
# This template wires Agent Skills (https://agentskills.io) into Plano so the
|
||||||
|
# built-in Plano-Orchestrator can decide *per request* which skill(s) to attach
|
||||||
|
# to the selected route.
|
||||||
|
#
|
||||||
|
# 1. Install skills locally:
|
||||||
|
# planoai skills add owner/pdf-processing
|
||||||
|
# planoai skills add owner/code-review
|
||||||
|
# Skills land under .plano/skills/<name>/SKILL.md.
|
||||||
|
#
|
||||||
|
# 2. (Required for project-scope skills) Mark the project trusted so its
|
||||||
|
# skills auto-load at startup:
|
||||||
|
# planoai skills trust
|
||||||
|
#
|
||||||
|
# 3. Reference the installed skills under `routing_preferences[].skills`.
|
||||||
|
# During the intent step Plano-Orchestrator receives both a <routes> and a
|
||||||
|
# <skills> XML block; it picks a route and zero or more skills, and the
|
||||||
|
# SKILL.md bodies of the chosen skills are injected into the upstream
|
||||||
|
# system prompt for that turn.
|
||||||
|
|
||||||
|
model_providers:
|
||||||
|
- model: anthropic/claude-sonnet-4-5
|
||||||
|
default: true
|
||||||
|
access_key: $ANTHROPIC_API_KEY
|
||||||
|
|
||||||
|
- model: openai/gpt-4.1-2025-04-14
|
||||||
|
access_key: $OPENAI_API_KEY
|
||||||
|
|
||||||
|
# Catalog of skills available to this project. Entries are skill names
|
||||||
|
# (resolved against .plano/skills/<name>/) — the CLI inlines each SKILL.md
|
||||||
|
# body into the rendered config at `planoai up` time.
|
||||||
|
#
|
||||||
|
# Omit `skills:` entirely to auto-include every discovered skill.
|
||||||
|
skills:
|
||||||
|
- pdf-processing
|
||||||
|
- code-review
|
||||||
|
|
||||||
|
listeners:
|
||||||
|
- type: model
|
||||||
|
name: model_listener
|
||||||
|
port: 12000
|
||||||
|
|
||||||
|
# Routing preferences double as Plano-Orchestrator's <routes> catalog. Attach
|
||||||
|
# `skills:` to a route to make those skills eligible for activation when the
|
||||||
|
# orchestrator picks that route.
|
||||||
|
routing_preferences:
|
||||||
|
- name: code review
|
||||||
|
description: |
|
||||||
|
Reviewing pull requests, analyzing diffs, and suggesting improvements
|
||||||
|
to existing code.
|
||||||
|
models:
|
||||||
|
- anthropic/claude-sonnet-4-5
|
||||||
|
- openai/gpt-4.1-2025-04-14
|
||||||
|
skills:
|
||||||
|
- code-review
|
||||||
|
|
||||||
|
- name: document understanding
|
||||||
|
description: |
|
||||||
|
Summarizing PDFs and other long-form documents, extracting structured
|
||||||
|
data such as tables, line items, or signatures.
|
||||||
|
models:
|
||||||
|
- anthropic/claude-sonnet-4-5
|
||||||
|
- openai/gpt-4.1-2025-04-14
|
||||||
|
skills:
|
||||||
|
- pdf-processing
|
||||||
|
selection_policy:
|
||||||
|
prefer: cheapest
|
||||||
|
|
||||||
|
tracing:
|
||||||
|
random_sampling: 100
|
||||||
|
|
@ -27,3 +27,8 @@ templates:
|
||||||
template_file: conversational_state_v1_responses.yaml
|
template_file: conversational_state_v1_responses.yaml
|
||||||
demo_configs: []
|
demo_configs: []
|
||||||
transform: none
|
transform: none
|
||||||
|
|
||||||
|
- template_id: skills_routing
|
||||||
|
template_file: skills_routing.yaml
|
||||||
|
demo_configs: []
|
||||||
|
transform: none
|
||||||
|
|
|
||||||
394
cli/test/test_skills.py
Normal file
394
cli/test/test_skills.py
Normal file
|
|
@ -0,0 +1,394 @@
|
||||||
|
"""Tests for cli/planoai/skills.py and the config-rendering hooks that
|
||||||
|
materialize SKILL.md bodies into the rendered plano config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from planoai.skills import (
|
||||||
|
AGENTS_SKILLS_DIR,
|
||||||
|
PROJECT_SKILLS_DIR,
|
||||||
|
USER_SKILLS_DIR,
|
||||||
|
Skill,
|
||||||
|
discover_skills,
|
||||||
|
parse_skill_md,
|
||||||
|
total_catalog_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_user_scopes(tmp_path, monkeypatch):
|
||||||
|
"""Default both user-tier scopes to non-existent dirs so the dev's real
|
||||||
|
~/.plano/skills and ~/.agents/skills cannot bleed into tests.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr("planoai.skills.USER_SKILLS_DIR", tmp_path / "_no_user_skills")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"planoai.skills.AGENTS_SKILLS_DIR", tmp_path / "_no_agents_skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_skill(
|
||||||
|
base: Path,
|
||||||
|
name: str,
|
||||||
|
description: str = "Process PDFs. Use when handling PDF files.",
|
||||||
|
body: str = "# Body\n\nDo the thing.",
|
||||||
|
extra_frontmatter: str = "",
|
||||||
|
) -> Path:
|
||||||
|
skill_dir = base / name
|
||||||
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
frontmatter = f"name: {name}\ndescription: {description}\n{extra_frontmatter}"
|
||||||
|
(skill_dir / "SKILL.md").write_text(
|
||||||
|
f"---\n{frontmatter}---\n\n{body}",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return skill_dir / "SKILL.md"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_skill_md_minimal(tmp_path):
|
||||||
|
skill_md = _write_skill(tmp_path, "pdf-processing")
|
||||||
|
|
||||||
|
skill, diagnostics = parse_skill_md(skill_md)
|
||||||
|
|
||||||
|
assert skill is not None
|
||||||
|
assert skill.name == "pdf-processing"
|
||||||
|
assert skill.description.startswith("Process PDFs")
|
||||||
|
assert "Do the thing." in skill.body
|
||||||
|
assert skill.location == skill_md.resolve()
|
||||||
|
assert skill.base_dir == skill_md.parent.resolve()
|
||||||
|
# No warnings or errors for a well-formed skill.
|
||||||
|
assert diagnostics == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_skill_md_lenient_when_name_mismatches_directory(tmp_path):
|
||||||
|
skill_dir = tmp_path / "some-dir"
|
||||||
|
skill_dir.mkdir()
|
||||||
|
(skill_dir / "SKILL.md").write_text(
|
||||||
|
"---\nname: different-name\ndescription: ok\n---\n\nbody",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
skill, diagnostics = parse_skill_md(skill_dir / "SKILL.md")
|
||||||
|
|
||||||
|
assert skill is not None
|
||||||
|
assert skill.name == "different-name"
|
||||||
|
assert any(
|
||||||
|
"does not match parent directory" in d.message for d in diagnostics
|
||||||
|
), diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_skill_md_warns_on_invalid_name(tmp_path):
|
||||||
|
skill_dir = tmp_path / "Bad-Name"
|
||||||
|
skill_dir.mkdir()
|
||||||
|
(skill_dir / "SKILL.md").write_text(
|
||||||
|
"---\nname: Bad-Name\ndescription: ok\n---\n\nbody",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
skill, diagnostics = parse_skill_md(skill_dir / "SKILL.md")
|
||||||
|
assert skill is not None
|
||||||
|
assert any("violates spec naming rules" in d.message for d in diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_skill_md_recovers_from_unquoted_colons(tmp_path):
|
||||||
|
skill_dir = tmp_path / "code-review"
|
||||||
|
skill_dir.mkdir()
|
||||||
|
(skill_dir / "SKILL.md").write_text(
|
||||||
|
"---\nname: code-review\n"
|
||||||
|
"description: Use this skill when: the user asks about code review\n"
|
||||||
|
"---\n\nbody",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
skill, diagnostics = parse_skill_md(skill_dir / "SKILL.md")
|
||||||
|
# Lenient parse retries with quoted values and succeeds.
|
||||||
|
assert skill is not None
|
||||||
|
assert skill.name == "code-review"
|
||||||
|
assert "Use this skill when:" in skill.description
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_skill_md_rejects_when_description_missing(tmp_path):
|
||||||
|
skill_dir = tmp_path / "broken"
|
||||||
|
skill_dir.mkdir()
|
||||||
|
(skill_dir / "SKILL.md").write_text(
|
||||||
|
"---\nname: broken\n---\n\nbody",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
skill, diagnostics = parse_skill_md(skill_dir / "SKILL.md")
|
||||||
|
assert skill is None
|
||||||
|
assert any(d.severity == "error" for d in diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_skill_md_rejects_when_frontmatter_missing(tmp_path):
|
||||||
|
skill_dir = tmp_path / "no-frontmatter"
|
||||||
|
skill_dir.mkdir()
|
||||||
|
(skill_dir / "SKILL.md").write_text("just markdown", encoding="utf-8")
|
||||||
|
|
||||||
|
skill, diagnostics = parse_skill_md(skill_dir / "SKILL.md")
|
||||||
|
assert skill is None
|
||||||
|
assert any("frontmatter" in d.message for d in diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_skills_project_only(tmp_path, monkeypatch):
|
||||||
|
(tmp_path / ".plano").mkdir()
|
||||||
|
project_skills_dir = tmp_path / ".plano" / "skills"
|
||||||
|
project_skills_dir.mkdir()
|
||||||
|
_write_skill(project_skills_dir, "pdf-processing")
|
||||||
|
_write_skill(project_skills_dir, "code-review")
|
||||||
|
|
||||||
|
skills, diagnostics = discover_skills(
|
||||||
|
project_root=tmp_path, include_user_scope=False
|
||||||
|
)
|
||||||
|
|
||||||
|
names = sorted(s.name for s in skills)
|
||||||
|
assert names == ["code-review", "pdf-processing"]
|
||||||
|
assert all(s.scope == "project" for s in skills)
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_skills_picks_up_agents_scope(tmp_path, monkeypatch):
|
||||||
|
"""`npx skills add` writes into ~/.agents/skills/<name>. That directory
|
||||||
|
must be discovered as a user-tier (auto-trusted) scope so the upstream
|
||||||
|
CLI works without Plano-specific awareness.
|
||||||
|
"""
|
||||||
|
agents_dir = tmp_path / "fake-home" / ".agents" / "skills"
|
||||||
|
agents_dir.mkdir(parents=True)
|
||||||
|
_write_skill(agents_dir, "pdf", description="agents-scope description")
|
||||||
|
|
||||||
|
monkeypatch.setattr("planoai.skills.AGENTS_SKILLS_DIR", agents_dir)
|
||||||
|
|
||||||
|
(tmp_path / ".plano").mkdir()
|
||||||
|
(tmp_path / ".plano" / "skills").mkdir()
|
||||||
|
|
||||||
|
skills, _ = discover_skills(project_root=tmp_path, include_user_scope=True)
|
||||||
|
by_name = {s.name: s for s in skills}
|
||||||
|
assert "pdf" in by_name
|
||||||
|
assert by_name["pdf"].scope == "agents"
|
||||||
|
assert by_name["pdf"].description == "agents-scope description"
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_skills_user_scope_shadows_agents_scope(tmp_path, monkeypatch):
|
||||||
|
"""When the same skill name exists in both ~/.plano/skills and
|
||||||
|
~/.agents/skills, the Plano-native one wins and a diagnostic is emitted.
|
||||||
|
|
||||||
|
Project root lives in its own subtree (with no .plano/ ancestor) so it
|
||||||
|
cannot collide with the patched user-scope dir.
|
||||||
|
"""
|
||||||
|
home = tmp_path / "fake-home"
|
||||||
|
home.mkdir()
|
||||||
|
agents_dir = home / ".agents" / "skills"
|
||||||
|
agents_dir.mkdir(parents=True)
|
||||||
|
_write_skill(agents_dir, "pdf", description="agents copy")
|
||||||
|
monkeypatch.setattr("planoai.skills.AGENTS_SKILLS_DIR", agents_dir)
|
||||||
|
|
||||||
|
user_dir = home / ".plano" / "skills"
|
||||||
|
user_dir.mkdir(parents=True)
|
||||||
|
_write_skill(user_dir, "pdf", description="user copy")
|
||||||
|
monkeypatch.setattr("planoai.skills.USER_SKILLS_DIR", user_dir)
|
||||||
|
|
||||||
|
project_root = tmp_path / "elsewhere" / "proj"
|
||||||
|
project_root.mkdir(parents=True)
|
||||||
|
skills, diagnostics = discover_skills(
|
||||||
|
project_root=project_root, include_user_scope=True
|
||||||
|
)
|
||||||
|
by_name = {s.name: s for s in skills}
|
||||||
|
assert by_name["pdf"].scope == "user"
|
||||||
|
assert by_name["pdf"].description == "user copy"
|
||||||
|
assert any("shadows ~/.agents/skills" in d.message for d in diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_skills_project_overrides_user_scope(tmp_path, monkeypatch):
|
||||||
|
user_skills_dir = tmp_path / "fake-home" / ".plano" / "skills"
|
||||||
|
user_skills_dir.mkdir(parents=True)
|
||||||
|
_write_skill(user_skills_dir, "shared", description="user-scope description")
|
||||||
|
|
||||||
|
monkeypatch.setattr("planoai.skills.USER_SKILLS_DIR", user_skills_dir)
|
||||||
|
|
||||||
|
(tmp_path / ".plano").mkdir()
|
||||||
|
project_skills_dir = tmp_path / ".plano" / "skills"
|
||||||
|
project_skills_dir.mkdir()
|
||||||
|
_write_skill(project_skills_dir, "shared", description="project-scope description")
|
||||||
|
_write_skill(project_skills_dir, "only-project")
|
||||||
|
|
||||||
|
skills, diagnostics = discover_skills(
|
||||||
|
project_root=tmp_path, include_user_scope=True
|
||||||
|
)
|
||||||
|
|
||||||
|
by_name = {s.name: s for s in skills}
|
||||||
|
assert by_name["shared"].scope == "project"
|
||||||
|
assert by_name["shared"].description == "project-scope description"
|
||||||
|
assert by_name["only-project"].scope == "project"
|
||||||
|
assert any("shadows user-scope skill" in d.message for d in diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
def test_total_catalog_size_counts_name_and_description():
|
||||||
|
skills = [
|
||||||
|
Skill(
|
||||||
|
name="a",
|
||||||
|
description="d1",
|
||||||
|
location=Path("/x"),
|
||||||
|
base_dir=Path("/"),
|
||||||
|
body="b",
|
||||||
|
scope="project",
|
||||||
|
),
|
||||||
|
Skill(
|
||||||
|
name="bb",
|
||||||
|
description="dd",
|
||||||
|
location=Path("/y"),
|
||||||
|
base_dir=Path("/"),
|
||||||
|
body="b",
|
||||||
|
scope="project",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
assert total_catalog_size(skills) == (1 + 2) + (2 + 2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_skills_in_config_default_inlines_bodies(tmp_path, monkeypatch):
|
||||||
|
project_root = tmp_path
|
||||||
|
(project_root / ".plano").mkdir()
|
||||||
|
project_skills_dir = project_root / ".plano" / "skills"
|
||||||
|
project_skills_dir.mkdir()
|
||||||
|
_write_skill(
|
||||||
|
project_skills_dir,
|
||||||
|
"pdf-processing",
|
||||||
|
description="Process PDFs.",
|
||||||
|
body="# Body\nfollow these steps.",
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_home = tmp_path / "home"
|
||||||
|
fake_home.mkdir()
|
||||||
|
monkeypatch.setenv("HOME", str(fake_home))
|
||||||
|
# Mark the project trusted so .plano/skills is loaded.
|
||||||
|
trusted = fake_home / ".plano" / "trusted_projects.json"
|
||||||
|
trusted.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
trusted.write_text(
|
||||||
|
json.dumps({"trusted_projects": [str(project_root.resolve())]}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"planoai.skills.USER_SKILLS_DIR",
|
||||||
|
fake_home / ".plano" / "skills",
|
||||||
|
)
|
||||||
|
|
||||||
|
from planoai.config_generator import materialize_skills_in_config
|
||||||
|
|
||||||
|
config_yaml = {"version": "v0.4.0"}
|
||||||
|
materialize_skills_in_config(config_yaml, project_root)
|
||||||
|
|
||||||
|
assert "skills" in config_yaml
|
||||||
|
materialized = config_yaml["skills"]
|
||||||
|
assert len(materialized) == 1
|
||||||
|
entry = materialized[0]
|
||||||
|
assert entry["name"] == "pdf-processing"
|
||||||
|
assert "follow these steps." in entry["body"]
|
||||||
|
assert entry["scope"] == "project"
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_skills_in_config_skips_untrusted_project_skills(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
project_root = tmp_path
|
||||||
|
(project_root / ".plano").mkdir()
|
||||||
|
project_skills_dir = project_root / ".plano" / "skills"
|
||||||
|
project_skills_dir.mkdir()
|
||||||
|
_write_skill(project_skills_dir, "pdf-processing")
|
||||||
|
|
||||||
|
fake_home = tmp_path / "home"
|
||||||
|
fake_home.mkdir()
|
||||||
|
monkeypatch.setenv("HOME", str(fake_home))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"planoai.skills.USER_SKILLS_DIR",
|
||||||
|
fake_home / ".plano" / "skills",
|
||||||
|
)
|
||||||
|
|
||||||
|
from planoai.config_generator import materialize_skills_in_config
|
||||||
|
|
||||||
|
config_yaml = {"version": "v0.4.0"}
|
||||||
|
materialize_skills_in_config(config_yaml, project_root)
|
||||||
|
# Untrusted -> project skills are not loaded.
|
||||||
|
assert "skills" not in config_yaml
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_skills_in_config_loads_agents_scope_without_trust(
|
||||||
|
tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
"""Even with no project trust, skills installed by `npx skills add` into
|
||||||
|
~/.agents/skills/<name> must materialize into the rendered config — that
|
||||||
|
directory is the universal Agent Skills install location and is
|
||||||
|
user-tier, not project-tier.
|
||||||
|
"""
|
||||||
|
project_root = tmp_path / "proj"
|
||||||
|
(project_root / ".plano").mkdir(parents=True)
|
||||||
|
|
||||||
|
fake_home = tmp_path / "home"
|
||||||
|
fake_home.mkdir()
|
||||||
|
monkeypatch.setenv("HOME", str(fake_home))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"planoai.skills.USER_SKILLS_DIR",
|
||||||
|
fake_home / ".plano" / "skills",
|
||||||
|
)
|
||||||
|
|
||||||
|
agents_dir = fake_home / ".agents" / "skills"
|
||||||
|
agents_dir.mkdir(parents=True)
|
||||||
|
_write_skill(agents_dir, "pdf", body="# Body\nhandle the pdf.")
|
||||||
|
monkeypatch.setattr("planoai.skills.AGENTS_SKILLS_DIR", agents_dir)
|
||||||
|
|
||||||
|
from planoai.config_generator import materialize_skills_in_config
|
||||||
|
|
||||||
|
config_yaml = {"version": "v0.4.0"}
|
||||||
|
materialize_skills_in_config(config_yaml, project_root)
|
||||||
|
|
||||||
|
assert "skills" in config_yaml
|
||||||
|
materialized = config_yaml["skills"]
|
||||||
|
assert len(materialized) == 1
|
||||||
|
assert materialized[0]["name"] == "pdf"
|
||||||
|
assert materialized[0]["scope"] == "agents"
|
||||||
|
assert "handle the pdf." in materialized[0]["body"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_materialize_skills_in_config_respects_allow_list(tmp_path, monkeypatch):
|
||||||
|
project_root = tmp_path
|
||||||
|
(project_root / ".plano").mkdir()
|
||||||
|
skills_dir = project_root / ".plano" / "skills"
|
||||||
|
skills_dir.mkdir()
|
||||||
|
_write_skill(skills_dir, "skill-a")
|
||||||
|
_write_skill(skills_dir, "skill-b")
|
||||||
|
|
||||||
|
fake_home = tmp_path / "home"
|
||||||
|
fake_home.mkdir()
|
||||||
|
monkeypatch.setenv("HOME", str(fake_home))
|
||||||
|
trusted = fake_home / ".plano" / "trusted_projects.json"
|
||||||
|
trusted.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
trusted.write_text(
|
||||||
|
json.dumps({"trusted_projects": [str(project_root.resolve())]}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"planoai.skills.USER_SKILLS_DIR",
|
||||||
|
fake_home / ".plano" / "skills",
|
||||||
|
)
|
||||||
|
|
||||||
|
from planoai.config_generator import materialize_skills_in_config
|
||||||
|
|
||||||
|
config_yaml = {
|
||||||
|
"version": "v0.4.0",
|
||||||
|
"skills": ["skill-a"],
|
||||||
|
"routing_preferences": [
|
||||||
|
{
|
||||||
|
"name": "demo route",
|
||||||
|
"description": "demo",
|
||||||
|
"models": ["openai/gpt-4o"],
|
||||||
|
"skills": ["skill-a", "does-not-exist"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
materialize_skills_in_config(config_yaml, project_root)
|
||||||
|
|
||||||
|
assert [s["name"] for s in config_yaml["skills"]] == ["skill-a"]
|
||||||
|
# Unknown allow-list entries are pruned but the known one is kept.
|
||||||
|
assert config_yaml["routing_preferences"][0]["skills"] == ["skill-a"]
|
||||||
235
cli/test/test_skills_cmd.py
Normal file
235
cli/test/test_skills_cmd.py
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
"""CLI tests for the `planoai skills` command group."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from planoai.skills_cmd import skills
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_project(tmp_path: Path) -> Path:
|
||||||
|
"""Create a project that find_project_root will pick up via .plano/."""
|
||||||
|
project = tmp_path / "project"
|
||||||
|
project.mkdir()
|
||||||
|
(project / ".plano").mkdir()
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_user_scopes(tmp_path, monkeypatch):
|
||||||
|
"""Default both user-tier scopes to non-existent dirs so the dev's real
|
||||||
|
~/.plano/skills and ~/.agents/skills cannot bleed into the test sandbox.
|
||||||
|
Individual tests can override these via further monkeypatching.
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr("planoai.skills.USER_SKILLS_DIR", tmp_path / "no-user-skills")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"planoai.skills.AGENTS_SKILLS_DIR", tmp_path / "no-agents-skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_skill(base: Path, name: str, description: str = "demo skill") -> None:
|
||||||
|
skill_dir = base / name
|
||||||
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(skill_dir / "SKILL.md").write_text(
|
||||||
|
f"---\nname: {name}\ndescription: {description}\n---\n\nbody",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_empty(tmp_path, monkeypatch):
|
||||||
|
project = _seed_project(tmp_path)
|
||||||
|
monkeypatch.chdir(project)
|
||||||
|
# Isolate user-scope skills dir.
|
||||||
|
monkeypatch.setattr("planoai.skills.USER_SKILLS_DIR", tmp_path / "no-such-home")
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(skills, ["list"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "No skills installed" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_shows_project_skills(tmp_path, monkeypatch):
|
||||||
|
project = _seed_project(tmp_path)
|
||||||
|
monkeypatch.chdir(project)
|
||||||
|
monkeypatch.setattr("planoai.skills.USER_SKILLS_DIR", tmp_path / "no-such-home")
|
||||||
|
|
||||||
|
_write_skill(project / ".plano" / "skills", "pdf-processing")
|
||||||
|
_write_skill(project / ".plano" / "skills", "code-review")
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(skills, ["list", "--no-user-scope"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "pdf-processing" in result.output
|
||||||
|
assert "code-review" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_deletes_skill_dir(tmp_path, monkeypatch):
|
||||||
|
project = _seed_project(tmp_path)
|
||||||
|
monkeypatch.chdir(project)
|
||||||
|
monkeypatch.setattr("planoai.skills.USER_SKILLS_DIR", tmp_path / "no-such-home")
|
||||||
|
|
||||||
|
skills_dir = project / ".plano" / "skills"
|
||||||
|
_write_skill(skills_dir, "pdf-processing")
|
||||||
|
(skills_dir / ".skills.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"skills": {
|
||||||
|
"pdf-processing": {
|
||||||
|
"source": "git",
|
||||||
|
"repo": "owner/pdf-processing",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(skills, ["remove", "pdf-processing"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert not (skills_dir / "pdf-processing").exists()
|
||||||
|
manifest = json.loads((skills_dir / ".skills.json").read_text(encoding="utf-8"))
|
||||||
|
assert "pdf-processing" not in manifest["skills"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_unknown_skill_errors(tmp_path, monkeypatch):
|
||||||
|
project = _seed_project(tmp_path)
|
||||||
|
monkeypatch.chdir(project)
|
||||||
|
monkeypatch.setattr("planoai.skills.USER_SKILLS_DIR", tmp_path / "no-such-home")
|
||||||
|
(project / ".plano" / "skills").mkdir()
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(skills, ["remove", "nope"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_falls_back_to_git_when_no_npx(tmp_path, monkeypatch):
|
||||||
|
project = _seed_project(tmp_path)
|
||||||
|
monkeypatch.chdir(project)
|
||||||
|
|
||||||
|
# Force the npx branch off and stub git clone to create a SKILL.md.
|
||||||
|
monkeypatch.setattr("planoai.skills_cmd._has_npx", lambda: False)
|
||||||
|
monkeypatch.setattr("planoai.skills_cmd._has_git", lambda: True)
|
||||||
|
|
||||||
|
def fake_subprocess_run(cmd, **kwargs):
|
||||||
|
# cmd is like ["git", "clone", ..., url, dest]
|
||||||
|
dest = Path(cmd[-1])
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
(dest / "SKILL.md").write_text(
|
||||||
|
"---\nname: my-skill\ndescription: example\n---\n\nbody",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(dest / ".git").mkdir()
|
||||||
|
return mock.Mock(returncode=0)
|
||||||
|
|
||||||
|
monkeypatch.setattr("planoai.skills_cmd.subprocess.run", fake_subprocess_run)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(skills, ["add", "owner/my-skill"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert (project / ".plano" / "skills" / "my-skill" / "SKILL.md").exists()
|
||||||
|
# Trust hint should be shown for untrusted projects with project-scope installs.
|
||||||
|
assert "planoai skills trust" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_discovers_skill_installed_into_agents_scope_by_npx(tmp_path, monkeypatch):
|
||||||
|
"""`npx skills add` writes to ~/.agents/skills/<name>; planoai must
|
||||||
|
pick it up from that universal scope and *not* nag about trust.
|
||||||
|
"""
|
||||||
|
project = _seed_project(tmp_path)
|
||||||
|
monkeypatch.chdir(project)
|
||||||
|
|
||||||
|
agents_dir = tmp_path / "agents" / "skills"
|
||||||
|
agents_dir.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr("planoai.skills.AGENTS_SKILLS_DIR", agents_dir)
|
||||||
|
|
||||||
|
# Pretend npx is on $PATH and succeeds, dropping the skill in ~/.agents/skills
|
||||||
|
# rather than .plano/skills (which is what the upstream CLI actually does).
|
||||||
|
monkeypatch.setattr("planoai.skills_cmd._has_npx", lambda: True)
|
||||||
|
|
||||||
|
def fake_install_via_npx(target, project_root, console):
|
||||||
|
skill_dir = agents_dir / "pdf"
|
||||||
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(skill_dir / "SKILL.md").write_text(
|
||||||
|
"---\nname: pdf\ndescription: process pdfs\n---\n\nbody",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr("planoai.skills_cmd._install_via_npx", fake_install_via_npx)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(skills, ["add", "openai/skills"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "scope=agents" in result.output
|
||||||
|
assert "planoai skills trust" not in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_includes_agents_scope_entries(tmp_path, monkeypatch):
|
||||||
|
project = _seed_project(tmp_path)
|
||||||
|
monkeypatch.chdir(project)
|
||||||
|
|
||||||
|
agents_dir = tmp_path / "agents-skills"
|
||||||
|
agents_dir.mkdir()
|
||||||
|
monkeypatch.setattr("planoai.skills.AGENTS_SKILLS_DIR", agents_dir)
|
||||||
|
_write_skill(agents_dir, "pdf")
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(skills, ["list"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "pdf" in result.output
|
||||||
|
assert "agents" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_rejects_agents_scope_skill(tmp_path, monkeypatch):
|
||||||
|
project = _seed_project(tmp_path)
|
||||||
|
monkeypatch.chdir(project)
|
||||||
|
(project / ".plano" / "skills").mkdir()
|
||||||
|
|
||||||
|
agents_dir = tmp_path / "agents-skills"
|
||||||
|
agents_dir.mkdir()
|
||||||
|
monkeypatch.setattr("planoai.skills.AGENTS_SKILLS_DIR", agents_dir)
|
||||||
|
_write_skill(agents_dir, "pdf")
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(skills, ["remove", "pdf"])
|
||||||
|
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "npx skills remove" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_trust_marks_project(tmp_path, monkeypatch):
|
||||||
|
project = _seed_project(tmp_path)
|
||||||
|
monkeypatch.chdir(project)
|
||||||
|
|
||||||
|
fake_home = tmp_path / "home"
|
||||||
|
fake_home.mkdir()
|
||||||
|
monkeypatch.setenv("HOME", str(fake_home))
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(skills, ["trust"])
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
trusted_file = fake_home / ".plano" / "trusted_projects.json"
|
||||||
|
assert trusted_file.exists()
|
||||||
|
data = json.loads(trusted_file.read_text(encoding="utf-8"))
|
||||||
|
assert str(project.resolve()) in data["trusted_projects"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_rejects_invalid_target(tmp_path, monkeypatch):
|
||||||
|
project = _seed_project(tmp_path)
|
||||||
|
monkeypatch.chdir(project)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(skills, ["add", "not-a-spec"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
|
@ -316,6 +316,52 @@ properties:
|
||||||
description: "Maximum token length for the orchestrator/routing model context window. Default is 8192."
|
description: "Maximum token length for the orchestrator/routing model context window. Default is 8192."
|
||||||
system_prompt:
|
system_prompt:
|
||||||
type: string
|
type: string
|
||||||
|
skills:
|
||||||
|
type: array
|
||||||
|
description: |
|
||||||
|
Agent Skills (https://agentskills.io) registered for this Plano project.
|
||||||
|
Each entry may be a string (the skill name, resolved against
|
||||||
|
.plano/skills/<name>/ or ~/.plano/skills/<name>/) or an object with name
|
||||||
|
+ optional inline metadata. The Python CLI auto-populates body/path
|
||||||
|
during config rendering. Skills are attached to routes via
|
||||||
|
`routing_preferences[].skills`; when omitted there, the orchestrator
|
||||||
|
sees every entry declared (or auto-discovered) here.
|
||||||
|
items:
|
||||||
|
oneOf:
|
||||||
|
- type: string
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
path:
|
||||||
|
type: string
|
||||||
|
description: "Absolute path to the SKILL.md file (set by the CLI at render time)."
|
||||||
|
base_dir:
|
||||||
|
type: string
|
||||||
|
description: "Absolute path to the skill directory (set by the CLI at render time)."
|
||||||
|
body:
|
||||||
|
type: string
|
||||||
|
description: "Markdown body of SKILL.md (inlined at render time so WASM does not need filesystem access)."
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- project
|
||||||
|
- user
|
||||||
|
compatibility:
|
||||||
|
type: string
|
||||||
|
license:
|
||||||
|
type: string
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: string
|
||||||
|
allowed_tools:
|
||||||
|
type: string
|
||||||
|
additionalProperties: false
|
||||||
|
required:
|
||||||
|
- name
|
||||||
prompt_targets:
|
prompt_targets:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
|
|
@ -549,6 +595,17 @@ properties:
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
minItems: 1
|
minItems: 1
|
||||||
|
skills:
|
||||||
|
type: array
|
||||||
|
description: |
|
||||||
|
Agent Skills associated with this routing preference. When
|
||||||
|
Plano-Orchestrator selects this route, the listed skills are also
|
||||||
|
considered for activation and their SKILL.md bodies are injected
|
||||||
|
into the upstream system prompt. Skill names must match an entry
|
||||||
|
in the top-level `skills:` catalog or be discoverable under
|
||||||
|
`.plano/skills/` or `~/.plano/skills/`.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
selection_policy:
|
selection_policy:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|
|
||||||
|
|
@ -132,11 +132,15 @@ impl AgentSelector {
|
||||||
.determine_orchestration(messages, Some(usage_preferences), request_id)
|
.determine_orchestration(messages, Some(usage_preferences), request_id)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Some(routes)) => {
|
Ok(Some(selection)) => {
|
||||||
debug!(count = routes.len(), "determined agents via orchestration");
|
debug!(
|
||||||
|
count = selection.routes.len(),
|
||||||
|
skill_count = selection.skills.len(),
|
||||||
|
"determined agents via orchestration"
|
||||||
|
);
|
||||||
let mut selected_agents = Vec::new();
|
let mut selected_agents = Vec::new();
|
||||||
|
|
||||||
for (route_name, agent_name) in routes {
|
for (route_name, agent_name) in selection.routes {
|
||||||
debug!(route = %route_name, agent = %agent_name, "processing route");
|
debug!(route = %route_name, agent = %agent_name, "processing route");
|
||||||
let selected_agent = agents
|
let selected_agent = agents
|
||||||
.iter()
|
.iter()
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ use crate::tracing::{
|
||||||
collect_custom_trace_attributes, llm as tracing_llm, operation_component,
|
collect_custom_trace_attributes, llm as tracing_llm, operation_component,
|
||||||
plano as tracing_plano, set_service_name,
|
plano as tracing_plano, set_service_name,
|
||||||
};
|
};
|
||||||
use model_selection::router_chat_get_upstream_model;
|
use model_selection::{inject_activated_skills_into_request, router_chat_get_upstream_model};
|
||||||
|
|
||||||
const PERPLEXITY_PROVIDER_PREFIX: &str = "perplexity/";
|
const PERPLEXITY_PROVIDER_PREFIX: &str = "perplexity/";
|
||||||
|
|
||||||
|
|
@ -282,26 +282,16 @@ async fn llm_chat_inner(
|
||||||
Err(response) => return Ok(response),
|
Err(response) => return Ok(response),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Serialize request for upstream BEFORE router consumes it
|
// Route the request (or use pinned model from session cache) BEFORE
|
||||||
let client_request_bytes_for_upstream: Bytes =
|
// serializing for upstream — skill body injection happens here, so the
|
||||||
match ProviderRequestType::to_bytes(&client_request) {
|
// upstream bytes must be produced after routing returns.
|
||||||
Ok(bytes) => bytes.into(),
|
let (resolved_model, activated_skills) = if let Some(cached_model) = pinned_model {
|
||||||
Err(err) => {
|
|
||||||
warn!(error = %err, "failed to serialize request for upstream");
|
|
||||||
let mut r = Response::new(full(format!("Failed to serialize request: {}", err)));
|
|
||||||
*r.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
|
|
||||||
return Ok(r);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Phase 3: Route the request (or use pinned model from session cache) ---
|
|
||||||
let resolved_model = if let Some(cached_model) = pinned_model {
|
|
||||||
info!(
|
info!(
|
||||||
session_id = %session_id.as_deref().unwrap_or(""),
|
session_id = %session_id.as_deref().unwrap_or(""),
|
||||||
model = %cached_model,
|
model = %cached_model,
|
||||||
"using pinned routing decision from cache"
|
"using pinned routing decision from cache"
|
||||||
);
|
);
|
||||||
cached_model
|
(cached_model, Vec::new())
|
||||||
} else {
|
} else {
|
||||||
let routing_span = info_span!(
|
let routing_span = info_span!(
|
||||||
"routing",
|
"routing",
|
||||||
|
|
@ -313,11 +303,16 @@ async fn llm_chat_inner(
|
||||||
route.selected_model = tracing::field::Empty,
|
route.selected_model = tracing::field::Empty,
|
||||||
routing.determination_ms = tracing::field::Empty,
|
routing.determination_ms = tracing::field::Empty,
|
||||||
);
|
);
|
||||||
|
// The router consumes the request (it converts it to OpenAI format
|
||||||
|
// internally to extract conversation messages). Clone so we can
|
||||||
|
// still mutate the original below when Plano-Orchestrator activates
|
||||||
|
// any Agent Skills.
|
||||||
|
let request_for_routing = client_request.clone();
|
||||||
let routing_result = match async {
|
let routing_result = match async {
|
||||||
set_service_name(operation_component::ROUTING);
|
set_service_name(operation_component::ROUTING);
|
||||||
router_chat_get_upstream_model(
|
router_chat_get_upstream_model(
|
||||||
Arc::clone(&state.orchestrator_service),
|
Arc::clone(&state.orchestrator_service),
|
||||||
client_request,
|
request_for_routing,
|
||||||
&request_path,
|
&request_path,
|
||||||
&request_id,
|
&request_id,
|
||||||
inline_routing_preferences,
|
inline_routing_preferences,
|
||||||
|
|
@ -335,8 +330,11 @@ async fn llm_chat_inner(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (router_selected_model, route_name) =
|
let (router_selected_model, route_name, activated) = (
|
||||||
(routing_result.model_name, routing_result.route_name);
|
routing_result.model_name,
|
||||||
|
routing_result.route_name,
|
||||||
|
routing_result.activated_skills,
|
||||||
|
);
|
||||||
let model = if router_selected_model != "none" {
|
let model = if router_selected_model != "none" {
|
||||||
router_selected_model
|
router_selected_model
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -362,10 +360,34 @@ async fn llm_chat_inner(
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
model
|
(model, activated)
|
||||||
};
|
};
|
||||||
tracing::Span::current().record(tracing_llm::MODEL_NAME, resolved_model.as_str());
|
tracing::Span::current().record(tracing_llm::MODEL_NAME, resolved_model.as_str());
|
||||||
|
|
||||||
|
// If Plano-Orchestrator activated any Agent Skills for this route, inject
|
||||||
|
// their SKILL.md bodies into the system prompt before we hand the bytes
|
||||||
|
// off to the upstream provider.
|
||||||
|
if !activated_skills.is_empty() {
|
||||||
|
info!(
|
||||||
|
count = activated_skills.len(),
|
||||||
|
skills = ?activated_skills.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
|
||||||
|
"injecting activated Agent Skills into upstream system prompt"
|
||||||
|
);
|
||||||
|
inject_activated_skills_into_request(&mut client_request, &activated_skills);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize request for upstream AFTER potential skill injection.
|
||||||
|
let client_request_bytes_for_upstream: Bytes =
|
||||||
|
match ProviderRequestType::to_bytes(&client_request) {
|
||||||
|
Ok(bytes) => bytes.into(),
|
||||||
|
Err(err) => {
|
||||||
|
warn!(error = %err, "failed to serialize request for upstream");
|
||||||
|
let mut r = Response::new(full(format!("Failed to serialize request: {}", err)));
|
||||||
|
*r.status_mut() = StatusCode::INTERNAL_SERVER_ERROR;
|
||||||
|
return Ok(r);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// --- Phase 4: Forward to upstream and stream back ---
|
// --- Phase 4: Forward to upstream and stream back ---
|
||||||
send_upstream(
|
send_upstream(
|
||||||
&state.http_client,
|
&state.http_client,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
use common::configuration::TopLevelRoutingPreference;
|
use common::configuration::{SkillRef, TopLevelRoutingPreference};
|
||||||
|
use common::skills_runtime::augment_system_prompt_with_skills;
|
||||||
|
use hermesllm::apis::openai::{Message, MessageContent, Role};
|
||||||
use hermesllm::clients::endpoints::SupportedUpstreamAPIs;
|
use hermesllm::clients::endpoints::SupportedUpstreamAPIs;
|
||||||
|
use hermesllm::providers::request::ProviderRequest;
|
||||||
|
use hermesllm::transforms::lib::ExtractText;
|
||||||
use hermesllm::ProviderRequestType;
|
use hermesllm::ProviderRequestType;
|
||||||
use hyper::StatusCode;
|
use hyper::StatusCode;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -29,6 +33,10 @@ pub struct RoutingResult {
|
||||||
/// Full ranked list — use subsequent entries as fallbacks on 429/5xx.
|
/// Full ranked list — use subsequent entries as fallbacks on 429/5xx.
|
||||||
pub models: Vec<String>,
|
pub models: Vec<String>,
|
||||||
pub route_name: Option<String>,
|
pub route_name: Option<String>,
|
||||||
|
/// Agent Skills activated by Plano-Orchestrator for this request.
|
||||||
|
/// Their `body` field (the SKILL.md content) is prepended to the
|
||||||
|
/// upstream system prompt by the caller in `send_upstream`.
|
||||||
|
pub activated_skills: Vec<SkillRef>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RoutingError {
|
pub struct RoutingError {
|
||||||
|
|
@ -128,8 +136,8 @@ pub async fn router_chat_get_upstream_model(
|
||||||
|
|
||||||
match routing_result {
|
match routing_result {
|
||||||
Ok(route) => match route {
|
Ok(route) => match route {
|
||||||
Some((route_name, ranked_models)) => {
|
Some(decision) => {
|
||||||
let model_name = ranked_models.first().cloned().unwrap_or_default();
|
let model_name = decision.models.first().cloned().unwrap_or_default();
|
||||||
current_span.record("route.selected_model", model_name.as_str());
|
current_span.record("route.selected_model", model_name.as_str());
|
||||||
bs_metrics::record_router_decision(
|
bs_metrics::record_router_decision(
|
||||||
route_label,
|
route_label,
|
||||||
|
|
@ -139,8 +147,9 @@ pub async fn router_chat_get_upstream_model(
|
||||||
);
|
);
|
||||||
Ok(RoutingResult {
|
Ok(RoutingResult {
|
||||||
model_name,
|
model_name,
|
||||||
models: ranked_models,
|
models: decision.models,
|
||||||
route_name: Some(route_name),
|
route_name: Some(decision.route_name),
|
||||||
|
activated_skills: decision.activated_skills,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
|
@ -159,6 +168,7 @@ pub async fn router_chat_get_upstream_model(
|
||||||
model_name: "none".to_string(),
|
model_name: "none".to_string(),
|
||||||
models: vec!["none".to_string()],
|
models: vec!["none".to_string()],
|
||||||
route_name: None,
|
route_name: None,
|
||||||
|
activated_skills: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -172,3 +182,61 @@ pub async fn router_chat_get_upstream_model(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prepend the bodies of `activated_skills` to the system prompt of the
|
||||||
|
/// upstream request so the chosen LLM has access to each skill's instructions.
|
||||||
|
/// Works across every provider variant by going through the OpenAI message
|
||||||
|
/// shape (`get_messages`/`set_messages`).
|
||||||
|
///
|
||||||
|
/// When there is already a leading system message we augment it in place;
|
||||||
|
/// otherwise a new system message is inserted at position 0. No-op when
|
||||||
|
/// `activated_skills` is empty.
|
||||||
|
pub fn inject_activated_skills_into_request(
|
||||||
|
client_request: &mut ProviderRequestType,
|
||||||
|
activated_skills: &[SkillRef],
|
||||||
|
) {
|
||||||
|
if activated_skills.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let skill_refs: Vec<&SkillRef> = activated_skills.iter().collect();
|
||||||
|
|
||||||
|
let mut messages = client_request.get_messages();
|
||||||
|
|
||||||
|
let (system_idx, base_text) = match messages.iter().position(|m| m.role == Role::System) {
|
||||||
|
Some(idx) => {
|
||||||
|
let text = messages[idx]
|
||||||
|
.content
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.extract_text())
|
||||||
|
.unwrap_or_default();
|
||||||
|
(Some(idx), Some(text))
|
||||||
|
}
|
||||||
|
None => (None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let augmented = augment_system_prompt_with_skills(base_text, &skill_refs);
|
||||||
|
let Some(augmented_text) = augmented else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match system_idx {
|
||||||
|
Some(idx) => {
|
||||||
|
messages[idx].content = Some(MessageContent::Text(augmented_text));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
messages.insert(
|
||||||
|
0,
|
||||||
|
Message {
|
||||||
|
role: Role::System,
|
||||||
|
content: Some(MessageContent::Text(augmented_text)),
|
||||||
|
name: None,
|
||||||
|
tool_calls: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client_request.set_messages(&messages);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -314,11 +314,12 @@ async fn init_app_state(
|
||||||
.orchestrator_model_context_length
|
.orchestrator_model_context_length
|
||||||
.unwrap_or(brightstaff::router::orchestrator_model_v1::MAX_TOKEN_LEN);
|
.unwrap_or(brightstaff::router::orchestrator_model_v1::MAX_TOKEN_LEN);
|
||||||
|
|
||||||
let orchestrator_service = Arc::new(OrchestratorService::with_routing(
|
let orchestrator_service = Arc::new(OrchestratorService::with_routing_and_skills(
|
||||||
format!("{llm_provider_url}{CHAT_COMPLETIONS_PATH}"),
|
format!("{llm_provider_url}{CHAT_COMPLETIONS_PATH}"),
|
||||||
orchestrator_model_name,
|
orchestrator_model_name,
|
||||||
orchestrator_llm_provider,
|
orchestrator_llm_provider,
|
||||||
config.routing_preferences.clone(),
|
config.routing_preferences.clone(),
|
||||||
|
config.skills.clone(),
|
||||||
metrics_service,
|
metrics_service,
|
||||||
session_ttl_seconds,
|
session_ttl_seconds,
|
||||||
session_cache,
|
session_cache,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
use std::{borrow::Cow, collections::HashMap, sync::Arc, time::Duration};
|
use std::{borrow::Cow, collections::HashMap, sync::Arc, time::Duration};
|
||||||
|
|
||||||
use common::{
|
use common::{
|
||||||
configuration::{AgentUsagePreference, OrchestrationPreference, TopLevelRoutingPreference},
|
configuration::{
|
||||||
|
AgentUsagePreference, OrchestrationPreference, SkillRef, TopLevelRoutingPreference,
|
||||||
|
},
|
||||||
consts::{ARCH_PROVIDER_HINT_HEADER, REQUEST_ID_HEADER},
|
consts::{ARCH_PROVIDER_HINT_HEADER, REQUEST_ID_HEADER},
|
||||||
};
|
};
|
||||||
use hermesllm::apis::openai::Message;
|
use hermesllm::apis::openai::Message;
|
||||||
|
|
@ -13,7 +15,7 @@ use tracing::{debug, info};
|
||||||
|
|
||||||
use super::http::{self, post_and_extract_content};
|
use super::http::{self, post_and_extract_content};
|
||||||
use super::model_metrics::ModelMetricsService;
|
use super::model_metrics::ModelMetricsService;
|
||||||
use super::orchestrator_model::OrchestratorModel;
|
use super::orchestrator_model::{OrchestratorModel, OrchestratorSelection};
|
||||||
|
|
||||||
use crate::metrics as bs_metrics;
|
use crate::metrics as bs_metrics;
|
||||||
use crate::metrics::labels as metric_labels;
|
use crate::metrics::labels as metric_labels;
|
||||||
|
|
@ -30,12 +32,27 @@ pub struct OrchestratorService {
|
||||||
orchestrator_model: Arc<dyn OrchestratorModel>,
|
orchestrator_model: Arc<dyn OrchestratorModel>,
|
||||||
orchestrator_provider_name: String,
|
orchestrator_provider_name: String,
|
||||||
top_level_preferences: HashMap<String, TopLevelRoutingPreference>,
|
top_level_preferences: HashMap<String, TopLevelRoutingPreference>,
|
||||||
|
/// Agent Skills catalog (deduplicated by name) attached to any
|
||||||
|
/// `routing_preferences[].skills` list. Empty when no route has skills.
|
||||||
|
skills_catalog: Vec<SkillRef>,
|
||||||
metrics_service: Option<Arc<ModelMetricsService>>,
|
metrics_service: Option<Arc<ModelMetricsService>>,
|
||||||
session_cache: Option<Arc<dyn SessionCache>>,
|
session_cache: Option<Arc<dyn SessionCache>>,
|
||||||
session_ttl: Duration,
|
session_ttl: Duration,
|
||||||
tenant_header: Option<String>,
|
tenant_header: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Result of `determine_route`: which route was picked, the ranked candidate
|
||||||
|
/// models for that route, and the Agent Skill bodies the orchestrator chose
|
||||||
|
/// to activate alongside it. Skills are resolved against
|
||||||
|
/// `routing_preferences[<route>].skills`, so unknown / cross-route names are
|
||||||
|
/// silently dropped.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct RouteDecision {
|
||||||
|
pub route_name: String,
|
||||||
|
pub models: Vec<String>,
|
||||||
|
pub activated_skills: Vec<SkillRef>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum OrchestrationError {
|
pub enum OrchestrationError {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
|
|
@ -66,6 +83,7 @@ impl OrchestratorService {
|
||||||
orchestrator_model,
|
orchestrator_model,
|
||||||
orchestrator_provider_name,
|
orchestrator_provider_name,
|
||||||
top_level_preferences: HashMap::new(),
|
top_level_preferences: HashMap::new(),
|
||||||
|
skills_catalog: Vec::new(),
|
||||||
metrics_service: None,
|
metrics_service: None,
|
||||||
session_cache: None,
|
session_cache: None,
|
||||||
session_ttl: Duration::from_secs(DEFAULT_SESSION_TTL_SECONDS),
|
session_ttl: Duration::from_secs(DEFAULT_SESSION_TTL_SECONDS),
|
||||||
|
|
@ -84,14 +102,53 @@ impl OrchestratorService {
|
||||||
session_cache: Arc<dyn SessionCache>,
|
session_cache: Arc<dyn SessionCache>,
|
||||||
tenant_header: Option<String>,
|
tenant_header: Option<String>,
|
||||||
max_token_length: usize,
|
max_token_length: usize,
|
||||||
|
) -> Self {
|
||||||
|
Self::with_routing_and_skills(
|
||||||
|
orchestrator_url,
|
||||||
|
orchestration_model_name,
|
||||||
|
orchestrator_provider_name,
|
||||||
|
top_level_prefs,
|
||||||
|
None,
|
||||||
|
metrics_service,
|
||||||
|
session_ttl_seconds,
|
||||||
|
session_cache,
|
||||||
|
tenant_header,
|
||||||
|
max_token_length,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `with_routing`, but also seeds the orchestrator with a catalog of
|
||||||
|
/// Agent Skills referenced by `routing_preferences[].skills`. The
|
||||||
|
/// orchestrator gets a `<skills>` block in its system prompt and may
|
||||||
|
/// select zero or more skills alongside the picked route; this enables
|
||||||
|
/// the LLM handler to inject the chosen SKILL.md bodies into the
|
||||||
|
/// upstream request.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn with_routing_and_skills(
|
||||||
|
orchestrator_url: String,
|
||||||
|
orchestration_model_name: String,
|
||||||
|
orchestrator_provider_name: String,
|
||||||
|
top_level_prefs: Option<Vec<TopLevelRoutingPreference>>,
|
||||||
|
skills_catalog: Option<Vec<SkillRef>>,
|
||||||
|
metrics_service: Option<Arc<ModelMetricsService>>,
|
||||||
|
session_ttl_seconds: Option<u64>,
|
||||||
|
session_cache: Arc<dyn SessionCache>,
|
||||||
|
tenant_header: Option<String>,
|
||||||
|
max_token_length: usize,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let top_level_preferences: HashMap<String, TopLevelRoutingPreference> = top_level_prefs
|
let top_level_preferences: HashMap<String, TopLevelRoutingPreference> = top_level_prefs
|
||||||
.map_or_else(HashMap::new, |prefs| {
|
.map_or_else(HashMap::new, |prefs| {
|
||||||
prefs.into_iter().map(|p| (p.name.clone(), p)).collect()
|
prefs.into_iter().map(|p| (p.name.clone(), p)).collect()
|
||||||
});
|
});
|
||||||
|
|
||||||
let orchestrator_model = Arc::new(orchestrator_model_v1::OrchestratorModelV1::new(
|
let skills_catalog = build_skills_catalog_for_routes(
|
||||||
|
skills_catalog.as_deref().unwrap_or(&[]),
|
||||||
|
&top_level_preferences,
|
||||||
|
);
|
||||||
|
|
||||||
|
let orchestrator_model = Arc::new(orchestrator_model_v1::OrchestratorModelV1::with_skills(
|
||||||
HashMap::new(),
|
HashMap::new(),
|
||||||
|
skills_catalog.clone(),
|
||||||
orchestration_model_name,
|
orchestration_model_name,
|
||||||
max_token_length,
|
max_token_length,
|
||||||
));
|
));
|
||||||
|
|
@ -105,6 +162,7 @@ impl OrchestratorService {
|
||||||
orchestrator_model,
|
orchestrator_model,
|
||||||
orchestrator_provider_name,
|
orchestrator_provider_name,
|
||||||
top_level_preferences,
|
top_level_preferences,
|
||||||
|
skills_catalog,
|
||||||
metrics_service,
|
metrics_service,
|
||||||
session_cache: Some(session_cache),
|
session_cache: Some(session_cache),
|
||||||
session_ttl,
|
session_ttl,
|
||||||
|
|
@ -170,7 +228,7 @@ impl OrchestratorService {
|
||||||
messages: &[Message],
|
messages: &[Message],
|
||||||
inline_routing_preferences: Option<Vec<TopLevelRoutingPreference>>,
|
inline_routing_preferences: Option<Vec<TopLevelRoutingPreference>>,
|
||||||
request_id: &str,
|
request_id: &str,
|
||||||
) -> Result<Option<(String, Vec<String>)>> {
|
) -> Result<Option<RouteDecision>> {
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
@ -206,9 +264,13 @@ impl OrchestratorService {
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let result = if let Some(ref routes) = orchestration_result {
|
let result = if let Some(ref selection) = orchestration_result {
|
||||||
if routes.len() > 1 {
|
if selection.routes.len() > 1 {
|
||||||
let all_routes: Vec<&str> = routes.iter().map(|(name, _)| name.as_str()).collect();
|
let all_routes: Vec<&str> = selection
|
||||||
|
.routes
|
||||||
|
.iter()
|
||||||
|
.map(|(name, _)| name.as_str())
|
||||||
|
.collect();
|
||||||
info!(
|
info!(
|
||||||
routes = ?all_routes,
|
routes = ?all_routes,
|
||||||
using = %all_routes.first().unwrap_or(&"none"),
|
using = %all_routes.first().unwrap_or(&"none"),
|
||||||
|
|
@ -216,7 +278,7 @@ impl OrchestratorService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((route_name, _)) = routes.first() {
|
if let Some((route_name, _)) = selection.routes.first() {
|
||||||
let top_pref = inline_top_map
|
let top_pref = inline_top_map
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|m| m.get(route_name))
|
.and_then(|m| m.get(route_name))
|
||||||
|
|
@ -227,7 +289,16 @@ impl OrchestratorService {
|
||||||
Some(svc) => svc.rank_models(&pref.models, &pref.selection_policy).await,
|
Some(svc) => svc.rank_models(&pref.models, &pref.selection_policy).await,
|
||||||
None => pref.models.clone(),
|
None => pref.models.clone(),
|
||||||
};
|
};
|
||||||
Some((route_name.clone(), ranked))
|
let activated_skills = resolve_activated_skills(
|
||||||
|
&self.skills_catalog,
|
||||||
|
pref.skills.as_deref().unwrap_or(&[]),
|
||||||
|
&selection.skills,
|
||||||
|
);
|
||||||
|
Some(RouteDecision {
|
||||||
|
route_name: route_name.clone(),
|
||||||
|
models: ranked,
|
||||||
|
activated_skills,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
@ -239,7 +310,7 @@ impl OrchestratorService {
|
||||||
};
|
};
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
selected_model = ?result,
|
selected_route = ?result.as_ref().map(|r| (&r.route_name, r.models.first(), r.activated_skills.iter().map(|s| s.name.as_str()).collect::<Vec<_>>())),
|
||||||
"plano-orchestrator determined route"
|
"plano-orchestrator determined route"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -253,7 +324,7 @@ impl OrchestratorService {
|
||||||
messages: &[Message],
|
messages: &[Message],
|
||||||
usage_preferences: Option<Vec<AgentUsagePreference>>,
|
usage_preferences: Option<Vec<AgentUsagePreference>>,
|
||||||
request_id: Option<String>,
|
request_id: Option<String>,
|
||||||
) -> Result<Option<Vec<(String, String)>>> {
|
) -> Result<Option<OrchestratorSelection>> {
|
||||||
if messages.is_empty() {
|
if messages.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
@ -328,6 +399,61 @@ impl OrchestratorService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the orchestrator-visible skills catalog (deduplicated by name) from
|
||||||
|
/// the union of every skill name referenced under
|
||||||
|
/// `routing_preferences[].skills`. Skills that are not referenced by any
|
||||||
|
/// route are excluded — they would just clutter the prompt with no way for
|
||||||
|
/// the orchestrator to attach them to a route.
|
||||||
|
fn build_skills_catalog_for_routes(
|
||||||
|
catalog: &[SkillRef],
|
||||||
|
routes: &HashMap<String, TopLevelRoutingPreference>,
|
||||||
|
) -> Vec<SkillRef> {
|
||||||
|
let mut referenced: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||||
|
for route in routes.values() {
|
||||||
|
if let Some(names) = route.skills.as_ref() {
|
||||||
|
for name in names {
|
||||||
|
referenced.insert(name.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out: Vec<SkillRef> = Vec::new();
|
||||||
|
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
for skill in catalog {
|
||||||
|
if referenced.contains(skill.name.as_str()) && seen.insert(skill.name.clone()) {
|
||||||
|
out.push(skill.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter the orchestrator-selected skill names down to the SKILL.md bodies
|
||||||
|
/// allowed for the chosen route, preserving the order the orchestrator
|
||||||
|
/// returned. Unknown names (either not in the catalog or not allowed by the
|
||||||
|
/// route) are silently dropped; the orchestrator can hallucinate.
|
||||||
|
fn resolve_activated_skills(
|
||||||
|
catalog: &[SkillRef],
|
||||||
|
route_allowlist: &[String],
|
||||||
|
selected: &[String],
|
||||||
|
) -> Vec<SkillRef> {
|
||||||
|
let allowed: std::collections::HashSet<&str> =
|
||||||
|
route_allowlist.iter().map(String::as_str).collect();
|
||||||
|
let mut out: Vec<SkillRef> = Vec::with_capacity(selected.len());
|
||||||
|
let mut taken: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||||
|
for name in selected {
|
||||||
|
if !allowed.contains(name.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !taken.insert(name.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(skill) = catalog.iter().find(|s| &s.name == name) {
|
||||||
|
out.push(skill.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -10,20 +10,37 @@ pub enum OrchestratorModelError {
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, OrchestratorModelError>;
|
pub type Result<T> = std::result::Result<T, OrchestratorModelError>;
|
||||||
|
|
||||||
|
/// The result of running Plano-Orchestrator over a conversation: zero or more
|
||||||
|
/// selected routes (each mapped to its upstream model name) plus zero or more
|
||||||
|
/// selected Agent Skills. Skills are filtered down by the consumer to the
|
||||||
|
/// catalog defined under `routing_preferences[].skills` for the chosen route.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct OrchestratorSelection {
|
||||||
|
pub routes: Vec<(String, String)>,
|
||||||
|
pub skills: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OrchestratorSelection {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.routes.is_empty() && self.skills.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// OrchestratorModel trait for handling orchestration requests.
|
/// OrchestratorModel trait for handling orchestration requests.
|
||||||
/// Returns multiple routes as the model output format is:
|
/// Returns multiple routes and skills as the model output format is:
|
||||||
/// {"route": ["route_name_1", "route_name_2", ...]}
|
/// {"route": ["route_name_1", ...], "skills": ["skill_name_1", ...]}
|
||||||
pub trait OrchestratorModel: Send + Sync {
|
pub trait OrchestratorModel: Send + Sync {
|
||||||
fn generate_request(
|
fn generate_request(
|
||||||
&self,
|
&self,
|
||||||
messages: &[Message],
|
messages: &[Message],
|
||||||
usage_preferences: &Option<Vec<AgentUsagePreference>>,
|
usage_preferences: &Option<Vec<AgentUsagePreference>>,
|
||||||
) -> ChatCompletionsRequest;
|
) -> ChatCompletionsRequest;
|
||||||
/// Returns a vector of (route_name, model_name) tuples for all matched routes.
|
/// Parses the orchestrator's raw model output into selected routes (each
|
||||||
|
/// mapped to a model) and selected skill names.
|
||||||
fn parse_response(
|
fn parse_response(
|
||||||
&self,
|
&self,
|
||||||
content: &str,
|
content: &str,
|
||||||
usage_preferences: &Option<Vec<AgentUsagePreference>>,
|
usage_preferences: &Option<Vec<AgentUsagePreference>>,
|
||||||
) -> Result<Option<Vec<(String, String)>>>;
|
) -> Result<Option<OrchestratorSelection>>;
|
||||||
fn get_model_name(&self) -> String;
|
fn get_model_name(&self) -> String;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use common::configuration::{AgentUsagePreference, OrchestrationPreference};
|
use common::configuration::{AgentUsagePreference, OrchestrationPreference, SkillRef};
|
||||||
use hermesllm::apis::openai::{ChatCompletionsRequest, Message, MessageContent, Role};
|
use hermesllm::apis::openai::{ChatCompletionsRequest, Message, MessageContent, Role};
|
||||||
use hermesllm::transforms::lib::ExtractText;
|
use hermesllm::transforms::lib::ExtractText;
|
||||||
use serde::{ser::Serialize as SerializeTrait, Deserialize, Serialize};
|
use serde::{ser::Serialize as SerializeTrait, Deserialize, Serialize};
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
use super::orchestrator_model::{OrchestratorModel, OrchestratorModelError};
|
use super::orchestrator_model::{OrchestratorModel, OrchestratorModelError, OrchestratorSelection};
|
||||||
|
|
||||||
pub const MAX_TOKEN_LEN: usize = 8192; // Default max token length for the orchestration model
|
pub const MAX_TOKEN_LEN: usize = 8192; // Default max token length for the orchestration model
|
||||||
|
|
||||||
|
|
@ -138,10 +138,47 @@ Return your answer strictly in JSON as follows:
|
||||||
If no routes are needed, return an empty list for `route`.
|
If no routes are needed, return an empty list for `route`.
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
/// System prompt used when one or more Agent Skills are attached to candidate
|
||||||
|
/// routes. Adds a `<skills>` block alongside `<routes>` and asks the model to
|
||||||
|
/// also pick zero or more skills that should be loaded into the downstream
|
||||||
|
/// LLM's system prompt.
|
||||||
|
pub const ARCH_ORCHESTRATOR_V1_SYSTEM_PROMPT_WITH_SKILLS: &str = r#"
|
||||||
|
You are a helpful assistant that selects the most suitable routes and Agent Skills based on user intent.
|
||||||
|
You are provided with a list of available routes enclosed within <routes></routes> XML tags:
|
||||||
|
<routes>
|
||||||
|
{routes}
|
||||||
|
</routes>
|
||||||
|
|
||||||
|
You are provided with a list of available Agent Skills enclosed within <skills></skills> XML tags:
|
||||||
|
<skills>
|
||||||
|
{skills}
|
||||||
|
</skills>
|
||||||
|
|
||||||
|
You are also given the conversation context enclosed within <conversation></conversation> XML tags:
|
||||||
|
<conversation>
|
||||||
|
{conversation}
|
||||||
|
</conversation>
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
1. Analyze the latest user intent from the conversation.
|
||||||
|
2. Compare it against the available routes to find which routes can help fulfill the request.
|
||||||
|
3. Independently compare it against the available skills; pick the skills whose descriptions match what the user is trying to do. Skills can be combined with any route. Activating a skill loads detailed instructions into the next response's system prompt.
|
||||||
|
4. Respond only with exact names from <routes> and <skills>.
|
||||||
|
5. If no routes or skills can help, return empty lists.
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
Return your answer strictly in JSON as follows:
|
||||||
|
{{"route": ["route_name_1", "..."], "skills": ["skill_name_1", "..."]}}
|
||||||
|
Use empty lists for `route` and/or `skills` when nothing applies.
|
||||||
|
"#;
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, OrchestratorModelError>;
|
pub type Result<T> = std::result::Result<T, OrchestratorModelError>;
|
||||||
pub struct OrchestratorModelV1 {
|
pub struct OrchestratorModelV1 {
|
||||||
agent_orchestration_json_str: String,
|
agent_orchestration_json_str: String,
|
||||||
agent_orchestration_to_model_map: HashMap<String, String>,
|
agent_orchestration_to_model_map: HashMap<String, String>,
|
||||||
|
/// Pre-rendered `<skills>` block (one JSON entry per skill, name +
|
||||||
|
/// description). Empty when no skills are attached to any route.
|
||||||
|
skills_catalog_json_str: String,
|
||||||
orchestration_model: String,
|
orchestration_model: String,
|
||||||
max_token_length: usize,
|
max_token_length: usize,
|
||||||
}
|
}
|
||||||
|
|
@ -151,10 +188,27 @@ impl OrchestratorModelV1 {
|
||||||
agent_orchestrations: HashMap<String, Vec<OrchestrationPreference>>,
|
agent_orchestrations: HashMap<String, Vec<OrchestrationPreference>>,
|
||||||
orchestration_model: String,
|
orchestration_model: String,
|
||||||
max_token_length: usize,
|
max_token_length: usize,
|
||||||
|
) -> Self {
|
||||||
|
Self::with_skills(
|
||||||
|
agent_orchestrations,
|
||||||
|
Vec::new(),
|
||||||
|
orchestration_model,
|
||||||
|
max_token_length,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `new`, but additionally seeds the orchestrator with an Agent
|
||||||
|
/// Skills catalog. When `skills_catalog` is empty the orchestrator uses
|
||||||
|
/// the routes-only system prompt; otherwise it asks the model to also
|
||||||
|
/// pick zero or more skills from the catalog.
|
||||||
|
pub fn with_skills(
|
||||||
|
agent_orchestrations: HashMap<String, Vec<OrchestrationPreference>>,
|
||||||
|
skills_catalog: Vec<SkillRef>,
|
||||||
|
orchestration_model: String,
|
||||||
|
max_token_length: usize,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let agent_orchestration_values: Vec<OrchestrationPreference> =
|
let agent_orchestration_values: Vec<OrchestrationPreference> =
|
||||||
agent_orchestrations.values().flatten().cloned().collect();
|
agent_orchestrations.values().flatten().cloned().collect();
|
||||||
// Format routes: each route as JSON on its own line with standard spacing
|
|
||||||
let agent_orchestration_json_str = agent_orchestration_values
|
let agent_orchestration_json_str = agent_orchestration_values
|
||||||
.iter()
|
.iter()
|
||||||
.map(to_spaced_json)
|
.map(to_spaced_json)
|
||||||
|
|
@ -165,19 +219,48 @@ impl OrchestratorModelV1 {
|
||||||
.flat_map(|(model, prefs)| prefs.iter().map(|pref| (pref.name.clone(), model.clone())))
|
.flat_map(|(model, prefs)| prefs.iter().map(|pref| (pref.name.clone(), model.clone())))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let skills_catalog_json_str = render_skills_catalog(&skills_catalog);
|
||||||
|
|
||||||
OrchestratorModelV1 {
|
OrchestratorModelV1 {
|
||||||
orchestration_model,
|
orchestration_model,
|
||||||
max_token_length,
|
max_token_length,
|
||||||
agent_orchestration_json_str,
|
agent_orchestration_json_str,
|
||||||
agent_orchestration_to_model_map,
|
agent_orchestration_to_model_map,
|
||||||
|
skills_catalog_json_str,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// JSON shape suitable for the `<skills>` block in the orchestrator prompt:
|
||||||
|
/// `{"name": "...", "description": "..."}`. Only metadata that helps the
|
||||||
|
/// orchestrator pick a skill is included; the full SKILL.md body is injected
|
||||||
|
/// separately, after a skill has been selected.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
struct SkillCatalogEntry<'a> {
|
||||||
|
name: &'a str,
|
||||||
|
description: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_skills_catalog(skills: &[SkillRef]) -> String {
|
||||||
|
skills
|
||||||
|
.iter()
|
||||||
|
.map(|s| SkillCatalogEntry {
|
||||||
|
name: &s.name,
|
||||||
|
description: s.catalog_description(),
|
||||||
|
})
|
||||||
|
.map(|entry| to_spaced_json(&entry))
|
||||||
|
.collect::<Vec<String>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
struct AgentOrchestratorResponse {
|
struct AgentOrchestratorResponse {
|
||||||
/// The route field now expects an array of route names: ["route_name_1", "route_name_2", ...]
|
/// The route field expects an array of route names: ["route_name_1", "route_name_2", ...].
|
||||||
pub route: Option<Vec<String>>,
|
pub route: Option<Vec<String>>,
|
||||||
|
/// Optional array of Agent Skill names the orchestrator chose to activate.
|
||||||
|
/// Absent or empty when no skills should be loaded for this turn.
|
||||||
|
#[serde(default)]
|
||||||
|
pub skills: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOKEN_LENGTH_DIVISOR: usize = 4; // Approximate token length divisor for UTF-8 characters
|
const TOKEN_LENGTH_DIVISOR: usize = 4; // Approximate token length divisor for UTF-8 characters
|
||||||
|
|
@ -209,7 +292,13 @@ impl OrchestratorModel for OrchestratorModelV1 {
|
||||||
// Ensure the conversation does not exceed the configured token budget.
|
// Ensure the conversation does not exceed the configured token budget.
|
||||||
// We use `len() / TOKEN_LENGTH_DIVISOR` as a cheap token estimate to
|
// We use `len() / TOKEN_LENGTH_DIVISOR` as a cheap token estimate to
|
||||||
// avoid running a real tokenizer on the hot path.
|
// avoid running a real tokenizer on the hot path.
|
||||||
let mut token_count = ARCH_ORCHESTRATOR_V1_SYSTEM_PROMPT.len() / TOKEN_LENGTH_DIVISOR;
|
let template_len = if self.skills_catalog_json_str.is_empty() {
|
||||||
|
ARCH_ORCHESTRATOR_V1_SYSTEM_PROMPT.len()
|
||||||
|
} else {
|
||||||
|
ARCH_ORCHESTRATOR_V1_SYSTEM_PROMPT_WITH_SKILLS.len()
|
||||||
|
+ self.skills_catalog_json_str.len()
|
||||||
|
};
|
||||||
|
let mut token_count = template_len / TOKEN_LENGTH_DIVISOR;
|
||||||
let mut selected_messages_list_reversed: Vec<Message> = vec![];
|
let mut selected_messages_list_reversed: Vec<Message> = vec![];
|
||||||
for (selected_messsage_count, message) in messages_vec.iter().rev().enumerate() {
|
for (selected_messsage_count, message) in messages_vec.iter().rev().enumerate() {
|
||||||
let message_text = message.content.extract_text();
|
let message_text = message.content.extract_text();
|
||||||
|
|
@ -289,14 +378,16 @@ impl OrchestratorModel for OrchestratorModelV1 {
|
||||||
// Generate the orchestrator request message based on the usage preferences.
|
// Generate the orchestrator request message based on the usage preferences.
|
||||||
// If preferences are passed in request then we use them;
|
// If preferences are passed in request then we use them;
|
||||||
// Otherwise, we use the default orchestration modelpreferences.
|
// Otherwise, we use the default orchestration modelpreferences.
|
||||||
let orchestrator_message =
|
let routes_block = match convert_to_orchestrator_preferences(usage_preferences_from_request)
|
||||||
match convert_to_orchestrator_preferences(usage_preferences_from_request) {
|
{
|
||||||
Some(prefs) => generate_orchestrator_message(&prefs, &selected_conversation_list),
|
Some(prefs) => prefs,
|
||||||
None => generate_orchestrator_message(
|
None => self.agent_orchestration_json_str.clone(),
|
||||||
&self.agent_orchestration_json_str,
|
|
||||||
&selected_conversation_list,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
let orchestrator_message = generate_orchestrator_message(
|
||||||
|
&routes_block,
|
||||||
|
&self.skills_catalog_json_str,
|
||||||
|
&selected_conversation_list,
|
||||||
|
);
|
||||||
|
|
||||||
ChatCompletionsRequest {
|
ChatCompletionsRequest {
|
||||||
model: self.orchestration_model.clone(),
|
model: self.orchestration_model.clone(),
|
||||||
|
|
@ -316,7 +407,7 @@ impl OrchestratorModel for OrchestratorModelV1 {
|
||||||
&self,
|
&self,
|
||||||
content: &str,
|
content: &str,
|
||||||
usage_preferences: &Option<Vec<AgentUsagePreference>>,
|
usage_preferences: &Option<Vec<AgentUsagePreference>>,
|
||||||
) -> Result<Option<Vec<(String, String)>>> {
|
) -> Result<Option<OrchestratorSelection>> {
|
||||||
if content.is_empty() {
|
if content.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
@ -326,20 +417,21 @@ impl OrchestratorModel for OrchestratorModelV1 {
|
||||||
|
|
||||||
let selected_routes = orchestrator_response.route.unwrap_or_default();
|
let selected_routes = orchestrator_response.route.unwrap_or_default();
|
||||||
|
|
||||||
// Filter out empty routes
|
|
||||||
let valid_routes: Vec<String> = selected_routes
|
let valid_routes: Vec<String> = selected_routes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|route| !route.is_empty())
|
.filter(|route| !route.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if valid_routes.is_empty() {
|
let selected_skills: Vec<String> = orchestrator_response
|
||||||
return Ok(None);
|
.skills
|
||||||
}
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
let mut result: Vec<(String, String)> = Vec::new();
|
let mut routes: Vec<(String, String)> = Vec::new();
|
||||||
|
|
||||||
if let Some(usage_preferences) = usage_preferences {
|
if let Some(usage_preferences) = usage_preferences {
|
||||||
// If usage preferences are defined, we need to find the model that matches each selected route
|
|
||||||
for selected_route in valid_routes {
|
for selected_route in valid_routes {
|
||||||
let model_name: Option<String> = usage_preferences
|
let model_name: Option<String> = usage_preferences
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -351,7 +443,7 @@ impl OrchestratorModel for OrchestratorModelV1 {
|
||||||
.map(|pref| pref.model.clone());
|
.map(|pref| pref.model.clone());
|
||||||
|
|
||||||
if let Some(model_name) = model_name {
|
if let Some(model_name) = model_name {
|
||||||
result.push((selected_route, model_name));
|
routes.push((selected_route, model_name));
|
||||||
} else {
|
} else {
|
||||||
warn!(
|
warn!(
|
||||||
route = %selected_route,
|
route = %selected_route,
|
||||||
|
|
@ -361,14 +453,13 @@ impl OrchestratorModel for OrchestratorModelV1 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If no usage preferences are passed in request then use the default orchestration model preferences
|
|
||||||
for selected_route in valid_routes {
|
for selected_route in valid_routes {
|
||||||
if let Some(model) = self
|
if let Some(model) = self
|
||||||
.agent_orchestration_to_model_map
|
.agent_orchestration_to_model_map
|
||||||
.get(&selected_route)
|
.get(&selected_route)
|
||||||
.cloned()
|
.cloned()
|
||||||
{
|
{
|
||||||
result.push((selected_route, model));
|
routes.push((selected_route, model));
|
||||||
} else {
|
} else {
|
||||||
warn!(
|
warn!(
|
||||||
route = %selected_route,
|
route = %selected_route,
|
||||||
|
|
@ -379,11 +470,14 @@ impl OrchestratorModel for OrchestratorModelV1 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.is_empty() {
|
if routes.is_empty() && selected_skills.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Some(result))
|
Ok(Some(OrchestratorSelection {
|
||||||
|
routes,
|
||||||
|
skills: selected_skills,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_model_name(&self) -> String {
|
fn get_model_name(&self) -> String {
|
||||||
|
|
@ -391,7 +485,11 @@ impl OrchestratorModel for OrchestratorModelV1 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_orchestrator_message(prefs: &str, selected_conversation_list: &Vec<Message>) -> String {
|
fn generate_orchestrator_message(
|
||||||
|
prefs: &str,
|
||||||
|
skills_catalog: &str,
|
||||||
|
selected_conversation_list: &Vec<Message>,
|
||||||
|
) -> String {
|
||||||
// Format conversation with 4-space indentation (equivalent to Python's json.dumps(obj, indent=4))
|
// Format conversation with 4-space indentation (equivalent to Python's json.dumps(obj, indent=4))
|
||||||
let formatter = serde_json::ser::PrettyFormatter::with_indent(b" ");
|
let formatter = serde_json::ser::PrettyFormatter::with_indent(b" ");
|
||||||
let mut conversation_buf = Vec::new();
|
let mut conversation_buf = Vec::new();
|
||||||
|
|
@ -399,9 +497,19 @@ fn generate_orchestrator_message(prefs: &str, selected_conversation_list: &Vec<M
|
||||||
SerializeTrait::serialize(&selected_conversation_list, &mut serializer).unwrap();
|
SerializeTrait::serialize(&selected_conversation_list, &mut serializer).unwrap();
|
||||||
let conversation_json = String::from_utf8(conversation_buf).unwrap_or_default();
|
let conversation_json = String::from_utf8(conversation_buf).unwrap_or_default();
|
||||||
|
|
||||||
|
let template = if skills_catalog.is_empty() {
|
||||||
ARCH_ORCHESTRATOR_V1_SYSTEM_PROMPT
|
ARCH_ORCHESTRATOR_V1_SYSTEM_PROMPT
|
||||||
|
} else {
|
||||||
|
ARCH_ORCHESTRATOR_V1_SYSTEM_PROMPT_WITH_SKILLS
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = template
|
||||||
.replace("{routes}", prefs)
|
.replace("{routes}", prefs)
|
||||||
.replace("{conversation}", &conversation_json)
|
.replace("{conversation}", &conversation_json);
|
||||||
|
if !skills_catalog.is_empty() {
|
||||||
|
out = out.replace("{skills}", skills_catalog);
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn convert_to_orchestrator_preferences(
|
fn convert_to_orchestrator_preferences(
|
||||||
|
|
@ -1349,23 +1457,30 @@ If no routes are needed, return an empty list for `route`.
|
||||||
let orchestrator =
|
let orchestrator =
|
||||||
OrchestratorModelV1::new(agent_orchestrations, "test-model".to_string(), 2000);
|
OrchestratorModelV1::new(agent_orchestrations, "test-model".to_string(), 2000);
|
||||||
|
|
||||||
|
fn routes(pairs: &[(&str, &str)]) -> OrchestratorSelection {
|
||||||
|
OrchestratorSelection {
|
||||||
|
routes: pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(r, m)| (r.to_string(), m.to_string()))
|
||||||
|
.collect(),
|
||||||
|
skills: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Case 1: Valid JSON with single route in array
|
// Case 1: Valid JSON with single route in array
|
||||||
let input = r#"{"route": ["Image generation"]}"#;
|
let input = r#"{"route": ["Image generation"]}"#;
|
||||||
let result = orchestrator.parse_response(input, &None).unwrap();
|
let result = orchestrator.parse_response(input, &None).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(result, Some(routes(&[("Image generation", "gpt-4o")])));
|
||||||
result,
|
|
||||||
Some(vec![("Image generation".to_string(), "gpt-4o".to_string())])
|
|
||||||
);
|
|
||||||
|
|
||||||
// Case 2: Valid JSON with multiple routes in array
|
// Case 2: Valid JSON with multiple routes in array
|
||||||
let input = r#"{"route": ["Image generation", "Code generation"]}"#;
|
let input = r#"{"route": ["Image generation", "Code generation"]}"#;
|
||||||
let result = orchestrator.parse_response(input, &None).unwrap();
|
let result = orchestrator.parse_response(input, &None).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result,
|
result,
|
||||||
Some(vec![
|
Some(routes(&[
|
||||||
("Image generation".to_string(), "gpt-4o".to_string()),
|
("Image generation", "gpt-4o"),
|
||||||
("Code generation".to_string(), "gpt-4o".to_string())
|
("Code generation", "gpt-4o")
|
||||||
])
|
]))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Case 3: Valid JSON with empty array
|
// Case 3: Valid JSON with empty array
|
||||||
|
|
@ -1396,14 +1511,65 @@ If no routes are needed, return an empty list for `route`.
|
||||||
// Case 7: Single quotes and \n in JSON
|
// Case 7: Single quotes and \n in JSON
|
||||||
let input = "{'route': ['Image generation']}\\n";
|
let input = "{'route': ['Image generation']}\\n";
|
||||||
let result = orchestrator.parse_response(input, &None).unwrap();
|
let result = orchestrator.parse_response(input, &None).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(result, Some(routes(&[("Image generation", "gpt-4o")])));
|
||||||
result,
|
|
||||||
Some(vec![("Image generation".to_string(), "gpt-4o".to_string())])
|
|
||||||
);
|
|
||||||
|
|
||||||
// Case 8: Array with unknown route (not in orchestrations map)
|
// Case 8: Array with unknown route (not in orchestrations map)
|
||||||
let input = r#"{"route": ["Unknown route"]}"#;
|
let input = r#"{"route": ["Unknown route"]}"#;
|
||||||
let result = orchestrator.parse_response(input, &None).unwrap();
|
let result = orchestrator.parse_response(input, &None).unwrap();
|
||||||
assert_eq!(result, None);
|
assert_eq!(result, None);
|
||||||
|
|
||||||
|
// Case 9: Routes plus selected skills are propagated through.
|
||||||
|
let input = r#"{"route": ["Image generation"], "skills": ["pdf-processing"]}"#;
|
||||||
|
let result = orchestrator.parse_response(input, &None).unwrap().unwrap();
|
||||||
|
assert_eq!(result.routes.len(), 1);
|
||||||
|
assert_eq!(result.skills, vec!["pdf-processing".to_string()]);
|
||||||
|
|
||||||
|
// Case 10: Skills-only selection (no routes) still surfaces as Some.
|
||||||
|
let input = r#"{"route": [], "skills": ["pdf-processing"]}"#;
|
||||||
|
let result = orchestrator.parse_response(input, &None).unwrap().unwrap();
|
||||||
|
assert!(result.routes.is_empty());
|
||||||
|
assert_eq!(result.skills, vec!["pdf-processing".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_system_prompt_with_skills_block() {
|
||||||
|
let orchestrator = OrchestratorModelV1::with_skills(
|
||||||
|
HashMap::from([(
|
||||||
|
"gpt-4o".to_string(),
|
||||||
|
vec![OrchestrationPreference {
|
||||||
|
name: "Image generation".to_string(),
|
||||||
|
description: "generating image".to_string(),
|
||||||
|
}],
|
||||||
|
)]),
|
||||||
|
vec![SkillRef {
|
||||||
|
name: "pdf-processing".to_string(),
|
||||||
|
description: "Extract structured data from PDFs.".to_string(),
|
||||||
|
path: None,
|
||||||
|
base_dir: None,
|
||||||
|
body: None,
|
||||||
|
scope: None,
|
||||||
|
compatibility: None,
|
||||||
|
license: None,
|
||||||
|
metadata: None,
|
||||||
|
allowed_tools: None,
|
||||||
|
}],
|
||||||
|
"test-model".to_string(),
|
||||||
|
usize::MAX,
|
||||||
|
);
|
||||||
|
|
||||||
|
let conversation: Vec<Message> = serde_json::from_str(
|
||||||
|
r#"[{"role": "user", "content": "extract the invoice totals from this pdf"}]"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let req = orchestrator.generate_request(&conversation, &None);
|
||||||
|
let prompt = req.messages[0].content.extract_text();
|
||||||
|
|
||||||
|
assert!(prompt.contains("<skills>"));
|
||||||
|
assert!(prompt.contains("</skills>"));
|
||||||
|
assert!(prompt.contains(r#""name": "pdf-processing""#));
|
||||||
|
assert!(prompt.contains("Extract structured data from PDFs."));
|
||||||
|
// Response format documentation must mention the `skills` array
|
||||||
|
// so the orchestrator emits it.
|
||||||
|
assert!(prompt.contains(r#""skills""#));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ mod tests {
|
||||||
"openai/gpt-4o".to_string(),
|
"openai/gpt-4o".to_string(),
|
||||||
"openai/gpt-4o-mini".to_string(),
|
"openai/gpt-4o-mini".to_string(),
|
||||||
],
|
],
|
||||||
|
skills: None,
|
||||||
selection_policy: SelectionPolicy {
|
selection_policy: SelectionPolicy {
|
||||||
prefer: SelectionPreference::None,
|
prefer: SelectionPreference::None,
|
||||||
},
|
},
|
||||||
|
|
@ -44,6 +45,7 @@ mod tests {
|
||||||
"anthropic/claude-3-sonnet".to_string(),
|
"anthropic/claude-3-sonnet".to_string(),
|
||||||
"openai/gpt-4o-mini".to_string(),
|
"openai/gpt-4o-mini".to_string(),
|
||||||
],
|
],
|
||||||
|
skills: None,
|
||||||
selection_policy: SelectionPolicy {
|
selection_policy: SelectionPolicy {
|
||||||
prefer: SelectionPreference::None,
|
prefer: SelectionPreference::None,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,12 @@ pub struct TopLevelRoutingPreference {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub models: Vec<String>,
|
pub models: Vec<String>,
|
||||||
|
/// Agent Skills associated with this route. When Plano-Orchestrator
|
||||||
|
/// selects this route, every skill listed here is also offered to the
|
||||||
|
/// orchestrator in the `<skills>` block; selected skills have their
|
||||||
|
/// SKILL.md body prepended to the upstream system prompt.
|
||||||
|
#[serde(default)]
|
||||||
|
pub skills: Option<Vec<String>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub selection_policy: SelectionPolicy,
|
pub selection_policy: SelectionPolicy,
|
||||||
}
|
}
|
||||||
|
|
@ -224,6 +230,17 @@ pub struct Configuration {
|
||||||
pub state_storage: Option<StateStorageConfig>,
|
pub state_storage: Option<StateStorageConfig>,
|
||||||
pub routing_preferences: Option<Vec<TopLevelRoutingPreference>>,
|
pub routing_preferences: Option<Vec<TopLevelRoutingPreference>>,
|
||||||
pub model_metrics_sources: Option<Vec<MetricsSource>>,
|
pub model_metrics_sources: Option<Vec<MetricsSource>>,
|
||||||
|
/// Agent Skills (https://agentskills.io) installed for this project.
|
||||||
|
///
|
||||||
|
/// The Plano CLI discovers `.plano/skills/<name>/SKILL.md` files at render
|
||||||
|
/// time and materializes them into this list with `body` already loaded so
|
||||||
|
/// downstream consumers do not need filesystem access. Skills are scoped
|
||||||
|
/// to specific routes via `routing_preferences[].skills`; Plano-Orchestrator
|
||||||
|
/// receives a `<skills>` block alongside `<routes>` for any skills attached
|
||||||
|
/// to candidate routes, and selected skills have their SKILL.md body
|
||||||
|
/// injected into the upstream system prompt.
|
||||||
|
#[serde(default)]
|
||||||
|
pub skills: Option<Vec<SkillRef>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
|
@ -611,6 +628,45 @@ pub struct PromptTarget {
|
||||||
pub auto_llm_dispatch_on_response: Option<bool>,
|
pub auto_llm_dispatch_on_response: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An Agent Skill (https://agentskills.io) as materialized by the Plano CLI.
|
||||||
|
///
|
||||||
|
/// At runtime brightstaff and the WASM filters reason over the catalog
|
||||||
|
/// (`name` + `description`) and, when a skill is selected, inject the
|
||||||
|
/// pre-loaded `body` into the downstream system prompt.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct SkillRef {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub base_dir: Option<String>,
|
||||||
|
/// Full SKILL.md markdown body (post-frontmatter). Inlined here at render
|
||||||
|
/// time so the WASM sandbox does not need filesystem access.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub body: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub scope: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub compatibility: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub license: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<HashMap<String, String>>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub allowed_tools: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SkillRef {
|
||||||
|
/// Best-effort short summary suitable for the `<skills>` block sent to
|
||||||
|
/// Plano-Orchestrator: only the public-facing description, never the
|
||||||
|
/// full SKILL.md body. The body is injected separately, after a skill
|
||||||
|
/// has been selected.
|
||||||
|
pub fn catalog_description(&self) -> &str {
|
||||||
|
&self.description
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// convert PromptTarget to ChatCompletionTool
|
// convert PromptTarget to ChatCompletionTool
|
||||||
impl From<&PromptTarget> for ChatCompletionTool {
|
impl From<&PromptTarget> for ChatCompletionTool {
|
||||||
fn from(val: &PromptTarget) -> Self {
|
fn from(val: &PromptTarget) -> Self {
|
||||||
|
|
@ -807,4 +863,34 @@ disable_signals: false
|
||||||
let overrides: super::Overrides = serde_yaml::from_str(yaml_missing).unwrap();
|
let overrides: super::Overrides = serde_yaml::from_str(yaml_missing).unwrap();
|
||||||
assert_eq!(overrides.disable_signals, None);
|
assert_eq!(overrides.disable_signals, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_top_level_routing_preference_skills_deserialize() {
|
||||||
|
let yaml = r#"
|
||||||
|
name: code review
|
||||||
|
description: reviewing, analyzing, and suggesting improvements to existing code
|
||||||
|
models:
|
||||||
|
- openai/gpt-4o
|
||||||
|
skills:
|
||||||
|
- code-review-skill
|
||||||
|
"#;
|
||||||
|
let pref: super::TopLevelRoutingPreference = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
assert_eq!(pref.name, "code review");
|
||||||
|
assert_eq!(
|
||||||
|
pref.skills.as_deref(),
|
||||||
|
Some(&["code-review-skill".to_string()][..])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_top_level_routing_preference_skills_optional() {
|
||||||
|
let yaml = r#"
|
||||||
|
name: code generation
|
||||||
|
description: generating new code
|
||||||
|
models:
|
||||||
|
- openai/gpt-4o
|
||||||
|
"#;
|
||||||
|
let pref: super::TopLevelRoutingPreference = serde_yaml::from_str(yaml).unwrap();
|
||||||
|
assert!(pref.skills.is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ pub mod path;
|
||||||
pub mod pii;
|
pub mod pii;
|
||||||
pub mod ratelimit;
|
pub mod ratelimit;
|
||||||
pub mod routing;
|
pub mod routing;
|
||||||
|
pub mod skills_runtime;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
pub mod tokenizer;
|
pub mod tokenizer;
|
||||||
pub mod traces;
|
pub mod traces;
|
||||||
|
|
|
||||||
215
crates/common/src/skills_runtime.rs
Normal file
215
crates/common/src/skills_runtime.rs
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
//! Runtime helpers for handling Agent Skills selected by Plano-Orchestrator.
|
||||||
|
//!
|
||||||
|
//! These functions live in `common` (rather than `brightstaff` or a WASM
|
||||||
|
//! crate) so they can be unit-tested on the native target without dragging
|
||||||
|
//! in proxy-wasm host-call symbols or tokio runtime dependencies.
|
||||||
|
|
||||||
|
use crate::configuration::{SkillRef, TopLevelRoutingPreference};
|
||||||
|
|
||||||
|
/// Filter `skills` down to the subset attached to `route_name` via
|
||||||
|
/// `routing_preferences[].skills`. When the selected route has no `skills:`
|
||||||
|
/// list, returns an empty vector — skills are scoped to routes, not global.
|
||||||
|
///
|
||||||
|
/// `routing_preferences` is the source of truth for which skills are even
|
||||||
|
/// eligible for the orchestrator to activate on a given route.
|
||||||
|
pub fn skills_for_route<'a>(
|
||||||
|
skills: &'a [SkillRef],
|
||||||
|
routing_preferences: &[TopLevelRoutingPreference],
|
||||||
|
route_name: &str,
|
||||||
|
) -> Vec<&'a SkillRef> {
|
||||||
|
let Some(route) = routing_preferences.iter().find(|p| p.name == route_name) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Some(allow) = route.skills.as_ref() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut out: Vec<&SkillRef> = Vec::with_capacity(allow.len());
|
||||||
|
for name in allow {
|
||||||
|
if let Some(skill) = skills.iter().find(|s| &s.name == name) {
|
||||||
|
out.push(skill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a list of orchestrator-selected skill names to their `SkillRef`s.
|
||||||
|
/// Unknown names are dropped silently — the orchestrator can hallucinate.
|
||||||
|
/// Results are deduplicated by name, preserving the order Plano-Orchestrator
|
||||||
|
/// returned.
|
||||||
|
pub fn resolve_selected_skills<'a>(
|
||||||
|
skills: &'a [SkillRef],
|
||||||
|
selected_names: &[String],
|
||||||
|
) -> Vec<&'a SkillRef> {
|
||||||
|
let mut out: Vec<&SkillRef> = Vec::with_capacity(selected_names.len());
|
||||||
|
for name in selected_names {
|
||||||
|
if out.iter().any(|s| &s.name == name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(skill) = skills.iter().find(|s| &s.name == name) {
|
||||||
|
out.push(skill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append the bodies of activated skills to a system prompt, wrapped in
|
||||||
|
/// `<skill_content name="...">` tags so a future context-management pass can
|
||||||
|
/// recognize and recompact them.
|
||||||
|
///
|
||||||
|
/// Returns `None` only if no base system prompt was supplied and no skills
|
||||||
|
/// were activated. When skills are present the wrapper text always appears so
|
||||||
|
/// the downstream model receives a clear, well-structured instruction block.
|
||||||
|
pub fn augment_system_prompt_with_skills(
|
||||||
|
base_system_prompt: Option<String>,
|
||||||
|
activated_skills: &[&SkillRef],
|
||||||
|
) -> Option<String> {
|
||||||
|
if activated_skills.is_empty() {
|
||||||
|
return base_system_prompt;
|
||||||
|
}
|
||||||
|
let mut buf = String::new();
|
||||||
|
if let Some(base) = base_system_prompt {
|
||||||
|
if !base.is_empty() {
|
||||||
|
buf.push_str(&base);
|
||||||
|
buf.push('\n');
|
||||||
|
buf.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.push_str(
|
||||||
|
"The following Agent Skills have been activated for this request. \
|
||||||
|
Follow their instructions when relevant; resolve relative paths \
|
||||||
|
against each skill's base directory.\n\n",
|
||||||
|
);
|
||||||
|
for skill in activated_skills {
|
||||||
|
buf.push_str(&format!("<skill_content name=\"{}\"", skill.name));
|
||||||
|
if let Some(base_dir) = skill.base_dir.as_deref() {
|
||||||
|
buf.push_str(&format!(" base_dir=\"{}\"", base_dir));
|
||||||
|
}
|
||||||
|
buf.push_str(">\n");
|
||||||
|
if let Some(body) = skill.body.as_deref() {
|
||||||
|
buf.push_str(body.trim_end());
|
||||||
|
buf.push('\n');
|
||||||
|
} else {
|
||||||
|
buf.push_str(&format!("(skill description) {}\n", skill.description));
|
||||||
|
}
|
||||||
|
buf.push_str("</skill_content>\n\n");
|
||||||
|
}
|
||||||
|
Some(buf.trim_end().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::configuration::SelectionPolicy;
|
||||||
|
|
||||||
|
fn skill(name: &str, body: &str) -> SkillRef {
|
||||||
|
SkillRef {
|
||||||
|
name: name.to_string(),
|
||||||
|
description: format!("desc for {}", name),
|
||||||
|
path: Some(format!("/skills/{}/SKILL.md", name)),
|
||||||
|
base_dir: Some(format!("/skills/{}", name)),
|
||||||
|
body: Some(body.to_string()),
|
||||||
|
scope: Some("project".to_string()),
|
||||||
|
compatibility: None,
|
||||||
|
license: None,
|
||||||
|
metadata: None,
|
||||||
|
allowed_tools: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn route(name: &str, skill_names: Option<Vec<&str>>) -> TopLevelRoutingPreference {
|
||||||
|
TopLevelRoutingPreference {
|
||||||
|
name: name.to_string(),
|
||||||
|
description: format!("desc for {}", name),
|
||||||
|
models: vec!["openai/gpt-4o".to_string()],
|
||||||
|
skills: skill_names.map(|v| v.into_iter().map(String::from).collect()),
|
||||||
|
selection_policy: SelectionPolicy::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skills_for_route_returns_attached_skills() {
|
||||||
|
let catalog = vec![
|
||||||
|
skill("pdf-processing", "extract"),
|
||||||
|
skill("code-review", "review"),
|
||||||
|
];
|
||||||
|
let routes = vec![
|
||||||
|
route("code review", Some(vec!["code-review"])),
|
||||||
|
route("doc work", Some(vec!["pdf-processing"])),
|
||||||
|
];
|
||||||
|
let resolved = skills_for_route(&catalog, &routes, "code review");
|
||||||
|
assert_eq!(resolved.len(), 1);
|
||||||
|
assert_eq!(resolved[0].name, "code-review");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skills_for_route_empty_when_route_has_no_skills_list() {
|
||||||
|
let catalog = vec![skill("pdf-processing", "extract")];
|
||||||
|
let routes = vec![route("code review", None)];
|
||||||
|
let resolved = skills_for_route(&catalog, &routes, "code review");
|
||||||
|
assert!(resolved.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skills_for_route_empty_when_route_missing() {
|
||||||
|
let catalog = vec![skill("pdf-processing", "extract")];
|
||||||
|
let routes = vec![route("code review", Some(vec!["pdf-processing"]))];
|
||||||
|
let resolved = skills_for_route(&catalog, &routes, "no-such-route");
|
||||||
|
assert!(resolved.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skills_for_route_drops_unknown_skill_names() {
|
||||||
|
let catalog = vec![skill("pdf-processing", "extract")];
|
||||||
|
let routes = vec![route(
|
||||||
|
"code review",
|
||||||
|
Some(vec!["pdf-processing", "ghost-skill"]),
|
||||||
|
)];
|
||||||
|
let resolved = skills_for_route(&catalog, &routes, "code review");
|
||||||
|
assert_eq!(resolved.len(), 1);
|
||||||
|
assert_eq!(resolved[0].name, "pdf-processing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_selected_skills_drops_unknown_and_dedupes() {
|
||||||
|
let catalog = vec![
|
||||||
|
skill("pdf-processing", "extract"),
|
||||||
|
skill("code-review", "review"),
|
||||||
|
];
|
||||||
|
let names = vec![
|
||||||
|
"code-review".to_string(),
|
||||||
|
"ghost".to_string(),
|
||||||
|
"code-review".to_string(),
|
||||||
|
"pdf-processing".to_string(),
|
||||||
|
];
|
||||||
|
let resolved = resolve_selected_skills(&catalog, &names);
|
||||||
|
assert_eq!(resolved.len(), 2);
|
||||||
|
assert_eq!(resolved[0].name, "code-review");
|
||||||
|
assert_eq!(resolved[1].name, "pdf-processing");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn augment_passthrough_with_no_skills() {
|
||||||
|
let augmented = augment_system_prompt_with_skills(Some("you are helpful".to_string()), &[]);
|
||||||
|
assert_eq!(augmented.as_deref(), Some("you are helpful"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn augment_includes_skill_bodies() {
|
||||||
|
let s = skill("pdf-processing", "extract text and tables");
|
||||||
|
let augmented =
|
||||||
|
augment_system_prompt_with_skills(Some("you are helpful".to_string()), &[&s])
|
||||||
|
.expect("augmented");
|
||||||
|
assert!(augmented.starts_with("you are helpful"));
|
||||||
|
assert!(augmented.contains("<skill_content name=\"pdf-processing\""));
|
||||||
|
assert!(augmented.contains("extract text and tables"));
|
||||||
|
assert!(augmented.contains("base_dir=\"/skills/pdf-processing\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn augment_without_base_prompt_still_works() {
|
||||||
|
let s = skill("code-review", "look at diffs");
|
||||||
|
let augmented = augment_system_prompt_with_skills(None, &[&s]).expect("augmented");
|
||||||
|
assert!(augmented.contains("<skill_content name=\"code-review\""));
|
||||||
|
assert!(augmented.contains("look at diffs"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -209,20 +209,16 @@ impl StreamContext {
|
||||||
} else {
|
} else {
|
||||||
info!("no default prompt target found, forwarding request to upstream llm");
|
info!("no default prompt target found, forwarding request to upstream llm");
|
||||||
let mut messages = Vec::new();
|
let mut messages = Vec::new();
|
||||||
// add system prompt
|
if let Some(system_prompt) = self.system_prompt.as_ref().clone() {
|
||||||
match self.system_prompt.as_ref() {
|
|
||||||
None => {}
|
|
||||||
Some(system_prompt) => {
|
|
||||||
let system_prompt_message = Message {
|
let system_prompt_message = Message {
|
||||||
role: SYSTEM_ROLE.to_string(),
|
role: SYSTEM_ROLE.to_string(),
|
||||||
content: Some(ContentType::Text(system_prompt.clone())),
|
content: Some(ContentType::Text(system_prompt)),
|
||||||
model: None,
|
model: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
};
|
};
|
||||||
messages.push(system_prompt_message);
|
messages.push(system_prompt_message);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
messages.append(
|
messages.append(
|
||||||
&mut self
|
&mut self
|
||||||
|
|
|
||||||
|
|
@ -63,4 +63,5 @@ Built by contributors to the widely adopted `Envoy Proxy <https://www.envoyproxy
|
||||||
resources/deployment
|
resources/deployment
|
||||||
resources/configuration_reference
|
resources/configuration_reference
|
||||||
resources/cli_reference
|
resources/cli_reference
|
||||||
|
resources/skills
|
||||||
resources/llms_txt
|
resources/llms_txt
|
||||||
|
|
|
||||||
177
docs/source/resources/skills.rst
Normal file
177
docs/source/resources/skills.rst
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
.. _agent_skills:
|
||||||
|
|
||||||
|
Agent Skills
|
||||||
|
============
|
||||||
|
|
||||||
|
Plano can load `Agent Skills <https://agentskills.io>`_ — lightweight,
|
||||||
|
markdown-defined capabilities — and let Plano-Orchestrator decide *per request*
|
||||||
|
which skills to attach to the downstream LLM call. Skills attach to entries in
|
||||||
|
``routing_preferences``: when the orchestrator picks a route, it also picks
|
||||||
|
zero or more skills from that route's allow-list, and brightstaff injects
|
||||||
|
each selected ``SKILL.md`` body into the upstream system prompt before
|
||||||
|
forwarding the request.
|
||||||
|
|
||||||
|
Why use this?
|
||||||
|
-------------
|
||||||
|
|
||||||
|
- **Modular instructions.** Ship a skill (markdown + scripts + assets) rather
|
||||||
|
than baking 500-token instructions into every system prompt.
|
||||||
|
- **Progressive disclosure.** Skill names and one-line descriptions are
|
||||||
|
always visible to the orchestrator; full instructions load only when a
|
||||||
|
skill is activated.
|
||||||
|
- **Per-route scoping.** A ``skills:`` list on a ``routing_preferences``
|
||||||
|
entry constrains which skills can be activated for that route.
|
||||||
|
|
||||||
|
Install a skill
|
||||||
|
---------------
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
# via the upstream Agent Skills CLI (recommended for multi-skill repos)
|
||||||
|
planoai skills add openai/skills
|
||||||
|
|
||||||
|
# planoai falls back to a direct git clone if `npx` is unavailable; this
|
||||||
|
# path expects a single-skill repo with a SKILL.md at the root.
|
||||||
|
planoai skills add owner/code-review
|
||||||
|
|
||||||
|
Where do skills end up?
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
Plano looks for skills across three scopes (highest precedence first):
|
||||||
|
|
||||||
|
============== ============================== =========================== =====================================
|
||||||
|
Scope Location Trust required Typical installer
|
||||||
|
============== ============================== =========================== =====================================
|
||||||
|
``project`` ``.plano/skills/<name>/`` Yes — ``planoai skills ``planoai skills add`` (git fallback)
|
||||||
|
trust``
|
||||||
|
``user`` ``~/.plano/skills/<name>/`` No (auto-trusted) manual
|
||||||
|
``agents`` ``~/.agents/skills/<name>/`` No (auto-trusted) ``npx skills add`` / upstream CLI
|
||||||
|
============== ============================== =========================== =====================================
|
||||||
|
|
||||||
|
The ``agents`` scope is the universal Agent Skills install location used by
|
||||||
|
``npx skills add`` (see https://github.com/vercel-labs/add-skill). Because
|
||||||
|
``npx skills add`` doesn't know about Plano, it never writes into
|
||||||
|
``.plano/skills/``; instead it drops the skill under ``~/.agents/skills/<name>``
|
||||||
|
and symlinks it into every recognised agent (Claude Code, Cursor, …). Plano
|
||||||
|
treats that directory as an auto-trusted user-tier scope, so anything
|
||||||
|
installed there is picked up automatically — no ``planoai skills trust``
|
||||||
|
needed.
|
||||||
|
|
||||||
|
A ``.plano/skills/.skills.json`` manifest is maintained only for installs
|
||||||
|
that land in project scope (the git fallback). The ``agents`` scope owns
|
||||||
|
its own bookkeeping in ``~/.agents/``.
|
||||||
|
|
||||||
|
Trust the project
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
Project-level skills are loaded only after you mark the project trusted. This
|
||||||
|
matches the recommendation in the `Adding skills support guide
|
||||||
|
<https://agentskills.io/client-implementation/adding-skills-support.md>`_:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
planoai skills trust
|
||||||
|
|
||||||
|
# revoke trust later if needed
|
||||||
|
planoai skills trust --revoke
|
||||||
|
|
||||||
|
Skills under ``~/.plano/skills/`` and ``~/.agents/skills/`` are always
|
||||||
|
trusted and ignore this setting.
|
||||||
|
|
||||||
|
Discover and remove
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
planoai skills list
|
||||||
|
planoai skills remove pdf-processing
|
||||||
|
|
||||||
|
Configure routing
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
Reference installed skills from your ``config.yaml`` in two places:
|
||||||
|
|
||||||
|
1. The top-level ``skills:`` catalog (optional — omit to auto-include every
|
||||||
|
discovered skill).
|
||||||
|
2. Each ``routing_preferences`` entry that should make a skill eligible for
|
||||||
|
activation. The orchestrator's ``<skills>`` block is built from the union
|
||||||
|
of every ``routing_preferences[].skills`` list; skills not referenced by
|
||||||
|
any route are silently dropped.
|
||||||
|
|
||||||
|
.. code-block:: yaml
|
||||||
|
|
||||||
|
skills:
|
||||||
|
- pdf-processing
|
||||||
|
- code-review
|
||||||
|
|
||||||
|
routing_preferences:
|
||||||
|
- name: code review
|
||||||
|
description: |
|
||||||
|
Reviewing pull requests, analyzing diffs, and suggesting improvements
|
||||||
|
to existing code.
|
||||||
|
models:
|
||||||
|
- anthropic/claude-sonnet-4-5
|
||||||
|
- openai/gpt-4.1-2025-04-14
|
||||||
|
skills:
|
||||||
|
- code-review
|
||||||
|
|
||||||
|
- name: document understanding
|
||||||
|
description: |
|
||||||
|
Summarizing PDFs and other long-form documents, extracting structured
|
||||||
|
data such as tables, line items, or signatures.
|
||||||
|
models:
|
||||||
|
- anthropic/claude-sonnet-4-5
|
||||||
|
skills:
|
||||||
|
- pdf-processing
|
||||||
|
selection_policy:
|
||||||
|
prefer: cheapest
|
||||||
|
|
||||||
|
When ``planoai up`` runs, the CLI walks ``.plano/skills/`` and
|
||||||
|
``~/.plano/skills/``, parses each ``SKILL.md``, and inlines the markdown body
|
||||||
|
into the rendered Plano config so the brightstaff orchestrator can attach it
|
||||||
|
to the request without any filesystem access.
|
||||||
|
|
||||||
|
How routing works
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
At request time:
|
||||||
|
|
||||||
|
1. The brightstaff routing service builds an ``<skills>`` block in the
|
||||||
|
Plano-Orchestrator prompt — alongside the existing ``<routes>`` block —
|
||||||
|
listing every skill referenced by ``routing_preferences[].skills`` with
|
||||||
|
its name and short description.
|
||||||
|
2. The orchestrator replies with JSON of the form
|
||||||
|
``{"route": ["..."], "skills": ["..."]}``.
|
||||||
|
3. brightstaff resolves each selected skill name against the chosen route's
|
||||||
|
``skills:`` allow-list. Names that aren't allowed for that route (or are
|
||||||
|
not in the catalog) are dropped.
|
||||||
|
4. The activated ``SkillRef`` bodies are prepended to the upstream request's
|
||||||
|
system prompt — wrapped in
|
||||||
|
``<skill_content name="..." base_dir="...">…</skill_content>`` tags — and
|
||||||
|
the request is forwarded to the chosen model.
|
||||||
|
|
||||||
|
If the orchestrator picks only skills and no route, the request falls back
|
||||||
|
to the originally-requested model (or the default) and the skill bodies are
|
||||||
|
injected the same way.
|
||||||
|
|
||||||
|
Bootstrap from the template
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
A ready-made template wires the moving pieces together:
|
||||||
|
|
||||||
|
.. code-block:: bash
|
||||||
|
|
||||||
|
planoai init --template skills_routing
|
||||||
|
|
||||||
|
Out of scope
|
||||||
|
------------
|
||||||
|
|
||||||
|
- Hot-reload of ``.plano/skills/`` while Plano is running — re-run
|
||||||
|
``planoai up`` to pick up new skills.
|
||||||
|
- Server-side execution of bundled ``scripts/`` from skills. The upstream
|
||||||
|
client runs scripts as part of the progressive-disclosure model from the
|
||||||
|
`specification <https://agentskills.io/specification.md>`_.
|
||||||
|
- Subagent delegation per skill. See the
|
||||||
|
`client-implementation guide
|
||||||
|
<https://agentskills.io/client-implementation/adding-skills-support.md>`_
|
||||||
|
for the advanced pattern.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue