DeepSeek Harness turn completed after reasoning-only step
If the model's last response contains only reasoning, finish.reason is stop, the interface shows neither an answer nor a tool call, and yet the turn reports completed, the model did not decide it was done; the decision chain treated "no answer" as "task complete". According to Discussion #9990, such turns go idle silently, and the user must send another message to move them forward. The sections below cover the decision chain, three handling paths, and a deterministic reproduction.
What DeepSeek Harness shows when the final step is reasoning-only
Three features appearing together identify this case rather than an ordinary model pause. Check the symptom first, then the session record:
- The turn ends as
completed. Expected: not an error and notaborted. - The last assistant message contains only a
reasoningblock. Expected: notextand notool-call. - The final answer is empty while the task is unfinished. Expected: a file was not created or a command never ran.
- The user sends "continue". Expected: the model usually finishes the work, which shows that capability and context are present and only the finalization decision is missing.
DeepSeek Harness call chain: two checks turn "no answer" into "task complete"
According to the community's source review in the thread, the problem is not one adapter but the combination of "the adapter's empty check is too narrow" and "the loop never looks at content". For the overall loop structure, see the DeepSeek Harness turn walkthrough; two steps explain the failure:
llm-pi-ai'smapStopReasonreturnsEMPTY_RESPONSEonly whenmessage.content.length === 0. Expected: a response holding only a reasoning block has a non-zero length, so it passes as a normalstop.- At step finalization in
agent.ts,agent-loopfilters fortool-calland returns{ kind: 'completed' }when the count is zero. Expected: it never checks for visible text. - As a result, "no output, no action" and "the model really finished" are indistinguishable in state. Expected: the retry mechanism is never triggered.
DSH plugin fixes: change the check, steer, or retry
All three paths stop an empty answer from counting as completion; they differ in whether you change the provider route, a plugin event, or the frontend. Fix the current provider with path 1, avoid core changes with path 2, and use path 3 as a temporary backstop.
Path 1: fold reasoning-only stops into EMPTY_RESPONSE
This is the smallest change and its scope is limited to the pi-ai route: treat "no text and no tool call" as an empty response and reuse the existing retry policy.
- In
packages/llm/llm-pi-ai/src/stream.ts, findcase "stop"insidemapStopReason. Expected: the stop branch is located. - Change the empty check from
content.length === 0to "no text block and no tool-call block". Expected: reasoning blocks no longer count as visible content. - Keep the existing
EMPTY_RESPONSE_CODEand failure structure. Expected: the upper retry policy takes over unchanged. - Rerun the same reasoning-only response. Expected: it is marked as a retryable error instead of a normal stop.
case "stop": {
const visible = message.content.some(
(b) => (b.type === "text" && b.text.trim() !== "") || b.type === "tool-call",
)
if (!visible) {
return { kind: "error", failure: { code: EMPTY_RESPONSE_CODE } }
}
return { kind: "stop" }
}
Path 2: use a turn-stopping plugin to steer the agent
If you do not want to change core, hook agent/turn-stopping and inject a "continue" reminder before the turn actually finalizes. According to the discussion, that event is dispatched on the finalization path in agent.ts, so a plugin can see the last message and call agent.steer.
- Create a local plugin and register an
agent/turn-stoppinglistener. Expected: the listener is called during turn finalization. - Read the last assistant message and check for no
tool-calland no non-emptytext. Expected: the plugin can classify the final message. - On a match, call
agent.steerwith thestall-guardsource and thenoticeform. Expected: a reminder is injected with the intended source and form. - Cap continues at two per turn with a counter. Expected: the loop stops after two steers instead of spinning.
- Rerun with the same failing proxy. Expected: the run continues from step one to step five, creates the file, and produces a normal final answer.
// Logic sketch; confirm event payload fields against the local dsh-agent-loop types.
if (!hasToolCall && !hasVisibleText && steered < 2) {
steered += 1
await agent.steer("The previous step produced no output and no action. Continue the task.", {
source: { kind: "stall-guard" },
form: "notice",
})
}
Path 3: make the frontend surface the empty answer
If the runtime cannot change yet, at least turn the silent failure into something visible and clickable. This is a visibility patch, not a root fix:
- When a turn ends, check both the end reason and the final text. Expected: both values are available when rendering.
- When the reason is
completedand the final text is empty, do not render it as a normal completion. Expected: the empty turn is not shown as success. - Show a clear message and a retry entry. Expected: the user sees an actionable prompt.
- When the user clicks, start the next turn as a new message. Expected: the model generates again instead of staying on an empty answer, while the stored state remains completed.
How to reproduce the DeepSeek Harness empty turn with a proxy
Verifying the three paths requires a stable reproduction, not waiting for the model to make a rare mistake. The discussion gives a deterministic method:
- Put a small proxy in front of an OpenAI-Responses endpoint and have it handle only the first
/v1/responsesrequest. Expected: only the first request is modified. - Remove
messageandfunction_callitems plusoutput_textandcontent_partevents from the SSE stream, keeping only reasoning. Expected: the stream carries reasoning only. - Point a provider configured with
api: openai-responsesat the proxy and use a model with reasoning. Expected: requests reach the proxy. - Send a task such as "create a.txt and report completion". Expected: one step,
turn_endiscompleted, the final text is empty, and the file was not created. - Apply any one of the fixes and rerun. Expected: the step count and the file result change.
Boundaries and verification for the DeepSeek Harness empty turn
Not every empty answer deserves a continue; the check must require "final step, no text, no action" together. Otherwise normal finalization turns into a loop:
- A final step with short text is a normal answer. Expected: no continue is triggered.
- A final step with a tool call is still executing. Expected: the tool result drives the next turn.
- A turn ended by an explicit user interruption. Expected: a plugin steer does not pull it back.
- A turn that already continued twice with no output. Expected: stop and expose the problem instead of adding attempts.
- Leaked tool-call markers inside the reasoning. Expected: treat it as a different adapter problem, not something steering can mask.
Source: Discussion #9990.
FAQ
DeepSeek Harness checks only two conditions and neither looks at visible text. According to Discussion #9990, llm-pi-ai's mapStopReason returns EMPTY_RESPONSE only when content.length === 0; a content array with a reasoning block returns a normal stop, and agent-loop decides to finish solely on the presence of a tool call. The empty answer is therefore recorded as completed.
In DeepSeek Harness it will not, because the change targets the case with no visible text and no tool call, not every step containing reasoning. A normal step with a final text block or a tool-call block still takes the stop path; only a step that thinks and then neither writes nor acts enters the existing retryable error path.
The DeepSeek Harness turn-stopping plugin checks the last assistant message in the agent/turn-stopping event; without a tool call and non-empty text, it calls agent.steer with a reminder to continue. According to the implementation described in the thread, the reminder uses the stall-guard source and the notice form, at most twice per turn; with the same failing proxy the run reached step five and completed the file creation.
In DeepSeek Harness it can, but that is a visibility fix and does not continue the current turn automatically. When the final text is empty and the turn ended as completed, the frontend can show a retry entry; one click starts a new turn as a new message. It does not replace the loop's content check, only turns a silent failure into an actionable state.
Related Terms
- EMPTY_RESPONSE
- EMPTY_RESPONSE is DSH's retryable error code for a step in which the model produced no content. The current implementation raises it only when the content array length is zero, so a response containing only reasoning blocks never enters that branch.— https://github.com/deepseek-ai/deepseek-harness/discussions/9990
- turn
- A turn is one complete execution in the DSH agent loop, from receiving a user message to deciding the end. The end reason is determined by whether the last step produced a tool call; completed means the loop believes the task is finished.— https://github.com/deepseek-ai/deepseek-harness/discussions/9990
- reasoning block
- A reasoning block is a thinking fragment in the model response that is not shown directly to the user. It can constitute a step on its own, so a non-empty content array does not mean the user saw an answer.— https://github.com/deepseek-ai/deepseek-harness/discussions/9990
- steer
- Steer is the mechanism that lets a DSH plugin inject a reminder and continue execution before a turn ends. The turn-stopping event is dispatched on the turn finalization path in `agent.ts`, so a plugin can intervene before completed is decided.— https://github.com/deepseek-ai/deepseek-harness/discussions/9990
Sources
- deepseek-harness Discussion #9990: turn silently ends as completed when the final step contains only reasoning blocks· deepseek-ai (GitHub Discussions)