core/skills: announce the skill tools in a notice, not a catalog

This commit is contained in:
2026-10-09 21:40:45 +08:00
parent e72d2359d7
commit 8729330120
12 changed files with 139 additions and 94 deletions
+26 -1
View File
@@ -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:
+30 -37
View File
@@ -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:
-6
View File
@@ -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