DeepSeek Harness tool_addition 400: mid-conversation fix
A session runs fine, then you install a plugin or an MCP server hot-loads two tools, and from the next turn on nothing succeeds — 400 INVALID_REQUEST: messages.N: tool_addition blocks can only be in system messages, always at the same position, and a new request_id does not change it. This is not a one-off network hiccup: an invalid content block was written into session history and is resent verbatim on every turn (#10553). Split the timeline into three stages and you can see exactly how it gets locked in.
DeepSeek Harness: Before it happens: the capability is declared on the model entry, not the endpoint
toolUpdate is derived from the model entry's compat.supportsMidConvoToolChanges, and the projection reads only that declared value — it never checks whether the endpoint accepts these blocks.
- Open the model entry the session is using and look for
toolUpdate: "addition-only"orcompat.supportsMidConvoToolChanges. - Note this:
conversationUpdates()indsh-llm-pi-ai(lib/index.js:789) uses it to derivetoolUpdate: 'in-history'. - The consumer is
projectToolUpdates(packages/llm/llm/src/content.ts:424), which follows the declaration and does not probe endpoint capabilities. - That is why the same model entry works against a self-hosted compatible endpoint and returns 400 against the official one — the entry did not change, the endpoint did.
This is the first domino: the capability is described for the wrong object. It describes an endpoint but is declared on a model.
DSH plugin: When it happens: one tool-table change writes the block into history
The trigger is not "what you changed by hand" but "the tool table changed once mid-session": a plugin appends tools, MCP hot-loads, or the skill catalog refreshes, and the harness generates a developer/message carrying tool-addition.
- Plugin case: after the first turn, a plugin such as
billion-contextregisters 6 tools into the same session (acp_cache,compress,search_context, and so on). - MCP case: on turn 9, an MCP server hot-loads 2 new tools and the harness appends a developer/message with
source: tool-registry. - Skill case: the client receives a notice like "available skill catalog changed", and the runtime context changes with it.
- That developer/message is persisted into
session.v4.jsonl.zstdand contains severaltool-additionblocks. - On the next turn the serializer converts it to the wire format, and the official endpoint rejects it at that position:
messages.N: tool_addition blocks can only be in system messages.
One counterexample is worth noting: on another community install, two sessions performed 116 and 12 mid-session tool additions with zero failures. The same action does not always reproduce, which means another layer interacting with in-history system messages decides the final shape.
DeepSeek Harness: After it happens: the block stays in history and is resent every turn
Once that developer/message is on disk it is part of session history; every later request re-serializes and sends it, so the failure is session-scoped and retrying cannot recover it.
- After the failure on turn 2, turns 3 and 4 fail identically even if you force input in — the session event log produces no new developer/message and no new request/header.
- The
messages.2,messages.28, ormessages.340in the error is a 0-based index into the request array; a stable index means the same history is sent every time. - Restarting the harness does nothing: the history is on disk, not in memory.
- The community conclusion: both serializers that can emit these blocks reject rather than relocate —
dsh-llm-pi-aithrowsUNSUPPORTED_CONTENTatlib/index.js:1215, anddsh-llm-deepseeklikewise rejects tool-change blocks outside developer messages.
So "change the role back to system" does not work: the harness has no path that downgrades the role on its own. The question is whether to send the block, not what shape to send it in.
DSH Plugin: Stop the bleeding: from easiest to most thorough
Ordered by cost: switch to a model that does not declare mid-conversation tool changes → start a new session → attach a routing plugin that re-projects the request.
- Switch the model: point the session at a model that does not declare
toolUpdate(the community useddeepseek-v4-pro), and the block is no longer sent, so the session is immediately usable. The old history is untouched; only the wire format changes for that turn. - Start a new session: the bluntest option, leaving the bad history on disk. Good when you have no spare model.
- Attach the community routing plugin:
dsh-tool-update-route-guard(v0.1.0, commit54ecf0e), installed withnpm install github:argszero/dsh-tool-update-route-guard. - Add a mount entry in the patch and list provider/model wildcards that cannot carry these blocks under
config.routes. - Start with
audit: trueso it only observes and does not modify requests; once the hit range is confirmed, turn auditing off. - For matched routes the plugin re-dispatches through the core's own unsupported-route projection, leaving persistent history alone while restoring the session; it can be removed at any time.
The plugin author is refreshingly restrained: it suppresses the symptom only on routes you name, and the real fix belongs in core — either resolve capability from the endpoint, or let the route drop the declaration after the endpoint rejects it.
DeepSeek Harness: The two trigger paths: DeepSeek vs Anthropic
The same 400 does not have exactly the same mechanism on the DeepSeek endpoint and the official Anthropic endpoint, so do not mix them up when diagnosing.
| Path | Capability source | Key difference |
|---|---|---|
| DeepSeek Messages | model entry declares toolUpdate: addition-only | the beta header is added by dsh-llm-deepseek when the body carries blocks |
| Official Anthropic endpoint | compat.supportsMidConvoToolChanges derives in-history | the pi-ai path reshapes only when every tool is deferred (lib/index.js:1949); mixed tool tables take another route |
The mixed tool table matters: not every request carries mid-conversation-tool-changes-2026-07-01, so some turns in the same session pass and others always fail, which looks random.
DSH plugin: How to tell whether the session is still recoverable
There is one test: switch the request to a model that does not declare mid-conversation tool changes and see whether the next turn passes. If it does, the history is fine and only the send format mismatched the endpoint.
- Copy the session directory first;
session.v4.jsonl.zstdis the basis for every later judgment. - In the UI, switch that session to a model entry without a
toolUpdatedeclaration. - Send the shortest possible message, such as "continue", and see whether the same 400 returns.
- If it passes, the session is recoverable; if it still fails with the same
messages.Nindex, the block was written somewhere else, so inspect the session history. - To stay on the original model, attach the routing plugin, repeat step 3, and turn on
audit: trueto compare requests.
The community used a capturing fetch for a byte-level comparison: the unprotected request had role: user, system plus a tool_addition block and the beta header, while the protected request had neither, with credential headers, user turns, and tool declarations still present. That is direct evidence the plugin changes only the send format, not the history.
Cautions
Treat the session as evidence, not as a scratch pad.
- Do not rely on retries. Every turn resends the same history; retries only add delay.
- Do not disable toolUpdate globally just to get around it. You may lose normal mid-session tool addition at the same time.
- Write down the
messages.Nindex. It stays put and is the strongest evidence that history is polluted. - A plugin mitigation is not a fix. Core is still unfixed, so re-verify behavior after upgrading.
- Back up the session directory first.
~/.dsh/sessions/.../session.v4.jsonl.zstdis the only evidence; copy it before cleaning anything.
If your error text is not tool_addition but UNSUPPORTED_CONTENT or another block type, that is a different problem in the same family — do not apply these conclusions to it.See DeepSeek Harness: session 400 when every tool is deferred for the related case.
FAQ
No, retrying will not help in DeepSeek Harness: those blocks are already written into the session history, so every request resends the same one; the error position is stable and each retry only gets a new request_id with the same result. Either switch to a model that does not declare mid-conversation tool changes, or start a new session.
Three actions commonly rewrite the tool table mid-session in DeepSeek Harness: a plugin registers tools into the same session after the first turn, an MCP server hot-loads new tools, or the skill catalog changes. The community reproduced it with a plugin adding 6 tools and an MCP server hot-loading 2, and manual Web UI actions do not prevent it.
DeepSeek Harness declares the capability on the model entry rather than on the endpoint, which is why the same session behaves differently across endpoints. The community checked dsh-llm-pi-ai and found conversationUpdates() derives toolUpdate: 'in-history' from compat.supportsMidConvoToolChanges; the projection reads only that declared value and never checks whether the endpoint accepts it.
No, changing the tool_addition role from user back to system does not repair the session, because both DeepSeek Harness serializers choose to reject rather than relocate: dsh-llm-pi-ai throws UNSUPPORTED_CONTENT at lib/index.js:1215, and dsh-llm-deepseek likewise rejects tool-change blocks outside developer messages. The real problem is the mismatch between declaration and endpoint.
It is not an official fix but a community plugin for DeepSeek Harness (v0.1.0, commit 54ecf0e), installed with npm install github:argszero/dsh-tool-update-route-guard. In the patch, list routes that cannot carry these blocks under config.routes and start with audit: true so it only reports without changing requests.
Related Terms
- tool_addition
- tool_addition is a content block in the Anthropic Messages protocol that represents tools added mid-conversation and may only appear in system-role messages. Once written into session history it is resent every turn, so an invalid position means a permanent 400.— https://github.com/deepseek-ai/deepseek-harness/discussions/10553
- toolUpdate
- toolUpdate is a capability declaration on a DeepSeek Harness model entry, with values such as addition-only or in-history, that decides whether mid-conversation tool changes go into session history. It describes a model capability, but whether anything is sent depends on the serializer and the endpoint.— https://github.com/deepseek-ai/deepseek-harness/discussions/10563
- supportsMidConvoToolChanges
- supportsMidConvoToolChanges is a boolean declaration under a model entry's compat section from which the harness derives whether toolUpdate is in-history. When true, the projection follows that value and no longer checks whether the target endpoint accepts the blocks.— https://github.com/deepseek-ai/deepseek-harness/discussions/10563
- mid-conversation-tool-changes-2026-07-01
- mid-conversation-tool-changes-2026-07-01 is Anthropic's beta request header that enables mid-conversation tool changes. `dsh-llm-deepseek` adds it when the request body carries such blocks, while requests on the pi-ai path include it depending on the shape of the tool table.— https://github.com/deepseek-ai/deepseek-harness/discussions/10563
Sources
- #10553 — Mid-conversation tool change places tool_addition blocks in a non-system message· deepseek-ai (GitHub Discussions)
- #10563 — tool_addition blocks can only be in system messages (permanent 400 on the official Anthropic endpoint)· deepseek-ai (GitHub Discussions)
- deepseek-ai/deepseek-harness (source repository)· GitHub