diff --git a/internal/agent/api/provider.go b/internal/agent/api/provider.go index a4c27fa0..100d423a 100644 --- a/internal/agent/api/provider.go +++ b/internal/agent/api/provider.go @@ -609,12 +609,24 @@ func parseOpenAICompatibleSSEBody(raw []byte) (*CompletionResponse, bool) { found = true contentBuf.WriteString(ck.Content) reasoningBuf.WriteString(ck.ReasoningContent) - for i, tc := range ck.ToolCalls { - acc := accs[i] + for _, tc := range ck.ToolCalls { + // ★ 必须用**上游的 index**(StreamIndex),不能用切片下标。 + // + // 流式协议里每个分片带 index 表明它属于第几个 tool_call。 + // 并行多工具时上游会**分成多个 chunk**下发:每个 chunk 的 + // ToolCalls 切片长度可能都是 1(下标恒为 0),若用切片下标累积, + // 12 个并行调用的参数分片会**全部拼进 accs[0]**,其余 11 个 + // 拿到空串 ⇒ 被 argsLookDropped 判为"丢参数"。 + // + // 线上实证(2026-10-25):2 小时内 74 次 "has empty arguments", + // 每次恰好 12 条(= 同一响应的 12 个并行 tool_call 全部报空), + // 模型侧表现为"工具拿不到参数",用户侧表现为"agent 忘了刚说要做什么"。 + idx := tc.StreamIndex + acc := accs[idx] if acc == nil { acc = &sseAcc{} - accs[i] = acc - toolOrder = append(toolOrder, i) + accs[idx] = acc + toolOrder = append(toolOrder, idx) } if tc.ID != "" { acc.id = tc.ID diff --git a/internal/agent/api/sse_toolcall_index_test.go b/internal/agent/api/sse_toolcall_index_test.go new file mode 100644 index 00000000..7588d5db --- /dev/null +++ b/internal/agent/api/sse_toolcall_index_test.go @@ -0,0 +1,84 @@ +package api + +import ( + "strings" + "testing" +) + +// 流式并行 tool_call 的**分片归属**判据。 +// +// 真实缺陷(2026-10-25 线上,2 小时 74 次 "has empty arguments"): +// 累积时用 `accs[i]`(切片下标)而不是 tc.StreamIndex(上游 index)。 +// +// 并行多工具时上游把每个 tool_call 的 arguments **分成多个 chunk** 下发, +// 每个 chunk 的 ToolCalls 切片长度通常是 1(下标恒为 0)⇒ 12 个并行调用的 +// 参数分片全部拼进 accs[0],其余 11 个拿到空串。 +// +// 症状:模型侧"工具拿不到参数";用户侧"agent 忘了刚说要做什么"。 +// 每次恰好 12 条告警(= 同一响应的 12 个 tool_call 全报空)就是它的指纹。 + +// sseWithToolCalls 构造一段 SSE 响应体:每个 tool_call 一个独立分片。 +func sseWithToolCalls(entries [][2]string) string { + var b strings.Builder + for i, e := range entries { + sel := e[0] + args := e[1] + // 每个分片只含一个 tool_call,index 各不相同 —— 这正是并行多工具的真实形态 + b.WriteString(`data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":` + sel + + `,"id":"call_` + sel + `","type":"function","function":{"name":"` + e[0] + `_tool","arguments":"` + args + `"}}]},"finish_reason":null}]}` + "\n\n") + _ = i + } + b.WriteString(`data: [DONE]` + "\n") + return b.String() +} + +// 每个并行 tool_call 必须拿到**自己的**参数,不能全落到第一个。 +func TestSSE_ParallelToolCallsKeepOwnArguments(t *testing.T) { + // 3 个并行调用,分片 index 0/1/2,各带不同参数 + body := sseWithToolCalls([][2]string{ + {"0", `{\"days\":7}`}, + {"1", `{}`}, + {"2", `{\"q\":\"x\"}`}, + }) + resp, ok := parseOpenAICompatibleSSEBody([]byte(body)) + if !ok { + t.Skip("SSE 解析路径未启用(该 provider 可能不走流式)") + } + if len(resp.ToolCalls) != 3 { + t.Fatalf("应有 3 个 tool_call,实际 %d 个", len(resp.ToolCalls)) + } + for _, tc := range resp.ToolCalls { + if argsLookDropped(tc.RawArguments) { + t.Fatalf("tool_call %q 的参数被判为空(分片归属错了):raw=%q", + tc.Name, tc.RawArguments) + } + } +} + +// 同一 tool_call 的 arguments 分成多片时,必须按 index 拼接完整。 +func TestSSE_SplitArgumentsAccumulateByIndex(t *testing.T) { + // index 1 的参数分两片下发;index 0 只有一片。 + // 若用切片下标累积,第二片会被错记到 accs[0]。 + body := `data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_a","function":{"name":"a_tool","arguments":"{\"x\":1}"}}]}}]}` + "\n\n" + + `data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_b","function":{"name":"b_tool","arguments":"{\"y\":"}}]}}]}` + "\n\n" + + `data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"2}"}}]}}]}` + "\n\n" + + `data: [DONE]` + "\n" + + resp, ok := parseOpenAICompatibleSSEBody([]byte(body)) + if !ok { + t.Skip("SSE 解析路径未启用") + } + if len(resp.ToolCalls) != 2 { + t.Fatalf("应有 2 个 tool_call,实际 %d 个", len(resp.ToolCalls)) + } + byName := map[string]string{} + for _, tc := range resp.ToolCalls { + byName[tc.Name] = tc.RawArguments + } + if got := byName["a_tool"]; got != `{"x":1}` { + t.Fatalf("a_tool 参数错误(分片串了):%q", got) + } + if got := byName["b_tool"]; got != `{"y":2}` { + t.Fatalf("b_tool 参数未按 index 拼接:%q(期望 {\"y\":2})", got) + } +} diff --git a/internal/agent/core/output.go b/internal/agent/core/output.go index 17071a5c..71053c31 100644 --- a/internal/agent/core/output.go +++ b/internal/agent/core/output.go @@ -1,6 +1,7 @@ package core import ( + "encoding/json" "fmt" "log" "strings" @@ -143,15 +144,56 @@ func stringOrArray(v interface{}) []string { case []interface{}: out := make([]string, 0, len(t)) for _, e := range t { - if s, ok := e.(string); ok { - out = append(out, s) - } + out = append(out, coerceToString(e)) } return out } + // ★ 模型把结构化值直接传成对象/数字(而非 JSON 字符串)时必须**序列化保留**, + // 不能返回 nil。 + // + // 实测代价(2026-10-23 起,QQ 发送失败率 1.3% → 34%,132 次/天): + // 模型对 meta 的传参形态不稳定 —— 有时传 `"{\"user_id\":123}"`(字符串), + // 有时直接传 `{"user_id":123}`(对象)。后者在此返回 nil,而 + // parseOutputItems 对 len(metas)==0 的处理是**静默填空数组**: + // + // if len(metas) == 0 { metas = make([]string, len(payloads)) } + // + // ⇒ 插件收到 meta="" ⇒ 报 "meta 中需要 group_id 或 user_id 字段"。 + // 症状是"agent 答应了却没发到",而它完全不知道参数被吞了。 + if t, ok := marshalToJSONString(v); ok { + return []string{t} + } return nil } +// coerceToString 把数组元素统一成字符串;结构化元素序列化为 JSON。 +// +// 不做的话 `payload: [{"text":"a"}]` 之类会被静默丢弃(元素类型不是 string +// 就不 append),调用方只看到"少了几条",无从知道原因。 +func coerceToString(v interface{}) string { + if s, ok := v.(string); ok { + return s + } + if t, ok := marshalToJSONString(v); ok { + return t + } + return "" +} + +// marshalToJSONString 把非字符串值序列化成 JSON 字符串。 +// +// 返回 ok=false 表示 nil(即"确实没提供"),由调用方按缺失处理。 +func marshalToJSONString(v interface{}) (string, bool) { + if v == nil { + return "", false + } + b, err := json.Marshal(v) + if err != nil { + return "", false + } + return string(b), true +} + func (a *Agent) executeOutputSendTool(tc agentAPI.ToolCall) string { channel := strings.TrimPrefix(tc.Name, "output_send__") if channel == "" { diff --git a/internal/agent/core/output_meta_shape_test.go b/internal/agent/core/output_meta_shape_test.go new file mode 100644 index 00000000..1af8ef73 --- /dev/null +++ b/internal/agent/core/output_meta_shape_test.go @@ -0,0 +1,101 @@ +package core + +// meta / payload 传参形态的判据。 +// +// ## 真实事故(2026-10-23 起,持续到 10-25) +// +// 生产实测:`output_send__qq` 失败率从 1.3% 涨到 34%(132 次/天)。 +// +// 根因链: +// +// 模型对 meta 的传参形态不稳定: +// 有时传字符串 "{\"user_id\":123}" ← 能正常工作 +// 有时直接传对象 {"user_id":123} ← 从 10-22 "一 call 多发" 之后变多 +// +// stringOrArray 只认 string / []string / []interface{},遇到对象返回 nil +// ⇒ parseOutputItems 对 len(metas)==0 的处理是**静默填空数组** +// ⇒ 插件收到 meta="" ⇒ "meta 中需要 group_id 或 user_id 字段" +// +// 用户侧表现:agent 答应了却没发到,且它完全不知道参数被吞了 +// (表现为"忘了刚说过的话")。 +// +// ## 正确边界 +// +// 内核**不知道** meta 的语义,但它知道"模型给出了一个值"。 +// 该值不是字符串时必须**序列化保留**,不能当"没提供"处理。 + +import "testing" + +// 模型把 meta 传成对象时,必须序列化成 JSON 字符串保留下来。 +func TestParseOutputItems_MetaAsObjectIsPreserved(t *testing.T) { + args := map[string]interface{}{ + "payload": "你好", + "type": "text", + "meta": map[string]interface{}{"user_id": float64(2198972886)}, + } + items, err := parseOutputItems(args) + if err != nil { + t.Fatalf("不该报错:%v", err) + } + if len(items) != 1 { + t.Fatalf("应有 1 条待发送项,实际 %d", len(items)) + } + if items[0].meta == "" { + t.Fatal("meta 被静默丢弃(插件将报「需要 group_id 或 user_id」)") + } + // 必须是可被插件 json.Unmarshal 的 JSON 字符串 + if items[0].meta != `{"user_id":2198972886}` { + t.Fatalf("meta 应序列化为 JSON 字符串,实际 %q", items[0].meta) + } +} + +// 字符串形态必须**原样透传**(既有行为不能变)。 +func TestParseOutputItems_MetaAsStringUnchanged(t *testing.T) { + const raw = `{"user_id":123}` + items, err := parseOutputItems(map[string]interface{}{ + "payload": "你好", + "meta": raw, + }) + if err != nil { + t.Fatalf("不该报错:%v", err) + } + if items[0].meta != raw { + t.Fatalf("字符串形态应原样透传,实际 %q", items[0].meta) + } +} + +// meta 数组里的对象元素也必须保留(一 call 多发时每条的 meta 可能不同)。 +func TestParseOutputItems_MetaArrayWithObjects(t *testing.T) { + items, err := parseOutputItems(map[string]interface{}{ + "payload": []interface{}{"a", "b"}, + "meta": []interface{}{ + map[string]interface{}{"user_id": float64(1)}, + map[string]interface{}{"user_id": float64(2)}, + }, + }) + if err != nil { + t.Fatalf("不该报错:%v", err) + } + if len(items) != 2 { + t.Fatalf("应有 2 条,实际 %d", len(items)) + } + for i, it := range items { + if it.meta == "" { + t.Fatalf("第 %d 条的 meta 被丢弃", i+1) + } + } + if items[0].meta == items[1].meta { + t.Fatal("两条的 meta 不应相同(一一对应关系丢失)") + } +} + +// 确实没提供 meta 时,仍按"缺失"处理(空字符串),不能凭空造值。 +func TestParseOutputItems_MissingMetaStaysEmpty(t *testing.T) { + items, err := parseOutputItems(map[string]interface{}{"payload": "你好"}) + if err != nil { + t.Fatalf("不该报错:%v", err) + } + if items[0].meta != "" { + t.Fatalf("未提供 meta 时应为空,实际 %q", items[0].meta) + } +}