mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17: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):
|
||||
state_obj.instance_state.workspace.policy_confirm_hook = None
|
||||
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 (
|
||||
clear_private_grants,
|
||||
clear_ssrf_assume_public_cidrs,
|
||||
|
||||
@@ -21,6 +21,7 @@ from plyngent.prompting import (
|
||||
)
|
||||
from plyngent.tools.access import AccessDecision
|
||||
from plyngent.tools.process.pty_session import PtyManager
|
||||
from plyngent.tools.skills import SkillWriteDecision
|
||||
from plyngent.tools.workspace import AccessMode, parse_access_mode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -347,6 +348,54 @@ def prompt_policy_fetch_confirm(
|
||||
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(
|
||||
path: Path,
|
||||
mode: AccessMode,
|
||||
|
||||
@@ -590,6 +590,7 @@ def status_cmd(state: ReplState) -> None:
|
||||
f"rounds={last_rounds}\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"skills={len(state.skills.skills) if state.skills is not None else 'off'} "
|
||||
f"workspace={state.workspace}"
|
||||
)
|
||||
|
||||
@@ -1250,6 +1251,54 @@ def mcp_slash_cmd(state: ReplState, action: str | None) -> None:
|
||||
_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")
|
||||
@click.argument("action", required=False, type=click.Choice(["list", "revoke"]))
|
||||
@click.argument("target", required=False)
|
||||
|
||||
@@ -35,6 +35,7 @@ if TYPE_CHECKING:
|
||||
from plyngent.memory import MemoryStore
|
||||
from plyngent.memory.database.schema import Session as SessionRow
|
||||
from plyngent.runtime.mcp_client import McpManager
|
||||
from plyngent.skills import SkillStore
|
||||
from plyngent.tools.access import AccessDecision
|
||||
from plyngent.tools.workspace import AccessMode
|
||||
|
||||
@@ -88,6 +89,8 @@ class ReplState:
|
||||
session_state: SessionState = field(default_factory=SessionState)
|
||||
# MCP connections started by the host app before this state was built.
|
||||
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)
|
||||
# Session ids for Tab complete (updated when listing/creating/resuming).
|
||||
_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._install_config_access()
|
||||
self._install_skills()
|
||||
from plyngent.cli.limits import set_auto_continue_default
|
||||
|
||||
set_auto_continue_default(enabled=self.auto_continue_limits)
|
||||
@@ -302,6 +306,71 @@ class ReplState:
|
||||
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:
|
||||
if not self.tools_enabled:
|
||||
return None
|
||||
@@ -309,6 +378,7 @@ class ReplState:
|
||||
from plyngent.tools.catalog import register_builtin_tools
|
||||
from plyngent.tools.danger import classify_danger
|
||||
from plyngent.tools.plugins import load_plugin_tools
|
||||
from plyngent.tools.skills import SKILL_TOOL_NAMES
|
||||
|
||||
plugins_cfg = self.config.plugins_config
|
||||
catalog = register_builtin_tools()
|
||||
@@ -323,7 +393,9 @@ class ReplState:
|
||||
# Connections were started by the host; registration is sync-only.
|
||||
_ = register_mcp_tools(self.mcp_manager, self.config.mcp_config)
|
||||
# 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"
|
||||
# Always attach soft-confirm path so non-YOLO tools still prompt under YOLO mode.
|
||||
return ToolRegistry(
|
||||
@@ -357,6 +429,7 @@ class ReplState:
|
||||
system_prompt = compose_agent_system_content(
|
||||
agent_cfg.system_prompt,
|
||||
agent_cfg.tool_directives,
|
||||
self._skills_text(),
|
||||
mcp_text,
|
||||
)
|
||||
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.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()
|
||||
preferred_provider = self.provider_name if self.provider_name in selectable else None
|
||||
@@ -576,6 +651,8 @@ class ReplState:
|
||||
self.workspace = 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:
|
||||
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