The Final Piece: Suppressing Duplicate Assistant Messages in the Agent UI
In the complex ecosystem of an autonomous LLM-driven fleet management agent, few problems are as insidious as the "double response" bug. The symptom was simple: every time the agent ran, the conversation history showed not one but multiple assistant messages for the same run — the agent's intermediate reasoning, its tool-calling narration, and its final verdict all appeared as separate entries. For the human operator monitoring the vast-manager UI, this was confusing and noisy. For the LLM itself, it was worse: the agent's own prompt context was being polluted with redundant content, eating into the precious token budget and potentially confusing the model about what had actually been said.
Message [msg 4971] represents the final stroke in a multi-layered fix for this problem. It is a deceptively short message — a single apply_patch call targeting the UI HTML file — but it closes a loop that began with deep investigative work across the Python agent backend, the Go API server, and the JavaScript frontend. To understand why this message was written, one must trace the path that led to it.
The Investigation: Tracing the Source of Duplicates
The story begins with the user reporting "double responses" in the agent conversation. The assistant's initial investigation ([msg 4961]) revealed overlapping agent runs triggered by both the systemd timer and the path unit, with stale pending notifications being reprocessed across runs. But the deeper issue was structural: within a single agent run, the LLM was producing multiple assistant messages. The agent's architecture used a tool-calling loop where the model would first output reasoning (a "thinking" or "narrative" step), then issue tool calls, then produce a final answer. Each of these steps was being saved as a separate assistant message in the conversation database.
The assistant's reasoning in [msg 4964] crystallized the approach: "Suppress duplicate intermediate assistant responses in context and UI" and "Prompt agent to avoid narrative text on tool-calling steps." This was a two-pronged strategy — fix the source (prompt the model to stop producing narrative text during tool calls) and fix the downstream effects (filter out the duplicates wherever they appear).
The Python Backend Fix
Message [msg 4969] implemented the first half of the fix in the Python agent (vast_agent.py). The assistant modified the message-building loop that constructs the LLM prompt from the conversation history. Instead of passing every assistant message to the model, the new code identified the last assistant message per run_id and only included that one. The reasoning was sound: intermediate assistant messages contain tool-call requests and internal reasoning that the model doesn't need to see again — they are ephemeral scaffolding, not durable conversation. The final assistant message per run contains the verdict and any explanatory text, which is what the model should see as its own prior output.
But this only fixed the LLM prompt. The UI still showed all the intermediate messages, because the JavaScript rendering code in ui.html had no such filtering logic.
The UI Rendering Problem
Message [msg 4970] shows the assistant reading the UI HTML file to find the renderConversation function. The existing code tracked lastRun to detect when the run_id changed, but it didn't filter out intermediate assistant messages within the same run. Every message in the conversation array was rendered verbatim.
The assistant's reasoning block in [msg 4970] reveals a key design consideration: "I'm thinking about whether to skip tool calls as it seems they only keep the last assistant message. This might lead to losing important decisions from earlier if only the final verdict is kept. I believe the final should include explanations, though." This shows the assistant weighing the trade-off between completeness and clarity. The decision was that the final assistant message per run is authoritative — it contains the verdict, any explanations, and the summary of actions taken. The intermediate tool-call messages are implementation details that clutter the human-readable view.
The Subject Message: Applying the UI Fix
Message [msg 4971] is the direct application of this decision. The patch is concise but structurally significant:
const lastAssistantIdByRun = {};
for (const m of msgs) {
if (m.role === 'assistant') lastAssistantIdByRun[m.run_id] = m.id;
}
This pre-processing loop builds a map from run_id to the last (highest) assistant message ID for that run. By iterating through all messages and overwriting the map entry for each assistant message, the final value for each run_id is the last assistant message in the array — which corresponds to the final response of that agent run.
The existing rendering loop (which follows this new code) can then check: "Is this message the last assistant message for its run? If not, skip it." The exact rendering logic wasn't shown in the patch excerpt, but the data structure is now available for that check.
Assumptions and Design Decisions
The fix makes several important assumptions:
- The last assistant message per run is the most complete and useful one. This assumes that the agent's final verdict message contains all the information from intermediate steps, either through explicit summarization or because the model's final output is designed to be self-contained. In practice, the agent's system prompt had been updated (in [msg 4968]) to instruct the model to avoid narrative text during tool-calling steps and to include explanations only in the final message.
- Intermediate assistant messages are never needed for context. This is a stronger claim. The assistant's reasoning acknowledged the risk: "This might lead to losing important decisions from earlier if only the final verdict is kept." The mitigation was that the final message should include explanations. But there's an edge case: if a run's final message is truncated or the model fails to summarize its earlier reasoning, information could be lost.
- Run IDs are monotonic and reliable. The fix relies on
run_idto group messages. If run IDs were reused or non-monotonic, the map could produce incorrect results. The assistant had previously fixed run ID generation to be monotonic via persistent session state (as noted in the chunk summary), so this assumption was validated. - The UI and the LLM prompt should use the same filtering strategy. This is a consistency argument: if the model doesn't see intermediate messages, the human operator shouldn't either. Both views of the conversation should tell the same story.
Input Knowledge Required
To understand this message, one needs knowledge of:
- The agent architecture: The agent runs in a tool-calling loop where each iteration produces an assistant message. These messages are stored in a SQLite-backed conversation database.
- The conversation data model: Messages have
id,run_id,role(user/assistant/tool),timestamp, andcontentfields. - The UI rendering pipeline: The
renderConversationfunction inui.htmliterates over the messages array and builds HTML for each message. - The duplicate message problem: Intermediate assistant messages (tool-call requests, reasoning blocks) were being saved alongside final verdict messages, causing visual duplication and token waste.
- The Python-side fix: Message [msg 4969] had already implemented the same filtering logic in the Python agent's prompt-building code.
Output Knowledge Created
This message produced:
- A filtered UI view: The conversation panel now shows only one assistant message per run — the final response. This dramatically reduces visual noise for the human operator.
- Consistency between prompt and display: Both the LLM and the human now see the same filtered conversation history, preventing confusion when discussing past runs.
- A reusable data structure: The
lastAssistantIdByRunmap could be used for other purposes, such as highlighting the final message in the UI or computing metrics about run completeness. - A pattern for future filtering: The approach of pre-computing a "last message per group" map before rendering is a general pattern that could be applied to other grouping scenarios (e.g., last tool output per call, last user message per session).
The Thinking Process
The assistant's reasoning across the messages leading to [msg 4971] shows a methodical, layered approach to debugging:
- Observation: The user reports duplicate responses.
- Investigation: The assistant checks systemd logs, conversation history, and run timing to understand the scope of the problem ([msg 4961]).
- Root cause analysis: The assistant identifies that intermediate assistant messages (narrative text during tool-calling steps) are being saved as separate messages, and that both the LLM prompt and the UI render them all.
- Strategy formulation: The assistant creates a todo list with two items — suppress duplicates in context (LLM prompt) and suppress duplicates in UI ([msg 4964]).
- Backend fix: The assistant patches the Python agent to filter intermediate messages from the LLM prompt ([msg 4969]).
- UI investigation: The assistant reads the UI HTML to understand the rendering code ([msg 4970]).
- UI fix: The assistant applies the parallel fix to the JavaScript rendering code ([msg 4971]). This sequence demonstrates a "defense in depth" approach to the duplicate message problem. Rather than fixing only the symptom (UI clutter) or only the cause (model behavior), the assistant fixed both the prompt context and the UI rendering, ensuring that even if one layer fails, the other provides a safety net.
Conclusion
Message [msg 4971] is a small patch with a large impact. It completes the suppression of duplicate assistant messages across both the LLM's view and the human's view of the agent conversation. The fix is elegant in its simplicity — a single pre-processing loop that builds a map of last-assistant-ID per run — but it rests on a foundation of careful investigation, architectural understanding, and cross-layer consistency. In the world of autonomous agent engineering, where every token counts and every UI element must communicate clearly, this kind of meticulous cleanup is what separates a prototype from a production system.