feat: add repairOrphanedToolPairs utility for fixing orphaned tool_use/tool_result pairs
This commit is contained in:
parent
408b3ea06f
commit
46a5dde93a
2 changed files with 185 additions and 0 deletions
95
pkg/agent/sanitize.go
Normal file
95
pkg/agent/sanitize.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
// repairOrphanedToolPairs scans messages and:
|
||||
// 1. Injects synthetic tool_result messages for any tool_use that lacks a matching result
|
||||
// 2. Drops tool_result messages that lack a preceding tool_use
|
||||
func repairOrphanedToolPairs(msgs []providers.Message) []providers.Message {
|
||||
if len(msgs) == 0 {
|
||||
return msgs
|
||||
}
|
||||
|
||||
// 1. Collect all tool_call IDs from assistant messages
|
||||
toolCallIDs := map[string]bool{}
|
||||
for _, m := range msgs {
|
||||
if m.Role == "assistant" {
|
||||
for _, tc := range m.ToolCalls {
|
||||
if tc.ID != "" {
|
||||
toolCallIDs[tc.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Drop orphaned tool_results (no matching tool_call)
|
||||
filtered := make([]providers.Message, 0, len(msgs))
|
||||
for i, m := range msgs {
|
||||
if m.Role == "tool" && m.ToolCallID != "" && !toolCallIDs[m.ToolCallID] {
|
||||
logger.DebugCF("agent", "Dropping orphaned tool_result", map[string]any{
|
||||
"tool_call_id": m.ToolCallID,
|
||||
"index": i,
|
||||
})
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
|
||||
// 3. Collect existing tool_result IDs
|
||||
resultIDs := map[string]bool{}
|
||||
for _, m := range filtered {
|
||||
if m.Role == "tool" && m.ToolCallID != "" {
|
||||
resultIDs[m.ToolCallID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Build repaired slice, injecting synthetic results for orphaned tool_calls.
|
||||
// Track pending tool_calls from each assistant message and flush missing
|
||||
// results after the last consecutive tool result (or at end of input).
|
||||
repaired := make([]providers.Message, 0, len(filtered))
|
||||
var pendingCalls []providers.ToolCall
|
||||
|
||||
flushPending := func() {
|
||||
for _, tc := range pendingCalls {
|
||||
if tc.ID == "" || resultIDs[tc.ID] {
|
||||
continue
|
||||
}
|
||||
name := tc.Name
|
||||
if tc.Function != nil {
|
||||
name = tc.Function.Name
|
||||
}
|
||||
logger.DebugCF("agent", "Injecting synthetic tool_result for orphaned tool_call", map[string]any{
|
||||
"tool_call_id": tc.ID,
|
||||
"tool_name": name,
|
||||
})
|
||||
repaired = append(repaired, providers.Message{
|
||||
Role: "tool",
|
||||
ToolCallID: tc.ID,
|
||||
Content: "[tool result unavailable — session history was compressed]",
|
||||
})
|
||||
resultIDs[tc.ID] = true
|
||||
}
|
||||
pendingCalls = nil
|
||||
}
|
||||
|
||||
for _, m := range filtered {
|
||||
// When we hit a non-tool message and have pending calls, flush synthetics
|
||||
if m.Role != "tool" && pendingCalls != nil {
|
||||
flushPending()
|
||||
}
|
||||
|
||||
repaired = append(repaired, m)
|
||||
|
||||
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
|
||||
pendingCalls = m.ToolCalls
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any remaining pending calls at the end of input
|
||||
flushPending()
|
||||
|
||||
return repaired
|
||||
}
|
||||
90
pkg/agent/sanitize_test.go
Normal file
90
pkg/agent/sanitize_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
)
|
||||
|
||||
func TestRepairOrphanedToolPairs_NoOrphans(t *testing.T) {
|
||||
msgs := []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{
|
||||
Role: "assistant", Content: "let me check",
|
||||
ToolCalls: []providers.ToolCall{{ID: "call_1", Name: "exec"}},
|
||||
},
|
||||
{Role: "tool", Content: "output", ToolCallID: "call_1"},
|
||||
{Role: "assistant", Content: "done"},
|
||||
}
|
||||
repaired := repairOrphanedToolPairs(msgs)
|
||||
if len(repaired) != 4 {
|
||||
t.Errorf("expected 4 messages, got %d", len(repaired))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairOrphanedToolPairs_OrphanToolUseAtEnd(t *testing.T) {
|
||||
msgs := []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{
|
||||
Role: "assistant", Content: "let me check",
|
||||
ToolCalls: []providers.ToolCall{{ID: "call_1", Name: "exec"}},
|
||||
},
|
||||
}
|
||||
repaired := repairOrphanedToolPairs(msgs)
|
||||
if len(repaired) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d", len(repaired))
|
||||
}
|
||||
if repaired[2].Role != "tool" {
|
||||
t.Errorf("expected injected tool message, got role=%q", repaired[2].Role)
|
||||
}
|
||||
if repaired[2].ToolCallID != "call_1" {
|
||||
t.Errorf("expected ToolCallID=call_1, got %q", repaired[2].ToolCallID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairOrphanedToolPairs_MultipleToolCallsPartialResults(t *testing.T) {
|
||||
msgs := []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{
|
||||
Role: "assistant", Content: "checking two things",
|
||||
ToolCalls: []providers.ToolCall{
|
||||
{ID: "call_1", Name: "exec"},
|
||||
{ID: "call_2", Name: "web_fetch"},
|
||||
},
|
||||
},
|
||||
{Role: "tool", Content: "output1", ToolCallID: "call_1"},
|
||||
}
|
||||
repaired := repairOrphanedToolPairs(msgs)
|
||||
if len(repaired) != 4 {
|
||||
t.Fatalf("expected 4 messages, got %d", len(repaired))
|
||||
}
|
||||
if repaired[3].ToolCallID != "call_2" {
|
||||
t.Errorf("expected injected result for call_2, got ToolCallID=%q", repaired[3].ToolCallID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairOrphanedToolPairs_OrphanToolResultDropped(t *testing.T) {
|
||||
msgs := []providers.Message{
|
||||
{Role: "user", Content: "hello"},
|
||||
{Role: "tool", Content: "orphaned output", ToolCallID: "call_orphan"},
|
||||
{Role: "assistant", Content: "hi"},
|
||||
}
|
||||
repaired := repairOrphanedToolPairs(msgs)
|
||||
if len(repaired) != 2 {
|
||||
t.Fatalf("expected 2 messages (user + assistant), got %d", len(repaired))
|
||||
}
|
||||
if repaired[0].Role != "user" || repaired[1].Role != "assistant" {
|
||||
t.Errorf("unexpected message roles: %q, %q", repaired[0].Role, repaired[1].Role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairOrphanedToolPairs_EmptyInput(t *testing.T) {
|
||||
repaired := repairOrphanedToolPairs(nil)
|
||||
if len(repaired) != 0 {
|
||||
t.Errorf("expected 0 messages for nil input, got %d", len(repaired))
|
||||
}
|
||||
repaired = repairOrphanedToolPairs([]providers.Message{})
|
||||
if len(repaired) != 0 {
|
||||
t.Errorf("expected 0 messages for empty input, got %d", len(repaired))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue