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,
|
DEFAULT_TODO_NAG_STRATEGY,
|
||||||
inject_todo_nag_for_stack_with_events,
|
inject_todo_nag_for_stack_with_events,
|
||||||
parse_todo_nag_strategy,
|
parse_todo_nag_strategy,
|
||||||
|
refresh_synthetic_todo_nags,
|
||||||
)
|
)
|
||||||
from .usage import TokenUsage
|
from .usage import TokenUsage
|
||||||
|
|
||||||
@@ -304,6 +305,9 @@ class ChatAgent:
|
|||||||
user_index = self._user_index(user_msg)
|
user_index = self._user_index(user_msg)
|
||||||
if self.todo_stack is not None:
|
if self.todo_stack is not None:
|
||||||
self.todo_stack.begin_turn()
|
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
|
completed = False
|
||||||
turn_usage = TokenUsage()
|
turn_usage = TokenUsage()
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ from .events import (
|
|||||||
ToolResultEvent,
|
ToolResultEvent,
|
||||||
UsageEvent,
|
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
|
from .usage import resolve_round_usage, token_usage_from_api
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -349,12 +353,16 @@ async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
|||||||
while True:
|
while True:
|
||||||
while rounds_used < allowance:
|
while rounds_used < allowance:
|
||||||
rounds_used += 1
|
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(
|
request_messages = compact_messages_for_request(
|
||||||
messages,
|
messages,
|
||||||
max_tokens=max_context_tokens,
|
max_tokens=max_context_tokens,
|
||||||
prompt_tokens_hint=prompt_tokens_hint,
|
prompt_tokens_hint=prompt_tokens_hint,
|
||||||
sent_estimate_tokens=sent_estimate_tokens,
|
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)
|
sent_est = estimate_messages_tokens(request_messages)
|
||||||
param = ChatCompletionsParam(
|
param = ChatCompletionsParam(
|
||||||
messages=request_messages,
|
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"})
|
TODO_NAG_STRATEGIES: frozenset[str] = frozenset({"developer", "user", "synthetic_tool", "none"})
|
||||||
DEFAULT_TODO_NAG_STRATEGY: TodoNagStrategy = "developer"
|
DEFAULT_TODO_NAG_STRATEGY: TodoNagStrategy = "developer"
|
||||||
_SYNTHETIC_TOOL_NAME = "todo_list"
|
_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:
|
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:
|
def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> str:
|
||||||
"""Append forged todo_list call + result. Returns the synthetic tool_call id."""
|
"""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(
|
messages.append(
|
||||||
AssistantChatMessage(
|
AssistantChatMessage(
|
||||||
content=UNSET,
|
content=UNSET,
|
||||||
@@ -82,6 +84,55 @@ def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> st
|
|||||||
return call_id
|
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(
|
def inject_todo_nag(
|
||||||
messages: list[AnyChatMessage],
|
messages: list[AnyChatMessage],
|
||||||
body: str,
|
body: str,
|
||||||
|
|||||||
@@ -497,3 +497,123 @@ async def test_loop_none_nag_strategy_skips_inject() -> None:
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
set_todo_stack(None)
|
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