ChatGPTClaudeGrok / X / CursorGemini

DeepSeek Harness tool_addition 400: mid-conversation fix

TroubleshootingPublished 2026-10-30Author: DeepSeek Plugin Market
DeepSeek HarnessDSHtool_addition400INVALID_REQUESTAnthropicMCP
A mid-session tool change makes DeepSeek Harness return 400 INVALID_REQUEST: tool_addition blocks can only be in system messages. Timeline plus three ways out.

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.

  1. Open the model entry the session is using and look for toolUpdate: "addition-only" or compat.supportsMidConvoToolChanges.
  2. Note this: conversationUpdates() in dsh-llm-pi-ai (lib/index.js:789) uses it to derive toolUpdate: 'in-history'.
  3. The consumer is projectToolUpdates (packages/llm/llm/src/content.ts:424), which follows the declaration and does not probe endpoint capabilities.
  4. 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.

  1. Plugin case: after the first turn, a plugin such as billion-context registers 6 tools into the same session (acp_cache, compress, search_context, and so on).
  2. MCP case: on turn 9, an MCP server hot-loads 2 new tools and the harness appends a developer/message with source: tool-registry.
  3. Skill case: the client receives a notice like "available skill catalog changed", and the runtime context changes with it.
  4. That developer/message is persisted into session.v4.jsonl.zstd and contains several tool-addition blocks.
  5. 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.

  1. 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.
  2. The messages.2, messages.28, or messages.340 in the error is a 0-based index into the request array; a stable index means the same history is sent every time.
  3. Restarting the harness does nothing: the history is on disk, not in memory.
  4. The community conclusion: both serializers that can emit these blocks 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.

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.

  1. Switch the model: point the session at a model that does not declare toolUpdate (the community used deepseek-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.
  2. Start a new session: the bluntest option, leaving the bad history on disk. Good when you have no spare model.
  3. Attach the community routing plugin: dsh-tool-update-route-guard (v0.1.0, commit 54ecf0e), installed with npm install github:argszero/dsh-tool-update-route-guard.
  4. Add a mount entry in the patch and list provider/model wildcards that cannot carry these blocks under config.routes.
  5. Start with audit: true so it only observes and does not modify requests; once the hit range is confirmed, turn auditing off.
  6. 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.

PathCapability sourceKey difference
DeepSeek Messagesmodel entry declares toolUpdate: addition-onlythe beta header is added by dsh-llm-deepseek when the body carries blocks
Official Anthropic endpointcompat.supportsMidConvoToolChanges derives in-historythe 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.

  1. Copy the session directory first; session.v4.jsonl.zstd is the basis for every later judgment.
  2. In the UI, switch that session to a model entry without a toolUpdate declaration.
  3. Send the shortest possible message, such as "continue", and see whether the same 400 returns.
  4. If it passes, the session is recoverable; if it still fails with the same messages.N index, the block was written somewhere else, so inspect the session history.
  5. To stay on the original model, attach the routing plugin, repeat step 3, and turn on audit: true to 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.

  1. Do not rely on retries. Every turn resends the same history; retries only add delay.
  2. Do not disable toolUpdate globally just to get around it. You may lose normal mid-session tool addition at the same time.
  3. Write down the messages.N index. It stays put and is the strongest evidence that history is polluted.
  4. A plugin mitigation is not a fix. Core is still unfixed, so re-verify behavior after upgrading.
  5. Back up the session directory first. ~/.dsh/sessions/.../session.v4.jsonl.zstd is 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.

ChatGPTClaudeGrok / X / CursorGemini

FAQ

DeepSeek Harness returns 400 INVALID_REQUEST saying tool_addition blocks can only be in system messages — will retrying help?

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.

Which actions change the tool table mid-session in DeepSeek Harness and trigger this 400?

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.

Why does the same DeepSeek Harness session work on a self-hosted Anthropic-compatible endpoint but return 400 on the official endpoint?

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.

Can changing the tool_addition role from user back to system repair a DeepSeek Harness session?

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.

How do I use the dsh-tool-update-route-guard plugin in DeepSeek Harness, and is it an official fix?

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