Three compounding bugs prevented tool calling with llama.cpp:
- Stream parser set argsComplete on partial JSON (e.g. "{"), dropping
subsequent argument deltas — fix: use json.Valid to detect completeness
- Missing tool_choice default — llama.cpp needs explicit "auto" to
activate its GBNF grammar constraint; now set when tools are present
- Tool names in history used internal format (fs.ls) while definitions
used API format (fs_ls) — now re-sanitized in translateMessage
Additional changes:
- Disable SDK retries for local providers (500s are deterministic)
- Dynamic capability probing via /props (llama.cpp) and /api/show
(Ollama), replacing hardcoded model prefix list
- Engine respects forced arm ToolUse capability when router is active
- Bundled /init skill with Go template blocks, context-aware for local
vs cloud models, deduplication rules against CLAUDE.md
- Tool result compaction for local models — previous round results
replaced with size markers to stay within small context windows
- Text-only fallback when tool-parse errors occur on local models
- "text-only" TUI indicator when model lacks tool support
- Session ResetError for retry after stream failures
- AllowedTools per-turn filtering in engine buildRequest
159 lines
5.6 KiB
Go
159 lines
5.6 KiB
Go
package engine
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
|
|
"somegit.dev/Owlibou/gnoma/internal/message"
|
|
)
|
|
|
|
func TestCompactPreviousToolResults_NoAssistant(t *testing.T) {
|
|
msgs := []message.Message{
|
|
{Role: message.RoleUser, Content: []message.Content{message.NewTextContent("hello")}},
|
|
}
|
|
got := compactPreviousToolResults(msgs)
|
|
if len(got) != 1 || got[0].TextContent() != "hello" {
|
|
t.Error("should return messages unchanged when no assistant message exists")
|
|
}
|
|
}
|
|
|
|
func TestCompactPreviousToolResults_SingleRound(t *testing.T) {
|
|
// user → assistant(tool_call) → tool_result
|
|
// Only one round, tool result is the latest — should NOT be compacted.
|
|
msgs := []message.Message{
|
|
{Role: message.RoleUser, Content: []message.Content{message.NewTextContent("do /init")}},
|
|
{Role: message.RoleAssistant, Content: []message.Content{
|
|
message.NewToolCallContent(message.ToolCall{ID: "c1", Name: "fs.ls", Arguments: json.RawMessage(`{}`)}),
|
|
}},
|
|
toolResultMsg("c1distances", "file1.go\nfile2.go\nfile3.go\n"),
|
|
}
|
|
got := compactPreviousToolResults(msgs)
|
|
// Tool result is after the last assistant message — should be intact.
|
|
result := got[2].Content[0].ToolResult
|
|
if result.Content == "" || len(result.Content) < 10 {
|
|
t.Errorf("latest tool result should be intact, got %q", result.Content)
|
|
}
|
|
}
|
|
|
|
func TestCompactPreviousToolResults_TwoRounds(t *testing.T) {
|
|
bigContent := make([]byte, 2000)
|
|
for i := range bigContent {
|
|
bigContent[i] = 'x'
|
|
}
|
|
|
|
msgs := []message.Message{
|
|
{Role: message.RoleUser, Content: []message.Content{message.NewTextContent("do /init")}},
|
|
// Round 0
|
|
{Role: message.RoleAssistant, Content: []message.Content{
|
|
message.NewToolCallContent(message.ToolCall{ID: "c1", Name: "fs.read", Arguments: json.RawMessage(`{}`)}),
|
|
}},
|
|
toolResultMsg("c1", string(bigContent)), // 2000 chars — should be compacted
|
|
// Round 1
|
|
{Role: message.RoleAssistant, Content: []message.Content{
|
|
message.NewToolCallContent(message.ToolCall{ID: "c2", Name: "fs.write", Arguments: json.RawMessage(`{}`)}),
|
|
}},
|
|
toolResultMsg("c2", "file written"), // latest — should be intact
|
|
}
|
|
|
|
got := compactPreviousToolResults(msgs)
|
|
|
|
// Round 0 tool result (index 2) should be compacted
|
|
r0 := got[2].Content[0].ToolResult
|
|
if len(r0.Content) > 100 {
|
|
t.Errorf("round 0 tool result should be compacted, got %d chars", len(r0.Content))
|
|
}
|
|
if r0.ToolCallID != "c1" {
|
|
t.Errorf("compacted result should preserve ToolCallID, got %q", r0.ToolCallID)
|
|
}
|
|
|
|
// Round 1 tool result (index 4) should be intact
|
|
r1 := got[4].Content[0].ToolResult
|
|
if r1.Content != "file written" {
|
|
t.Errorf("latest tool result should be intact, got %q", r1.Content)
|
|
}
|
|
}
|
|
|
|
func TestCompactPreviousToolResults_PreservesNonToolMessages(t *testing.T) {
|
|
msgs := []message.Message{
|
|
{Role: message.RoleUser, Content: []message.Content{message.NewTextContent("hello")}},
|
|
{Role: message.RoleAssistant, Content: []message.Content{
|
|
message.NewTextContent("I'll read the file"),
|
|
message.NewToolCallContent(message.ToolCall{ID: "c1", Name: "fs.read", Arguments: json.RawMessage(`{}`)}),
|
|
}},
|
|
toolResultMsg("c1", "file contents here..."),
|
|
{Role: message.RoleAssistant, Content: []message.Content{message.NewTextContent("done")}},
|
|
}
|
|
got := compactPreviousToolResults(msgs)
|
|
|
|
// User text message should be unchanged
|
|
if got[0].TextContent() != "hello" {
|
|
t.Errorf("user message should be unchanged, got %q", got[0].TextContent())
|
|
}
|
|
// Assistant text should be unchanged
|
|
if got[1].TextContent() != "I'll read the file" {
|
|
t.Errorf("assistant message should be unchanged, got %q", got[1].TextContent())
|
|
}
|
|
}
|
|
|
|
func TestCompactPreviousToolResults_PreservesErrorFlag(t *testing.T) {
|
|
msgs := []message.Message{
|
|
{Role: message.RoleUser, Content: []message.Content{message.NewTextContent("hi")}},
|
|
{Role: message.RoleAssistant, Content: []message.Content{
|
|
message.NewToolCallContent(message.ToolCall{ID: "c1", Name: "fs.read", Arguments: json.RawMessage(`{}`)}),
|
|
}},
|
|
errorToolResultMsg("c1", "permission denied: /etc/shadow"),
|
|
{Role: message.RoleAssistant, Content: []message.Content{message.NewTextContent("sorry")}},
|
|
}
|
|
got := compactPreviousToolResults(msgs)
|
|
r := got[2].Content[0].ToolResult
|
|
if !r.IsError {
|
|
t.Error("compacted error result should preserve IsError=true")
|
|
}
|
|
}
|
|
|
|
func TestCompactPreviousToolResults_DoesNotMutateOriginal(t *testing.T) {
|
|
original := "a]long tool result content that should not be modified"
|
|
msgs := []message.Message{
|
|
{Role: message.RoleUser, Content: []message.Content{message.NewTextContent("hi")}},
|
|
{Role: message.RoleAssistant, Content: []message.Content{
|
|
message.NewToolCallContent(message.ToolCall{ID: "c1", Name: "fs.read", Arguments: json.RawMessage(`{}`)}),
|
|
}},
|
|
toolResultMsg("c1", original),
|
|
{Role: message.RoleAssistant, Content: []message.Content{message.NewTextContent("ok")}},
|
|
}
|
|
_ = compactPreviousToolResults(msgs)
|
|
// Original message should be unchanged
|
|
if msgs[2].Content[0].ToolResult.Content != original {
|
|
t.Error("compaction should not mutate the original messages")
|
|
}
|
|
}
|
|
|
|
// helpers
|
|
|
|
func toolResultMsg(toolCallID, content string) message.Message {
|
|
return message.Message{
|
|
Role: message.RoleUser,
|
|
Content: []message.Content{{
|
|
Type: message.ContentToolResult,
|
|
ToolResult: &message.ToolResult{
|
|
ToolCallID: toolCallID,
|
|
Content: content,
|
|
},
|
|
}},
|
|
}
|
|
}
|
|
|
|
func errorToolResultMsg(toolCallID, content string) message.Message {
|
|
return message.Message{
|
|
Role: message.RoleUser,
|
|
Content: []message.Content{{
|
|
Type: message.ContentToolResult,
|
|
ToolResult: &message.ToolResult{
|
|
ToolCallID: toolCallID,
|
|
Content: content,
|
|
IsError: true,
|
|
},
|
|
}},
|
|
}
|
|
}
|