From bd18a88370b1da1e311748f6f7e25b02fed99af8 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 19 Sep 2026 09:15:13 +0800 Subject: [PATCH] agent/notices: host notices as trailing developer messages --- src/plyngent/agent/__init__.py | 4 ++ src/plyngent/agent/chat.py | 23 ++++++- src/plyngent/agent/notices.py | 92 ++++++++++++++++++++++++++++ tests/test_agent/test_notices.py | 101 +++++++++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 src/plyngent/agent/notices.py create mode 100644 tests/test_agent/test_notices.py diff --git a/src/plyngent/agent/__init__.py b/src/plyngent/agent/__init__.py index 65e2133..b120b7a 100644 --- a/src/plyngent/agent/__init__.py +++ b/src/plyngent/agent/__init__.py @@ -20,6 +20,10 @@ from .events import ToolResultEvent as ToolResultEvent from .events import UsageEvent as UsageEvent from .loop import DEFAULT_MAX_ROUNDS as DEFAULT_MAX_ROUNDS 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 resume_notice as resume_notice from .tools import DangerClassifier as DangerClassifier from .tools import ToolConfirmHook as ToolConfirmHook from .tools import ToolDefinition as ToolDefinition diff --git a/src/plyngent/agent/chat.py b/src/plyngent/agent/chat.py index f2b2815..7d1ef1d 100644 --- a/src/plyngent/agent/chat.py +++ b/src/plyngent/agent/chat.py @@ -9,6 +9,7 @@ from msgspec import UNSET from plyngent.lmproto.openai_compatible.model import ( AssistantChatMessage, AssistantFunctionToolCall, + DeveloperChatMessage, SystemChatMessage, ToolChatMessage, UserChatMessage, @@ -40,6 +41,7 @@ if TYPE_CHECKING: from plyngent.memory import MemoryStore from .events import AgentEvent + from .notices import Notice from .todo_nag import TodoNagStrategy from .todo_stack import TodoStack from .types import AnyLLMClient @@ -138,12 +140,16 @@ def incomplete_turn_user_text(messages: Sequence[AnyChatMessage]) -> str | None: Incomplete when the last non-system message is a user message (failed before any commit), or tool results after a complete tool batch (failed on a later model round — keep tools so retry does not re-execute side effects). + Trailing developer messages (host notices, directive checkpoints) belong to + the turn but never end it, so they are skipped when reading the tail. """ # Skip leading system prompt only. start = 0 if messages and isinstance(messages[0], SystemChatMessage): start = 1 body = list(messages[start:]) + while body and isinstance(body[-1], DeveloperChatMessage): + _ = body.pop() if not body: return None last = body[-1] @@ -420,6 +426,21 @@ class ChatAgent: if load: await self.load_history() + async def push_notice(self, notice: Notice) -> DeveloperChatMessage: + """Append a host notice for the model and persist it with the transcript. + + Notices are trailing ``developer`` messages, never system-prompt edits: + the model reads the newest host state last. Hosts with a durable session + store them as normal history (a side agent without memory keeps its + notice local to that exchange). The checkpoint cursor stays ahead of the + new message, so it is never re-persisted by a later tool batch. + """ + message = notice.to_message() + self.messages.append(message) + await self._persist(message) + self._persist_from = len(self.messages) + return message + async def _persist(self, message: AnyChatMessage) -> None: if self.memory is not None and self.session_id is not None: _ = await self.memory.append_message(self.session_id, message) @@ -461,8 +482,6 @@ class ChatAgent: def _developer_tail_end(self, start: int) -> int: """Extend *start* through trailing developer checkpoint messages.""" - from plyngent.lmproto.openai_compatible.model import DeveloperChatMessage - end = start while end < len(self.messages) and isinstance(self.messages[end], DeveloperChatMessage): end += 1 diff --git a/src/plyngent/agent/notices.py b/src/plyngent/agent/notices.py new file mode 100644 index 0000000..0c1eb8f --- /dev/null +++ b/src/plyngent/agent/notices.py @@ -0,0 +1,92 @@ +"""Host notices: short system-level messages appended after existing history. + +A notice never edits the system prompt. It is written as a trailing +``developer`` message — the mid-history system channel, coerced to ``system`` +for providers without that role (see ``lmproto.openai_compatible.compat``) — so +the model reads the newest host state last, right before the next user turn. + +Hosts show :attr:`Notice.summary` to the user and persist +:meth:`Notice.to_message` with the rest of the transcript when they have a +durable session; side agents without memory keep it local to that exchange. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime + +from plyngent.lmproto.openai_compatible.model import DeveloperChatMessage + +# 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" + + +@dataclass(frozen=True, slots=True) +class Notice: + """One host notice: model-facing :attr:`body`, user-facing :attr:`summary`.""" + + kind: str + body: str + summary: str = "" + + def to_message(self) -> DeveloperChatMessage: + """Render as a trailing ``developer`` message (system-level channel).""" + return DeveloperChatMessage(content=f"{NOTICE_MARKER}: {self.kind}]\n{self.body.strip()}") + + +def format_moment(moment: datetime | None) -> str: + """Local ``YYYY-MM-DD HH:MM`` stamp for notices; ``unknown`` when absent.""" + if moment is None: + return "unknown" + if moment.tzinfo is None: + # SQLite hands back naive datetimes for tz-aware columns. + moment = moment.replace(tzinfo=UTC) + return moment.astimezone().strftime("%Y-%m-%d %H:%M") + + +def resume_notice(*, last_active: datetime | None) -> Notice: + """Notice for a session resumed in a freshly started process. + + Only process-scoped state is gone after a restart (PTY sessions, running + commands, temporary workspaces); durable session state — transcript, todo + stack, workspace binding, access grants — is restored before this lands. + """ + stamp = format_moment(last_active) + body = ( + f"This session was resumed in a new plyngent process (previous run's last " + f"activity: {stamp}). Process-scoped state did not survive the restart: PTY " + "sessions, running commands and temporary workspaces are gone, and any shell " + "working directory you changed no longer applies. The transcript, todo stack, " + "workspace binding and directory-access grants were restored. A turn that looks " + "unfinished was cut off mid-way: re-read files or re-run commands before relying " + "on their earlier output." + ) + summary = f"resumed in a new process (previous run's last activity: {stamp})" + return Notice(kind="resume", body=body, summary=summary) + + +_ASIDE_BODIES: dict[str, str] = { + "read": ( + "Side question: this exchange is not saved to the session transcript and does not " + "change the main conversation. Tools here are read-only (file/VCS reads, ask, fetch " + "GET): you cannot edit files or run state-changing commands, so describe a change " + "instead of applying it." + ), + "no": ( + "Side question: this exchange is not saved to the session transcript and does not " + "change the main conversation. Tools are disabled here — answer from this " + "conversation and your own knowledge, and say so when something cannot be verified." + ), + "full": ( + "Side question: this exchange is not saved to the session transcript and does not " + "change the main conversation. Full tools are available and their side effects are " + "real, but nothing here is kept: re-check anything you would need later." + ), +} + + +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") diff --git a/tests/test_agent/test_notices.py b/tests/test_agent/test_notices.py new file mode 100644 index 0000000..469c362 --- /dev/null +++ b/tests/test_agent/test_notices.py @@ -0,0 +1,101 @@ +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.chat import incomplete_turn_user_text +from plyngent.config.models import DatabaseConfig +from plyngent.lmproto.openai_compatible.model import ( + AssistantChatMessage, + AssistantFunctionTool, + AssistantFunctionToolCall, + DeveloperChatMessage, + SystemChatMessage, + ToolChatMessage, + UserChatMessage, +) +from plyngent.memory import MemoryStore +from tests.test_agent.test_loop import ScriptedClient + + +def test_notice_message_carries_marker_and_kind() -> None: + message = Notice(kind="resume", body=" hello there ").to_message() + assert isinstance(message, DeveloperChatMessage) + assert message.content == "[plyngent notice: resume]\nhello there" + + +def test_format_moment_localizes_and_labels_missing() -> None: + assert format_moment(None) == "unknown" + aware = datetime(2026, 9, 14, 22, 9, tzinfo=UTC) + assert format_moment(aware) == aware.astimezone().strftime("%Y-%m-%d %H:%M") + # SQLite hands back naive datetimes for tz-aware columns; read them as UTC. + assert format_moment(aware.replace(tzinfo=None)) == format_moment(aware) + offset = datetime(2026, 9, 14, 22, 9, tzinfo=timezone(timedelta(hours=8))) + assert format_moment(offset) == offset.astimezone().strftime("%Y-%m-%d %H:%M") + + +def test_resume_notice_states_what_is_gone_and_restored() -> None: + notice = resume_notice(last_active=datetime(2026, 9, 14, 22, 9, tzinfo=UTC)) + assert notice.kind == "resume" + assert "resumed in a new plyngent process" in notice.body + assert "PTY sessions" in notice.body + assert "grants were restored" in notice.body + assert notice.summary.startswith("resumed in a new process") + assert format_moment(datetime(2026, 9, 14, 22, 9, tzinfo=UTC)) in notice.summary + + +def test_aside_notice_matches_tool_scope() -> None: + read = aside_notice(tools_mode="read") + assert read.kind == "aside" + assert "read-only" in read.body + assert "not saved" in read.body + assert "read tools" in read.summary + + assert "Tools are disabled" in aside_notice(tools_mode="no").body + assert "Full tools are available" in aside_notice(tools_mode="full").body + # Unknown mode degrades to the read-only wording rather than an empty notice. + assert aside_notice(tools_mode="weird").body == read.body + + +async def test_push_notice_appends_and_persists() -> None: + store = await MemoryStore.open(DatabaseConfig()) + try: + session = await store.create_session(name="t") + _ = await store.append_message(session.sid, SystemChatMessage(content="sys")) + _ = await store.append_message(session.sid, UserChatMessage(content="hi")) + agent = ChatAgent(ScriptedClient([]), model="m", memory=store, session_id=session.sid) + await agent.load_history() + + message = await agent.push_notice(resume_notice(last_active=None)) + + assert agent.messages[-1] is message + assert agent.persist_from == len(agent.messages) # never re-persisted by a batch + loaded = await store.list_messages(session.sid) + assert isinstance(loaded[-1], DeveloperChatMessage) + assert loaded[-1].content.startswith("[plyngent notice: resume]") + finally: + await store.close() + + +def test_trailing_developer_message_keeps_incomplete_turn_detectable() -> None: + """A pushed notice must not hide a turn that can still be retried.""" + notice = resume_notice(last_active=None).to_message() + assert incomplete_turn_user_text([UserChatMessage(content="do it"), notice]) == "do it" + batch = [ + UserChatMessage(content="do it"), + AssistantChatMessage( + content="", + tool_calls=[ + AssistantFunctionToolCall( + id="1", + function=AssistantFunctionTool(name="ping", arguments="{}"), + ) + ], + ), + ToolChatMessage(content="pong", tool_call_id="1"), + notice, + ] + assert incomplete_turn_user_text(batch) == "do it" + # A finished turn (trailing assistant) stays non-retryable past a notice. + done = [UserChatMessage(content="do it"), AssistantChatMessage(content="ok"), notice] + assert incomplete_turn_user_text(done) is None