Get started

Engineering NotesConversation engine

A 400 that sticks: add tool results on write, fix shapes on send

Model APIs remember nothing between calls, so an agent resends the whole conversation context every round. A round is one model call plus running the tool calls in its response. If a single item in that context breaks a provider's rules, the provider rejects the entire request with a 400. The trouble is that the rejection does not stop there. The next turn assembles its request from the same stored context, gets the same 400, and the conversation never moves again, whatever the user asks. One turn that hits the tool call cap and leaves one call without a result is enough to block a conversation this way.

Four shapes cause the rejection: a tool call with no result, a message inserted between a tool call and its result, an item another provider produced, and a field that exists only in responses. The first two break the meaning of the conversation on any provider, so we stop them when writing to storage. The last two follow rules that differ by provider, so the stored copy stays as it is and we adjust on send. Right before sending, one more pass removes calls without results. This post covers that split.

The four shapes that get a request rejected, and where each is stopped
Figure 1. The four shapes that get a request rejected, and where each is stopped

A rejected context is assembled again on the next turn

OwlAI stores each LLM call as one node, and the next request is assembled by walking from the conversation's last node up through its parents (“Keeping old answers out of edited questions with a conversation tree”). A tool call sits among the items node N got back; its result sits among the items node N+1 sent. In the normal flow the next round always starts with the results, so every call has one. Things break when there is no next round.

Here is how the real APIs reject each of the four shapes.

Shape Error the provider returned
Tool call with no result No tool output found for function call (OpenAI)
Another message between a call and its result tool_use ids were found without tool_result blocks immediately after (Anthropic)
Item another provider produced Invalid 'input[1].id': '', Expected an ID that begins with 'fc' (OpenAI)
Response-only field Unknown parameter: 'input[0].created_by' (OpenAI)

In every case what gets blocked is not one request but one conversation. The user has no way around it, so the system must never produce these shapes in the first place.

Calls that are not run get a result on the spot

There are four cases where the model emits a tool call and the loop does not run it.

  • The turn reaches its total tool call cap or its time limit, and it wraps up without running the remaining calls
  • A pre-execution check refuses the call, such as a repeated call with the same arguments or the cap on calls that need approval
  • The model calls the tool that submits its final answer together with other tools
  • The user stops the turn and an in-flight call halts

The loop attaches a result to each call at the point where it decides not to run it. The result says the call was not run and why. And the loop does not carry that result to the next model call; it saves it as a node right away. It creates a node under the previous one that holds only these results, without calling the LLM, and records it as succeeded. The final-answer tool is the one exception and gets a success result. If it got a failure, the model on the next turn would read the submission as failed and try to submit again.

Putting the results on the next round's input would spare the extra node and keep the code simple. But a cap or a stop usually means there is no next round, and even when there is one, a worker that dies in between leaves the call without a result. Saving at the moment of the decision is what guarantees every call has a result however the turn ends.

Messages the user sends mid-turn follow the same rule. While the model queries the database several times, the user may redirect it: "this year, not last year." Inserting that message while a tool is running puts a message between a call and its result. Anthropic's API documentation says no message may come between the assistant's tool use message and the user's tool result message (checked 2026-11-03). So a user message goes in only after every call from the previous round has its result, right before the next model call (“One agent loop for every feature, with the differences in hooks”). The cost is delay. If a long-running tool is in progress, the user's words go in after it finishes.

Provider-specific shapes are fixed on send, with the stored copy untouched

Switch models mid-conversation and the earlier context contains items the previous provider produced. Even reasoning takes a different shape at each provider.

Provider Reasoning item Server-side compaction result
OpenAI reasoning models An encrypted block only OpenAI can decrypt An encrypted block
Claude A thinking block with a signature A compaction block containing a summary
vLLM Reasoning filled in as plain text None

OpenAI rejects a reasoning item from Claude because its ID is empty, and one from vLLM because its content is filled in. Tool call IDs are the same story. OpenAI accepts only its own IDs starting with fc, so sending a toolu_… ID that Claude assigned gets a 400.

The stored copy keeps these items exactly as received. Converting it to the new provider's format would make the encrypted reasoning and thinking blocks unusable when the conversation returns to the original model (“OpenAI stores responses by default, so we resend the conversation”). Instead, when assembling the request we pass the target provider and model for this call, and right before sending we convert to the shape that target reads.

  • Reasoning items from other providers are dropped.
  • Tool call IDs from other providers are removed, keeping only the call_id that links a call to its result. The same request with only the ID removed went through with a 200.
  • If the target is a text-only model, images in earlier messages become a note that carries the file name.

Switching models within the same provider type sends a request byte-for-byte identical to the one before the switch.

Response-only fields are also removed on send. Items returned by the OpenAI Responses API carry response-only fields such as status, and putting them back into the next request as received gets it rejected. A denylist that strips known rejected fields is short to write, but when the provider adds a field to responses, that field slips through. The created_by field on server-side compaction items caused a 400 exactly this way. So for OpenAI we keep an allowlist per item type and send only fields confirmed with a 200 in a real API round trip.

The allowlist rests on real round trips, not on SDK type declarations. The OpenAI SDK's request types declare status, yet the deployed API rejects it. So a test compares the allowlist with the SDK's request types and fails when an SDK upgrade adds a field; it passes only after someone measures that field against the real API and decides. vLLM does not use this list. It speaks the same Responses API format, but it rejects reasoning items that lack status and id, and it refuses the encrypted field the OpenAI path sends. So these rules live separately for each provider.

Compaction results the target cannot read are expanded back to the original

A server-side compaction result can be read only by the provider type that produced it. One OpenAI model accurately summarized a compaction result made by another OpenAI model, but to Claude the same result is unreadable ciphertext.

When a conversation is compacted, a new node with no parent holds the compacted context, and it carries a pointer to the original node from before compaction (“Keeping old answers out of edited questions with a conversation tree”). If assembly finds that the path starts with a compaction result the target cannot read, it discards that result and follows the pointer to assemble the original path instead. If the original also starts at a compaction node, the same rule applies again, and the nodes already expanded are recorded so a damaged pointer cannot form a cycle. An old compaction node without a pointer falls back to its plain-text summary if Claude made it; if OpenAI made it, the earlier context is lost and a warning is logged.

Expanding a conversation compacted several times brings back the whole original, which can exceed the target model's input limit. In that case the oldest turns are dropped first. Dropping starts at the input limit minus 2%, and tokens are counted by sending the request to the target provider's token counting API. Expansion repeats every round until the target's own compaction runs, so to keep a counting round trip off every round, we skip counting when a count of one token per character is below the line. In our measurements even the densest tokenizer did not exceed 0.64 tokens per character. Dropping whole turns means a tool call and its result leave together, and the last turn, which holds the current question, always stays. When the target is vLLM there is no server-side compaction, so OwlAI's own compactor takes the expanded context and shrinks it (“Compaction thresholds come from the input limit, not the context window”).

Calls without results are removed once more right before sending

We cannot promise that the rule of attaching results covers every path. A process can end abruptly, and some conversations were stored before the rule existed. So the last step of assembly removes two things.

  • Tool calls with no result for the same call_id, and tool calls with an empty call_id
  • Reasoning items that lost every following message or tool call to that cleanup

OpenAI also rejects a reasoning item left on its own, so removing only the call would turn one 400 into another. The only reasoning this cleanup removes is reasoning that lost its following items at this step, so reasoning in a normal context is untouched.

In a normal context this cleanup changes nothing. If it removes anything, some path somewhere is not attaching results, so it logs the removed items as a warning. This cleanup is also what gets old conversations stored without results moving again.

Design choices

Calls without results could be stopped by the pre-send cleanup alone. Every request passes through that one place, so no path would need its own code. We did not make it the main mechanism because the model would lose context. A call removed by the cleanup disappears from the conversation context, and the model on the next turn does not even know it called that tool. A result attached on the spot records why the call was not run, so the model knows what was blocked. The cost is that every new path that skips calls needs its own code to attach results.

The allowlist picks which way mistakes go. With a denylist, the mistake is sending an unknown field and blocking the conversation; with an allowlist, it is not yet sending a new field. The first is an outage users hit; the second is a feature that arrives late. In exchange, someone has to measure against the real API again every time the SDK is upgraded or a provider adds a field.

Keeping the stored copy as received costs the first request after a model switch. Moving to another provider expands the original from before compaction and makes the input larger, and even within the same provider the cache is empty, so the earlier conversation is read from scratch. So when a conversation has been compacted or the earlier context exceeds 100,000 tokens by default, a confirmation dialog warns about the higher cost before the switch.

If your agent switches between providers' models in one conversation and has several paths that end without running a tool call, this split carries over as is: keep the rules that belong to the conversation's meaning when writing, and match each provider's shape rules when sending.