langchain - ✅(Solved) Fix Duplicate model answers when using TodoListMiddleware [1 pull requests, 2 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
langchain-ai/langchain#35310Fetched 2026-04-08 00:26:42
View on GitHub
Comments
2
Participants
2
Timeline
10
Reactions
0
Timeline (top)
commented ×2mentioned ×2subscribed ×2closed ×1

When using TodoListMiddleware, the model sometimes generates both text content (the final answer) and a write_todos tool call (marking all todos as completed) in the same response.

Because the response contains a tool call, the agent loop continues:

  1. Model generates text answer + write_todos(all completed)
  2. Agent executes write_todos tool → gets ToolMessage back
  3. Agent loops back to model (because there was a tool call)
  4. Model regenerates the answer (slightly different)

The user sees the answer twice (or more with streaming).

Error Message

Error Message and Stack Trace (if applicable)

No error — the agent produces duplicate output.

Root Cause

Because the response contains a tool call, the agent loop continues:

  1. Model generates text answer + write_todos(all completed)
  2. Agent executes write_todos tool → gets ToolMessage back
  3. Agent loops back to model (because there was a tool call)
  4. Model regenerates the answer (slightly different)

Fix Action

Workaround

The TodoListMiddleware.after_model could detect this case and strip the write_todos tool call from the AI message, updating the todos directly in state instead. Since the AI message would then have no tool calls, the agent loop would terminate normally.

def after_model(self, state, runtime):
    # ... existing parallel write_todos check ...

    last_ai_msg = next(
        (msg for msg in reversed(messages) if isinstance(msg, AIMessage)), None
    )
    if not last_ai_msg or not last_ai_msg.tool_calls:
        return None

    tool_calls = last_ai_msg.tool_calls
    if len(tool_calls) != 1 or tool_calls[0]["name"] != "write_todos":
        return None

    # Check if message has text content alongside the tool call
    has_text = _has_text_content(last_ai_msg)
    if not has_text:
        return None

    # Check if all todos are completed
    todos = tool_calls[0].get("args", {}).get("todos", [])
    if not todos or not all(t.get("status") == "completed" for t in todos):
        return None

    # Strip write_todos tool call, update todos directly
    clean_msg = AIMessage(
        content=last_ai_msg.content,
        response_metadata=last_ai_msg.response_metadata,
        id=last_ai_msg.id,
    )
    return {"messages": [clean_msg], "todos": todos}

This uses the same-ID replacement pattern that add_messages supports to replace the original AI message with one that has no tool calls.

PR fix notes

PR #35314: fix(langchain-classic): prevent duplicate output in TodoListMiddleware when model returns text and write_todos simultaneously

Description (problem / solution / changelog)

When the model generates a final text answer and a write_todos(all completed) tool call in the same response, the agent loop continues due to the presence of a tool call, causing the model to regenerate the answer. This fix detects that case in after_model and strips the write_todos tool call from the AI message, updating todos directly in state so the agent terminates normally.

Fixes #35310

Changed files

  • libs/langchain_v1/langchain/agents/middleware/todo.py (modified, +23/-0)
  • libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_todo.py (modified, +31/-1)

Code Example

from langchain.agents import create_agent
from langchain.agents.middleware import TodoListMiddleware

agent = create_agent(
    model=llm,
    tools=tools,
    system_prompt=prompt,
    middleware=[TodoListMiddleware()],
)

---

def after_model(self, state, runtime):
    # ... existing parallel write_todos check ...

    last_ai_msg = next(
        (msg for msg in reversed(messages) if isinstance(msg, AIMessage)), None
    )
    if not last_ai_msg or not last_ai_msg.tool_calls:
        return None

    tool_calls = last_ai_msg.tool_calls
    if len(tool_calls) != 1 or tool_calls[0]["name"] != "write_todos":
        return None

    # Check if message has text content alongside the tool call
    has_text = _has_text_content(last_ai_msg)
    if not has_text:
        return None

    # Check if all todos are completed
    todos = tool_calls[0].get("args", {}).get("todos", [])
    if not todos or not all(t.get("status") == "completed" for t in todos):
        return None

    # Strip write_todos tool call, update todos directly
    clean_msg = AIMessage(
        content=last_ai_msg.content,
        response_metadata=last_ai_msg.response_metadata,
        id=last_ai_msg.id,
    )
    return {"messages": [clean_msg], "todos": todos}
RAW_BUFFERClick to expand / collapse

Checked other resources

  • I added a very descriptive title to this issue.
  • I used the GitHub search to find a similar bug and didn't find it.
  • I searched the LangChain documentation.
  • I am sure that this is a bug in LangChain rather than my code.
  • The bug is not resolved by updating to the latest stable version of LangChain (or the specific integration package).

Example Code

from langchain.agents import create_agent
from langchain.agents.middleware import TodoListMiddleware

agent = create_agent(
    model=llm,
    tools=tools,
    system_prompt=prompt,
    middleware=[TodoListMiddleware()],
)

Error Message and Stack Trace (if applicable)

No error — the agent produces duplicate output.

Description

When using TodoListMiddleware, the model sometimes generates both text content (the final answer) and a write_todos tool call (marking all todos as completed) in the same response.

Because the response contains a tool call, the agent loop continues:

  1. Model generates text answer + write_todos(all completed)
  2. Agent executes write_todos tool → gets ToolMessage back
  3. Agent loops back to model (because there was a tool call)
  4. Model regenerates the answer (slightly different)

The user sees the answer twice (or more with streaming).

Expected behavior

When the model's only tool call is write_todos and all todos are marked completed, and the response also contains text content (the final answer), the agent should terminate instead of looping back to the model.

Workaround

The TodoListMiddleware.after_model could detect this case and strip the write_todos tool call from the AI message, updating the todos directly in state instead. Since the AI message would then have no tool calls, the agent loop would terminate normally.

def after_model(self, state, runtime):
    # ... existing parallel write_todos check ...

    last_ai_msg = next(
        (msg for msg in reversed(messages) if isinstance(msg, AIMessage)), None
    )
    if not last_ai_msg or not last_ai_msg.tool_calls:
        return None

    tool_calls = last_ai_msg.tool_calls
    if len(tool_calls) != 1 or tool_calls[0]["name"] != "write_todos":
        return None

    # Check if message has text content alongside the tool call
    has_text = _has_text_content(last_ai_msg)
    if not has_text:
        return None

    # Check if all todos are completed
    todos = tool_calls[0].get("args", {}).get("todos", [])
    if not todos or not all(t.get("status") == "completed" for t in todos):
        return None

    # Strip write_todos tool call, update todos directly
    clean_msg = AIMessage(
        content=last_ai_msg.content,
        response_metadata=last_ai_msg.response_metadata,
        id=last_ai_msg.id,
    )
    return {"messages": [clean_msg], "todos": todos}

This uses the same-ID replacement pattern that add_messages supports to replace the original AI message with one that has no tool calls.

System Info

  • langchain 1.2.10
  • langchain-core 1.2.13
  • Python 3.12
  • Model: Gemini 2.5 Pro (via langchain-google-genai)

extent analysis

Fix Plan

1. Implement after_model override in TodoListMiddleware

class CustomTodoListMiddleware(TodoListMiddleware):
    def after_model(self, state, runtime):
        # ... existing parallel write_todos check ...

        last_ai_msg = next(
            (msg for msg in reversed(runtime.messages) if isinstance(msg, AIMessage)), None
        )
        if not last_ai_msg or not last_ai_msg.tool_calls:
            return None

        tool_calls = last_ai_msg.tool_calls
        if len(tool_calls) != 1 or tool_calls[0]["name"] != "write_todos":
            return None

        # Check if message has text content alongside the tool call
        has_text = _has_text_content(last_ai_msg)
        if not has_text:
            return None

        # Check if all todos are completed
        todos = tool_calls[0].get("args", {}).get("todos", [])
        if not todos or not all(t.get("status") == "completed" for t in todos):
            return None

        # Strip write_todos tool call, update todos directly
        clean_msg = AIMessage(
            content=last_ai_msg.content,
            response_metadata=last_ai_msg.response_metadata,
            id=last_ai_msg.id,
        )
        return {"messages": [clean_msg], "todos": todos}

2. Update middleware in agent creation

agent = create_agent(
    model=llm,
    tools=tools,
    system_prompt=prompt,
    middleware=[CustomTodoListMiddleware()],
)

Verification

  1. Run your agent with the updated middleware.
  2. Observe that the agent no longer produces duplicate output.
  3. Verify that the write_todos tool call is correctly stripped from the AI message when all todos are completed and the response contains text content.

Extra Tips

  • Make sure to test your

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

When the model's only tool call is write_todos and all todos are marked completed, and the response also contains text content (the final answer), the agent should terminate instead of looping back to the model.

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

langchain - ✅(Solved) Fix Duplicate model answers when using TodoListMiddleware [1 pull requests, 2 comments, 2 participants]