mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-25 08:04:57 +08:00
core/agent: refresh synthetic_tool todo nags to live stack
This commit is contained in:
@@ -23,6 +23,7 @@ from .todo_nag import (
|
||||
DEFAULT_TODO_NAG_STRATEGY,
|
||||
inject_todo_nag_for_stack_with_events,
|
||||
parse_todo_nag_strategy,
|
||||
refresh_synthetic_todo_nags,
|
||||
)
|
||||
from .usage import TokenUsage
|
||||
|
||||
@@ -304,6 +305,9 @@ class ChatAgent:
|
||||
user_index = self._user_index(user_msg)
|
||||
if self.todo_stack is not None:
|
||||
self.todo_stack.begin_turn()
|
||||
# Keep forged synthetic_tool nags aligned with the live stack so a
|
||||
# previously dirty stack does not re-surface after it was cleaned.
|
||||
_ = refresh_synthetic_todo_nags(self.messages, self.todo_stack)
|
||||
|
||||
completed = False
|
||||
turn_usage = TokenUsage()
|
||||
|
||||
@@ -37,7 +37,11 @@ from .events import (
|
||||
ToolResultEvent,
|
||||
UsageEvent,
|
||||
)
|
||||
from .todo_nag import DEFAULT_TODO_NAG_STRATEGY, inject_todo_nag_for_stack_with_events
|
||||
from .todo_nag import (
|
||||
DEFAULT_TODO_NAG_STRATEGY,
|
||||
inject_todo_nag_for_stack_with_events,
|
||||
refresh_synthetic_todo_nags,
|
||||
)
|
||||
from .usage import resolve_round_usage, token_usage_from_api
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -349,12 +353,16 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
||||
while True:
|
||||
while rounds_used < allowance:
|
||||
rounds_used += 1
|
||||
# Request copy: shrink old tool dumps, then rewrite forged todo nags
|
||||
# so cleaned stacks do not re-appear with stale OPEN WORK text.
|
||||
request_messages = compact_messages_for_request(
|
||||
messages,
|
||||
max_tokens=max_context_tokens,
|
||||
prompt_tokens_hint=prompt_tokens_hint,
|
||||
sent_estimate_tokens=sent_estimate_tokens,
|
||||
)
|
||||
if todo_stack is not None:
|
||||
_ = refresh_synthetic_todo_nags(request_messages, todo_stack)
|
||||
sent_est = estimate_messages_tokens(request_messages)
|
||||
param = ChatCompletionsParam(
|
||||
messages=request_messages,
|
||||
|
||||
@@ -29,6 +29,8 @@ type TodoNagKind = Literal["turn_start", "end_of_turn"]
|
||||
TODO_NAG_STRATEGIES: frozenset[str] = frozenset({"developer", "user", "synthetic_tool", "none"})
|
||||
DEFAULT_TODO_NAG_STRATEGY: TodoNagStrategy = "developer"
|
||||
_SYNTHETIC_TOOL_NAME = "todo_list"
|
||||
# Forged call ids from :func:`_append_synthetic_todo_list` (not model-authored).
|
||||
_SYNTHETIC_CALL_ID_PREFIX = "todo-nag-"
|
||||
|
||||
|
||||
def parse_todo_nag_strategy(raw: str | None) -> TodoNagStrategy:
|
||||
@@ -63,7 +65,7 @@ def synthetic_todo_list_result(stack: TodoStack) -> str:
|
||||
|
||||
def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> str:
|
||||
"""Append forged todo_list call + result. Returns the synthetic tool_call id."""
|
||||
call_id = f"todo-nag-{uuid.uuid4().hex[:12]}"
|
||||
call_id = f"{_SYNTHETIC_CALL_ID_PREFIX}{uuid.uuid4().hex[:12]}"
|
||||
messages.append(
|
||||
AssistantChatMessage(
|
||||
content=UNSET,
|
||||
@@ -82,6 +84,55 @@ def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> st
|
||||
return call_id
|
||||
|
||||
|
||||
def is_synthetic_todo_nag_call_id(call_id: str) -> bool:
|
||||
"""True for forged ``todo_list`` nag tool_call ids (not model-authored)."""
|
||||
return call_id.startswith(_SYNTHETIC_CALL_ID_PREFIX)
|
||||
|
||||
|
||||
def refresh_synthetic_todo_nags(
|
||||
messages: list[AnyChatMessage],
|
||||
stack: TodoStack,
|
||||
) -> int:
|
||||
"""Rewrite forged ``todo_list`` nag results to the live stack render.
|
||||
|
||||
Synthetic nags are append-only snapshots. After the stack is cleaned (or
|
||||
otherwise mutated), older nag results still sit in history and re-surface
|
||||
on later model requests with stale OPEN WORK. Call this on a **request
|
||||
copy** (not necessarily durable history) before each completion so the
|
||||
model always sees the current stack for forged nags.
|
||||
|
||||
Real model-authored ``todo_list`` results are left unchanged.
|
||||
Returns the number of tool messages updated.
|
||||
"""
|
||||
body = stack.render()
|
||||
synth_ids: set[str] = set()
|
||||
for msg in messages:
|
||||
if not isinstance(msg, AssistantChatMessage):
|
||||
continue
|
||||
tool_calls = msg.tool_calls
|
||||
if tool_calls is UNSET or not tool_calls:
|
||||
continue
|
||||
for call in tool_calls:
|
||||
if (
|
||||
isinstance(call, AssistantFunctionToolCall)
|
||||
and is_synthetic_todo_nag_call_id(call.id)
|
||||
and call.function.name == _SYNTHETIC_TOOL_NAME
|
||||
):
|
||||
synth_ids.add(call.id)
|
||||
|
||||
updated = 0
|
||||
for index, msg in enumerate(messages):
|
||||
if not isinstance(msg, ToolChatMessage):
|
||||
continue
|
||||
if msg.tool_call_id not in synth_ids and not is_synthetic_todo_nag_call_id(msg.tool_call_id):
|
||||
continue
|
||||
if msg.content == body:
|
||||
continue
|
||||
messages[index] = ToolChatMessage(tool_call_id=msg.tool_call_id, content=body)
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
|
||||
def inject_todo_nag(
|
||||
messages: list[AnyChatMessage],
|
||||
body: str,
|
||||
|
||||
@@ -497,3 +497,123 @@ async def test_loop_none_nag_strategy_skips_inject() -> None:
|
||||
)
|
||||
finally:
|
||||
set_todo_stack(None)
|
||||
|
||||
|
||||
def test_refresh_synthetic_todo_nags_updates_stale_results() -> None:
|
||||
"""Forged nags keep call ids; results track the live stack (not a frozen dirty snapshot)."""
|
||||
from plyngent.agent.todo_nag import (
|
||||
inject_todo_nag_for_stack,
|
||||
is_synthetic_todo_nag_call_id,
|
||||
refresh_synthetic_todo_nags,
|
||||
)
|
||||
|
||||
stack = TodoStack()
|
||||
_ = stack.push("stale dirty item")
|
||||
messages: list[AnyChatMessage] = []
|
||||
assert inject_todo_nag_for_stack(messages, stack, kind="end_of_turn", strategy="synthetic_tool")
|
||||
assert any(
|
||||
isinstance(m, ToolChatMessage)
|
||||
and "stale dirty item" in m.content
|
||||
and is_synthetic_todo_nag_call_id(m.tool_call_id)
|
||||
for m in messages
|
||||
)
|
||||
|
||||
_ = stack.clear()
|
||||
n = refresh_synthetic_todo_nags(messages, stack)
|
||||
assert n >= 1
|
||||
for m in messages:
|
||||
if isinstance(m, ToolChatMessage) and is_synthetic_todo_nag_call_id(m.tool_call_id):
|
||||
assert "stale dirty item" not in m.content
|
||||
assert "empty" in m.content.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_synthetic_tool_refreshes_after_stack_cleared() -> None:
|
||||
"""After a dirty stack is cleaned, later turns must not re-show old nag text."""
|
||||
|
||||
class CaptureClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
self.payloads: list[list[AnyChatMessage]] = []
|
||||
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[False] = False
|
||||
) -> ChatCompletionResponse: ...
|
||||
|
||||
@overload
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: Literal[True]
|
||||
) -> AsyncIterator[ChatCompletionChunk]: ...
|
||||
|
||||
async def chat_completions(
|
||||
self, param: ChatCompletionsParam, *, stream: bool = False
|
||||
) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]:
|
||||
del stream
|
||||
self.calls += 1
|
||||
self.payloads.append(list(param.messages))
|
||||
if self.calls == 1:
|
||||
# First stop → end-of-turn synthetic nag → second call clears.
|
||||
message = AssistantChatMessage(content="first stop")
|
||||
finish = "stop"
|
||||
elif self.calls == 2:
|
||||
message = AssistantChatMessage(
|
||||
content="",
|
||||
tool_calls=[
|
||||
AssistantFunctionToolCall(
|
||||
id="clr",
|
||||
function=AssistantFunctionTool(name="todo_clear", arguments="{}"),
|
||||
)
|
||||
],
|
||||
)
|
||||
finish = "tool_calls"
|
||||
else:
|
||||
message = AssistantChatMessage(content="after clear")
|
||||
finish = "stop"
|
||||
return ChatCompletionResponse(
|
||||
id="1",
|
||||
object="chat.completion",
|
||||
created=0,
|
||||
model="m",
|
||||
choices=[
|
||||
ChatCompletionChoice(
|
||||
index=0,
|
||||
message=message,
|
||||
logprobs={},
|
||||
finish_reason=finish,
|
||||
)
|
||||
],
|
||||
system_fingerprint="",
|
||||
usage={},
|
||||
)
|
||||
|
||||
stack = TodoStack()
|
||||
_ = stack.push("was dirty")
|
||||
client = CaptureClient()
|
||||
agent = ChatAgent(
|
||||
client, # type: ignore[arg-type]
|
||||
model="m",
|
||||
tools=ToolRegistry(list(TODO_TOOLS)),
|
||||
stream=False,
|
||||
todo_stack=stack,
|
||||
todo_nag_strategy="synthetic_tool",
|
||||
)
|
||||
set_todo_stack(stack)
|
||||
try:
|
||||
async for _event in agent.run("turn1"):
|
||||
pass
|
||||
assert stack.is_empty()
|
||||
n_after_turn1 = client.calls
|
||||
async for _event in agent.run("turn2 clean"):
|
||||
pass
|
||||
# Later request payloads must not re-present the old dirty item via synth nags.
|
||||
for payload in client.payloads[n_after_turn1:]:
|
||||
for msg in payload:
|
||||
if isinstance(msg, ToolChatMessage) and msg.tool_call_id.startswith("todo-nag-"):
|
||||
assert "was dirty" not in msg.content
|
||||
# Durable history refreshed at turn start as well.
|
||||
for msg in agent.messages:
|
||||
if isinstance(msg, ToolChatMessage) and msg.tool_call_id.startswith("todo-nag-"):
|
||||
assert "was dirty" not in msg.content
|
||||
finally:
|
||||
set_todo_stack(None)
|
||||
|
||||
Reference in New Issue
Block a user