mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
cli/skills: expose skills to the session
ReplState builds the store from [skills] against the current workspace and publishes its roots as static read-only roots, so read_file/grep_files see a skill directory while writing stays denied; the roots are rebuilt when the workspace changes, since the project-scope directories hang off it. Discovered skills are folded into the system prompt the way MCP instructions are, with the catalog cap and its 'use skill_search' hint, and only when tools are on and injection is not switched off; [skills].enabled = false drops the tools from the registry entirely. The write policy is installed with the CLI confirm: once, session, or no, timed with a default of no and independent of YOLO, because a skill directory is not a grant. A library host that installs no hook keeps the deny default. /skills lists, searches, reads, and reloads; /status counts what was found.
This commit is contained in:
@@ -387,6 +387,9 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration
|
|||||||
if isinstance(state_obj, ReplState):
|
if isinstance(state_obj, ReplState):
|
||||||
state_obj.instance_state.workspace.policy_confirm_hook = None
|
state_obj.instance_state.workspace.policy_confirm_hook = None
|
||||||
state_obj.instance_state.workspace.policy_allowed_commands.clear()
|
state_obj.instance_state.workspace.policy_allowed_commands.clear()
|
||||||
|
from plyngent.tools.skills import set_skill_write_policy
|
||||||
|
|
||||||
|
set_skill_write_policy(None, instance=state_obj.instance_state)
|
||||||
from plyngent.tools.net import (
|
from plyngent.tools.net import (
|
||||||
clear_private_grants,
|
clear_private_grants,
|
||||||
clear_ssrf_assume_public_cidrs,
|
clear_ssrf_assume_public_cidrs,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from plyngent.prompting import (
|
|||||||
)
|
)
|
||||||
from plyngent.tools.access import AccessDecision
|
from plyngent.tools.access import AccessDecision
|
||||||
from plyngent.tools.process.pty_session import PtyManager
|
from plyngent.tools.process.pty_session import PtyManager
|
||||||
|
from plyngent.tools.skills import SkillWriteDecision
|
||||||
from plyngent.tools.workspace import AccessMode, parse_access_mode
|
from plyngent.tools.workspace import AccessMode, parse_access_mode
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -347,6 +348,54 @@ def prompt_policy_fetch_confirm(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_skill_write_confirm(
|
||||||
|
skill_dir: Path,
|
||||||
|
action: str,
|
||||||
|
detail: str,
|
||||||
|
timeout_seconds: float,
|
||||||
|
) -> SkillWriteDecision:
|
||||||
|
"""Ask whether to write into *skill_dir* (timed; default deny).
|
||||||
|
|
||||||
|
``o``/``y`` allows this write; ``s`` allows this skill directory for the rest
|
||||||
|
of the chat; anything else (or a timeout, cancel, or non-interactive stdin)
|
||||||
|
denies. Independent of YOLO: a skill directory is not a path grant, so the
|
||||||
|
human is asked even with ``--yes`` unless ``[skills].allow_write`` is set.
|
||||||
|
"""
|
||||||
|
lines = [
|
||||||
|
f"action: {action} skill {skill_dir.name!r}",
|
||||||
|
f"path: {skill_dir}",
|
||||||
|
f"change: {detail}",
|
||||||
|
f"(timeout {timeout_seconds:g}s defaults to DENY; not skipped by YOLO)",
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
with pause_task_cancel_for_prompt():
|
||||||
|
backend = get_prompt_backend()
|
||||||
|
if not backend.is_interactive():
|
||||||
|
return SkillWriteDecision(allow=False)
|
||||||
|
backend.echo()
|
||||||
|
backend.secho(format_tool_confirm_box(f"skills {action}", "\n".join(lines)), fg="yellow")
|
||||||
|
backend.echo()
|
||||||
|
prompt = (
|
||||||
|
f"[skills] allow {action} in {skill_dir.name!r}? [o]nce/[s]ession/[n]o (timeout {timeout_seconds:g}s): "
|
||||||
|
)
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
_ = sys.stderr.write(prompt)
|
||||||
|
_ = sys.stderr.flush()
|
||||||
|
raw = _read_yes_no_line_with_timeout(timeout_seconds)
|
||||||
|
if raw is None:
|
||||||
|
with contextlib.suppress(OSError, NonInteractiveError):
|
||||||
|
backend.secho(f"[skills] timed out after {timeout_seconds:g}s — denied", fg="red", err=True)
|
||||||
|
return SkillWriteDecision(allow=False)
|
||||||
|
token = raw.strip().lower()
|
||||||
|
if token in {"s", "session"}:
|
||||||
|
return SkillWriteDecision(allow=True, session=True)
|
||||||
|
if token in {"o", "once", "y", "yes"}:
|
||||||
|
return SkillWriteDecision(allow=True)
|
||||||
|
return SkillWriteDecision(allow=False)
|
||||||
|
except NonInteractiveError, KeyboardInterrupt, EOFError:
|
||||||
|
return SkillWriteDecision(allow=False)
|
||||||
|
|
||||||
|
|
||||||
def prompt_directory_access_confirm(
|
def prompt_directory_access_confirm(
|
||||||
path: Path,
|
path: Path,
|
||||||
mode: AccessMode,
|
mode: AccessMode,
|
||||||
|
|||||||
@@ -590,6 +590,7 @@ def status_cmd(state: ReplState) -> None:
|
|||||||
f"rounds={last_rounds}\n"
|
f"rounds={last_rounds}\n"
|
||||||
f"usage_session={session_u.format_line(billed=True)}\n"
|
f"usage_session={session_u.format_line(billed=True)}\n"
|
||||||
f"grants={len(access_grant_rows(instance=state.instance_state, session=state.session_state))} "
|
f"grants={len(access_grant_rows(instance=state.instance_state, session=state.session_state))} "
|
||||||
|
f"skills={len(state.skills.skills) if state.skills is not None else 'off'} "
|
||||||
f"workspace={state.workspace}"
|
f"workspace={state.workspace}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1250,6 +1251,54 @@ def mcp_slash_cmd(state: ReplState, action: str | None) -> None:
|
|||||||
_print_mcp_status(state)
|
_print_mcp_status(state)
|
||||||
|
|
||||||
|
|
||||||
|
@slash.command("skills")
|
||||||
|
@click.argument("action", required=False, type=click.Choice(["list", "search", "read", "reload"]))
|
||||||
|
@click.argument("target", required=False)
|
||||||
|
@click.option("--file", "file", default="", help="File inside the skill (with ``read``).")
|
||||||
|
@click.pass_obj
|
||||||
|
def skills_slash_cmd(state: ReplState, action: str | None, target: str | None, file: str) -> None:
|
||||||
|
"""List skills, search or read them, or rescan the roots.
|
||||||
|
|
||||||
|
``/skills`` or ``/skills list`` — discovered skills with their source root.
|
||||||
|
``/skills search PATTERN`` — regex over every skill's contents.
|
||||||
|
``/skills read NAME [--file F]`` — print a skill's SKILL.md (or one file).
|
||||||
|
``/skills reload`` — rescan the roots after an external edit.
|
||||||
|
"""
|
||||||
|
from plyngent.tools.context import bind_tool_context
|
||||||
|
from plyngent.tools.skills import SKILL_TOOL_NAMES, format_skill_table, skill_read, skill_search
|
||||||
|
|
||||||
|
if state.skills is None:
|
||||||
|
click.echo("skills: disabled (set [skills].enabled = true)")
|
||||||
|
return
|
||||||
|
act = (action or "list").lower()
|
||||||
|
if act == "reload":
|
||||||
|
state.reload_skills()
|
||||||
|
_slash_rebuild_tools_if_on(state)
|
||||||
|
click.echo(f"skills reloaded: {len(state.skills.skills)} visible; tools rebuilt")
|
||||||
|
return
|
||||||
|
# The tool handlers read the bound instance (the registry binds it for a
|
||||||
|
# model call; a slash command has to do it itself).
|
||||||
|
with bind_tool_context(instance=state.instance_state, session=state.session_state):
|
||||||
|
if act == "search":
|
||||||
|
if not target:
|
||||||
|
msg = "/skills search requires a pattern"
|
||||||
|
raise click.UsageError(msg)
|
||||||
|
click.echo(_await(skill_search.handler(target)))
|
||||||
|
return
|
||||||
|
if act == "read":
|
||||||
|
if not target:
|
||||||
|
msg = "/skills read requires a skill name"
|
||||||
|
raise click.UsageError(msg)
|
||||||
|
click.echo(_await(skill_read.handler(target, file or "SKILL.md")))
|
||||||
|
return
|
||||||
|
click.echo(format_skill_table(state.skills))
|
||||||
|
if not state.tools_enabled:
|
||||||
|
click.secho(
|
||||||
|
f"note: tools are off, so {', '.join(sorted(SKILL_TOOL_NAMES))} are not offered to the model",
|
||||||
|
fg="bright_black",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@slash.command("grants")
|
@slash.command("grants")
|
||||||
@click.argument("action", required=False, type=click.Choice(["list", "revoke"]))
|
@click.argument("action", required=False, type=click.Choice(["list", "revoke"]))
|
||||||
@click.argument("target", required=False)
|
@click.argument("target", required=False)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ if TYPE_CHECKING:
|
|||||||
from plyngent.memory import MemoryStore
|
from plyngent.memory import MemoryStore
|
||||||
from plyngent.memory.database.schema import Session as SessionRow
|
from plyngent.memory.database.schema import Session as SessionRow
|
||||||
from plyngent.runtime.mcp_client import McpManager
|
from plyngent.runtime.mcp_client import McpManager
|
||||||
|
from plyngent.skills import SkillStore
|
||||||
from plyngent.tools.access import AccessDecision
|
from plyngent.tools.access import AccessDecision
|
||||||
from plyngent.tools.workspace import AccessMode
|
from plyngent.tools.workspace import AccessMode
|
||||||
|
|
||||||
@@ -88,6 +89,8 @@ class ReplState:
|
|||||||
session_state: SessionState = field(default_factory=SessionState)
|
session_state: SessionState = field(default_factory=SessionState)
|
||||||
# MCP connections started by the host app before this state was built.
|
# MCP connections started by the host app before this state was built.
|
||||||
mcp_manager: McpManager | None = None
|
mcp_manager: McpManager | None = None
|
||||||
|
# Discovered skills (None when ``[skills].enabled`` is false).
|
||||||
|
skills: SkillStore | None = field(default=None, init=False, repr=False)
|
||||||
_todo_persist_tasks: set[object] = field(default_factory=set, init=False, repr=False)
|
_todo_persist_tasks: set[object] = field(default_factory=set, init=False, repr=False)
|
||||||
# Session ids for Tab complete (updated when listing/creating/resuming).
|
# Session ids for Tab complete (updated when listing/creating/resuming).
|
||||||
_session_id_cache: list[int] = field(default_factory=list, init=False, repr=False)
|
_session_id_cache: list[int] = field(default_factory=list, init=False, repr=False)
|
||||||
@@ -103,6 +106,7 @@ class ReplState:
|
|||||||
self.instance_state.workspace_root = self.workspace
|
self.instance_state.workspace_root = self.workspace
|
||||||
self.instance_state.workspace.root = self.workspace
|
self.instance_state.workspace.root = self.workspace
|
||||||
self._install_config_access()
|
self._install_config_access()
|
||||||
|
self._install_skills()
|
||||||
from plyngent.cli.limits import set_auto_continue_default
|
from plyngent.cli.limits import set_auto_continue_default
|
||||||
|
|
||||||
set_auto_continue_default(enabled=self.auto_continue_limits)
|
set_auto_continue_default(enabled=self.auto_continue_limits)
|
||||||
@@ -302,6 +306,71 @@ class ReplState:
|
|||||||
err=True,
|
err=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _install_skills(self) -> None:
|
||||||
|
"""Build the skill store and expose its roots read-only to path tools.
|
||||||
|
|
||||||
|
Roots follow ``[skills]`` (explicit paths first, then our own user
|
||||||
|
directory and other harnesses' directories, with the project-scope ones
|
||||||
|
tied to the current workspace). Writing is *not* part of this: the roots
|
||||||
|
are read-only, and skill writes go through the write policy installed
|
||||||
|
below, so a skill directory never becomes a general write grant.
|
||||||
|
"""
|
||||||
|
from plyngent.cli.limits import prompt_skill_write_confirm
|
||||||
|
from plyngent.config.path import get_default_path
|
||||||
|
from plyngent.skills import SkillStore, build_roots
|
||||||
|
from plyngent.tools.skills import SkillWritePolicy, set_skill_write_policy
|
||||||
|
from plyngent.tools.workspace import set_static_read_roots
|
||||||
|
|
||||||
|
cfg = self.config.skills_config
|
||||||
|
set_skill_write_policy(
|
||||||
|
SkillWritePolicy(allow_all=cfg.allow_write, confirm=prompt_skill_write_confirm),
|
||||||
|
instance=self.instance_state,
|
||||||
|
)
|
||||||
|
if not cfg.enabled:
|
||||||
|
self.skills = None
|
||||||
|
self.instance_state.skills = None
|
||||||
|
_ = set_static_read_roots([], instance=self.instance_state)
|
||||||
|
return
|
||||||
|
roots = build_roots(
|
||||||
|
cfg,
|
||||||
|
workspace=self.workspace,
|
||||||
|
user_config_dir=get_default_path().parent,
|
||||||
|
home=Path.home(),
|
||||||
|
)
|
||||||
|
self.skills = SkillStore(roots)
|
||||||
|
self.instance_state.skills = self.skills
|
||||||
|
_ = set_static_read_roots([root.path for root in roots], instance=self.instance_state)
|
||||||
|
|
||||||
|
def reload_skills(self) -> None:
|
||||||
|
"""Rescan the roots (after an external edit or a config change)."""
|
||||||
|
if self.skills is not None:
|
||||||
|
self.skills.reload()
|
||||||
|
|
||||||
|
def _skills_text(self) -> str:
|
||||||
|
"""Fold the discovered skills into the system prompt (like MCP instructions).
|
||||||
|
|
||||||
|
Only names, sources, and descriptions: the bodies stay on disk so a turn
|
||||||
|
pays for the skill it actually reads. Notes and shadowed copies are left
|
||||||
|
to ``skill_list``, which the model can call when something looks off.
|
||||||
|
"""
|
||||||
|
cfg = self.config.skills_config
|
||||||
|
if self.skills is None or not self.tools_enabled or not cfg.inject_catalog:
|
||||||
|
return ""
|
||||||
|
skills = self.skills.skills
|
||||||
|
if not skills:
|
||||||
|
return ""
|
||||||
|
limit = max(0, cfg.max_catalog_skills)
|
||||||
|
shown = skills[:limit]
|
||||||
|
lines = [
|
||||||
|
"### Skills",
|
||||||
|
"Available skills (read a skill's SKILL.md with `skill_read` before following it):",
|
||||||
|
]
|
||||||
|
lines.extend(f"- {skill.name} ({skill.root.label}): {skill.description}" for skill in shown)
|
||||||
|
if len(skills) > len(shown):
|
||||||
|
lines.append(f"- … and {len(skills) - len(shown)} more; use `skill_search` or `skill_list`")
|
||||||
|
lines.append("Search all skills with `skill_search`; create or update one with `skill_create` / `skill_edit`.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _tool_registry(self) -> ToolRegistry | None:
|
def _tool_registry(self) -> ToolRegistry | None:
|
||||||
if not self.tools_enabled:
|
if not self.tools_enabled:
|
||||||
return None
|
return None
|
||||||
@@ -309,6 +378,7 @@ class ReplState:
|
|||||||
from plyngent.tools.catalog import register_builtin_tools
|
from plyngent.tools.catalog import register_builtin_tools
|
||||||
from plyngent.tools.danger import classify_danger
|
from plyngent.tools.danger import classify_danger
|
||||||
from plyngent.tools.plugins import load_plugin_tools
|
from plyngent.tools.plugins import load_plugin_tools
|
||||||
|
from plyngent.tools.skills import SKILL_TOOL_NAMES
|
||||||
|
|
||||||
plugins_cfg = self.config.plugins_config
|
plugins_cfg = self.config.plugins_config
|
||||||
catalog = register_builtin_tools()
|
catalog = register_builtin_tools()
|
||||||
@@ -323,7 +393,9 @@ class ReplState:
|
|||||||
# Connections were started by the host; registration is sync-only.
|
# Connections were started by the host; registration is sync-only.
|
||||||
_ = register_mcp_tools(self.mcp_manager, self.config.mcp_config)
|
_ = register_mcp_tools(self.mcp_manager, self.config.mcp_config)
|
||||||
# Local surface: builtins + allowlisted plugins (import registers into catalog).
|
# Local surface: builtins + allowlisted plugins (import registers into catalog).
|
||||||
tools = catalog.select(surface="local")
|
# ``[skills].enabled = false`` drops the skill tools from the surface.
|
||||||
|
excluded = None if self.config.skills_config.enabled else set(SKILL_TOOL_NAMES)
|
||||||
|
tools = catalog.select(surface="local", exclude_names=excluded)
|
||||||
yolo = self.effective_yolo() != "off"
|
yolo = self.effective_yolo() != "off"
|
||||||
# Always attach soft-confirm path so non-YOLO tools still prompt under YOLO mode.
|
# Always attach soft-confirm path so non-YOLO tools still prompt under YOLO mode.
|
||||||
return ToolRegistry(
|
return ToolRegistry(
|
||||||
@@ -357,6 +429,7 @@ class ReplState:
|
|||||||
system_prompt = compose_agent_system_content(
|
system_prompt = compose_agent_system_content(
|
||||||
agent_cfg.system_prompt,
|
agent_cfg.system_prompt,
|
||||||
agent_cfg.tool_directives,
|
agent_cfg.tool_directives,
|
||||||
|
self._skills_text(),
|
||||||
mcp_text,
|
mcp_text,
|
||||||
)
|
)
|
||||||
on_limit = prompt_continue_limit_async if (self.interactive_limits or auto_continue_enabled()) else None
|
on_limit = prompt_continue_limit_async if (self.interactive_limits or auto_continue_enabled()) else None
|
||||||
@@ -539,6 +612,8 @@ class ReplState:
|
|||||||
|
|
||||||
self.config.reload()
|
self.config.reload()
|
||||||
self.instance_state.workspace.path_denylist = tuple(self.config.agent_config.path_denylist or ())
|
self.instance_state.workspace.path_denylist = tuple(self.config.agent_config.path_denylist or ())
|
||||||
|
# Roots and their switches came from the file that was just re-read.
|
||||||
|
self._install_skills()
|
||||||
|
|
||||||
selectable = self.config.selectable_providers()
|
selectable = self.config.selectable_providers()
|
||||||
preferred_provider = self.provider_name if self.provider_name in selectable else None
|
preferred_provider = self.provider_name if self.provider_name in selectable else None
|
||||||
@@ -576,6 +651,8 @@ class ReplState:
|
|||||||
self.workspace = resolved
|
self.workspace = resolved
|
||||||
self.instance_state.workspace_root = resolved
|
self.instance_state.workspace_root = resolved
|
||||||
self.instance_state.workspace.root = resolved
|
self.instance_state.workspace.root = resolved
|
||||||
|
# Project-scope skill roots follow the workspace.
|
||||||
|
self._install_skills()
|
||||||
if hasattr(self, "agent") and self.agent.tools is not None:
|
if hasattr(self, "agent") and self.agent.tools is not None:
|
||||||
self.agent.tools.set_instance_state(self.instance_state)
|
self.agent.tools.set_instance_state(self.instance_state)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"""CLI surface for skills: ``/skills``, the system-prompt catalog, and writes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tomlkit
|
||||||
|
|
||||||
|
from plyngent.cli.slash import handle_slash
|
||||||
|
from plyngent.cli.state import ReplState
|
||||||
|
from plyngent.config.models import DatabaseConfig, OpenAIProvider
|
||||||
|
from plyngent.config.store import ConfigStore
|
||||||
|
from plyngent.memory import MemoryStore
|
||||||
|
from plyngent.prompting import NonInteractiveBackend, temporary_backend
|
||||||
|
from plyngent.skills import SKILL_FILE
|
||||||
|
from tests.test_prompting import ScriptedBackend
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _write_skill(root: Path, name: str, *, description: str = "A test skill", body: str = "Body.") -> Path:
|
||||||
|
directory = root / name
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
_ = (directory / SKILL_FILE).write_text(
|
||||||
|
f"---\nname: {name}\ndescription: {description}\n---\n\n{body}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return directory
|
||||||
|
|
||||||
|
|
||||||
|
def _config_doc(tmp_path: Path, *, paths: list[Path] | None = None, extra: str = "") -> tomlkit.TOMLDocument:
|
||||||
|
lines = ["[skills]", "discover = []"]
|
||||||
|
if paths:
|
||||||
|
rendered = ", ".join(f'"{path}"' for path in paths)
|
||||||
|
lines.append(f"paths = [{rendered}]")
|
||||||
|
if extra:
|
||||||
|
lines.append(extra)
|
||||||
|
return tomlkit.parse("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def _state(
|
||||||
|
tmp_path: Path,
|
||||||
|
*,
|
||||||
|
skills_dir: Path | None = None,
|
||||||
|
tools_enabled: bool = False,
|
||||||
|
extra: str = "",
|
||||||
|
) -> ReplState:
|
||||||
|
memory = await MemoryStore.open(DatabaseConfig())
|
||||||
|
provider = OpenAIProvider(access_key_or_token="sk-test")
|
||||||
|
document = _config_doc(tmp_path, paths=[skills_dir] if skills_dir is not None else None, extra=extra)
|
||||||
|
config = ConfigStore(path=tmp_path / "plyngent.toml", document=document)
|
||||||
|
config.providers = {"local": provider}
|
||||||
|
# The skills root sits outside the workspace: reading it works through the
|
||||||
|
# static read-only roots, writing must stay denied by the path policy.
|
||||||
|
workspace = tmp_path / "project"
|
||||||
|
workspace.mkdir(exist_ok=True)
|
||||||
|
state = ReplState(
|
||||||
|
config=config,
|
||||||
|
memory=memory,
|
||||||
|
workspace=workspace,
|
||||||
|
provider_name="local",
|
||||||
|
provider=provider,
|
||||||
|
model="gpt-test",
|
||||||
|
tools_enabled=tools_enabled,
|
||||||
|
quiet=True,
|
||||||
|
)
|
||||||
|
await state.new_session("t")
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def skills_state(tmp_path: Path) -> AsyncIterator[ReplState]:
|
||||||
|
root = tmp_path / "ours"
|
||||||
|
root.mkdir()
|
||||||
|
_ = _write_skill(root, "acme-deploy", description="Deploy the acme stack", body="Use pdftotext.")
|
||||||
|
state = await _state(tmp_path, skills_dir=root, tools_enabled=True)
|
||||||
|
yield state
|
||||||
|
await state.memory.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_slash_status_counts_skills(skills_state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
assert await handle_slash(skills_state, "/status") is True
|
||||||
|
assert "skills=1" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_skills_are_discovered_from_a_configured_root(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "ours"
|
||||||
|
root.mkdir()
|
||||||
|
_ = _write_skill(root, "acme-deploy")
|
||||||
|
state = await _state(tmp_path, skills_dir=root)
|
||||||
|
try:
|
||||||
|
assert state.skills is not None
|
||||||
|
assert [skill.name for skill in state.skills.skills] == ["acme-deploy"]
|
||||||
|
assert state.instance_state.skills is state.skills
|
||||||
|
finally:
|
||||||
|
await state.memory.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_slash_skills_lists_search_and_reads(
|
||||||
|
skills_state: ReplState,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
assert await handle_slash(skills_state, "/skills") is True
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "skills: 1" in out
|
||||||
|
assert "acme-deploy" in out
|
||||||
|
assert "Deploy the acme stack" in out
|
||||||
|
|
||||||
|
assert await handle_slash(skills_state, "/skills search pdftotext") is True
|
||||||
|
assert "acme-deploy/SKILL.md:" in capsys.readouterr().out
|
||||||
|
|
||||||
|
assert await handle_slash(skills_state, "/skills read acme-deploy") is True
|
||||||
|
assert "description: Deploy the acme stack" in capsys.readouterr().out
|
||||||
|
|
||||||
|
assert await handle_slash(skills_state, "/skills read acme-deploy --file SKILL.md") is True
|
||||||
|
assert "acme-deploy" in capsys.readouterr().out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_slash_skills_reload_picks_up_a_new_skill(
|
||||||
|
tmp_path: Path,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
root = tmp_path / "ours"
|
||||||
|
root.mkdir()
|
||||||
|
state = await _state(tmp_path, skills_dir=root, tools_enabled=True)
|
||||||
|
try:
|
||||||
|
assert await handle_slash(state, "/skills") is True
|
||||||
|
assert "skills: 0" in capsys.readouterr().out
|
||||||
|
_ = _write_skill(root, "added-later")
|
||||||
|
assert await handle_slash(state, "/skills reload") is True
|
||||||
|
assert "skills reloaded: 1 visible" in capsys.readouterr().out
|
||||||
|
assert await handle_slash(state, "/skills") is True
|
||||||
|
assert "added-later" in capsys.readouterr().out
|
||||||
|
finally:
|
||||||
|
await state.memory.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_slash_skills_reports_a_disabled_feature(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
state = await _state(tmp_path, extra="enabled = false")
|
||||||
|
try:
|
||||||
|
assert await handle_slash(state, "/skills") is True
|
||||||
|
assert "skills: disabled" in capsys.readouterr().out
|
||||||
|
assert state.skills is None
|
||||||
|
assert state.instance_state.skills is None
|
||||||
|
finally:
|
||||||
|
await state.memory.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_slash_skills_requires_a_target(skills_state: ReplState, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
for command in ("/skills search", "/skills read"):
|
||||||
|
assert await handle_slash(skills_state, command) is True
|
||||||
|
assert "requires" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
async def test_skill_tools_are_offered_and_can_read_skill_files(
|
||||||
|
skills_state: ReplState,
|
||||||
|
) -> None:
|
||||||
|
"""The roots are read-only roots: path tools read them, writes are denied."""
|
||||||
|
assert skills_state.agent.tools is not None
|
||||||
|
assert skills_state.agent.tools.get("skill_list") is not None
|
||||||
|
|
||||||
|
skill_file = skills_state.skills.skills[0].skill_file if skills_state.skills else None
|
||||||
|
assert skill_file is not None
|
||||||
|
read = await skills_state.agent.tools.execute("read_file", f'{{"path": "{skill_file}"}}')
|
||||||
|
assert "Deploy the acme stack" in read
|
||||||
|
|
||||||
|
denied = await skills_state.agent.tools.execute(
|
||||||
|
"write_file",
|
||||||
|
f'{{"path": "{skill_file}", "content": "x"}}',
|
||||||
|
)
|
||||||
|
assert denied.startswith("error:")
|
||||||
|
assert "escapes workspace root" in denied
|
||||||
|
assert "Deploy the acme stack" in skill_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_skill_tools_are_absent_when_disabled(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "ours"
|
||||||
|
root.mkdir()
|
||||||
|
_ = _write_skill(root, "acme")
|
||||||
|
state = await _state(tmp_path, skills_dir=root, tools_enabled=True, extra="enabled = false")
|
||||||
|
try:
|
||||||
|
assert state.agent.tools is not None
|
||||||
|
assert state.agent.tools.get("skill_list") is None
|
||||||
|
assert state.agent.tools.get("read_file") is not None
|
||||||
|
finally:
|
||||||
|
await state.memory.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_skills_catalog_is_folded_into_the_system_prompt(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "ours"
|
||||||
|
root.mkdir()
|
||||||
|
_ = _write_skill(root, "acme-deploy", description="Deploy the acme stack")
|
||||||
|
state = await _state(tmp_path, skills_dir=root, tools_enabled=True)
|
||||||
|
try:
|
||||||
|
prompt = state.agent.system_prompt or ""
|
||||||
|
assert "### Skills" in prompt
|
||||||
|
assert "- acme-deploy (config-user): Deploy the acme stack" in prompt
|
||||||
|
assert "`skill_read`" in prompt
|
||||||
|
# The body stays on disk: only the catalog goes into the prompt.
|
||||||
|
assert "Body." not in prompt
|
||||||
|
finally:
|
||||||
|
await state.memory.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_skills_catalog_respects_the_cap(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "ours"
|
||||||
|
root.mkdir()
|
||||||
|
for name in ("alpha", "beta", "gamma"):
|
||||||
|
_ = _write_skill(root, name)
|
||||||
|
state = await _state(tmp_path, skills_dir=root, tools_enabled=True, extra="max_catalog_skills = 1")
|
||||||
|
try:
|
||||||
|
prompt = state.agent.system_prompt or ""
|
||||||
|
assert "alpha" in prompt
|
||||||
|
assert "beta" not in prompt
|
||||||
|
assert "… and 2 more" in prompt
|
||||||
|
finally:
|
||||||
|
await state.memory.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_skills_catalog_can_be_switched_off(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "ours"
|
||||||
|
root.mkdir()
|
||||||
|
_ = _write_skill(root, "acme")
|
||||||
|
state = await _state(tmp_path, skills_dir=root, tools_enabled=True, extra="inject_catalog = false")
|
||||||
|
try:
|
||||||
|
assert "### Skills" not in (state.agent.system_prompt or "")
|
||||||
|
# The tools are still offered; only the prompt block is gone.
|
||||||
|
assert state.agent.tools is not None
|
||||||
|
assert state.agent.tools.get("skill_list") is not None
|
||||||
|
finally:
|
||||||
|
await state.memory.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_skills_catalog_needs_tools(tmp_path: Path) -> None:
|
||||||
|
root = tmp_path / "ours"
|
||||||
|
root.mkdir()
|
||||||
|
_ = _write_skill(root, "acme")
|
||||||
|
state = await _state(tmp_path, skills_dir=root, tools_enabled=False)
|
||||||
|
try:
|
||||||
|
assert state.agent.tools is None
|
||||||
|
assert "### Skills" not in (state.agent.system_prompt or "")
|
||||||
|
finally:
|
||||||
|
await state.memory.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_slash_skills_notes_when_tools_are_off(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
root = tmp_path / "ours"
|
||||||
|
root.mkdir()
|
||||||
|
_ = _write_skill(root, "acme")
|
||||||
|
state = await _state(tmp_path, skills_dir=root, tools_enabled=False)
|
||||||
|
try:
|
||||||
|
assert state.tools_enabled is False
|
||||||
|
assert await handle_slash(state, "/skills list") is True
|
||||||
|
assert "tools are off" in capsys.readouterr().out
|
||||||
|
finally:
|
||||||
|
await state.memory.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_skill_write_confirm_noninteractive_denies() -> None:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from plyngent.cli.limits import prompt_skill_write_confirm
|
||||||
|
|
||||||
|
with temporary_backend(NonInteractiveBackend()):
|
||||||
|
decision = prompt_skill_write_confirm(Path("/skills/acme"), "create", "create skill", 1.0)
|
||||||
|
assert decision.allow is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_skill_write_confirm_answers(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from plyngent.cli import limits
|
||||||
|
|
||||||
|
target = Path("/skills/acme")
|
||||||
|
with temporary_backend(ScriptedBackend([])):
|
||||||
|
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "o\n")
|
||||||
|
once = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||||
|
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "s\n")
|
||||||
|
session = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||||
|
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: "n\n")
|
||||||
|
denied = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||||
|
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout: None)
|
||||||
|
timed_out = limits.prompt_skill_write_confirm(target, "create", "create skill", 30.0)
|
||||||
|
assert (once.allow, once.session) == (True, False)
|
||||||
|
assert (session.allow, session.session) == (True, True)
|
||||||
|
assert denied.allow is False
|
||||||
|
assert timed_out.allow is False
|
||||||
Reference in New Issue
Block a user