mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
agent/notices: host notices as trailing developer messages
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user