Summary
When the agent processes a message and the LLM returns a plain text final response (instead of a JSON-formatted {"tool_name": "response", "tool_args": {"text": "..."}} tool call), the response is saved to history correctly but never displayed in the WebUI. The user sees only the last "Calling LLM..." log entry without the response ever appearing.
Telegram/WhatsApp integrations are not affected — they receive the response directly via task.result() and render it independently of the context log.
Root Cause
Two issues combine to cause this:
1. Missing fallback for plain-text responses in _20_live_response.py
/a0/extensions/python/response_stream/_20_live_response.py only creates a type=response context log entry when the LLM's streamed output parses as JSON with tool_name == "response":
if (
not "tool_name" in parsed
or parsed["tool_name"] != "response"
or "tool_args" not in parsed
or "text" not in parsed["tool_args"]
or not parsed["tool_args"]["text"]
):
return # not a response
Many models (DeepSeek V4 flash, local/open-source models, or any model with customized system prompts) output the final response as plain text rather than wrapping it in the {"tool_name": "response", ...} JSON format. For these responses:
_20_live_response.py never fires → no type=response log entry is created
- The response IS stored in the agent's history via
hist_add_ai_response()
- But without a response log entry, the WebUI state sync never delivers the response content to the frontend
2. streaming_agent = None does not trigger a state push
In agent.py line 555, the monologue() method clears the streaming agent in its outer finally block:
finally:
self.context.streaming_agent = None # unset current streamer
# call monologue_end extensions
if self.context.task and self.context.task.is_alive():
await extension.call_extensions_async(
"monologue_end", self, loop_data=self.loop_data
)
Setting streaming_agent = None is a direct attribute assignment. It does not call context.log.log() and therefore does not trigger mark_dirty_all() or mark_dirty_for_context(). No state push is sent to the WebUI, so the frontend never learns that the agent finished streaming.
Since monologue_end extensions execute after streaming_agent is cleared, any log.log() call inside a monologue_end extension will trigger a state push that correctly reflects both the new log entry and the now-cleared streaming state.
Impact
- Any model that outputs final answers as plain text rather than JSON tool calls (DeepSeek, many open-source models, etc.)
- Users who customize the system prompt, potentially breaking the response tool format
- Telegram/WhatsApp are unaffected (direct response delivery)
- The bug has likely existed since
_20_live_response.py was introduced, but was masked by other issues (e.g., streaming_agent being serialized as 0 in persist_chat.py, which kept the agent appearing busy)
Fix
Add an extension at the monologue_end hook that serves as a fallback:
- Checks whether
_20_live_response.py already created a response log ("log_item_response" in loop_data.params_temporary)
- If not, retrieves the latest AI response from the agent's history
- Creates a
type=response log entry matching the same format as _20_live_response.py
Install as /a0/extensions/python/monologue_end/_85_ensure_response_log.py:
from helpers.extension import Extension
from agent import LoopData
class EnsureResponseLog(Extension):
"""Fallback: log the AI response when the LLM outputs plain text instead of JSON tool_call.
_20_live_response.py (response_stream) only creates a response log entry when the LLM
generates {"tool_name":"response","tool_args":{"text":"..."}}. Many models output
plain text for final responses, so _20_live_response.py doesn't fire.
This extension runs at monologue_end - AFTER streaming_agent has been cleared to
None - so the resulting state push correctly reflects both the response log entry
AND the cleared streaming state.
"""
async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
if not self.agent:
return
# Skip if _20_live_response.py already created a response log
if "log_item_response" in loop_data.params_temporary:
return
gen_item = loop_data.params_temporary.get("log_item_generating")
if not gen_item or not gen_item.id:
return
# Get the latest AI response from history
try:
topic = self.agent.history.current
if not topic or not topic.messages:
return
last_msg = topic.messages[-1]
if not (last_msg.ai and last_msg.content):
return
response_text = last_msg.content
if isinstance(response_text, dict):
response_text = response_text.get("text", str(response_text))
except Exception:
return
if not response_text or not isinstance(response_text, str) or len(response_text) < 3:
return
# Create response log entry (same format as _20_live_response.py)
# This triggers a state push that includes streaming_agent: None
self.agent.context.log.log(
type="response",
heading=f"icon://chat {self.agent.agent_name}: Responding",
content=response_text,
id=gen_item.id,
)
Why not fix _20_live_response.py directly?
A modification to _20_live_response.py would need to handle plain text streaming chunks that aren't valid JSON — a fragile parser-level change. The extension approach at monologue_end is cleaner because:
- It's a separate, self-contained fallback that runs only when needed
- It already has access to the complete response via the agent's history
- It naturally runs after
streaming_agent = None, solving both problems at once
Alternative
An alternative approach would be to modify the state monitor to automatically push state updates when streaming_agent changes (e.g., via a property setter or descriptor), but that would require deeper changes to core framework code.
Environment
- Agent Zero:
agent0ai/agent-zero (latest)
- Model: DeepSeek V4 flash (
chat_completions mode)
- Also affects: any model not consistently outputting
{"tool_name": "response", ...} JSON for final answers
Summary
When the agent processes a message and the LLM returns a plain text final response (instead of a JSON-formatted
{"tool_name": "response", "tool_args": {"text": "..."}}tool call), the response is saved to history correctly but never displayed in the WebUI. The user sees only the last "Calling LLM..." log entry without the response ever appearing.Telegram/WhatsApp integrations are not affected — they receive the response directly via
task.result()and render it independently of the context log.Root Cause
Two issues combine to cause this:
1. Missing fallback for plain-text responses in
_20_live_response.py/a0/extensions/python/response_stream/_20_live_response.pyonly creates atype=responsecontext log entry when the LLM's streamed output parses as JSON withtool_name == "response":Many models (DeepSeek V4 flash, local/open-source models, or any model with customized system prompts) output the final response as plain text rather than wrapping it in the
{"tool_name": "response", ...}JSON format. For these responses:_20_live_response.pynever fires → notype=responselog entry is createdhist_add_ai_response()2.
streaming_agent = Nonedoes not trigger a state pushIn
agent.pyline 555, themonologue()method clears the streaming agent in its outerfinallyblock:Setting
streaming_agent = Noneis a direct attribute assignment. It does not callcontext.log.log()and therefore does not triggermark_dirty_all()ormark_dirty_for_context(). No state push is sent to the WebUI, so the frontend never learns that the agent finished streaming.Since
monologue_endextensions execute afterstreaming_agentis cleared, anylog.log()call inside amonologue_endextension will trigger a state push that correctly reflects both the new log entry and the now-cleared streaming state.Impact
_20_live_response.pywas introduced, but was masked by other issues (e.g.,streaming_agentbeing serialized as0inpersist_chat.py, which kept the agent appearing busy)Fix
Add an extension at the
monologue_endhook that serves as a fallback:_20_live_response.pyalready created a response log ("log_item_response"inloop_data.params_temporary)type=responselog entry matching the same format as_20_live_response.pyInstall as
/a0/extensions/python/monologue_end/_85_ensure_response_log.py:Why not fix
_20_live_response.pydirectly?A modification to
_20_live_response.pywould need to handle plain text streaming chunks that aren't valid JSON — a fragile parser-level change. The extension approach atmonologue_endis cleaner because:streaming_agent = None, solving both problems at onceAlternative
An alternative approach would be to modify the state monitor to automatically push state updates when
streaming_agentchanges (e.g., via a property setter or descriptor), but that would require deeper changes to core framework code.Environment
agent0ai/agent-zero(latest)chat_completionsmode){"tool_name": "response", ...}JSON for final answers