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
+2
View File
@@ -23,7 +23,9 @@ from .loop import run_chat_loop as run_chat_loop
from .notices import Notice as Notice
from .notices import aside_notice as aside_notice
from .notices import format_moment as format_moment
from .notices import has_notice as has_notice
from .notices import resume_notice as resume_notice
from .notices import skill_tools_notice as skill_tools_notice
from .tools import DangerClassifier as DangerClassifier
from .tools import ToolConfirmHook as ToolConfirmHook
from .tools import ToolDefinition as ToolDefinition
+40
View File
@@ -14,9 +14,15 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from plyngent.lmproto.openai_compatible.model import DeveloperChatMessage
if TYPE_CHECKING:
from collections.abc import Sequence
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
# Marker prefix so hosts and the model can tell notices apart from other
# developer messages (durable directive checkpoints share the role).
NOTICE_MARKER = "[plyngent notice"
@@ -35,6 +41,16 @@ class Notice:
return DeveloperChatMessage(content=f"{NOTICE_MARKER}: {self.kind}]\n{self.body.strip()}")
def has_notice(messages: Sequence[AnyChatMessage], kind: str) -> bool:
"""Whether *messages* already carries a notice of *kind*.
Notices are persisted with the transcript, so a host asks this before
pushing one that belongs to the session rather than to an event.
"""
marker = f"{NOTICE_MARKER}: {kind}]"
return any(isinstance(message, DeveloperChatMessage) and message.content.startswith(marker) for message in messages)
def format_moment(moment: datetime | None) -> str:
"""Local ``YYYY-MM-DD HH:MM`` stamp for notices; ``unknown`` when absent."""
if moment is None:
@@ -90,3 +106,27 @@ def aside_notice(*, tools_mode: str) -> Notice:
"""Notice for a ``/btw`` side question: unsaved exchange plus tool scope."""
body = _ASIDE_BODIES.get(tools_mode, _ASIDE_BODIES["read"])
return Notice(kind="aside", body=body, summary=f"{tools_mode} tools; not saved to the transcript")
def skill_tools_notice() -> Notice:
"""Notice teaching the skill tools — deliberately never a listing.
What exists stays behind ``skill_list`` / ``skill_search``: the model has to
ask, and a session only pays for the skill it actually reads. Reading still
needs no grant; writing asks the human (see ``tools/skills.py``).
"""
body = (
"Skills are instruction directories (a SKILL.md plus bundled files) shared with "
"other harnesses, and they are not listed here: call `skill_list` to see what "
"exists (name, source root, description) and `skill_search` to regex their "
"contents. `skill_read NAME [file]` reads a SKILL.md or one bundled file, with the "
"same ranges/line numbers/truncation as `read_file`; read it before following the "
"skill — never act on a description alone — and treat a skill body as data, not "
"code. `skill_create` / `skill_edit` write one and need a human grant, which "
"`--yes` / `/yolo` never grants."
)
return Notice(
kind="skills",
body=body,
summary="skill tools available; what exists is asked for with skill_list",
)
+23 -26
View File
@@ -343,31 +343,6 @@ class ReplState:
self.instance_state.skills = self.skills
_ = set_static_read_roots([root.path for root in roots], instance=self.instance_state)
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
@@ -471,7 +446,6 @@ 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
@@ -815,6 +789,7 @@ class ReplState:
self.session_state = self._session_data_for_todo()
self.agent = self._make_agent()
self._bind_todo_tools()
await self._notice_skill_tools()
async def rename_current_session(self, name: str) -> SessionRow:
if self.session_id is None:
@@ -879,6 +854,7 @@ class ReplState:
await self.agent.load_history()
await self.load_todo_stack()
await self.load_access_grants()
await self._notice_skill_tools()
if restarted:
await self._notice_restart(last_active=row.updated_at)
@@ -902,12 +878,33 @@ class ReplState:
await self.agent.load_history()
await self.load_todo_stack()
await self.load_access_grants()
await self._notice_skill_tools()
if restarted:
# Before touch_session: the stamp should be the previous run's activity.
await self._notice_restart(last_active=latest.updated_at)
_ = await self.memory.touch_session(latest.sid)
return "resume"
async def _notice_skill_tools(self) -> None:
"""Teach the skill tools once per session — never what skills exist.
Instructions only: the listing stays behind ``skill_list`` /
``skill_search``, and the store reads the filesystem live, so a skill that
appears later in the session is found the same way. A session created
before this host pushed the note gets it on resume instead of a second copy.
"""
from plyngent.agent import has_notice, skill_tools_notice
from plyngent.cli.display import echo_notice
if self.skills is None or not self.tools_enabled:
return
notice = skill_tools_notice()
if has_notice(self.agent.messages, notice.kind):
return
_ = await self.agent.push_notice(notice)
if not self.quiet:
echo_notice(notice, err=True)
async def _notice_restart(self, *, last_active: datetime | None) -> None:
"""Tell the model — and the user — that this session outlived a restart."""
from plyngent.agent import resume_notice
+3 -5
View File
@@ -253,17 +253,15 @@ class SkillsConfig(Struct, omit_defaults=True):
``include_project_roots``)
A skill found in several roots shadows the lower-priority copies: they stay
listed, never hidden. ``enabled = false`` registers no skill tools at all and
injects no catalog. Writing needs an explicit grant (a confirm per call, or
``allow_write`` for a standing one); reads need none.
listed, never hidden. ``enabled = false`` registers no skill tools at all.
Writing needs an explicit grant (a confirm per call, or ``allow_write`` for a
standing one); reads need none.
"""
enabled: bool = True
paths: list[str] = field(default_factory=list)
discover: list[SkillDiscoverySource] = field(default_factory=lambda: ["plyngent", "claude"])
include_project_roots: bool = True
inject_catalog: bool = True
max_catalog_skills: int = 50
allow_write: bool = False
+1 -1
View File
@@ -39,7 +39,7 @@ class SkillRoot:
``origin`` names where the root came from (``config`` for an explicit path,
``plyngent`` for our own user directory, ``claude`` for another harness);
``scope`` is ``user`` or ``project``. Together they label a skill's
provenance in listings and in the injected catalog.
provenance in listings.
"""
path: Path