agent/notices: host notices as trailing developer messages

This commit is contained in:
2026-09-19 09:15:13 +08:00
parent e0913e60a4
commit bd18a88370
4 changed files with 218 additions and 2 deletions
+4
View File
@@ -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
+21 -2
View File
@@ -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
+92
View File
@@ -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")