mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
fix(agent): recover DeepSeek reasoning-only final answers
This commit is contained in:
1 parent
c43bd2bdc3
commit
860651f866
9 files changed
+104
-157
No files matched your search
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user