fix(agent): recover DeepSeek reasoning-only final answers

This commit is contained in:
Lancy committed 2026-11-02 09:11:50 +00:00
1 parent c43bd2bdc3
commit 860651f866
9 files changed
+104 -157

No files matched your search

+14
View File
@@ -6,6 +6,20 @@ chain-of-thought is requested on the wire. The `openai` provider adapts the
request shape per backend; this table is the reference for which protocol each
known backend uses and which parameters it honours or ignores.
## Reasoning without an answer
Reasoning text is not a final answer. If a response has no tool calls and its
answer content is empty or whitespace-only, Reasonix asks the model for a visible
answer and shows a retry notice.
This also applies to DeepSeek thinking responses with `finish_reason="stop"`:
a normal generation stop does not establish that an answer was delivered.
Recovery permits two additional model rounds. Three consecutive empty final
responses end the run with an error instead of silently succeeding. An output
limit (`finish_reason="length"`) retains its truncation notice and cause in the
recovery request. Reasoning remains separate from answer content.
## Auto-detected backends
These are recognised by base URL (see `internal/model/openai/host.go`) and
-22
View File
@@ -1123,28 +1123,6 @@ func hasVisibleFinalAnswer(text string) bool {
return strings.TrimSpace(text) != ""
}
// reasoningOnlyFinishHonoured reports whether the model finished with a stop
// signal but placed its answer in the reasoning stream rather than the content
// block. DeepSeek thinking mode does this occasionally: it streams a long
// reasoning_content, then returns finish_reason="stop" with an empty content.
// The model has signalled completion, so the host accepts the turn instead of
// retrying and forcing another expensive thinking round.
//
// The accept is scoped to DeepSeek thinking mode (ToolCallReasoningPolicy):
// for other providers a reasoning-only turn keeps the empty-final retry
// safety net — local <think>-tag models often recover a visible answer on
// the second attempt, and a gateway that mislabels truncation as "stop"
// must not have a degenerate turn committed as the final answer.
func reasoningOnlyFinishHonoured(p provider.Provider, u *provider.Usage, reasoning string) bool {
if !provider.RequiresToolCallReasoning(p) {
return false
}
if u == nil || u.FinishReason != "stop" {
return false
}
return strings.TrimSpace(reasoning) != ""
}
func emptyFinalNotice() string {
return "No visible answer was produced; asking the assistant to respond again."
}
-2
View File
@@ -73,8 +73,6 @@ type Options struct {
// unless a caller says otherwise, so a two-model stream says which model
// wrote each one rather than leaving it to be inferred.
EventSource string
// RequireVisibleFinal makes internal callers reject reasoning-only responses.
RequireVisibleFinal bool
// Gate is the per-call permission gate. nil disables gating.
Gate Gate
// CheckTargetAccess applies workspace path policy before hooks or previews.
+3 -7
View File
@@ -2,15 +2,12 @@
package agent
// agentRole is decided when the agent is built and does not change afterwards:
// a planner that may not execute, an executor told to reach for its tools, a
// caller that needs visible final text. That fixed lifetime is what separates
// these from planMode and the language settings beside them, which the user
// moves between turns.
// a planner that may not execute or an executor told to reach for its tools.
// That fixed lifetime separates these from planMode and the language settings
// beside them, which the user moves between turns.
type agentRole struct {
// executorHandoff is set by Coordinator for the executor agent alone.
executorHandoff bool
// requireVisibleFinal is set by internal callers that need final Content.
requireVisibleFinal bool
// readOnlyExecution is a construction-time defense for planner/research
// agents. Unlike planMode it is not a collaboration toggle: it stays on for
// the agent's lifetime and validates proxy calls after resolution.
@@ -27,7 +24,6 @@ type agentRole struct {
func newAgentRole(opts Options) agentRole {
return agentRole{
requireVisibleFinal: opts.RequireVisibleFinal,
readOnlyExecution: opts.ReadOnlyExecution,
plannerMCPExecution: opts.PlannerMCPExecution,
waiveOwedChecks: opts.WaiveOwedChecks,
-6
View File
@@ -72,9 +72,6 @@ func RunSubAgentWithSession(ctx context.Context, prov provider.Provider, reg *to
if kind := opts.RequireReviewReportKind; kind != "" {
prompt = prompt + "\n\n" + reviewReportTaskContract(kind)
}
// Nested reasoning stays isolated; the parent consumes only final Content.
// Require it so a reasoning-only stop cannot fall back to older tool text.
opts.RequireVisibleFinal = true
ctx = WithoutRunTimeSink(ctx)
sub := newDelegatedAgent(ctx, prov, reg, sess, opts, sink)
// One defer: this returns from a salvage, a review failure, a provider
@@ -151,9 +148,6 @@ func NewPlannerAgent(prov provider.Provider, reg *tool.Registry, sess *sessionst
opts.EventSource = event.UsageSourcePlanner
opts.ReadOnlyExecution = true
opts.PlannerMCPExecution = true
// The coordinator needs visible plan text to hand off to the executor;
// reasoning shown in a frontend is not a substitute for that contract.
opts.RequireVisibleFinal = true
// Keep construction-time filter for ordinary tools; use_capability stays
// because it is ReadOnly. Direct mcp__* tools are already excluded by
// PlannerToolRegistry. Dynamic MCP targets are re-checked after resolve.
+65 -81
View File
@@ -3,6 +3,7 @@ package agent
import (
"context"
"errors"
"fmt"
"reasonix/internal/state/sessionstore"
"strings"
"testing"
@@ -99,73 +100,49 @@ func lastAssistantContent(s *sessionstore.Session) string {
return out
}
// deepseekThinkingProvider marks a scripted provider as DeepSeek thinking mode
// (provider.ToolCallReasoningPolicy) — the scope within which a reasoning-only
// finish_reason="stop" turn is accepted as a final answer.
type deepseekThinkingProvider struct{ *scriptedProvider }
func (deepseekThinkingProvider) RequiresToolCallReasoning() bool { return true }
func TestRunAcceptsReasoningOnlyFinalWhenModelStopped(t *testing.T) {
// DeepSeek thinking mode streams a long reasoning_content and then
// finishes with finish_reason="stop" but an empty content block. The
// model has explicitly signalled completion and its reasoning was
// streamed to the user, so the host must accept the turn instead of
// retrying and forcing another expensive thinking round.
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{
{Type: provider.ChunkReasoning, Text: "The user asked a simple question; I have reasoned through it and the answer is ready."},
{Type: provider.ChunkUsage, Usage: &provider.Usage{FinishReason: "stop", TotalTokens: 10}},
{Type: provider.ChunkDone},
},
}}
a := New(deepseekThinkingProvider{prov}, tool.NewRegistry(), sessionstore.NewSession(""), Options{}, event.Discard)
if err := a.Run(context.Background(), "answer me"); err != nil {
t.Fatalf("Run: %v", err)
}
if prov.call != 1 {
t.Fatalf("provider calls = %d, want 1 (model signalled stop; no retry)", prov.call)
}
if sessionHasUserMessageContaining(a.sess.conversation, "visible answer") {
t.Fatal("must not inject a synthetic visible-answer retry when the model signalled stop")
}
if got := lastAssistantContent(a.sess.conversation); got != "" {
t.Fatalf("last assistant content = %q, want empty (answer lived in reasoning)", got)
}
}
func TestRunRetriesReasoningOnlyStopWithoutDeepSeekPolicy(t *testing.T) {
// Same chunk sequence as the accept test, but the provider does not
// declare DeepSeek thinking mode (ToolCallReasoningPolicy). The accept
// path must stay scoped to DeepSeek: local <think>-tag models keep the
// retry safety net that often recovers a visible answer on the second
// attempt, and a gateway that mislabels truncation as "stop" must not
// have a degenerate turn committed as the final answer.
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
{
{Type: provider.ChunkReasoning, Text: "thinking only, nothing visible"},
{Type: provider.ChunkUsage, Usage: &provider.Usage{FinishReason: "stop", TotalTokens: 10}},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "visible reply"},
{Type: provider.ChunkDone},
},
}}
a := New(prov, tool.NewRegistry(), sessionstore.NewSession(""), Options{}, event.Discard)
if err := a.Run(context.Background(), "answer me"); err != nil {
t.Fatalf("Run: %v", err)
}
if prov.call != 2 {
t.Fatalf("provider calls = %d, want 2 (non-DeepSeek providers must keep the retry)", prov.call)
}
if !sessionHasUserMessageContaining(a.sess.conversation, "visible answer") {
t.Fatal("missing synthetic visible-answer retry message for non-DeepSeek provider")
}
if got := lastAssistantContent(a.sess.conversation); got != "visible reply" {
t.Fatalf("last assistant content = %q, want visible reply", got)
func TestRunRequiresVisibleFinalAfterStop(t *testing.T) {
for _, deepseek := range []bool{false, true} {
for _, text := range []string{"", " \n\t", "visible reply"} {
t.Run(fmt.Sprintf("deepseek=%t/text=%q", deepseek, text), func(t *testing.T) {
first := append([]provider.Chunk{{Type: provider.ChunkText, Text: text}}, reasoningOnlyStop("The answer is ready.")...)
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
first,
{{Type: provider.ChunkText, Text: "recovered reply"}, {Type: provider.ChunkDone}},
}}
var p provider.Provider = prov
if deepseek {
p = deepseekThinkingProvider{prov}
}
sink := &recordSink{}
a := New(p, tool.NewRegistry(), sessionstore.NewSession(""), Options{}, sink)
if err := a.Run(context.Background(), "answer me"); err != nil {
t.Fatalf("Run: %v", err)
}
wantCalls, wantText, wantNotices := 2, "recovered reply", 1
if strings.TrimSpace(text) != "" {
wantCalls, wantText, wantNotices = 1, text, 0
}
if prov.call != wantCalls {
t.Fatalf("provider calls = %d, want %d", prov.call, wantCalls)
}
if got := lastAssistantContent(a.sess.conversation); got != wantText {
t.Fatalf("last assistant content = %q, want %q", got, wantText)
}
notices := 0
for _, e := range sink.kinds(event.Notice) {
if e.Code == event.NoticeCodeEmptyFinal {
notices++
}
}
if notices != wantNotices {
t.Fatalf("empty-answer notices = %d, want %d", notices, wantNotices)
}
})
}
}
}
@@ -241,24 +218,31 @@ func TestRunRetriesReasoningOnlyStopAfterToolRoundOnce(t *testing.T) {
}
}
func TestRunAcceptsSecondReasoningOnlyStopAfterToolRound(t *testing.T) {
prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
readFileToolRound(),
reasoningOnlyStop("done thinking"),
reasoningOnlyStop("still only thinking"),
}}
reg := tool.NewRegistry()
reg.Add(fakeReadFileTool{})
a := New(deepseekThinkingProvider{prov}, reg, sessionstore.NewSession(""), Options{}, event.Discard)
if err := a.Run(context.Background(), "what does the file say"); err != nil {
t.Fatalf("Run: %v", err)
}
if prov.call != 3 {
t.Fatalf("provider calls = %d, want a single bounded retry", prov.call)
}
if got := countUserMessagesContaining(a.sess.conversation, "visible answer"); got != 1 {
t.Fatalf("visible-answer retries = %d, want exactly 1", got)
func TestRunRejectsRepeatedDeepSeekReasoningOnlyStops(t *testing.T) {
for _, afterTool := range []bool{false, true} {
t.Run(fmt.Sprintf("afterTool=%t", afterTool), func(t *testing.T) {
prov := &scriptedProvider{name: "p"}
reg := tool.NewRegistry()
wantCalls := 3
if afterTool {
prov.turns = append(prov.turns, readFileToolRound())
reg.Add(fakeReadFileTool{})
wantCalls++
}
prov.turns = append(prov.turns, reasoningOnlyStop("first"), reasoningOnlyStop("second"), reasoningOnlyStop("third"))
a := New(deepseekThinkingProvider{prov}, reg, sessionstore.NewSession(""), Options{}, event.Discard)
err := a.Run(context.Background(), "answer me")
var empty *EmptyFinalError
if !errors.As(err, &empty) {
t.Fatalf("error = %v, want *EmptyFinalError", err)
}
if empty.Blocks != 3 || empty.FinishReason != "stop" {
t.Fatalf("EmptyFinalError = %+v, want 3 blocks ending on stop", empty)
}
if prov.call != wantCalls {
t.Fatalf("provider calls = %d, want %d", prov.call, wantCalls)
}
})
}
}
+6 -32
View File
@@ -7,7 +7,6 @@ import (
"math/rand/v2"
"reasonix/internal/contract/hostaudit"
"reasonix/internal/state/sessionstore"
"slices"
"strings"
"sync/atomic"
"time"
@@ -620,19 +619,13 @@ func (a *Agent) handleFinalResponse(ctx context.Context, state *turnRuntime, tex
// is reported here, where it is still the outcome of this turn.
a.reportReviewWarnings()
if !hasVisibleFinalAnswer(text) {
// A DeepSeek reasoning-only stop is honoured: retrying it forces another
// thinking round after the task is done. Tool results with no visible
// synthesis after them are the exception and get exactly one retry.
owedSynthesis := state.usedAnyTool && state.emptyFinalBlocks == 0 && silentSinceLastToolRound(a.sess.conversation.Messages)
if a.role.requireVisibleFinal || owedSynthesis || !reasoningOnlyFinishHonoured(a.svc.prov, usage, reasoning) {
state.emptyFinalBlocks++
if state.emptyFinalBlocks >= maxEmptyFinalBlocks {
return false, &EmptyFinalError{Blocks: state.emptyFinalBlocks, FinishReason: finishReasonOf(usage)}
}
a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeEmptyFinal, Text: emptyFinalNotice(), Detail: emptyFinalNoticeDetail(a.svc.prov.Name(), usage, len(reasoning))})
a.sess.conversation.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(emptyFinalRetryMessage(usage)), HostAuthored: true})
return true, nil
state.emptyFinalBlocks++
if state.emptyFinalBlocks >= maxEmptyFinalBlocks {
return false, &EmptyFinalError{Blocks: state.emptyFinalBlocks, FinishReason: finishReasonOf(usage)}
}
a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeEmptyFinal, Text: emptyFinalNotice(), Detail: emptyFinalNoticeDetail(a.svc.prov.Name(), usage, len(reasoning))})
a.sess.conversation.Add(provider.Message{Role: provider.RoleUser, Content: a.withTurnPreferences(emptyFinalRetryMessage(usage)), HostAuthored: true})
return true, nil
}
if readiness.applies {
event.RecordReadinessAudit(a.svc.sink, readiness.audit(hostaudit.ReadinessAllowed, a.turn.readinessRecovered))
@@ -647,25 +640,6 @@ func (a *Agent) handleFinalResponse(ctx context.Context, state *turnRuntime, tex
return false, nil // model gave a final answer
}
// silentSinceLastToolRound reports whether the transcript holds a tool result
// with no visible assistant text after it.
func silentSinceLastToolRound(messages []provider.Message) bool {
for _, message := range slices.Backward(messages) {
if message.LocalOnly {
continue
}
switch message.Role {
case provider.RoleTool:
return true
case provider.RoleAssistant:
if hasVisibleFinalAnswer(message.Content) {
return false
}
}
}
return false
}
// handleToolRound executes a tool batch, persists tool messages, handles
// cancellation, todo stall tracking, recovery finalization pause, and the
// max-steps grace round. cont=true continues the tool loop; cont=false returns
+6 -7
View File
@@ -78,13 +78,12 @@ func NewSession(prov provider.Provider, readOnlyReg *tool.Registry, hooks agent.
}
sess := sessionstore.NewSession(policyPrompt)
ag := agent.New(prov, readOnlyReg, sess, agent.Options{
ModelRef: strings.TrimSpace(modelRef),
MaxSteps: 6, // guardian reviews: enough for a few read-only tool calls
Temperature: temperature,
RequireVisibleFinal: true, // each review must produce its own parseable verdict
Hooks: hooks,
CheckTargetAccess: checkTargetAccess,
ReadOnlyExecution: true,
ModelRef: strings.TrimSpace(modelRef),
MaxSteps: 6, // guardian reviews: enough for a few read-only tool calls
Temperature: temperature,
Hooks: hooks,
CheckTargetAccess: checkTargetAccess,
ReadOnlyExecution: true,
// Use the shared context window so the guardian session can compact
// itself when it grows too large across many reviews.
ContextWindow: 100_000,
@@ -0,0 +1,10 @@
## 修复
- DeepSeek 只输出思考而没有答案时,现在会提示并最多重试两次;仍无答案则明确报错,不再静默结束。关联 #14940。
---
<!-- lang:en -->
## Fixed
- DeepSeek replies with reasoning but no answer now show a notice and retry up to twice, then report an error instead of silently finishing. #14940