mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-09 00:47:54 +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 .events import UsageEvent as UsageEvent
|
||||||
from .loop import DEFAULT_MAX_ROUNDS as DEFAULT_MAX_ROUNDS
|
from .loop import DEFAULT_MAX_ROUNDS as DEFAULT_MAX_ROUNDS
|
||||||
from .loop import run_chat_loop as run_chat_loop
|
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 DangerClassifier as DangerClassifier
|
||||||
from .tools import ToolConfirmHook as ToolConfirmHook
|
from .tools import ToolConfirmHook as ToolConfirmHook
|
||||||
from .tools import ToolDefinition as ToolDefinition
|
from .tools import ToolDefinition as ToolDefinition
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from msgspec import UNSET
|
|||||||
from plyngent.lmproto.openai_compatible.model import (
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
AssistantChatMessage,
|
AssistantChatMessage,
|
||||||
AssistantFunctionToolCall,
|
AssistantFunctionToolCall,
|
||||||
|
DeveloperChatMessage,
|
||||||
SystemChatMessage,
|
SystemChatMessage,
|
||||||
ToolChatMessage,
|
ToolChatMessage,
|
||||||
UserChatMessage,
|
UserChatMessage,
|
||||||
@@ -40,6 +41,7 @@ if TYPE_CHECKING:
|
|||||||
from plyngent.memory import MemoryStore
|
from plyngent.memory import MemoryStore
|
||||||
|
|
||||||
from .events import AgentEvent
|
from .events import AgentEvent
|
||||||
|
from .notices import Notice
|
||||||
from .todo_nag import TodoNagStrategy
|
from .todo_nag import TodoNagStrategy
|
||||||
from .todo_stack import TodoStack
|
from .todo_stack import TodoStack
|
||||||
from .types import AnyLLMClient
|
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
|
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
|
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).
|
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.
|
# Skip leading system prompt only.
|
||||||
start = 0
|
start = 0
|
||||||
if messages and isinstance(messages[0], SystemChatMessage):
|
if messages and isinstance(messages[0], SystemChatMessage):
|
||||||
start = 1
|
start = 1
|
||||||
body = list(messages[start:])
|
body = list(messages[start:])
|
||||||
|
while body and isinstance(body[-1], DeveloperChatMessage):
|
||||||
|
_ = body.pop()
|
||||||
if not body:
|
if not body:
|
||||||
return None
|
return None
|
||||||
last = body[-1]
|
last = body[-1]
|
||||||
@@ -420,6 +426,21 @@ class ChatAgent:
|
|||||||
if load:
|
if load:
|
||||||
await self.load_history()
|
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:
|
async def _persist(self, message: AnyChatMessage) -> None:
|
||||||
if self.memory is not None and self.session_id is not None:
|
if self.memory is not None and self.session_id is not None:
|
||||||
_ = await self.memory.append_message(self.session_id, message)
|
_ = await self.memory.append_message(self.session_id, message)
|
||||||
@@ -461,8 +482,6 @@ class ChatAgent:
|
|||||||
|
|
||||||
def _developer_tail_end(self, start: int) -> int:
|
def _developer_tail_end(self, start: int) -> int:
|
||||||
"""Extend *start* through trailing developer checkpoint messages."""
|
"""Extend *start* through trailing developer checkpoint messages."""
|
||||||
from plyngent.lmproto.openai_compatible.model import DeveloperChatMessage
|
|
||||||
|
|
||||||
end = start
|
end = start
|
||||||
while end < len(self.messages) and isinstance(self.messages[end], DeveloperChatMessage):
|
while end < len(self.messages) and isinstance(self.messages[end], DeveloperChatMessage):
|
||||||
end += 1
|
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