172 lines
5.7 KiB
Go
172 lines
5.7 KiB
Go
|
|
package agent
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"iter"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/VictorVargas/rony-llm-agent/pkg/llm"
|
||
|
|
"github.com/VictorVargas/rony-llm-agent/pkg/tools"
|
||
|
|
)
|
||
|
|
|
||
|
|
// scriptedLLM returns one canned response per call, in order.
|
||
|
|
type scriptedLLM struct {
|
||
|
|
responses []llm.CompletionResponse
|
||
|
|
calls int
|
||
|
|
// lastMessages records the request messages of the most recent call, so
|
||
|
|
// tests can assert the corrective nudge was actually sent.
|
||
|
|
lastMessages []llm.Message
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *scriptedLLM) Generate(_ context.Context, req llm.CompletionRequest) (llm.CompletionResponse, error) {
|
||
|
|
s.lastMessages = req.Messages
|
||
|
|
resp := s.responses[s.calls]
|
||
|
|
s.calls++
|
||
|
|
return resp, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *scriptedLLM) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamChunk, error] {
|
||
|
|
return func(yield func(llm.StreamChunk, error) bool) {
|
||
|
|
resp, _ := s.Generate(ctx, req)
|
||
|
|
if resp.Reasoning != "" {
|
||
|
|
if !yield(llm.StreamChunk{ReasoningDelta: resp.Reasoning}, nil) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// A response scripted with StopReason FinishThinkingBudget simulates
|
||
|
|
// a provider that cut the round mid-reasoning: the budget chunk is
|
||
|
|
// the last thing the stream produces.
|
||
|
|
if resp.StopReason == llm.FinishThinkingBudget {
|
||
|
|
yield(llm.StreamChunk{FinishReason: llm.FinishThinkingBudget}, nil)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if resp.Content != "" {
|
||
|
|
if !yield(llm.StreamChunk{Delta: resp.Content}, nil) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if len(resp.ToolCalls) > 0 {
|
||
|
|
if !yield(llm.StreamChunk{ToolCalls: resp.ToolCalls, FinishReason: "tool_calls"}, nil) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *scriptedLLM) Name() string { return "scripted" }
|
||
|
|
func (s *scriptedLLM) Capabilities() llm.ProviderCapabilities { return llm.ProviderCapabilities{} }
|
||
|
|
|
||
|
|
func editTestRegistry(t *testing.T, executed *int) tools.Registry {
|
||
|
|
t.Helper()
|
||
|
|
reg := tools.NewRegistry()
|
||
|
|
err := reg.Register(tools.Tool{
|
||
|
|
Name: "edit",
|
||
|
|
Description: "edit",
|
||
|
|
InputSchema: json.RawMessage(`{"type":"object"}`),
|
||
|
|
Handler: func(_ context.Context, _ json.RawMessage) (tools.ToolResult, error) {
|
||
|
|
*executed++
|
||
|
|
return tools.ToolResult{Content: "ok"}, nil
|
||
|
|
},
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
return reg
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestRunStream_RecoversFromUnparsedToolCall reproduces the failure seen
|
||
|
|
// live with Qwen3.6 + llama.cpp: the model writes its tool call as plain
|
||
|
|
// text inside its reasoning ("<tool_call><function=edit>...") so the server
|
||
|
|
// never parses it, the round has no tool calls, and the old loop simply
|
||
|
|
// ended the turn — abandoning the task mid-way with "now I'll fix X:" as the
|
||
|
|
// last words. The loop must instead nudge the model and let it re-issue the
|
||
|
|
// call for real.
|
||
|
|
func TestRunStream_RecoversFromUnparsedToolCall(t *testing.T) {
|
||
|
|
executed := 0
|
||
|
|
stub := &scriptedLLM{responses: []llm.CompletionResponse{
|
||
|
|
// Round 1: tool call emitted as text inside reasoning — unparsed.
|
||
|
|
{Reasoning: "I'll fix it now <tool_call> <function=edit> <parameter=path>x.py</parameter> </tool_call>", Content: "Voy a corregirlo:"},
|
||
|
|
// Round 2 (after the nudge): a real, parsed tool call.
|
||
|
|
{ToolCalls: []llm.ToolCall{{ID: "1", Name: "edit", Arguments: json.RawMessage(`{}`)}}},
|
||
|
|
// Round 3: final answer.
|
||
|
|
{Content: "Listo, corregido."},
|
||
|
|
}}
|
||
|
|
|
||
|
|
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 re-issued tool call to execute once, got %d", executed)
|
||
|
|
}
|
||
|
|
if !strings.Contains(final.String(), "Listo, corregido.") {
|
||
|
|
t.Fatalf("expected the turn to continue to a final answer, got %q", final.String())
|
||
|
|
}
|
||
|
|
// The corrective nudge must have been sent to the model.
|
||
|
|
foundNudge := false
|
||
|
|
for _, m := range stub.lastMessages {
|
||
|
|
if m.Role == llm.RoleUser && strings.Contains(m.Content, "NOT executed") {
|
||
|
|
foundNudge = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !foundNudge {
|
||
|
|
t.Fatal("expected the corrective nudge in the follow-up request messages")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestRunStream_NudgeGivesUpAfterLimit keeps a model that never emits a real
|
||
|
|
// tool call from ping-ponging forever: after maxUnparsedToolCallNudges the
|
||
|
|
// turn ends normally with whatever content there is.
|
||
|
|
func TestRunStream_NudgeGivesUpAfterLimit(t *testing.T) {
|
||
|
|
executed := 0
|
||
|
|
bad := llm.CompletionResponse{Content: "texto con <tool_call> falso"}
|
||
|
|
stub := &scriptedLLM{responses: []llm.CompletionResponse{bad, bad, bad, bad}}
|
||
|
|
|
||
|
|
loop := New(Config{LLM: stub, Tools: editTestRegistry(t, &executed), MaxIters: 10})
|
||
|
|
|
||
|
|
rounds := 0
|
||
|
|
for _, err := range loop.RunStream(context.Background(), "haz algo") {
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
_ = rounds
|
||
|
|
|
||
|
|
if stub.calls != maxUnparsedToolCallNudges+1 {
|
||
|
|
t.Fatalf("expected %d rounds (original + nudges), got %d", maxUnparsedToolCallNudges+1, stub.calls)
|
||
|
|
}
|
||
|
|
if executed != 0 {
|
||
|
|
t.Fatalf("no tool should have executed, got %d", executed)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestRun_RecoversFromUnparsedToolCall covers the non-streaming path.
|
||
|
|
func TestRun_RecoversFromUnparsedToolCall(t *testing.T) {
|
||
|
|
executed := 0
|
||
|
|
stub := &scriptedLLM{responses: []llm.CompletionResponse{
|
||
|
|
{Content: "ahora lo edito: <function=edit><parameter=path>x.py</parameter>"},
|
||
|
|
{ToolCalls: []llm.ToolCall{{ID: "1", Name: "edit", Arguments: json.RawMessage(`{}`)}}},
|
||
|
|
{Content: "Hecho."},
|
||
|
|
}}
|
||
|
|
|
||
|
|
loop := New(Config{LLM: stub, Tools: editTestRegistry(t, &executed), MaxIters: 10})
|
||
|
|
resp, err := loop.Run(context.Background(), "arregla x.py")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if executed != 1 {
|
||
|
|
t.Fatalf("expected the re-issued tool call to execute once, got %d", executed)
|
||
|
|
}
|
||
|
|
if resp.Content != "Hecho." {
|
||
|
|
t.Fatalf("expected the final answer, got %q", resp.Content)
|
||
|
|
}
|
||
|
|
}
|