Files
Ettore Di Giacinto f0df15cdfe fix(agent): guard the tool-call lookup that ends a job
consumeJob read Messages[len-2].ToolCalls[0] behind a guard that only
checked that the message list was non-empty. Neither index was safe.

When the model returns no tool selection, the fragment still ends in a
tool role but the message before it carries an empty ToolCalls slice.
On a LocalAI backend serving a 50176-token context, a 50603-token
request produced exactly that, and the read panicked with "index out of
range [0] with length 0". consumeJob runs on its own goroutine with no
recover, so the panic ended the process instead of the job, and the
agent crash-looped on every restart.

The lookup moves to lastToolCallName, which reports failure instead of
indexing when the conversation is too short or carries no tool call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZaoEXfGsjmkhXtVxtvPp4
2026-08-24 18:49:54 +00:00

82 lines
2.3 KiB
Go

package agent
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/sashabaranov/go-openai"
)
func toolCall(name string) []openai.ToolCall {
return []openai.ToolCall{{
Function: openai.FunctionCall{Name: name},
}}
}
var _ = Describe("lastToolCallName", func() {
It("returns the tool that produced the closing tool result", func() {
name, ok := lastToolCallName(Messages{
{Role: "user", Content: "hi"},
{Role: "assistant", ToolCalls: toolCall("stop")},
{Role: "tool", Content: "done"},
})
Expect(ok).To(BeTrue())
Expect(name).To(Equal("stop"))
})
It("reports no tool call when the conversation is empty", func() {
name, ok := lastToolCallName(Messages{})
Expect(ok).To(BeFalse())
Expect(name).To(BeEmpty())
})
It("reports no tool call when the conversation does not end in a tool result", func() {
name, ok := lastToolCallName(Messages{
{Role: "assistant", ToolCalls: toolCall("stop")},
{Role: "assistant", Content: "all done"},
})
Expect(ok).To(BeFalse())
Expect(name).To(BeEmpty())
})
// A tool result with nothing before it leaves no message to read the call
// from; indexing len-2 would reach behind the slice.
It("reports no tool call when the tool result is the only message", func() {
name, ok := lastToolCallName(Messages{
{Role: "tool", Content: "orphaned"},
})
Expect(ok).To(BeFalse())
Expect(name).To(BeEmpty())
})
// The panic this guard exists for: the model returned no tool selection —
// after a context-window overflow, for instance — so the message preceding
// the tool result carries an empty ToolCalls slice.
It("reports no tool call when the preceding message carries none", func() {
name, ok := lastToolCallName(Messages{
{Role: "user", Content: "hi"},
{Role: "assistant", Content: "", ToolCalls: nil},
{Role: "tool", Content: "done"},
})
Expect(ok).To(BeFalse())
Expect(name).To(BeEmpty())
})
It("reads the first tool call when the preceding message carries several", func() {
name, ok := lastToolCallName(Messages{
{Role: "assistant", ToolCalls: []openai.ToolCall{
{Function: openai.FunctionCall{Name: "first"}},
{Function: openai.FunctionCall{Name: "second"}},
}},
{Role: "tool", Content: "done"},
})
Expect(ok).To(BeTrue())
Expect(name).To(Equal("first"))
})
})