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>
77 lines
2.7 KiB
Go
77 lines
2.7 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/VictorVargas/rony-llm-agent/pkg/llm"
|
|
)
|
|
|
|
// TestRunStream_RecoversFromThinkingBudgetCut covers the reasoning-spiral
|
|
// failure seen live with Qwen3.6 + llama.cpp: the model thinks for tens of
|
|
// thousands of tokens without ever acting, the provider cuts the round with
|
|
// FinishThinkingBudget, and the loop must re-prompt for direct action instead
|
|
// of silently ending the turn with nothing.
|
|
func TestRunStream_RecoversFromThinkingBudgetCut(t *testing.T) {
|
|
executed := 0
|
|
stub := &scriptedLLM{responses: []llm.CompletionResponse{
|
|
// Round 1: pure reasoning, cut by the provider's budget enforcement.
|
|
{Reasoning: "hmm let me think about this again and again", StopReason: llm.FinishThinkingBudget},
|
|
// Round 2 (after the nudge): a real tool call.
|
|
{ToolCalls: []llm.ToolCall{{ID: "1", Name: "edit", Arguments: json.RawMessage(`{}`)}}},
|
|
// Round 3: final answer.
|
|
{Content: "Listo."},
|
|
}}
|
|
|
|
loop := New(Config{LLM: stub, Tools: editTestRegistry(t, &executed), MaxIters: 10})
|
|
|
|
var final strings.Builder
|
|
for chunk, err := range loop.RunStream(context.Background(), "arregla x.py") {
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
final.WriteString(chunk.Delta)
|
|
}
|
|
|
|
if executed != 1 {
|
|
t.Fatalf("expected the post-nudge tool call to execute once, got %d", executed)
|
|
}
|
|
if !strings.Contains(final.String(), "Listo.") {
|
|
t.Fatalf("expected the turn to continue to a final answer, got %q", final.String())
|
|
}
|
|
foundNudge := false
|
|
for _, m := range stub.lastMessages {
|
|
if m.Role == llm.RoleUser && strings.Contains(m.Content, "exceeded the thinking budget") {
|
|
foundNudge = true
|
|
}
|
|
}
|
|
if !foundNudge {
|
|
t.Fatal("expected the thinking-budget nudge in the follow-up request messages")
|
|
}
|
|
}
|
|
|
|
// TestRunStream_ThinkingBudgetNudgeGivesUpAfterLimit keeps a model that
|
|
// spirals every single round from ping-ponging forever: after
|
|
// maxThinkingBudgetNudges the turn ends.
|
|
func TestRunStream_ThinkingBudgetNudgeGivesUpAfterLimit(t *testing.T) {
|
|
executed := 0
|
|
spiral := llm.CompletionResponse{Reasoning: "thinking forever", StopReason: llm.FinishThinkingBudget}
|
|
stub := &scriptedLLM{responses: []llm.CompletionResponse{spiral, spiral, spiral, spiral}}
|
|
|
|
loop := New(Config{LLM: stub, Tools: editTestRegistry(t, &executed), MaxIters: 10})
|
|
|
|
for _, err := range loop.RunStream(context.Background(), "haz algo") {
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
if stub.calls != maxThinkingBudgetNudges+1 {
|
|
t.Fatalf("expected %d rounds (original + nudges), got %d", maxThinkingBudgetNudges+1, stub.calls)
|
|
}
|
|
if executed != 0 {
|
|
t.Fatalf("no tool should have executed, got %d", executed)
|
|
}
|
|
}
|