mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
core/skills: announce the skill tools in a notice, not a catalog
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user