From abcb345c0dcb0afa3179cd7ad0669538db4aba77 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 18 Jul 2026 12:27:15 +0800 Subject: [PATCH] core/agent: todo review nag as developer role, not fake user Internal control uses DeveloperChatMessage so retry/history treat it as non-human. Responses bridge maps developer mid-turn; compact/history label it. --- src/plyngent/agent/compact.py | 5 ++++- src/plyngent/agent/loop.py | 5 +++-- src/plyngent/agent/responses_bridge.py | 5 +++++ src/plyngent/agent/todo_stack.py | 4 ++-- src/plyngent/cli/slash.py | 6 ++++++ tests/test_agent/test_todo_stack.py | 9 +++++++-- 6 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/plyngent/agent/compact.py b/src/plyngent/agent/compact.py index 78eff8e..942a893 100644 --- a/src/plyngent/agent/compact.py +++ b/src/plyngent/agent/compact.py @@ -8,6 +8,7 @@ from plyngent.lmproto.openai_compatible.model import ( AssistantChatMessage, AssistantFunctionToolCall, ChatCompletionsParam, + DeveloperChatMessage, SystemChatMessage, ToolChatMessage, UserChatMessage, @@ -43,12 +44,14 @@ _SEED_MESSAGE_TEMPLATE = ( ) -def format_transcript(messages: Sequence[AnyChatMessage]) -> str: +def format_transcript(messages: Sequence[AnyChatMessage]) -> str: # noqa: C901 """Render messages as plain text for a summarization prompt.""" lines: list[str] = [] for msg in messages: if isinstance(msg, SystemChatMessage): lines.append(f"[system] {msg.content}") + elif isinstance(msg, DeveloperChatMessage): + lines.append(f"[developer] {msg.content}") elif isinstance(msg, UserChatMessage): lines.append(f"[user] {msg.content}") elif isinstance(msg, AssistantChatMessage): diff --git a/src/plyngent/agent/loop.py b/src/plyngent/agent/loop.py index ec917b9..d3ce2f2 100644 --- a/src/plyngent/agent/loop.py +++ b/src/plyngent/agent/loop.py @@ -13,10 +13,10 @@ from plyngent.lmproto.openai_compatible.model import ( AssistantChatMessage, AssistantFunctionToolCall, ChatCompletionsParam, + DeveloperChatMessage, StreamOptions, StreamToolCallDelta, ToolChatMessage, - UserChatMessage, ) from plyngent.typedef import Unset # noqa: TC001 @@ -377,7 +377,8 @@ async def run_chat_loop( and not todo_review_injected ): todo_review_injected = True - messages.append(UserChatMessage(content=todo_stack.review_prompt())) + # Non-user control identity so retry/history don't treat this as human input. + messages.append(DeveloperChatMessage(content=todo_stack.review_prompt())) continue return async for event in _execute_tool_calls( diff --git a/src/plyngent/agent/responses_bridge.py b/src/plyngent/agent/responses_bridge.py index abe497f..7b1ac40 100644 --- a/src/plyngent/agent/responses_bridge.py +++ b/src/plyngent/agent/responses_bridge.py @@ -29,6 +29,7 @@ from plyngent.lmproto.openai_compatible.model import ( ChatCompletionsParam, ChunkChoice, DeltaMessage, + DeveloperChatMessage, StreamFunctionDelta, StreamToolCallDelta, SystemChatMessage, @@ -97,6 +98,10 @@ def chat_messages_to_responses_input( if isinstance(message, SystemChatMessage): if message.content.strip(): instructions_parts.append(message.content) + elif isinstance(message, DeveloperChatMessage): + # Keep mid-turn control as input developer messages (not folded into instructions). + if message.content.strip(): + items.append(ResponseEasyInputMessage(role="developer", content=message.content)) elif isinstance(message, UserChatMessage): items.append(ResponseEasyInputMessage(role="user", content=message.content)) elif isinstance(message, AssistantChatMessage): diff --git a/src/plyngent/agent/todo_stack.py b/src/plyngent/agent/todo_stack.py index 5c49d44..4a0bc69 100644 --- a/src/plyngent/agent/todo_stack.py +++ b/src/plyngent/agent/todo_stack.py @@ -99,9 +99,9 @@ class TodoStack: return "\n".join(lines) def review_prompt(self) -> str: - """User-message body injected when the model finishes without todo ops.""" + """Control-message body (developer role) when the model finishes without todo ops.""" return ( - "[system: todo stack review]\n" + "Todo stack review (internal control — not a human message).\n" "Open sub-tasks remain and you did not call any todo_* tools this turn.\n" "Review the stack: mark finished items done, update in_progress, " "push new sub-tasks, or pop/cancel obsolete ones. Then continue the user work.\n\n" diff --git a/src/plyngent/cli/slash.py b/src/plyngent/cli/slash.py index 4f49cac..cbfdfd9 100644 --- a/src/plyngent/cli/slash.py +++ b/src/plyngent/cli/slash.py @@ -15,6 +15,8 @@ from plyngent.cli.selection import select_model, select_provider from plyngent.lmproto.openai_compatible.model import ( AssistantChatMessage, AssistantFunctionToolCall, + DeveloperChatMessage, + SystemChatMessage, ToolChatMessage, UserChatMessage, ) @@ -973,6 +975,10 @@ def _preview_content(text: str | None) -> str: def _format_history_message(index: int, message: AnyChatMessage) -> str: if isinstance(message, UserChatMessage): return f"{index}. user: {_preview_content(message.content)}" + if isinstance(message, DeveloperChatMessage): + return f"{index}. developer: {_preview_content(message.content)}" + if isinstance(message, SystemChatMessage): + return f"{index}. system: {_preview_content(message.content)}" if isinstance(message, AssistantChatMessage): parts: list[str] = [] if isinstance(message.content, str) and message.content: diff --git a/tests/test_agent/test_todo_stack.py b/tests/test_agent/test_todo_stack.py index 0350742..3ba1865 100644 --- a/tests/test_agent/test_todo_stack.py +++ b/tests/test_agent/test_todo_stack.py @@ -13,6 +13,7 @@ from plyngent.lmproto.openai_compatible.model import ( ChatCompletionChunk, ChatCompletionResponse, ChatCompletionsParam, + DeveloperChatMessage, UserChatMessage, ) from plyngent.memory import MemoryStore @@ -102,7 +103,7 @@ class ScriptedClient: text = "ok" if self.calls > 1 else "done without todos" # Detect review message in history for msg in param.messages: - if isinstance(msg, UserChatMessage) and "todo stack review" in msg.content: + if isinstance(msg, DeveloperChatMessage) and "Todo stack review" in msg.content: text = "reviewed stack" break return ChatCompletionResponse( @@ -142,7 +143,11 @@ async def test_loop_injects_todo_review_when_untouched() -> None: pass assert client.calls >= 2 assert any( - isinstance(m, UserChatMessage) and "todo stack review" in m.content for m in agent.messages + isinstance(m, DeveloperChatMessage) and "Todo stack review" in m.content + for m in agent.messages + ) + assert not any( + isinstance(m, UserChatMessage) and "Todo stack review" in m.content for m in agent.messages ) finally: set_todo_stack(None)