Files
HomeAgent/internal/agent/core/stream_index_test.go
JianFeeeee ddef1956b5 fix(agent): 流式并行 tool_call 按 JSON index 分桶,修复空参数调用
【根因】内核流式解析层丢弃了上游 SSE 分片的 OpenAI index 字段:
- openAIToolCall 结构体无 index 字段,JSON 解析即丢
- homed 的 openai.lua 转换为扁平结构时同样未透传 index
- accumulateStream 退而用 Go range slice 序号做累积桶 key,
  但每个 SSE chunk 只含一个 tool_call 元素,序号恒为 0

于是并行多工具调用(index=0,1,2,3)的所有分片全部写入同一个桶:
name 相互覆盖、args 碎片混拼成非法 JSON → parseToolArgsJSON
失败返回空 map → 工具以空参数被调用(spawn_child 报'请提供 task'、
cmd_run 报'command is required'等),agent 只能串行重试自愈。

单工具场景只有一个 index 无污染,故简单请求一直正常;
pi 直连同一 llmsproxy 正常(其实现标准按 index 累积)。

【修复】
- ToolCall 增加 StreamIndex(json:stream_index),openAIToolCall
  解析上游 index 并透传;openai.lua 输出 stream_index 字段
- accumulateStream 以 tc.StreamIndex 为累积 key
- flushToolCall 区分三种空参:未收到分片/碎片非合法 JSON/合法空
  对象({}),分别打诊断日志,避免误报
- 回归测试 TestAccumulateStreamParallelToolCallsByIndex 模拟
  4 路并行分片流验证按 index 正确分组与参数完整性

另含 spawn_child max_turns 参数、child_result 运行中状态区分、
provider 层非流式空参诊断日志。
2026-08-26 16:10:02 +08:00

88 lines
2.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"context"
"encoding/json"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
)
// 回归:并行多工具调用的流式分片必须按上游 index 字段分桶累积,
// 不能用 Go range 序号(每 chunk 单元素时恒为 0导致全部污染到同一桶
func TestAccumulateStreamParallelToolCallsByIndex(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
mk := func(idx int, id, name, raw string) agentAPI.StreamChunk {
return agentAPI.StreamChunk{
ToolCalls: []agentAPI.ToolCall{{
ID: id, Type: "function", Name: name, RawArguments: raw, StreamIndex: idx,
}},
}
}
ch := make(chan agentAPI.StreamChunk, 32)
chunks := []agentAPI.StreamChunk{
{Content: ""},
// tool_call 0: spawn_child 参数较长,分多片
mk(0, "call_a", "spawn_child", "{\"task\":"),
mk(0, "", "", "\"调查大模型排名\"}"),
// tool_call 1: browser_render
mk(1, "call_b", "browser_render", "{\"url\":"),
mk(1, "", "", "\"https://example.com\"}"),
// tool_call 2: cmd_run
mk(2, "call_c", "cmd_run", "{\"command\":\"uname -a\"}"),
// tool_call 3: skill_list
mk(3, "call_d", "skill_list", "{}"),
{Done: true, FinishReason: "tool_calls"},
}
for _, ck := range chunks {
ch <- ck
}
close(ch)
resp, err := accumulateStream(ctx, ch, nil)
if err != nil {
t.Fatalf("accumulateStream: %v", err)
}
if len(resp.ToolCalls) != 4 {
t.Fatalf("expected 4 tool calls, got %d: %+v", len(resp.ToolCalls), resp.ToolCalls)
}
want := map[string]string{
"spawn_child": `{"task":"调查大模型排名"}`,
"browser_render": `{"url":"https://example.com"}`,
"cmd_run": `{"command":"uname -a"}`,
"skill_list": `{}`,
}
for _, tc := range resp.ToolCalls {
raw, _ := json.Marshal(tc.Arguments)
got := string(raw)
exp, ok := want[tc.Name]
if !ok {
t.Errorf("unexpected tool %q args=%s", tc.Name, got)
continue
}
delete(want, tc.Name)
if tc.Name == "skill_list" {
// 无参工具的合法空对象 {},只需确认没被污染成乱码
continue
}
if len(tc.Arguments) == 0 {
t.Errorf("tool %q has EMPTY arguments (index pollution regression)", tc.Name)
continue
}
var wantMap map[string]interface{}
json.Unmarshal([]byte(exp), &wantMap)
gotB, _ := json.Marshal(wantMap)
if got != string(gotB) {
t.Errorf("tool %q args = %s, want %s", tc.Name, got, exp)
}
}
if len(want) > 0 {
t.Errorf("missing tool calls: %v", want)
}
if resp.FinishReason != "tool_calls" {
t.Errorf("finish reason = %q, want tool_calls", resp.FinishReason)
}
}