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>
128 lines
4.9 KiB
Go
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)
|
|
}
|
|
}
|