Summary
Any time the model's final .response text is not a raw JSON tool-call object — whether it's empty, wrapped in a markdown code fence, prefaced with commentary, or (most strikingly) a completely valid, well-formed plain-text answer with no JSON structure at all — process_llm_result_tools() in agent.py can silently discard it via a return message shortcut that never calls process_tools(). Since process_tools() is the only code path that ever calls self.context.log.log(type="response", ...) — the exact call that makes an answer appear in the chat log/UI — this shortcut means the model can generate a completely correct answer, and it simply never appears anywhere. No warning, no exception, no chat log entry. The UI just sits on "Waiting for input" forever, indistinguishable from every other silent failure mode, requiring the user to manually intervene with no diagnostic trail at all.
Root cause
message = llm_result.response
if not message and llm_result.reasoning:
if (
extract_tools.extract_tool_request(llm_result.reasoning) is not None
or extract_tools.is_misformatted_tool_request(llm_result.reasoning)
):
message = llm_result.reasoning
if extract_tools.extract_tool_request(message) is None:
if extract_tools.is_misformatted_tool_request(message):
return await self.process_tools(message)
return message # <-- the bug: silently returns raw text, process_tools() never runs
return await self.process_tools(message)
extract_tool_request() returns None for anything that isn't a bare JSON object as the entire message. is_misformatted_tool_request() only recognizes two narrow shapes: JSON wrapped in a markdown fence, or a partial {"thoughts": ...}-prefixed structure. Any message that doesn't match either — including an empty string, and including completely valid, well-written plain-prose text — falls through to return message. That return value propagates straight up through Agent.monologue()'s message loop (tools_result = await self.process_llm_result_tools(llm_result); if tools_result: return tools_result), and the loop ends. process_tools() — the function that actually logs the response, executes tool calls, and drives the misformat/retry/circuit-breaker system — is never invoked for this turn at all.
Live-reproduced, worst-case example
We watched this happen live with a full diagnostic trail: asked the agent a simple factual question. docker logs captured the complete, correctly-formatted, correct-content answer streaming out in real time (via the framework's own debug print statements) — including specific facts (a file path, a checksum) that were entirely correct. But chat.json never gained a new log entry, and the UI stayed on "Waiting for input" indefinitely. The model did the job perfectly; the framework threw the result away. There was no exception anywhere, no traceback, nothing to search for — total silence, despite a completely successful generation just moments before.
This is a more severe version of the empty-response case originally suspected: it's not limited to degenerate/error-adjacent outputs (a model confused by a failed tool call, an empty response). It fires on the single most common and desirable outcome — a plain, well-formed, correct final answer that simply wasn't wrapped in the required JSON envelope. Given that Agent Zero's own system prompt convention (behaviour.md) requires every reply to be raw JSON with no exceptions, this shortcut path should never be legitimate in the first place.
Why this is the correct, minimal fix rather than another special case
process_tools() already fully and correctly handles every one of these shapes — valid JSON (executes it), fenced JSON (with a companion fix — see the sibling issue on extract_tool_request fence-handling), and genuinely invalid/unparseable text (logs the standard misformat warning and feeds the existing consecutive-failure/circuit-breaker system). The return message branch adds nothing that process_tools() doesn't already do better — it just occasionally short-circuits around the only code path that makes an answer visible. The fix removes the redundant special case entirely rather than patching around it:
return await self.process_tools(message)
That's the whole function body change: always route through process_tools(), regardless of what extract_tool_request()/is_misformatted_tool_request() say. We've run this in production across a wide range of tasks (simple Q&A, heavy multi-tool research queries, external MCP tool integrations) with no observed downside, and it resolved a class of totally silent, hard-to-diagnose stalls that had been occurring on a majority of complex multi-step tasks.
Note on why this is a core-file fix, not an extension
process_llm_result_tools is not decorated @extension.extensible, so unlike some other Agent Zero internals, this can't currently be patched via the usr/extensions user-override mechanism — it requires touching agent.py directly. Adding the @extension.extensible decorator to this function (or to whatever calls it) would let users patch behavior like this from usr/extensions without maintaining a core-file diff across updates, which might be worth considering as part of fixing this.
Environment
Agent Zero v2.6, Docker deployment
Reproduced with a reasoning-capable chat model (separate .reasoning / .response fields via the Responses API transport mode), but the underlying condition — a non-empty, non-JSON .response — is provider-agnostic and can occur with any model that occasionally answers in plain prose instead of the required JSON envelope
Summary
Any time the model's final .response text is not a raw JSON tool-call object — whether it's empty, wrapped in a markdown code fence, prefaced with commentary, or (most strikingly) a completely valid, well-formed plain-text answer with no JSON structure at all — process_llm_result_tools() in agent.py can silently discard it via a return message shortcut that never calls process_tools(). Since process_tools() is the only code path that ever calls self.context.log.log(type="response", ...) — the exact call that makes an answer appear in the chat log/UI — this shortcut means the model can generate a completely correct answer, and it simply never appears anywhere. No warning, no exception, no chat log entry. The UI just sits on "Waiting for input" forever, indistinguishable from every other silent failure mode, requiring the user to manually intervene with no diagnostic trail at all.
Root cause
message = llm_result.response
if not message and llm_result.reasoning:
if (
extract_tools.extract_tool_request(llm_result.reasoning) is not None
or extract_tools.is_misformatted_tool_request(llm_result.reasoning)
):
message = llm_result.reasoning
if extract_tools.extract_tool_request(message) is None:
if extract_tools.is_misformatted_tool_request(message):
return await self.process_tools(message)
return message # <-- the bug: silently returns raw text, process_tools() never runs
return await self.process_tools(message)
extract_tool_request() returns None for anything that isn't a bare JSON object as the entire message. is_misformatted_tool_request() only recognizes two narrow shapes: JSON wrapped in a markdown fence, or a partial {"thoughts": ...}-prefixed structure. Any message that doesn't match either — including an empty string, and including completely valid, well-written plain-prose text — falls through to return message. That return value propagates straight up through Agent.monologue()'s message loop (tools_result = await self.process_llm_result_tools(llm_result); if tools_result: return tools_result), and the loop ends. process_tools() — the function that actually logs the response, executes tool calls, and drives the misformat/retry/circuit-breaker system — is never invoked for this turn at all.
Live-reproduced, worst-case example
We watched this happen live with a full diagnostic trail: asked the agent a simple factual question. docker logs captured the complete, correctly-formatted, correct-content answer streaming out in real time (via the framework's own debug print statements) — including specific facts (a file path, a checksum) that were entirely correct. But chat.json never gained a new log entry, and the UI stayed on "Waiting for input" indefinitely. The model did the job perfectly; the framework threw the result away. There was no exception anywhere, no traceback, nothing to search for — total silence, despite a completely successful generation just moments before.
This is a more severe version of the empty-response case originally suspected: it's not limited to degenerate/error-adjacent outputs (a model confused by a failed tool call, an empty response). It fires on the single most common and desirable outcome — a plain, well-formed, correct final answer that simply wasn't wrapped in the required JSON envelope. Given that Agent Zero's own system prompt convention (behaviour.md) requires every reply to be raw JSON with no exceptions, this shortcut path should never be legitimate in the first place.
Why this is the correct, minimal fix rather than another special case
process_tools() already fully and correctly handles every one of these shapes — valid JSON (executes it), fenced JSON (with a companion fix — see the sibling issue on extract_tool_request fence-handling), and genuinely invalid/unparseable text (logs the standard misformat warning and feeds the existing consecutive-failure/circuit-breaker system). The return message branch adds nothing that process_tools() doesn't already do better — it just occasionally short-circuits around the only code path that makes an answer visible. The fix removes the redundant special case entirely rather than patching around it:
return await self.process_tools(message)
That's the whole function body change: always route through process_tools(), regardless of what extract_tool_request()/is_misformatted_tool_request() say. We've run this in production across a wide range of tasks (simple Q&A, heavy multi-tool research queries, external MCP tool integrations) with no observed downside, and it resolved a class of totally silent, hard-to-diagnose stalls that had been occurring on a majority of complex multi-step tasks.
Note on why this is a core-file fix, not an extension
process_llm_result_tools is not decorated @extension.extensible, so unlike some other Agent Zero internals, this can't currently be patched via the usr/extensions user-override mechanism — it requires touching agent.py directly. Adding the @extension.extensible decorator to this function (or to whatever calls it) would let users patch behavior like this from usr/extensions without maintaining a core-file diff across updates, which might be worth considering as part of fixing this.
Environment
Agent Zero v2.6, Docker deployment
Reproduced with a reasoning-capable chat model (separate .reasoning / .response fields via the Responses API transport mode), but the underlying condition — a non-empty, non-JSON .response — is provider-agnostic and can occur with any model that occasionally answers in plain prose instead of the required JSON envelope