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.
This commit is contained in:
2026-07-18 12:27:15 +08:00
parent 596af1ea1b
commit abcb345c0d
6 changed files with 27 additions and 7 deletions
+4 -1
View File
@@ -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):
+3 -2
View File
@@ -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(
+5
View File
@@ -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):
+2 -2
View File
@@ -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"
+6
View File
@@ -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:
+7 -2
View File
@@ -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)