From f0df15cdfe32fdf0f74a7e6f04616016f106bc62 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Mon, 24 Aug 2026 18:49:54 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01KZaoEXfGsjmkhXtVxtvPp4 --- core/agent/agent.go | 4 +- core/agent/toolcall.go | 28 +++++++++++++ core/agent/toolcall_test.go | 81 +++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 core/agent/toolcall.go create mode 100644 core/agent/toolcall_test.go diff --git a/core/agent/agent.go b/core/agent/agent.go index 0074bea..98309b2 100644 --- a/core/agent/agent.go +++ b/core/agent/agent.go @@ -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) diff --git a/core/agent/toolcall.go b/core/agent/toolcall.go new file mode 100644 index 0000000..ce7330c --- /dev/null +++ b/core/agent/toolcall.go @@ -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 +} diff --git a/core/agent/toolcall_test.go b/core/agent/toolcall_test.go new file mode 100644 index 0000000..7e664c9 --- /dev/null +++ b/core/agent/toolcall_test.go @@ -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")) + }) +})