Skip to content

Commit edc342a

Browse files
committed
🐛 fix orphan-tool-message
1 parent c90afab commit edc342a

3 files changed

Lines changed: 133 additions & 2 deletions

File tree

agent/llm.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -974,7 +974,9 @@ func streamOnce(
974974
StreamOptions: &streamOptions{
975975
IncludeUsage: true,
976976
},
977-
Messages: convo,
977+
// 发送前消毒:剔除孤儿 tool 消息 / 剥掉无响应的 tool_calls,避免 API 400(见 issue #94),
978+
// 并自愈已被写进历史的坏配对(下次请求即恢复)。正常对话是 no-op。
979+
Messages: sanitizeToolPairs(convo),
978980
Tools: toolSpecs,
979981
// thinking 和 reasoning_effort 是两个独立顶层字段。各自 omitempty,
980982
// 用户设了就发、没设就不发,白名单内的值才透传(防 yaml 笔误)。
@@ -1086,7 +1088,13 @@ func streamOnce(
10861088
sort.Ints(idxs)
10871089
toolCalls := make([]ToolCall, 0, len(idxs))
10881090
for _, idx := range idxs {
1089-
toolCalls = append(toolCalls, *toolBuf[idx])
1091+
tc := *toolBuf[idx]
1092+
if tc.ID == "" {
1093+
// 供应商流式整段未给 id(部分第三方/自建 base_url 池子)→ 合成稳定 id。
1094+
// assistant 的 tool_call 与随后的 tool 结果都用它,保证 API 侧能配对(见 issue #94)。
1095+
tc.ID = fmt.Sprintf("call_%d", idx)
1096+
}
1097+
toolCalls = append(toolCalls, tc)
10901098
}
10911099
return contentBuilder.String(), reasoningBuilder.String(), toolCalls, finishReason, lastUsage, nil
10921100
}

agent/sanitize.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package agent
2+
3+
import "strings"
4+
5+
// sanitizeToolPairs 在发请求前修正消息序列,保证 tool 与 tool_calls 严格配对,规避两类 400:
6+
// - 孤儿 tool 消息:role=tool 但前面没有携带对应 tool_call 的 assistant
7+
// → "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'"
8+
// - 悬挂 tool_calls:assistant 带 tool_calls 但缺少对应的 tool 响应(部分 API 也会拒)
9+
//
10+
// 这类失配可能由旧版压缩切点、中途中断、供应商流式 tool_calls 异常、或历史损坏造成(见 issue #94)。
11+
// 做法:剔除孤儿 tool;剥掉 assistant 里无响应的 tool_call(剥空且无正文则整条丢弃)。
12+
// 正常对话(配对完整)下原样返回,不动前缀、不影响缓存。
13+
func sanitizeToolPairs(msgs []ChatMessage) []ChatMessage {
14+
// 第一遍:收集所有出现过的 tool 响应 id(用于判断某个 tool_call 有没有被回应)。
15+
answered := make(map[string]bool)
16+
for i := range msgs {
17+
if msgs[i].Role == "tool" && msgs[i].ToolCallID != "" {
18+
answered[msgs[i].ToolCallID] = true
19+
}
20+
}
21+
22+
out := make([]ChatMessage, 0, len(msgs))
23+
valid := make(map[string]bool) // 最近一条 assistant 保留下来的 tool_call id;只有这些 id 的 tool 才合法
24+
changed := false
25+
for _, m := range msgs {
26+
switch m.Role {
27+
case "assistant":
28+
if len(m.ToolCalls) > 0 {
29+
kept := make([]ToolCall, 0, len(m.ToolCalls))
30+
valid = make(map[string]bool)
31+
for _, tc := range m.ToolCalls {
32+
if tc.ID != "" && answered[tc.ID] {
33+
kept = append(kept, tc)
34+
valid[tc.ID] = true
35+
}
36+
}
37+
if len(kept) != len(m.ToolCalls) {
38+
changed = true
39+
m.ToolCalls = kept
40+
}
41+
// tool_calls 全被剥光且无正文 → 整条丢弃(空 assistant 无意义)
42+
if len(m.ToolCalls) == 0 && strings.TrimSpace(m.Content) == "" {
43+
changed = true
44+
continue
45+
}
46+
} else {
47+
valid = make(map[string]bool) // 普通 assistant 终结上一组 tool 配对
48+
}
49+
out = append(out, m)
50+
case "tool":
51+
if m.ToolCallID != "" && valid[m.ToolCallID] {
52+
out = append(out, m)
53+
} else {
54+
changed = true // 孤儿 tool,丢弃
55+
}
56+
default: // user / system:终结上一组 tool 配对
57+
valid = make(map[string]bool)
58+
out = append(out, m)
59+
}
60+
}
61+
if !changed {
62+
return msgs
63+
}
64+
return out
65+
}

agent/sanitize_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package agent
2+
3+
import "testing"
4+
5+
func roles(msgs []ChatMessage) []string {
6+
r := make([]string, len(msgs))
7+
for i, m := range msgs {
8+
r[i] = m.Role
9+
}
10+
return r
11+
}
12+
13+
func TestSanitizeDropsOrphanTool(t *testing.T) {
14+
// tool 消息前面没有对应 tool_call → 孤儿,应被丢弃
15+
in := []ChatMessage{
16+
{Role: "user", Content: "hi"},
17+
{Role: "tool", ToolCallID: "x", Content: "orphan result"},
18+
{Role: "assistant", Content: "answer"},
19+
}
20+
out := sanitizeToolPairs(in)
21+
got := roles(out)
22+
if len(got) != 2 || got[0] != "user" || got[1] != "assistant" {
23+
t.Fatalf("孤儿 tool 应被剔除,got %v", got)
24+
}
25+
}
26+
27+
func TestSanitizeKeepsValidPair(t *testing.T) {
28+
in := []ChatMessage{
29+
{Role: "user", Content: "hi"},
30+
{Role: "assistant", ToolCalls: []ToolCall{{ID: "x", Function: ToolCallFunc{Name: "Read"}}}},
31+
{Role: "tool", ToolCallID: "x", Content: "ok"},
32+
{Role: "assistant", Content: "done"},
33+
}
34+
out := sanitizeToolPairs(in)
35+
if len(out) != 4 {
36+
t.Fatalf("完整配对应原样保留,got %d 条: %v", len(out), roles(out))
37+
}
38+
}
39+
40+
func TestSanitizeStripsDanglingToolCalls(t *testing.T) {
41+
// assistant 带 tool_calls 但没有对应 tool 响应:
42+
// - 有正文 → 剥掉 tool_calls,保留正文
43+
// - 无正文 → 整条丢弃
44+
in := []ChatMessage{
45+
{Role: "user", Content: "hi"},
46+
{Role: "assistant", Content: "让我查一下", ToolCalls: []ToolCall{{ID: "x", Function: ToolCallFunc{Name: "Read"}}}},
47+
{Role: "assistant", ToolCalls: []ToolCall{{ID: "y", Function: ToolCallFunc{Name: "Grep"}}}}, // 无正文、无响应 → 丢
48+
{Role: "assistant", Content: "结果如下"},
49+
}
50+
out := sanitizeToolPairs(in)
51+
got := roles(out)
52+
if len(got) != 3 {
53+
t.Fatalf("悬挂 tool_calls 处理错误,got %d 条: %v", len(got), got)
54+
}
55+
if len(out[1].ToolCalls) != 0 || out[1].Content != "让我查一下" {
56+
t.Errorf("应剥掉无响应 tool_calls、保留正文,got %+v", out[1])
57+
}
58+
}

0 commit comments

Comments
 (0)