mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/agent: configurable todo nag strategies
Add [agent] todo_nag_strategy: developer (default), system, user, synthetic_tool (forged todo_list pair), or none. Shared inject helper used for turn-start and end-of-turn nags; CLI wires agent config.
This commit is contained in:
@@ -86,7 +86,7 @@ Module-level `@tool` handlers. Call `set_workspace_root()` before use.
|
|||||||
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for shells/REPLs and `python|bash -c` one-liners (argv + code preview); deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
|
- Destructive confirms: `classify_danger` + `ToolRegistry(on_confirm=…)`; CLI default deny; config `confirm_destructive` / `path_denylist`. Soft confirm also for shells/REPLs and `python|bash -c` one-liners (argv + code preview); deny may include a user comment for the model. Session YOLO: `/yolo on|off|once` and `--yes` (skip soft confirms; hard denylists unchanged; `once` expires after the next user turn).
|
||||||
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
|
- **`vcs`**: read-only VCS tools (`vcs_kind` / `vcs_status` / `vcs_diff` / `vcs_log` / `vcs_branch`) via `VcsBackend` protocol; **git** implemented; detectors are pluggable for other systems.
|
||||||
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core).
|
- **`chat`**: human prompts as tools — `ask_user_line` / `ask_user_choice` / `ask_user_form` (shared `prompting` core).
|
||||||
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]` turn-start + end-of-turn review); all-terminal non-empty = hygiene (`[TODO HYGIENE]`).
|
- **`todo`**: LIFO stack of **task groups** (not a queue of tasks) — `todo_push` creates one group of siblings; `todo_pop` removes the whole top group; `todo_update` by id; DFS breakdown push[T1,T2]→push[T1.1…]→pop→push[T2.1…]; stored on session row; open items = unfinished work (`[TODO OPEN WORK]`); all-terminal non-empty = hygiene (`[TODO HYGIENE]`); nag channel via `[agent] todo_nag_strategy` (`developer` default | `system` | `user` | `synthetic_tool` | `none`).
|
||||||
- **`DEFAULT_TOOLS`**: file + process + vcs + chat + todo tool list for a `ToolRegistry`.
|
- **`DEFAULT_TOOLS`**: file + process + vcs + chat + todo tool list for a `ToolRegistry`.
|
||||||
|
|
||||||
### Prompting (`prompting.py`)
|
### Prompting (`prompting.py`)
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ confirm_destructive = true
|
|||||||
path_denylist = ["/secrets/", ".ssh/", ".gnupg/"]
|
path_denylist = ["/secrets/", ".ssh/", ".gnupg/"]
|
||||||
# Soft context budget in estimated tokens (~4 chars/token when API usage missing).
|
# Soft context budget in estimated tokens (~4 chars/token when API usage missing).
|
||||||
max_context_tokens = 200000
|
max_context_tokens = 200000
|
||||||
|
# How open-todo nags are injected into model context:
|
||||||
|
# developer (default) | system | user | synthetic_tool | none
|
||||||
|
# todo_nag_strategy = "developer"
|
||||||
|
|
||||||
# Optional: custom compact/summarisation prompts (empty = built-in defaults).
|
# Optional: custom compact/summarisation prompts (empty = built-in defaults).
|
||||||
# compact_system_prompt = ""
|
# compact_system_prompt = ""
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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,
|
||||||
@@ -20,6 +19,7 @@ from .budget import (
|
|||||||
)
|
)
|
||||||
from .events import UsageEvent
|
from .events import UsageEvent
|
||||||
from .loop import DEFAULT_MAX_ROUNDS, run_chat_loop
|
from .loop import DEFAULT_MAX_ROUNDS, run_chat_loop
|
||||||
|
from .todo_nag import DEFAULT_TODO_NAG_STRATEGY, inject_todo_nag_for_stack, parse_todo_nag_strategy
|
||||||
from .usage import TokenUsage
|
from .usage import TokenUsage
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from .client import ChatClient
|
from .client import ChatClient
|
||||||
from .events import AgentEvent
|
from .events import AgentEvent
|
||||||
|
from .todo_nag import TodoNagStrategy
|
||||||
from .todo_stack import TodoStack
|
from .todo_stack import TodoStack
|
||||||
from .tools import ToolRegistry
|
from .tools import ToolRegistry
|
||||||
|
|
||||||
@@ -121,6 +122,7 @@ class ChatAgent:
|
|||||||
parallel_tools: bool
|
parallel_tools: bool
|
||||||
max_context_tokens: int
|
max_context_tokens: int
|
||||||
todo_stack: TodoStack | None
|
todo_stack: TodoStack | None
|
||||||
|
todo_nag_strategy: TodoNagStrategy
|
||||||
messages: list[AnyChatMessage]
|
messages: list[AnyChatMessage]
|
||||||
session_usage: TokenUsage
|
session_usage: TokenUsage
|
||||||
last_turn_usage: TokenUsage
|
last_turn_usage: TokenUsage
|
||||||
@@ -147,6 +149,7 @@ class ChatAgent:
|
|||||||
parallel_tools: bool = True,
|
parallel_tools: bool = True,
|
||||||
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
||||||
todo_stack: TodoStack | None = None,
|
todo_stack: TodoStack | None = None,
|
||||||
|
todo_nag_strategy: str | TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.client = client
|
self.client = client
|
||||||
self.model = model
|
self.model = model
|
||||||
@@ -162,6 +165,7 @@ class ChatAgent:
|
|||||||
self.parallel_tools = parallel_tools
|
self.parallel_tools = parallel_tools
|
||||||
self.max_context_tokens = max_context_tokens
|
self.max_context_tokens = max_context_tokens
|
||||||
self.todo_stack = todo_stack
|
self.todo_stack = todo_stack
|
||||||
|
self.todo_nag_strategy = parse_todo_nag_strategy(str(todo_nag_strategy))
|
||||||
self.messages = list(messages) if messages is not None else []
|
self.messages = list(messages) if messages is not None else []
|
||||||
self.session_usage = TokenUsage()
|
self.session_usage = TokenUsage()
|
||||||
self.last_turn_usage = TokenUsage()
|
self.last_turn_usage = TokenUsage()
|
||||||
@@ -298,7 +302,12 @@ class ChatAgent:
|
|||||||
self.todo_stack.begin_turn()
|
self.todo_stack.begin_turn()
|
||||||
# Non-empty stack at turn start → remind the model before first completion.
|
# Non-empty stack at turn start → remind the model before first completion.
|
||||||
if not self.todo_stack.is_empty():
|
if not self.todo_stack.is_empty():
|
||||||
self.messages.append(DeveloperChatMessage(content=self.todo_stack.turn_reminder_prompt()))
|
_ = inject_todo_nag_for_stack(
|
||||||
|
self.messages,
|
||||||
|
self.todo_stack,
|
||||||
|
kind="turn_start",
|
||||||
|
strategy=self.todo_nag_strategy,
|
||||||
|
)
|
||||||
|
|
||||||
completed = False
|
completed = False
|
||||||
turn_usage = TokenUsage()
|
turn_usage = TokenUsage()
|
||||||
@@ -318,6 +327,7 @@ class ChatAgent:
|
|||||||
parallel_tools=self.parallel_tools,
|
parallel_tools=self.parallel_tools,
|
||||||
max_context_tokens=self.max_context_tokens,
|
max_context_tokens=self.max_context_tokens,
|
||||||
todo_stack=self.todo_stack,
|
todo_stack=self.todo_stack,
|
||||||
|
todo_nag_strategy=self.todo_nag_strategy,
|
||||||
):
|
):
|
||||||
if isinstance(event, UsageEvent):
|
if isinstance(event, UsageEvent):
|
||||||
turn_rounds += 1
|
turn_rounds += 1
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ from plyngent.lmproto.openai_compatible.model import (
|
|||||||
AssistantChatMessage,
|
AssistantChatMessage,
|
||||||
AssistantFunctionToolCall,
|
AssistantFunctionToolCall,
|
||||||
ChatCompletionsParam,
|
ChatCompletionsParam,
|
||||||
DeveloperChatMessage,
|
|
||||||
StreamOptions,
|
StreamOptions,
|
||||||
StreamToolCallDelta,
|
StreamToolCallDelta,
|
||||||
ToolChatMessage,
|
ToolChatMessage,
|
||||||
@@ -38,6 +37,7 @@ from .events import (
|
|||||||
ToolResultEvent,
|
ToolResultEvent,
|
||||||
UsageEvent,
|
UsageEvent,
|
||||||
)
|
)
|
||||||
|
from .todo_nag import DEFAULT_TODO_NAG_STRATEGY, inject_todo_nag_for_stack
|
||||||
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:
|
||||||
@@ -46,6 +46,7 @@ if TYPE_CHECKING:
|
|||||||
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
|
from plyngent.lmproto.openai_compatible.model import AnyChatMessage, AnyToolItem
|
||||||
|
|
||||||
from .client import ChatClient
|
from .client import ChatClient
|
||||||
|
from .todo_nag import TodoNagStrategy
|
||||||
from .todo_stack import TodoStack
|
from .todo_stack import TodoStack
|
||||||
from .tools import ToolRegistry
|
from .tools import ToolRegistry
|
||||||
|
|
||||||
@@ -307,7 +308,7 @@ def _last_assistant(messages: list[AnyChatMessage], pre_len: int) -> AssistantCh
|
|||||||
return last
|
return last
|
||||||
|
|
||||||
|
|
||||||
async def run_chat_loop(
|
async def run_chat_loop( # noqa: C901 — multi-phase tool loop
|
||||||
client: ChatClient,
|
client: ChatClient,
|
||||||
messages: list[AnyChatMessage],
|
messages: list[AnyChatMessage],
|
||||||
*,
|
*,
|
||||||
@@ -321,6 +322,7 @@ async def run_chat_loop(
|
|||||||
parallel_tools: bool = True,
|
parallel_tools: bool = True,
|
||||||
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
max_context_tokens: int = DEFAULT_CONTEXT_MAX_TOKENS,
|
||||||
todo_stack: TodoStack | None = None,
|
todo_stack: TodoStack | None = None,
|
||||||
|
todo_nag_strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
|
||||||
) -> AsyncIterator[AgentEvent]:
|
) -> AsyncIterator[AgentEvent]:
|
||||||
"""Multi-round chat/tool loop; mutates ``messages`` in place and yields events.
|
"""Multi-round chat/tool loop; mutates ``messages`` in place and yields events.
|
||||||
|
|
||||||
@@ -330,8 +332,8 @@ async def run_chat_loop(
|
|||||||
Request payloads may shrink older tool results when over ``max_context_tokens``.
|
Request payloads may shrink older tool results when over ``max_context_tokens``.
|
||||||
|
|
||||||
When *todo_stack* is set and still needs review after a natural stop
|
When *todo_stack* is set and still needs review after a natural stop
|
||||||
(open items, or non-empty stack untouched this turn), injects a developer
|
(open items, or non-empty stack untouched this turn), injects a review nag
|
||||||
review message and continues once so the model reconciles unfinished work.
|
(channel from *todo_nag_strategy*) once so the model reconciles unfinished work.
|
||||||
"""
|
"""
|
||||||
tool_items: Sequence[AnyToolItem] | None = None
|
tool_items: Sequence[AnyToolItem] | None = None
|
||||||
if tools is not None and len(tools) > 0:
|
if tools is not None and len(tools) > 0:
|
||||||
@@ -373,9 +375,14 @@ async def run_chat_loop(
|
|||||||
if tool_calls is UNSET or not tool_calls or tools is None:
|
if tool_calls is UNSET or not tool_calls or tools is None:
|
||||||
if todo_stack is not None and todo_stack.needs_review() and not todo_review_injected:
|
if todo_stack is not None and todo_stack.needs_review() and not todo_review_injected:
|
||||||
todo_review_injected = True
|
todo_review_injected = True
|
||||||
# Non-user control identity so retry/history don't treat this as human input.
|
injected = inject_todo_nag_for_stack(
|
||||||
messages.append(DeveloperChatMessage(content=todo_stack.review_prompt()))
|
messages,
|
||||||
continue
|
todo_stack,
|
||||||
|
kind="end_of_turn",
|
||||||
|
strategy=todo_nag_strategy,
|
||||||
|
)
|
||||||
|
if injected:
|
||||||
|
continue
|
||||||
return
|
return
|
||||||
async for event in _execute_tool_calls(
|
async for event in _execute_tool_calls(
|
||||||
tools,
|
tools,
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Inject todo stack nags into the message list (configurable channel)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING, Literal, cast
|
||||||
|
|
||||||
|
from msgspec import UNSET
|
||||||
|
|
||||||
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
|
AssistantChatMessage,
|
||||||
|
AssistantFunctionTool,
|
||||||
|
AssistantFunctionToolCall,
|
||||||
|
DeveloperChatMessage,
|
||||||
|
SystemChatMessage,
|
||||||
|
ToolChatMessage,
|
||||||
|
UserChatMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||||
|
|
||||||
|
from .todo_stack import TodoStack
|
||||||
|
|
||||||
|
type TodoNagStrategy = Literal["developer", "system", "user", "synthetic_tool", "none"]
|
||||||
|
type TodoNagKind = Literal["turn_start", "end_of_turn"]
|
||||||
|
|
||||||
|
TODO_NAG_STRATEGIES: frozenset[str] = frozenset({"developer", "system", "user", "synthetic_tool", "none"})
|
||||||
|
DEFAULT_TODO_NAG_STRATEGY: TodoNagStrategy = "developer"
|
||||||
|
_SYNTHETIC_TOOL_NAME = "todo_list"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_todo_nag_strategy(raw: str | None) -> TodoNagStrategy:
|
||||||
|
"""Normalize config/CLI text to a strategy; unknown → developer."""
|
||||||
|
token = (raw or DEFAULT_TODO_NAG_STRATEGY).strip().lower().replace("-", "_")
|
||||||
|
if token not in TODO_NAG_STRATEGIES:
|
||||||
|
return DEFAULT_TODO_NAG_STRATEGY
|
||||||
|
return cast("TodoNagStrategy", token)
|
||||||
|
|
||||||
|
|
||||||
|
def nag_body(stack: TodoStack, kind: TodoNagKind) -> str:
|
||||||
|
if kind == "turn_start":
|
||||||
|
return stack.turn_reminder_prompt()
|
||||||
|
return stack.review_prompt()
|
||||||
|
|
||||||
|
|
||||||
|
def _append_synthetic_todo_list(messages: list[AnyChatMessage], body: str) -> None:
|
||||||
|
call_id = f"todo-nag-{uuid.uuid4().hex[:12]}"
|
||||||
|
messages.append(
|
||||||
|
AssistantChatMessage(
|
||||||
|
content=UNSET,
|
||||||
|
tool_calls=[
|
||||||
|
AssistantFunctionToolCall(
|
||||||
|
id=call_id,
|
||||||
|
function=AssistantFunctionTool(
|
||||||
|
name=_SYNTHETIC_TOOL_NAME,
|
||||||
|
arguments="{}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
messages.append(ToolChatMessage(tool_call_id=call_id, content=body))
|
||||||
|
|
||||||
|
|
||||||
|
def inject_todo_nag(
|
||||||
|
messages: list[AnyChatMessage],
|
||||||
|
body: str,
|
||||||
|
*,
|
||||||
|
strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
|
||||||
|
) -> bool:
|
||||||
|
"""Append a todo nag using *strategy*. Returns True if anything was appended.
|
||||||
|
|
||||||
|
Strategies:
|
||||||
|
- ``developer`` (default): control-plane message (safe for retry/history).
|
||||||
|
- ``system``: mid-conversation system message.
|
||||||
|
- ``user``: looks like a human turn (can confuse retry — use with care).
|
||||||
|
- ``synthetic_tool``: forged ``todo_list`` call + result pair (no handler run).
|
||||||
|
- ``none``: no injection.
|
||||||
|
"""
|
||||||
|
if strategy == "none" or not body.strip():
|
||||||
|
return False
|
||||||
|
|
||||||
|
envelopes: dict[TodoNagStrategy, type] = {
|
||||||
|
"developer": DeveloperChatMessage,
|
||||||
|
"system": SystemChatMessage,
|
||||||
|
"user": UserChatMessage,
|
||||||
|
}
|
||||||
|
if strategy in envelopes:
|
||||||
|
messages.append(envelopes[strategy](content=body))
|
||||||
|
return True
|
||||||
|
if strategy == "synthetic_tool":
|
||||||
|
_append_synthetic_todo_list(messages, body)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def inject_todo_nag_for_stack(
|
||||||
|
messages: list[AnyChatMessage],
|
||||||
|
stack: TodoStack,
|
||||||
|
*,
|
||||||
|
kind: TodoNagKind,
|
||||||
|
strategy: TodoNagStrategy = DEFAULT_TODO_NAG_STRATEGY,
|
||||||
|
) -> bool:
|
||||||
|
"""Build body from *stack* and inject with *strategy*."""
|
||||||
|
return inject_todo_nag(messages, nag_body(stack, kind), strategy=strategy)
|
||||||
@@ -187,6 +187,7 @@ class ReplState:
|
|||||||
parallel_tools=agent_cfg.parallel_tools,
|
parallel_tools=agent_cfg.parallel_tools,
|
||||||
max_context_tokens=agent_cfg.max_context_tokens,
|
max_context_tokens=agent_cfg.max_context_tokens,
|
||||||
todo_stack=self.todo_stack,
|
todo_stack=self.todo_stack,
|
||||||
|
todo_nag_strategy=agent_cfg.todo_nag_strategy,
|
||||||
)
|
)
|
||||||
|
|
||||||
def rebuild_client(self) -> None:
|
def rebuild_client(self) -> None:
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ class AgentConfig(Struct, omit_defaults=True):
|
|||||||
path_denylist: list[str] = field(default_factory=list)
|
path_denylist: list[str] = field(default_factory=list)
|
||||||
max_context_tokens: int = 200_000
|
max_context_tokens: int = 200_000
|
||||||
|
|
||||||
|
# How to inject todo stack nags into model context (see agent/todo_nag.py).
|
||||||
|
# developer | system | user | synthetic_tool | none
|
||||||
|
todo_nag_strategy: str = "developer"
|
||||||
|
|
||||||
# Compact / summarisation prompts (empty = use built-in defaults).
|
# Compact / summarisation prompts (empty = use built-in defaults).
|
||||||
compact_system_prompt: str = ""
|
compact_system_prompt: str = ""
|
||||||
compact_user_prefix: str = ""
|
compact_user_prefix: str = ""
|
||||||
|
|||||||
@@ -3,17 +3,22 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING, Literal, overload
|
from typing import TYPE_CHECKING, Literal, overload
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from msgspec import UNSET
|
||||||
|
|
||||||
from plyngent.agent import ChatAgent, ToolRegistry
|
from plyngent.agent import ChatAgent, ToolRegistry
|
||||||
|
from plyngent.agent.todo_nag import inject_todo_nag, parse_todo_nag_strategy
|
||||||
from plyngent.agent.todo_stack import TodoStack, parse_push_titles
|
from plyngent.agent.todo_stack import TodoStack, parse_push_titles
|
||||||
from plyngent.config.models import DatabaseConfig
|
from plyngent.config.models import DatabaseConfig
|
||||||
from plyngent.lmproto.openai_compatible.model import (
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
|
AnyChatMessage,
|
||||||
AssistantChatMessage,
|
AssistantChatMessage,
|
||||||
ChatCompletionChoice,
|
ChatCompletionChoice,
|
||||||
ChatCompletionChunk,
|
ChatCompletionChunk,
|
||||||
ChatCompletionResponse,
|
ChatCompletionResponse,
|
||||||
ChatCompletionsParam,
|
ChatCompletionsParam,
|
||||||
DeveloperChatMessage,
|
DeveloperChatMessage,
|
||||||
|
SystemChatMessage,
|
||||||
|
ToolChatMessage,
|
||||||
UserChatMessage,
|
UserChatMessage,
|
||||||
)
|
)
|
||||||
from plyngent.memory import MemoryStore
|
from plyngent.memory import MemoryStore
|
||||||
@@ -117,6 +122,43 @@ def test_todo_stack_needs_review() -> None:
|
|||||||
assert not stack.needs_review()
|
assert not stack.needs_review()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_todo_nag_strategy() -> None:
|
||||||
|
assert parse_todo_nag_strategy("developer") == "developer"
|
||||||
|
assert parse_todo_nag_strategy("SYNTHETIC-TOOL") == "synthetic_tool"
|
||||||
|
assert parse_todo_nag_strategy("nope") == "developer"
|
||||||
|
assert parse_todo_nag_strategy(None) == "developer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_inject_todo_nag_strategies() -> None:
|
||||||
|
body = "[TODO OPEN WORK] test"
|
||||||
|
messages: list[AnyChatMessage] = []
|
||||||
|
assert inject_todo_nag(messages, body, strategy="none") is False
|
||||||
|
assert messages == []
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
assert inject_todo_nag(messages, body, strategy="developer") is True
|
||||||
|
assert isinstance(messages[0], DeveloperChatMessage)
|
||||||
|
assert body in messages[0].content
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
assert inject_todo_nag(messages, body, strategy="system") is True
|
||||||
|
assert isinstance(messages[0], SystemChatMessage)
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
assert inject_todo_nag(messages, body, strategy="user") is True
|
||||||
|
assert isinstance(messages[0], UserChatMessage)
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
assert inject_todo_nag(messages, body, strategy="synthetic_tool") is True
|
||||||
|
assert len(messages) == 2
|
||||||
|
assert isinstance(messages[0], AssistantChatMessage)
|
||||||
|
tool_calls = messages[0].tool_calls
|
||||||
|
assert tool_calls is not UNSET and tool_calls
|
||||||
|
assert isinstance(messages[1], ToolChatMessage)
|
||||||
|
assert body in messages[1].content
|
||||||
|
assert messages[1].tool_call_id.startswith("todo-nag-")
|
||||||
|
|
||||||
|
|
||||||
def test_todo_prompts_signal_undone_work() -> None:
|
def test_todo_prompts_signal_undone_work() -> None:
|
||||||
stack = TodoStack()
|
stack = TodoStack()
|
||||||
item = stack.push("open work")
|
item = stack.push("open work")
|
||||||
@@ -293,3 +335,55 @@ async def test_loop_injects_todo_review_when_open_after_touch() -> None:
|
|||||||
assert any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages)
|
assert any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages)
|
||||||
finally:
|
finally:
|
||||||
set_todo_stack(None)
|
set_todo_stack(None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_loop_synthetic_tool_nag_strategy() -> None:
|
||||||
|
stack = TodoStack()
|
||||||
|
_ = stack.push("open work")
|
||||||
|
stack.begin_turn()
|
||||||
|
client = ScriptedClient()
|
||||||
|
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("do stuff"):
|
||||||
|
pass
|
||||||
|
assert any(isinstance(m, ToolChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages)
|
||||||
|
assert not any(isinstance(m, DeveloperChatMessage) and "[TODO OPEN WORK]" in m.content for m in agent.messages)
|
||||||
|
finally:
|
||||||
|
set_todo_stack(None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_loop_none_nag_strategy_skips_inject() -> None:
|
||||||
|
stack = TodoStack()
|
||||||
|
_ = stack.push("open work")
|
||||||
|
stack.begin_turn()
|
||||||
|
client = ScriptedClient()
|
||||||
|
agent = ChatAgent(
|
||||||
|
client, # type: ignore[arg-type]
|
||||||
|
model="m",
|
||||||
|
tools=ToolRegistry(list(TODO_TOOLS)),
|
||||||
|
stream=False,
|
||||||
|
todo_stack=stack,
|
||||||
|
todo_nag_strategy="none",
|
||||||
|
)
|
||||||
|
set_todo_stack(stack)
|
||||||
|
try:
|
||||||
|
async for _event in agent.run("do stuff"):
|
||||||
|
pass
|
||||||
|
# One completion only — no end-of-turn continue from nag.
|
||||||
|
assert client.calls == 1
|
||||||
|
assert not any(
|
||||||
|
isinstance(m, (DeveloperChatMessage, ToolChatMessage)) and "[TODO OPEN WORK]" in getattr(m, "content", "")
|
||||||
|
for m in agent.messages
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
set_todo_stack(None)
|
||||||
|
|||||||
Reference in New Issue
Block a user