The Stale Messages Bug: Debugging an LLM Agent's First Conversation
The Message
Now I can see the error: "No user query found in messages." The LLM requires a user message but on run #1 with an empty conversation, the messages are[system, user(observation)]— the user observation IS there but maybeno_action_neededreturned False but the fast-path already ran? Let me check the flow:
>
`` [grep] no_action_needed|fast.path|Skip LLM Found 3 matches /tmp/czk/cmd/vast-manager/agent/vast_agent.py: Line 11: 4. Optionally calls the LLM (skipped on fast-path) Line 902: def no_action_needed(demand: dict, fleet: dict, config: dict) -> bool: Line 1003: if no_action_needed(demand, fleet, agent_config): ``
This message, from an opencode coding session building an autonomous LLM-driven fleet management agent for GPU proving infrastructure, captures a pivotal debugging moment. The assistant has just deployed a major architectural overhaul — converting the agent from an ephemeral per-run invocation to a persistent conversational runtime with a rolling SQLite-backed conversation log — and the very first run on a fresh conversation has failed with a cryptic 400 error from the LLM API: "No user query found in messages." This message represents the diagnostic pivot point where the assistant stops deploying code and starts tracing the logical flow to find the root cause.
Context: The Conversational Agent Architecture
To understand why this message matters, we need to understand what was just built. The agent ([msg 4584]) had been redesigned around a persistent conversation model. Instead of each cron invocation starting from scratch with a fresh system prompt and no memory, the agent now:
- Loads a rolling conversation from a SQLite database via a Go API endpoint (
GET /api/agent/conversation) - Appends a new "observation" message (a compact summary of demand, fleet state, and performance metrics) for each run
- Constructs an LLM call with the system prompt + full conversation history
- Executes tool calls (launch instances, stop instances, etc.) and persists results back to the conversation
- Maintains context across runs, with summarization at ~30k tokens to stay within budget This was a fundamental shift from the old architecture where each run was a blank slate. The promise was genuine memory — the agent could learn from past decisions, understand operator preferences injected as user messages, and build coherent multi-step strategies. But the first run on a fresh conversation failed immediately.
The Error and the Initial Hypothesis
The error message "No user query found in messages" comes from the LLM provider (in this case, a Qwen 3.5-122b model served via an OpenAI-compatible API). The API requires at least one message with role user in the chat completion request. On run #1 with an empty conversation, the agent constructs messages as [system, user(observation)] — the observation is a user message, so why would the API reject it?
The assistant's initial hypothesis, visible in the message, is: "maybe no_action_needed returned False but the fast-path already ran?" This is a subtle and slightly confused hypothesis. The no_action_needed function is a fast-path check that skips the LLM entirely when demand is inactive and the fleet is stable. If the fast-path ran, the LLM wouldn't be called at all — so the error couldn't come from the fast-path running. The assistant seems to be wondering whether the code took a wrong branch: perhaps no_action_needed returned False (meaning action is needed), but some other condition caused the LLM call to be constructed incorrectly.
This hypothesis is wrong, but it's a productive wrong — it leads the assistant to grep for the relevant code paths, which reveals the actual bug.
The Grep: Tracing the Code Flow
The assistant runs a grep for no_action_needed|fast.path|Skip LLM and finds three matches:
- Line 11: A comment in the module docstring: "4. Optionally calls the LLM (skipped on fast-path)"
- Line 902: The function definition:
def no_action_needed(demand: dict, fleet: dict, config: dict) -> bool: - Line 1003: The call site:
if no_action_needed(demand, fleet, agent_config):This grep is the critical investigative step. The assistant is mapping the control flow to understand exactly what happens on run #1. The next message ([msg 4616]) reads the code around line 1003, and the following message ([msg 4617]) identifies the real bug:
The issue: on run #1 with empty conversation,messagesfrom the DB reload after appending the observation should have 1 user message. But the code appends the observation BEFORE step 7 (line ~985), and in step 8 rebuilds frommessageswhich was loaded at step 1 (before the append). The appended messages aren't in the localmessageslist.
This is the classic stale-reference bug. The agent's run_agent() function:
- Step 1: Loads conversation from DB into local variable
messages(empty list on fresh conversation) - Step ~5: Appends observation to DB via API call (
append_message(run_id, "user", obs)) - Step 8: Builds LLM messages from the local
messagesvariable — which is still the empty list from step 1 The observation was persisted to the database, but the local Python variable was never updated. The LLM call receives[system]— no user message — and the API returns 400.
Why the Initial Hypothesis Was Wrong
The assistant's initial guess about no_action_needed was a red herring, but an understandable one. The fast-path check at line 1003 is the most prominent conditional that could cause the LLM to be skipped or called with wrong parameters. The assistant was thinking: "If the fast-path ran, maybe it set some state that confused the LLM call." But the actual bug was simpler and more mundane: a stale local variable that wasn't refreshed after a side-effectful API call.
This kind of bug is common in systems that mix local state with remote persistence. The developer (or in this case, the AI assistant building the system) assumes that the local variable reflects the database state, but the append operation goes through a separate HTTP API call, and the local list is never re-fetched. The fix, implemented in [msg 4619], is to reload the conversation from the database right before building the LLM messages, ensuring the local list includes the newly appended observation.
Assumptions and Input Knowledge
To understand this message, the reader needs several pieces of context:
- The conversational agent architecture: That the agent now uses a persistent SQLite-backed conversation log, with messages appended via HTTP API calls to a Go backend.
- The
no_action_neededfast-path: A pre-LLM check that skips the expensive LLM call when no action is required (inactive demand, stable fleet). - The LLM API contract: That OpenAI-compatible chat endpoints require at least one message with role
userin the messages array. - The run flow: That
run_agent()proceeds through numbered steps — load conversation, fetch demand/fleet, append observation, optionally call LLM, execute tools. The assistant assumes that the localmessagesvariable and the database are synchronized — an assumption that turns out to be false. It also assumes, momentarily, that the error might be related to the fast-path check, which is a reasonable debugging heuristic: look at the most recent change (the fast-path was added in the conversational rewrite) and see if it could cause the observed symptom.
Output Knowledge Created
This message creates several valuable outputs:
- A confirmed error signature: "No user query found in messages" on run #1 of a fresh conversation.
- A traced code path: The grep output confirms the location of the fast-path check and the
no_action_neededfunction. - A refined hypothesis space: The assistant has narrowed the problem to the message construction logic, ruling out API format issues and model incompatibility.
- The impetus for the actual fix: The grep leads directly to reading the code around line 1003 ([msg 4616]), which reveals the stale-messages bug ([msg 4617]), which is then fixed ([msg 4619]).
The Thinking Process
What's most interesting about this message is the visible reasoning process. The assistant sees an error, forms an initial hypothesis, and immediately tests it with a grep. The hypothesis is slightly off-target, but the grep is the right tool — it surfaces the relevant code paths, and the next step (reading the actual code) reveals the true bug.
This is characteristic of effective debugging: you don't need to be right about the root cause on the first try. You need to be systematic — to trace the code flow, check your assumptions, and let the evidence guide you. The assistant's mistake was assuming the local messages variable was refreshed after the API call, a natural assumption when you're thinking about the logical flow rather than the actual variable scoping.
The message also reveals the assistant's mental model of the agent's execution flow. It thinks in terms of steps: "on run #1 with an empty conversation, the messages are [system, user(observation)]." This is correct about what should happen, but the assistant hasn't yet realized that the local variable doesn't reflect the database state. The grep is the bridge between the conceptual model and the actual code.
Broader Significance
This bug — a stale local reference after a remote API call — is a classic pitfall in distributed systems and agent architectures. As agents grow more sophisticated, they increasingly mix local computation with remote state. The conversational agent stores its memory in a database, but the Python process maintains a local cache. When the cache and the database diverge, subtle bugs emerge.
The fix — reloading from the database before building the LLM messages — is a simple pattern: always re-fetch state from the authoritative source before making decisions based on it. This is the database equivalent of "don't cache stale data," and it's a lesson that applies broadly to agent systems, microservice architectures, and any system where state is shared across processes.
The message also illustrates the iterative nature of building reliable autonomous agents. The conversational architecture was a major step forward — it gave the agent genuine memory and the ability to learn from experience. But the first run failed because of a mundane synchronization bug. Each iteration reveals new failure modes, and each fix makes the system more robust. The assistant didn't get discouraged by the 400 error; it treated it as a data point, traced the code, found the bug, and fixed it. Within minutes, the agent was running successfully with proper conversational context ([msg 4622]), demonstrating much better judgment than the ephemeral architecture it replaced.