rony-llm-agent/pkg/llm/providers/llamacpp/thinking_budget_test.go
Victor Vargas 8e887c8c78 fix(agent,llamacpp): recover turns killed by unparsed tool calls and reasoning spirals
Two failure modes seen live with Qwen3.6 on llama.cpp ended turns silently
mid-task:

- The model writes its tool call as plain text inside its reasoning, the
  server never parses it, and the round ends with nothing executed. The
  loop now detects the markers and nudges the model to re-issue the call
  for real (max 2 per turn).

- llama.cpp silently ignores the max_thinking_tokens field, so a model in
  a reasoning spiral ran until max_tokens (seen live: 25k+ tokens of
  nonstop thinking, ~20 min). The llamacpp client now enforces the budget
  client-side during Stream: once exceeded while the round is still pure
  reasoning, it cuts with FinishThinkingBudget and aborts the request
  (freeing the server slot); the loop answers with its own corrective
  nudge, on a separate counter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:50:45 -07:00

128 lines
4.9 KiB
Go

package llamacpp
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/VictorVargas/rony-llm-agent/pkg/llm"
)
// TestClient_Stream_ThinkingBudgetCutsPureReasoning: with MaxThinkingTokens
// set, a round that is still pure reasoning past the character budget must be
// cut with FinishThinkingBudget — and nothing after the cut may be delivered.
func TestClient_Stream_ThinkingBudgetCutsPureReasoning(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
// 5 chars per delta; budget = 2 tokens * 4 chars = 8 chars, so the
// second delta (total 10) tips it over.
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"aaaaa\"},\"finish_reason\":null}]}\n"))
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"bbbbb\"},\"finish_reason\":null}]}\n"))
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"never delivered\"},\"finish_reason\":null}]}\n"))
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"never delivered\"},\"finish_reason\":\"stop\"}]}\n"))
w.Write([]byte("data: [DONE]\n"))
}))
defer server.Close()
client, err := New(Config{BaseURL: server.URL + "/v1", MaxThinkingTokens: 2})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var chunks []llm.StreamChunk
for chunk, err := range client.Stream(context.Background(), llm.CompletionRequest{}) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
chunks = append(chunks, chunk)
}
if len(chunks) != 2 {
t.Fatalf("expected 2 chunks (reasoning + budget cut), got %d: %+v", len(chunks), chunks)
}
last := chunks[len(chunks)-1]
if last.FinishReason != llm.FinishThinkingBudget {
t.Errorf("expected finish reason %q, got %q", llm.FinishThinkingBudget, last.FinishReason)
}
if last.ReasoningDelta != "bbbbb" {
t.Errorf("expected the tipping reasoning delta on the final chunk, got %q", last.ReasoningDelta)
}
for _, c := range chunks {
if c.Delta != "" {
t.Errorf("no content should have been delivered, got %q", c.Delta)
}
}
}
// TestClient_Stream_ThinkingBudgetSparesStartedAnswer: once the model has
// begun its actual answer, exceeding the reasoning budget must NOT cut the
// stream — the spiral risk is over and real work is in flight.
func TestClient_Stream_ThinkingBudgetSparesStartedAnswer(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"aaaaa\"},\"finish_reason\":null}]}\n"))
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"Hola\"},\"finish_reason\":null}]}\n"))
// Over budget, but the answer already started.
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"bbbbbbbbbb\"},\"finish_reason\":null}]}\n"))
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\" mundo\"},\"finish_reason\":\"stop\"}]}\n"))
w.Write([]byte("data: [DONE]\n"))
}))
defer server.Close()
client, err := New(Config{BaseURL: server.URL + "/v1", MaxThinkingTokens: 2})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var content string
var finish string
for chunk, err := range client.Stream(context.Background(), llm.CompletionRequest{}) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
content += chunk.Delta
if chunk.FinishReason != "" {
finish = chunk.FinishReason
}
}
if content != "Hola mundo" {
t.Errorf("expected the full answer, got %q", content)
}
if finish != "stop" {
t.Errorf("expected a normal stop, got %q", finish)
}
}
// TestClient_Stream_NoThinkingBudgetMeansUnlimited: MaxThinkingTokens 0 keeps
// today's behavior — reasoning streams without any client-side cap.
func TestClient_Stream_NoThinkingBudgetMeansUnlimited(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"},\"finish_reason\":null}]}\n"))
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n"))
w.Write([]byte("data: [DONE]\n"))
}))
defer server.Close()
client, err := New(Config{BaseURL: server.URL + "/v1"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var content, finish string
for chunk, err := range client.Stream(context.Background(), llm.CompletionRequest{}) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
content += chunk.Delta
if chunk.FinishReason != "" {
finish = chunk.FinishReason
}
}
if content != "ok" || finish != "stop" {
t.Errorf("expected uncut stream (content %q, finish %q), got content %q finish %q", "ok", "stop", content, finish)
}
}