Problem:
During multi-step agentic tasks, the LLM occasionally returns an AIMessage with text content and tool_calls: [] — essentially a “status update” or “thinking out loud” message — before the task is actually complete. The ReactAgent router treats any AIMessage with empty tool_calls as a final answer and exits the loop immediately.
Example of the stall:
The agent is midway through a 5-step coding task. After completing step 3, instead of calling the next tool, the model returns:
AIMessage {
content: "Now I'll apply the backend changes and update the service tests.",
tool_calls: []
}
The ReactAgent router sees tool_calls.length === 0 → routes to END → loop exits → user is stuck at step 3 with no way to continue.
How often this happens:
In production with ~thousands of agent runs, we see this in roughly 5-8% of multi-step runs. It’s more common with:
- Long-running tasks (10+ tool calls)
- Complex multi-file changes where the model “plans ahead” verbally
- Context-heavy prompts near the token window boundary
- Certain model versions (Claude Sonnet is also most frequent)
Root cause in ReactAgent:
The #createModelRouter and #createAfterModelRouter both use this check:
if (AIMessage.isInstance(lastMessage) &&
(!lastMessage.tool_calls || lastMessage.tool_calls.length === 0)) {
return exitNode;
}
This is a hard binary — any text-only AI response = final answer. There’s no distinction between:
- Genuine final answer — “Here’s the summary of what I did” (should exit)
- Mid-task status update — “I’ll now update the tests” (should NOT exit)
Questions for the team:
-
Is there a built-in mechanism to prevent the loop from exiting on empty tool_calls? Something like a shouldContinue callback or a minIterations option?
-
Is this a known issue? We couldn’t find existing discussions about this specific failure mode. Happy to contribute a fix if you can point us to the right approach.
hi @Sivasankar
1. Is there a built-in option? No. There’s no shouldContinue and no minIterations. createAgent stops as soon as the model returns an AI message without tool calls. JS implements that rule in #createModelRouter, and Python’s create_agent has the same rule in _make_model_to_tools_edge. To the router, “I’ll now update the tests” and a real final answer are the same kind of message. recursionLimit and modelCallLimitMiddleware only cap the loop. They don’t extend it. minIterations would be the wrong control anyway, because it would force extra turns after tasks that really are finished. The fix is to give the model an unambiguous “done” signal.
2. Is this a known issue? The model behavior is well known. Some of your 5-8% may have other causes, though, so check a few failed runs first:
stop_reason: "max_tokens" (in response_metadata or additional_kwargs): the output was cut off before the tool_use block. This fits your “near the context limit” pattern. Raise maxTokens and/or add summarizationMiddleware.
- The raw provider response has a tool call, but the assembled
AIMessage has tool_calls: []: that’s an integration or streaming bug, not the model. Examples of this class: #9816, #10937.
end_turn with text only: the model really narrated instead of calling a tool.
Quick question before going further: how does your agent track progress through the steps?
Specifically:
- Do you use todoListMiddleware (or your own plan/task list in agent state), or does the model keep track of the steps only in the conversation?
- Are the steps known up front (e.g. from a ticket or a planner you control), or does the model come up with them during the run?
This changes the recommended fix. If there’s a task list in state, the guard can check “are there open tasks?” deterministically and nudge with the exact next step. Without one, the more robust option is an explicit task_complete tool as the only way to finish.
Hi @pawel-twardziak
We checked our traces for max_tokens and the streaming assembly bug — both ruled out (stalls happen at low token counts early in runs, and we verified a sample of raw provider payloads).
For end_turn with text only — we cannot confirm this yet. Our current pipeline strips response_metadata before persistence so stop_reason is not stored anywhere. We need to add logging for it first before we can say definitively.
On your two questions:
- We use
todoListMiddleware — but the stall happens both with and without todo state.
- Steps are fully dynamic — the model comes up with them during the run.
hi @Sivasankar
When you say the stall happens “without todo state”, do you mean:
- (a) runs where the model never called write_todos,
- (b) the stall happened before its first write_todos call
And when todos do exist at the moment of the stall, are there still open items?
Do you use LangSmith for tracing or something else?
Hi @pawel-twardziak ,
As far as we noticed it occured on the below case
- Some runs where the model never calls
write_todos at all (short/simple tasks where it skips planning and goes straight to tool calls, then stalls).
On tracing — we don’t use LangSmith. We have our own internal trace logging system.
hi @Sivasankar
It’s always hard to judge what is the main issue without seeing the traces.
It might be the model issue or the harness issue (e.g. tools that are confusing the model)
hi @Sivasankar
Thanks for the trace, it’s very informative. This run looks different from the “never calls write_todos” case, and most of it points at the tooling:
- Every
grep call failed (17/17 returned “No matches found”), including for code that exists.
- deepagents
grep is a literal search. The tool description says “NOT regex”. The model used regex alternation in 15 of 17 calls ("Team Lead|team_lead|admin|role"), which searches for that exact string.
- On the sandbox backend,
glob becomes find -name <glob> (backends/sandbox.ts, buildGrepCommand). -name matches only the file’s basename, without brace expansion, so the model’s **/*, **/*.{ts,tsx} and **/schema/**/*.ts never match. That’s why even grep("ResourceLimitError", glob="**/*") returned nothing while errors.ts:107 defines it. I reproduced this: those globs give 0 hits, *.ts works.
- The suggested fallback,
rg via execute, isn’t installed in your sandbox (rg: command not found).
-
A summarization happened partway through (the model reads /conversation_history/session_….md at msg 50). After that it re-reads the same files again and again (skills.route.ts and skill.repository.ts 5× each). After 19 turns and 81 tool calls there isn’t a single edit_file/write_file, and the todo list is still on item 1. The final “Applying backend changes…” message comes at the end of that spiral.
-
write_todos was called here, with 3 items still pending at the stall. The todo-aware nudge I posted earlier would have fired here and named “Implement backend skill limit validation” as the next step.
What I’d do:
- Fix search first. Install ripgrep in the sandbox image. Tell the model (system prompt) to use basename globs like
*.ts, or no glob, and one literal term per grep call. Alternatively, override grep in your backend to support ** and braces (e.g. find -path, or rg --glob). The basename-only find -name behaviour is probably worth an issue on deepagentsjs.
- Keep the completion guard (todos +
task_complete) as the safety net.
- On the stalled message, check
finish_reason and invalid_tool_calls. The first editing turn is the first large output in the run. If it was cut off, the tool call ends up in invalid_tool_calls, and the router only looks at tool_calls.
- Which model is this, exactly? The
gen-… ids look like OpenRouter. Is it still Claude 3.5 Sonnet?