From df24c21c140f8a47856483ceca494067afd85a5b Mon Sep 17 00:00:00 2001 From: Mason Daugherty <61371264+mdrxy@users.noreply.github.com> Date: Sat, 13 Jun 2026 06:40:59 +0000 Subject: [PATCH] fix(langchain): make `write_todos` calls tool-only to stop duplicated plan text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a boundary rule to the `TodoListMiddleware` default prompt and tool description: when calling `write_todos`, the assistant message must be tool-only. User-facing plan text, status updates, approval questions, and final answers should not appear in the same message as the call — they are sent once, after the tool result returns. This stops streaming consumers from showing the same plan/question twice (before and after the call) while preserving #37643's contract that the final answer lands after the call. Co-authored-by: open-swe[bot] --- .../langchain/agents/middleware/todo.py | 9 +++++-- .../middleware/implementations/test_todo.py | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/libs/langchain_v1/langchain/agents/middleware/todo.py b/libs/langchain_v1/langchain/agents/middleware/todo.py index 0513f4caf11..ddd8a0b1655 100644 --- a/libs/langchain_v1/langchain/agents/middleware/todo.py +++ b/libs/langchain_v1/langchain/agents/middleware/todo.py @@ -112,9 +112,13 @@ It is important to skip using this tool when: Being proactive with task management ensures you complete all requirements successfully Remember: If you only need to make a few tool calls to complete a task, and it is clear what you need to do, it is better to just do the task directly and NOT call this tool at all. +## Keep `write_todos` Calls Tool-Only + +A `write_todos` call updates the todo state — it does not communicate with the user. When you call `write_todos`, the assistant message must contain the tool call only. Do NOT include any user-facing content — plan text, status updates, approval questions, or final answers — in the same message as the call. Send that user-facing content once, in a message AFTER the tool result returns, never before or alongside the call. Putting the same plan or question both before and after the tool call shows the user a duplicated message. + ## When You Finish -`write_todos` tracks your work; it does not deliver the answer. Whatever the user asked for — computations, summaries, comparisons, data — must appear as text content in a message after your final `write_todos` call. Marking the last todo complete is not itself an answer to the user.""" # noqa: E501 +`write_todos` tracks your work; it does not deliver the answer. Whatever the user asked for — computations, summaries, comparisons, data — must appear as text content in a message after your final `write_todos` call, not before or alongside it. Marking the last todo complete is not itself an answer to the user.""" # noqa: E501 WRITE_TODOS_SYSTEM_PROMPT = """## `write_todos` @@ -130,10 +134,11 @@ Writing todos takes time and tokens, use it when it is helpful for managing comp - The `write_todos` tool should never be called multiple times in parallel. - Don't be afraid to revise the To-Do list as you go. New information may reveal new tasks that need to be done, or old tasks that are irrelevant. +- Keep `write_todos` calls tool-only. When you call `write_todos`, do NOT put user-facing plan text, status updates, approval questions, or final answers in the same assistant message as the tool call. The tool call updates the todo state; the user-facing message comes once, after the tool result returns. Emitting the same plan or question both before and alongside the call and again afterward shows the user a duplicated message. ## Finishing a task -When you finish all work, write your final answer in the message AFTER your last `write_todos` call — not in the same turn as that call. Start the final message with the substantive content the user asked for — the data, computation, summary, or analysis. The user wants the result, not confirmation that the work is done.""" # noqa: E501 +When you finish all work, write your final answer in the message AFTER your last `write_todos` call — not before or in the same turn as that call. Start the final message with the substantive content the user asked for — the data, computation, summary, or analysis. The user wants the result, not confirmation that the work is done.""" # noqa: E501 @tool(description=WRITE_TODOS_TOOL_DESCRIPTION) diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_todo.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_todo.py index 6da56153256..c34fe0bf52b 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_todo.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_todo.py @@ -79,6 +79,30 @@ def test_todo_middleware_default_prompts() -> None: assert tool.description == WRITE_TODOS_TOOL_DESCRIPTION +def test_system_prompt_mentions_tool_only_boundary() -> None: + """System prompt tells the model not to duplicate user-facing text around the call.""" + prompt = WRITE_TODOS_SYSTEM_PROMPT.lower() + # The tool-only boundary rule must be present (fixes duplicated plan text). + assert "tool-only" in prompt + # It must forbid user-facing content in the same message as the call. + assert "same assistant message as the tool call" in prompt + # It must preserve the #37643 contract: final answer lands after the call. + assert "AFTER your last `write_todos` call" in WRITE_TODOS_SYSTEM_PROMPT + assert "not before or in the same turn" in WRITE_TODOS_SYSTEM_PROMPT + + +def test_tool_description_mentions_tool_only_boundary() -> None: + """Tool description tells the model the call is tool-only with respect to text.""" + description = WRITE_TODOS_TOOL_DESCRIPTION + # The dedicated tool-only section must exist. + assert "Keep `write_todos` Calls Tool-Only" in description + # It must call out each kind of user-facing content that should not be duplicated. + for fragment in ("plan text", "status updates", "approval questions", "final answers"): + assert fragment in description + # It must preserve the #37643 contract: answer after the tool result, not before. + assert "after your final `write_todos` call, not before or alongside it" in description + + def test_adds_system_prompt_when_none_exists() -> None: """Test that middleware adds system prompt when request has none.""" middleware = TodoListMiddleware()