fix(langchain): neutralize write_todos tool message

Replaces the verbose tool-message dump ("Updated todo list to [...]")
with a neutral "Todo list updated." acknowledgment. The actual state
still lives in the `todos` field of the `Command` update — only the
text payload returned to the model changes.

Why: inlining the full todo list — especially with `status: completed`
entries on the final call — was priming some models to read the tool
result as "everything done, wrap up briefly" and emit a content-free
recap instead of continuing with the substantive answer. Reducing the
tool message to a plain ack removes that prime.
This commit is contained in:
Nick Hollon
2026-05-26 11:23:02 -04:00
parent c7858c46d8
commit db7460c35f
2 changed files with 24 additions and 35 deletions

View File

@@ -136,7 +136,7 @@ def write_todos(
return Command(
update={
"todos": todos,
"messages": [ToolMessage(f"Updated todo list to {todos}", tool_call_id=tool_call_id)],
"messages": [ToolMessage("Todo list updated.", tool_call_id=tool_call_id)],
}
)
@@ -150,7 +150,7 @@ def _write_todos(
update={
"todos": todos,
"messages": [
ToolMessage(f"Updated todo list to {todos}", tool_call_id=runtime.tool_call_id)
ToolMessage("Todo list updated.", tool_call_id=runtime.tool_call_id)
],
}
)

View File

@@ -296,43 +296,32 @@ def test_todo_middleware_custom_system_prompt_and_tool_description() -> None:
@pytest.mark.parametrize(
("todos", "expected_message"),
"todos",
[
([], "Updated todo list to []"),
(
[{"content": "Task 1", "status": "pending"}],
"Updated todo list to [{'content': 'Task 1', 'status': 'pending'}]",
),
(
[
{"content": "Task 1", "status": "pending"},
{"content": "Task 2", "status": "in_progress"},
],
(
"Updated todo list to ["
"{'content': 'Task 1', 'status': 'pending'}, "
"{'content': 'Task 2', 'status': 'in_progress'}]"
),
),
(
[
{"content": "Task 1", "status": "pending"},
{"content": "Task 2", "status": "in_progress"},
{"content": "Task 3", "status": "completed"},
],
(
"Updated todo list to ["
"{'content': 'Task 1', 'status': 'pending'}, "
"{'content': 'Task 2', 'status': 'in_progress'}, "
"{'content': 'Task 3', 'status': 'completed'}]"
),
),
[],
[{"content": "Task 1", "status": "pending"}],
[
{"content": "Task 1", "status": "pending"},
{"content": "Task 2", "status": "in_progress"},
],
[
{"content": "Task 1", "status": "pending"},
{"content": "Task 2", "status": "in_progress"},
{"content": "Task 3", "status": "completed"},
],
],
)
def test_todo_middleware_write_todos_tool_execution(
todos: list[dict[str, Any]], expected_message: str
todos: list[dict[str, Any]],
) -> None:
"""Test that the write_todos tool executes correctly."""
"""Test that the write_todos tool executes correctly.
The tool message is a neutral "Todo list updated." ack; the actual
state lives in the `todos` field of the Command update. Dumping the
full list inline (including `status: completed` entries) was priming
some models to interpret the tool result as "everything done, wrap
up briefly" instead of continuing to deliver the substantive answer.
"""
tool_call = {
"args": {"todos": todos},
"name": "write_todos",
@@ -341,7 +330,7 @@ def test_todo_middleware_write_todos_tool_execution(
}
result = write_todos.invoke(tool_call)
assert result.update["todos"] == todos
assert result.update["messages"][0].content == expected_message
assert result.update["messages"][0].content == "Todo list updated."
@pytest.mark.parametrize(