fix(world/analyst): recover reasoning-model answers so chat stops returning "empty response"
The analyst rendered "The analyst couldn't answer — empty response" whenever the
served model streamed its answer on the reasoning channel with an EMPTY content
channel — gpt-oss / harmony, deepseek-r1 and kin. The ONE completion path
(chatMessagesModel + chatMessagesModelStream) read only the content channel, so a
reasoning model's output was dropped and surfaced as a blank.
- Read the reasoning channel (message.reasoning_content / reasoning, and the SSE
delta.reasoning_content / reasoning) as a fallback when content is empty, and
forward reasoning deltas live so the replyExtractor shows them on the thinking line.
Content is always preferred; non-reasoning models (best/zen5) are unaffected.
- Replace the opaque "empty response" with an honest, model-named error
("the <model> model returned an empty answer"): never misreads a normal
completion's response id as an error code, never trips the 402 top-up CTA.
Tests: reasoning-channel recovery (buffered + SSE) and the honest empty-answer error.
Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
This commit is contained in:
+35
-3
@@ -121,7 +121,13 @@ type chatResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
// A reasoning model (gpt-oss / harmony, deepseek-r1) surfaces its working
|
||||
// on a SEPARATE channel; when it emits no final content we fall back to
|
||||
// this so the analyst still recovers an answer, not a blank "empty response".
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
} `json:"message"`
|
||||
FinishReason string `json:"finish_reason"`
|
||||
} `json:"choices"`
|
||||
Usage struct {
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
@@ -203,10 +209,36 @@ func (a *AIClient) chatMessagesModel(ctx context.Context, s *Server, bearer, mod
|
||||
logf("world: ai 2xx empty-choices error: status=%d code=%q model=%s", status, e.code, model)
|
||||
return "", status, "", e
|
||||
}
|
||||
logf("world: ai 2xx empty response: status=%d model=%s", status, model)
|
||||
return "", status, "", fmt.Errorf("empty response")
|
||||
logf("world: ai 2xx no choices: status=%d model=%s", status, model)
|
||||
return "", status, "", emptyAnswerErr(model)
|
||||
}
|
||||
return strings.TrimSpace(cr.Choices[0].Message.Content), cr.Usage.TotalTokens, cr.ID, nil
|
||||
// Prefer the answer (content) channel; fall back to the reasoning channel a
|
||||
// reasoning model (gpt-oss/harmony, deepseek-r1) uses when it returns no content —
|
||||
// so the analyst recovers its envelope/prose instead of a blank "empty response".
|
||||
msg := cr.Choices[0].Message
|
||||
// Pre-trim so a whitespace-only content channel falls through to reasoning.
|
||||
out := firstNonEmpty(strings.TrimSpace(msg.Content), strings.TrimSpace(msg.ReasoningContent), strings.TrimSpace(msg.Reasoning))
|
||||
if out == "" {
|
||||
// A 2xx with a real but content-free choice (e.g. a reasoning model that hit the
|
||||
// token cap before emitting content). The body IS a normal completion here — do
|
||||
// NOT run it through newAIError (its top-level `id` would be misread as an error
|
||||
// code); surface the model + stop reason honestly instead.
|
||||
logf("world: ai 2xx empty answer: status=%d model=%s finish=%s", status, model, cr.Choices[0].FinishReason)
|
||||
return "", status, cr.ID, emptyAnswerErr(model)
|
||||
}
|
||||
return out, cr.Usage.TotalTokens, cr.ID, nil
|
||||
}
|
||||
|
||||
// emptyAnswerErr is the honest error for a 2xx completion that carried no usable
|
||||
// answer (neither a content nor a reasoning channel). It names the served model so
|
||||
// the chat surfaces "the <model> model returned an empty answer" — actionable (switch
|
||||
// model) — instead of an opaque "empty response". Not a balance error, so it never
|
||||
// trips the top-up CTA.
|
||||
func emptyAnswerErr(model string) error {
|
||||
if model = strings.TrimSpace(model); model != "" {
|
||||
return fmt.Errorf("the %s model returned an empty answer", model)
|
||||
}
|
||||
return fmt.Errorf("the model returned an empty answer")
|
||||
}
|
||||
|
||||
// aiError is a typed inference error carrying the upstream HTTP status plus the
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -81,16 +80,21 @@ func (a *AIClient) chatMessagesModelStream(ctx context.Context, s *Server, beare
|
||||
return "", resp.StatusCode, "", err
|
||||
}
|
||||
if len(cr.Choices) == 0 {
|
||||
return "", resp.StatusCode, "", fmt.Errorf("empty response")
|
||||
return "", resp.StatusCode, "", emptyAnswerErr(model)
|
||||
}
|
||||
out := strings.TrimSpace(cr.Choices[0].Message.Content)
|
||||
if onDelta != nil && out != "" {
|
||||
msg := cr.Choices[0].Message
|
||||
out := firstNonEmpty(strings.TrimSpace(msg.Content), strings.TrimSpace(msg.ReasoningContent), strings.TrimSpace(msg.Reasoning))
|
||||
if out == "" {
|
||||
return "", resp.StatusCode, cr.ID, emptyAnswerErr(model)
|
||||
}
|
||||
if onDelta != nil {
|
||||
onDelta(out)
|
||||
}
|
||||
return out, cr.Usage.TotalTokens, cr.ID, nil
|
||||
}
|
||||
|
||||
var full strings.Builder
|
||||
var reasoning strings.Builder
|
||||
tokens := 0
|
||||
id := ""
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
@@ -108,7 +112,9 @@ func (a *AIClient) chatMessagesModelStream(ctx context.Context, s *Server, beare
|
||||
ID string `json:"id"`
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
} `json:"delta"`
|
||||
} `json:"choices"`
|
||||
Usage *struct {
|
||||
@@ -125,6 +131,20 @@ func (a *AIClient) chatMessagesModelStream(ctx context.Context, s *Server, beare
|
||||
tokens = frame.Usage.TotalTokens
|
||||
}
|
||||
for _, c := range frame.Choices {
|
||||
// Reasoning deltas (gpt-oss/harmony, deepseek-r1) ride a separate channel.
|
||||
// Forward them too — the replyExtractor routes this pre-envelope prose to the
|
||||
// live "thinking" line — and keep them as the answer fallback for a model that
|
||||
// never emits a content channel. (Not trimmed: preserve inter-chunk spacing.)
|
||||
rc := c.Delta.ReasoningContent
|
||||
if rc == "" {
|
||||
rc = c.Delta.Reasoning
|
||||
}
|
||||
if rc != "" {
|
||||
reasoning.WriteString(rc)
|
||||
if onDelta != nil {
|
||||
onDelta(rc)
|
||||
}
|
||||
}
|
||||
if c.Delta.Content != "" {
|
||||
full.WriteString(c.Delta.Content)
|
||||
if onDelta != nil {
|
||||
@@ -135,11 +155,17 @@ func (a *AIClient) chatMessagesModelStream(ctx context.Context, s *Server, beare
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
// A mid-stream drop still yields whatever arrived; the caller decides.
|
||||
if full.Len() == 0 {
|
||||
if full.Len() == 0 && reasoning.Len() == 0 {
|
||||
return "", resp.StatusCode, "", err
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(full.String()), tokens, id, nil
|
||||
// The content channel is the answer; fall back to the reasoning channel only when
|
||||
// the model emitted no content at all (mirrors the buffered path above).
|
||||
out := strings.TrimSpace(full.String())
|
||||
if out == "" {
|
||||
out = strings.TrimSpace(reasoning.String())
|
||||
}
|
||||
return out, tokens, id, nil
|
||||
}
|
||||
|
||||
// ── incremental "reply" extraction ───────────────────────────────────────────
|
||||
|
||||
@@ -304,7 +304,7 @@ func (s *Server) runAnalystLoop(ctx context.Context, bearer, model string, full
|
||||
}
|
||||
if err != nil || out == "" {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("empty response")
|
||||
err = emptyAnswerErr(firstNonEmpty(model, s.ai.model))
|
||||
}
|
||||
return "", nil, traces, tokens, id, err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -308,6 +309,149 @@ func readAllString(r *http.Request) (string, error) {
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
// writeChatMessage writes an OpenAI-shaped completion whose single choice carries
|
||||
// the given assistant message object verbatim — used to model a reasoning model that
|
||||
// leaves `content` empty and puts its answer on `reasoning_content`.
|
||||
func writeChatMessage(w http.ResponseWriter, message map[string]any) {
|
||||
resp := map[string]any{
|
||||
"id": "resp_test",
|
||||
"choices": []any{map[string]any{"message": message}},
|
||||
"usage": map[string]any{"total_tokens": 7},
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// postAnalyst drives the non-streaming analyst path with a signed-in bearer and
|
||||
// returns the decoded response envelope.
|
||||
func postAnalyst(t *testing.T, ts *httptest.Server, prompt string) map[string]any {
|
||||
t.Helper()
|
||||
reqBody, _ := json.Marshal(map[string]any{
|
||||
"messages": []map[string]string{{"role": "user", "content": prompt}},
|
||||
})
|
||||
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/world/analyst", strings.NewReader(string(reqBody)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer user-token")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (never 5xx)", resp.StatusCode)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestAnalystRecoversReasoningWhenNoContent is the regression guard for the "empty
|
||||
// response" break: a reasoning model (gpt-oss-120b / harmony) returns a 2xx with an
|
||||
// EMPTY content channel and its answer on `reasoning_content`. The completion path
|
||||
// must fall back to that channel so the analyst recovers the reply — not blank out.
|
||||
func TestAnalystRecoversReasoningWhenNoContent(t *testing.T) {
|
||||
ai := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeChatMessage(w, map[string]any{
|
||||
"content": "",
|
||||
"reasoning_content": `{"reply":"Markets are steady.","actions":[]}`,
|
||||
})
|
||||
}))
|
||||
defer ai.Close()
|
||||
|
||||
s := NewServer()
|
||||
s.ai.base = ai.URL
|
||||
mux := http.NewServeMux()
|
||||
s.Mount(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
out := postAnalyst(t, ts, "Reply with strict JSON only")
|
||||
if got, _ := out["reply"].(string); got != "Markets are steady." {
|
||||
t.Fatalf("reasoning-channel answer not recovered: reply=%q full=%+v", got, out)
|
||||
}
|
||||
if out["error"] != nil {
|
||||
t.Errorf("recovered answer should carry no error: %+v", out["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnalystEmptyAnswerNamesModel pins the honest-error contract: a genuinely empty
|
||||
// 2xx (a real choice, no content AND no reasoning) surfaces "the <model> model
|
||||
// returned an empty answer" — never an opaque "empty response", and never a balance
|
||||
// top-up (that is a distinct 402 contract).
|
||||
func TestAnalystEmptyAnswerNamesModel(t *testing.T) {
|
||||
ai := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeChatMessage(w, map[string]any{"content": ""})
|
||||
}))
|
||||
defer ai.Close()
|
||||
|
||||
s := NewServer() // default model "best"
|
||||
s.ai.base = ai.URL
|
||||
mux := http.NewServeMux()
|
||||
s.Mount(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
defer ts.Close()
|
||||
|
||||
out := postAnalyst(t, ts, "hi")
|
||||
if got, _ := out["reply"].(string); got != "" {
|
||||
t.Fatalf("expected empty reply, got %q", got)
|
||||
}
|
||||
errStr, _ := out["error"].(string)
|
||||
if strings.Contains(errStr, "empty response") {
|
||||
t.Fatalf("still surfacing the opaque 'empty response': %q", errStr)
|
||||
}
|
||||
if !strings.Contains(errStr, "empty answer") || !strings.Contains(errStr, "best") {
|
||||
t.Fatalf("empty error not honest/model-named: %q", errStr)
|
||||
}
|
||||
if out["topup"] == true {
|
||||
t.Fatalf("empty answer must not be a balance/top-up error: %+v", out)
|
||||
}
|
||||
if out["fallback"] != true {
|
||||
t.Errorf("empty answer should mark fallback=true: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatStreamRecoversReasoning covers the SSE path: a model that streams only
|
||||
// `reasoning_content` deltas (no content channel) must still yield an answer, and the
|
||||
// reasoning must be forwarded live (onDelta) so the UI shows the model working.
|
||||
func TestChatStreamRecoversReasoning(t *testing.T) {
|
||||
ai := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fl, _ := w.(http.Flusher)
|
||||
for _, f := range []string{
|
||||
`{"id":"resp_1","choices":[{"delta":{"reasoning_content":"thinking… "}}]}`,
|
||||
`{"id":"resp_1","choices":[{"delta":{"reasoning_content":"{\"reply\":\"from reasoning\"}"}}]}`,
|
||||
`[DONE]`,
|
||||
} {
|
||||
_, _ = io.WriteString(w, "data: "+f+"\n\n")
|
||||
if fl != nil {
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer ai.Close()
|
||||
|
||||
s := NewServer()
|
||||
s.ai.base = ai.URL
|
||||
var streamed strings.Builder
|
||||
out, _, id, err := s.ai.chatMessagesModelStream(context.Background(), s, "Bearer u", "gpt-oss-120b",
|
||||
[]chatMessage{{Role: "user", Content: "hi"}}, 0.4, 700, nil, func(tok string) { streamed.WriteString(tok) })
|
||||
if err != nil {
|
||||
t.Fatalf("stream err: %v", err)
|
||||
}
|
||||
if id != "resp_1" {
|
||||
t.Errorf("id = %q, want resp_1", id)
|
||||
}
|
||||
want := `thinking… {"reply":"from reasoning"}`
|
||||
if out != want {
|
||||
t.Fatalf("stream reasoning fallback wrong:\n got=%q\nwant=%q", out, want)
|
||||
}
|
||||
if streamed.String() != want {
|
||||
t.Errorf("reasoning deltas not forwarded live: %q", streamed.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeModelAndAgentRef(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"zen5": "zen5",
|
||||
|
||||
Reference in New Issue
Block a user