mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
core/skills: announce the skill tools in a notice, not a catalog
This commit is contained in:
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
|
||||
from plyngent.agent import ChatAgent, Notice, aside_notice, format_moment, resume_notice
|
||||
from plyngent.agent import ChatAgent, Notice, aside_notice, format_moment, has_notice, resume_notice, skill_tools_notice
|
||||
from plyngent.agent.chat import incomplete_turn_user_text
|
||||
from plyngent.config.models import DatabaseConfig
|
||||
from plyngent.lmproto.openai_compatible.model import (
|
||||
@@ -57,6 +57,31 @@ def test_aside_notice_matches_tool_scope() -> None:
|
||||
assert aside_notice(tools_mode="weird").body == read.body
|
||||
|
||||
|
||||
def test_skill_tools_notice_points_at_the_tools_without_listing() -> None:
|
||||
notice = skill_tools_notice()
|
||||
assert notice.kind == "skills"
|
||||
# Instructions only — what exists is asked for, never injected.
|
||||
assert "not listed here" in notice.body
|
||||
assert "`skill_list`" in notice.body
|
||||
assert "`skill_search`" in notice.body
|
||||
assert "`skill_read NAME [file]`" in notice.body
|
||||
assert "data, not code" in notice.body
|
||||
assert "skill_list" in notice.summary
|
||||
|
||||
|
||||
def test_has_notice_finds_a_persisted_row_by_kind() -> None:
|
||||
messages = [
|
||||
SystemChatMessage(content="sys"),
|
||||
skill_tools_notice().to_message(),
|
||||
resume_notice(last_active=None).to_message(),
|
||||
]
|
||||
assert has_notice(messages, "skills") is True
|
||||
assert has_notice(messages, "resume") is True
|
||||
assert has_notice(messages, "aside") is False
|
||||
# A plain developer row (a directive checkpoint) is not a notice.
|
||||
assert has_notice([DeveloperChatMessage(content="checkpoint")], "skills") is False
|
||||
|
||||
|
||||
async def test_push_notice_appends_and_persists() -> None:
|
||||
store = await MemoryStore.open(DatabaseConfig())
|
||||
try:
|
||||
|
||||
@@ -11,6 +11,7 @@ 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.lmproto.openai_compatible.model import DeveloperChatMessage
|
||||
from plyngent.memory import MemoryStore
|
||||
from plyngent.prompting import NonInteractiveBackend, temporary_backend
|
||||
from plyngent.skills import SKILL_FILE
|
||||
@@ -31,6 +32,15 @@ def _write_skill(root: Path, name: str, *, description: str = "A test skill", bo
|
||||
return directory
|
||||
|
||||
|
||||
def _skill_notice_rows(state: ReplState) -> list[str]:
|
||||
"""Body of every ``skills`` host notice in the session transcript."""
|
||||
return [
|
||||
message.content
|
||||
for message in state.agent.messages
|
||||
if isinstance(message, DeveloperChatMessage) and message.content.startswith("[plyngent notice: skills]")
|
||||
]
|
||||
|
||||
|
||||
def _config_doc(tmp_path: Path, *, paths: list[Path] | None = None, extra: str = "") -> tomlkit.TOMLDocument:
|
||||
lines = ["[skills]", "discover = []"]
|
||||
if paths:
|
||||
@@ -230,61 +240,44 @@ async def test_skill_tools_are_absent_when_disabled(tmp_path: Path) -> None:
|
||||
await state.memory.close()
|
||||
|
||||
|
||||
async def test_skills_catalog_is_folded_into_the_system_prompt(tmp_path: Path) -> None:
|
||||
async def test_skill_tools_note_teaches_the_tools_without_listing_skills(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.
|
||||
# Nothing about the skill set is injected: not the names, not the bodies.
|
||||
assert "acme-deploy" not in prompt
|
||||
assert "Deploy the acme stack" not in prompt
|
||||
assert "Body." not in prompt
|
||||
|
||||
notes = _skill_notice_rows(state)
|
||||
assert len(notes) == 1
|
||||
assert "`skill_list`" in notes[0]
|
||||
assert "`skill_search`" in notes[0]
|
||||
|
||||
# The note belongs to the session, not the event: a second push is a no-op.
|
||||
await state._notice_skill_tools()
|
||||
assert len(_skill_notice_rows(state)) == 1
|
||||
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:
|
||||
async def test_skill_tools_note_needs_the_feature_and_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=True, extra="inject_catalog = false")
|
||||
disabled = await _state(tmp_path, skills_dir=root, tools_enabled=True, extra="enabled = 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
|
||||
assert _skill_notice_rows(disabled) == []
|
||||
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)
|
||||
await disabled.memory.close()
|
||||
no_tools = 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 "")
|
||||
assert _skill_notice_rows(no_tools) == []
|
||||
finally:
|
||||
await state.memory.close()
|
||||
await no_tools.memory.close()
|
||||
|
||||
|
||||
async def test_slash_skills_notes_when_tools_are_off(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
|
||||
@@ -16,8 +16,6 @@ def test_skills_section_defaults(tmp_path: Path) -> None:
|
||||
assert store.skills_config.paths == []
|
||||
assert store.skills_config.discover == ["plyngent", "claude"]
|
||||
assert store.skills_config.include_project_roots is True
|
||||
assert store.skills_config.inject_catalog is True
|
||||
assert store.skills_config.max_catalog_skills == 50
|
||||
assert store.skills_config.allow_write is False
|
||||
|
||||
|
||||
@@ -30,8 +28,6 @@ enabled = false
|
||||
paths = ["~/team-skills", "/srv/skills"]
|
||||
discover = ["plyngent"]
|
||||
include_project_roots = false
|
||||
inject_catalog = false
|
||||
max_catalog_skills = 5
|
||||
allow_write = true
|
||||
""",
|
||||
encoding="utf-8",
|
||||
@@ -41,8 +37,6 @@ allow_write = true
|
||||
assert cfg.paths == ["~/team-skills", "/srv/skills"]
|
||||
assert cfg.discover == ["plyngent"]
|
||||
assert cfg.include_project_roots is False
|
||||
assert cfg.inject_catalog is False
|
||||
assert cfg.max_catalog_skills == 5
|
||||
assert cfg.allow_write is True
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user