Skip to content

WebUI doesn't display agent response when LLM outputs plain text instead of JSON tool call #1708

Description

@stife

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:

  1. Checks whether _20_live_response.py already created a response log ("log_item_response" in loop_data.params_temporary)
  2. If not, retrieves the latest AI response from the agent's history
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions