mirror of
https://github.com/mudler/LocalAGI.git
synced 2026-08-27 03:11:18 -04:00
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
This commit is contained in:
+1
-3
@@ -1407,9 +1407,7 @@ func (a *Agent) consumeJob(job *types.Job, role string) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(fragment.Messages) > 0 &&
|
||||
fragment.LastMessage().Role == "tool" {
|
||||
toolToCall := fragment.Messages[len(fragment.Messages)-2].ToolCalls[0].Function.Name
|
||||
if toolToCall, ok := lastToolCallName(fragment.Messages); ok {
|
||||
switch toolToCall {
|
||||
case action.StopActionName:
|
||||
job.Result.Finish(nil)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package agent
|
||||
|
||||
// lastToolCallName returns the name of the tool call that produced the
|
||||
// conversation's closing tool result, reporting false when the conversation
|
||||
// does not end in one or when the call that produced it cannot be recovered.
|
||||
//
|
||||
// Both guards are load-bearing. A tool result needs a message before it to read
|
||||
// the call from, and that message can carry an empty ToolCalls slice: when the
|
||||
// model returns no tool selection — after a context-window overflow, say — the
|
||||
// fragment still ends in a tool role. Reading ToolCalls[0] there panics, and
|
||||
// because consumeJob runs on its own goroutine with no recover, that panic ends
|
||||
// the process rather than the job.
|
||||
func lastToolCallName(messages Messages) (string, bool) {
|
||||
if len(messages) < 2 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if messages[len(messages)-1].Role != "tool" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
calls := messages[len(messages)-2].ToolCalls
|
||||
if len(calls) == 0 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return calls[0].Function.Name, true
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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"))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user