diff --git a/internal/lua/adapters/anthropic.lua b/internal/lua/adapters/anthropic.lua index acf018e..aafaf4f 100644 --- a/internal/lua/adapters/anthropic.lua +++ b/internal/lua/adapters/anthropic.lua @@ -108,22 +108,32 @@ function adapter.transform_stream_chunk(raw_chunk) -- first fragment of a tool call: emit index + id + name, empty args return json.encode({ content = "", done = false, + -- ★ 必须是**扁平**结构(name / raw_arguments 在顶层)且键名是 + -- stream_index —— homed 的 agentAPI.ToolCall 按 json tag 反序列化: + -- · 嵌套 ["function"]={...} ⇒ Go 侧取不到 name/raw_arguments(取零值) + -- · 键名写 index ⇒ StreamIndex 取零值 ⇒ 多个分片并到同一个桶, + -- argsRaw 混拼 ⇒ 每个工具报"参数不是合法 JSON"而一个都没真跑 + -- 两种形态都是**静默**失效,所以这里逐项对齐。 tool_calls = { { - index = chunk.index or 0, id = chunk.content_block.id or "", type = "function", - ["function"] = { name = chunk.content_block.name or "", arguments = "" } + name = chunk.content_block.name or "", + raw_arguments = "", + stream_index = chunk.index or 0 } } }) end if chunk.type == "content_block_delta" and chunk.delta then if chunk.delta.type == "input_json_delta" then -- incremental JSON fragment; clients accumulate across chunks + -- 同上:扁平 + stream_index。续传片 name 为空是正常的 —— + -- 内核按 stream_index 累积,补齐 name 后才 flush。 local unified = { content = "", done = false, tool_calls = { { - index = chunk.index or 0, id = "", type = "function", - ["function"] = { name = "", arguments = chunk.delta.partial_json or "" } + name = "", + raw_arguments = chunk.delta.partial_json or "", + stream_index = chunk.index or 0 } } } return json.encode(unified) end diff --git a/internal/lua/adapters/github.lua b/internal/lua/adapters/github.lua index 1a61ae7..3d2b2e9 100644 --- a/internal/lua/adapters/github.lua +++ b/internal/lua/adapters/github.lua @@ -86,10 +86,48 @@ function adapter.transform_stream_chunk(raw_chunk) if not chunk.choices or #chunk.choices == 0 then return "" end local delta = chunk.choices[1].delta or {} local fr = chunk.choices[1].finish_reason - return json.encode({ + local unified = { content = delta.content or "", done = (fr ~= nil) - }) + } + if delta.reasoning_content then + unified.reasoning_content = delta.reasoning_content + end + -- ★ 必须处理流式 tool_calls —— 此前只透 content/done,导致 deepseek 源 + -- 在**流式**模式下工具调用全部丢失,模型调不动任何工具且无任何报错。 + -- + -- 为什么难发现:非流式路径(transform_response)是好的,所以端到端 + -- 手工测试也过;而内核的 tool call 循环默认走流式。 + -- 功能判据(core 包的批内测试)直接构造 Go 结构体,绕过适配器。 + -- + -- 形态与 openai.lua 一致:OpenAI 兼容流式格式 + -- {function:{name,arguments}, id, type, index} → homed 扁平结构 + -- {id, type, name, raw_arguments, stream_index}。 + if delta.tool_calls then + local tcs = {} + for _, tc in ipairs(delta.tool_calls) do + local fn = tc["function"] + local name = (type(fn) == "table" and fn.name) or tc.name or "" + local raw_args = "" + if type(fn) == "table" and type(fn.arguments) == "string" then + raw_args = fn.arguments + elseif type(tc.arguments) == "string" then + raw_args = tc.arguments + end + -- 不能按 name 过滤:流式续传片 name 为空但携带 arguments, + -- 内核 accumulateStream 按 stream_index 分桶并累积 + table.insert(tcs, { + id = tc.id or "", + type = tc.type or "function", + name = name, + raw_arguments = raw_args, + -- 透传上游分片 index:并行多工具调用时内核按它区分归属桶 + stream_index = tc.index or 0 + }) + end + unified.tool_calls = tcs + end + return json.encode(unified) end return adapter diff --git a/internal/lua/adapters/groq.lua b/internal/lua/adapters/groq.lua index e6b8ea1..db11e06 100644 --- a/internal/lua/adapters/groq.lua +++ b/internal/lua/adapters/groq.lua @@ -85,10 +85,48 @@ function adapter.transform_stream_chunk(raw_chunk) if not chunk.choices or #chunk.choices == 0 then return "" end local delta = chunk.choices[1].delta or {} local fr = chunk.choices[1].finish_reason - return json.encode({ + local unified = { content = delta.content or "", done = (fr ~= nil) - }) + } + if delta.reasoning_content then + unified.reasoning_content = delta.reasoning_content + end + -- ★ 必须处理流式 tool_calls —— 此前只透 content/done,导致 deepseek 源 + -- 在**流式**模式下工具调用全部丢失,模型调不动任何工具且无任何报错。 + -- + -- 为什么难发现:非流式路径(transform_response)是好的,所以端到端 + -- 手工测试也过;而内核的 tool call 循环默认走流式。 + -- 功能判据(core 包的批内测试)直接构造 Go 结构体,绕过适配器。 + -- + -- 形态与 openai.lua 一致:OpenAI 兼容流式格式 + -- {function:{name,arguments}, id, type, index} → homed 扁平结构 + -- {id, type, name, raw_arguments, stream_index}。 + if delta.tool_calls then + local tcs = {} + for _, tc in ipairs(delta.tool_calls) do + local fn = tc["function"] + local name = (type(fn) == "table" and fn.name) or tc.name or "" + local raw_args = "" + if type(fn) == "table" and type(fn.arguments) == "string" then + raw_args = fn.arguments + elseif type(tc.arguments) == "string" then + raw_args = tc.arguments + end + -- 不能按 name 过滤:流式续传片 name 为空但携带 arguments, + -- 内核 accumulateStream 按 stream_index 分桶并累积 + table.insert(tcs, { + id = tc.id or "", + type = tc.type or "function", + name = name, + raw_arguments = raw_args, + -- 透传上游分片 index:并行多工具调用时内核按它区分归属桶 + stream_index = tc.index or 0 + }) + end + unified.tool_calls = tcs + end + return json.encode(unified) end return adapter diff --git a/internal/lua/adapters/kimicode.lua b/internal/lua/adapters/kimicode.lua index f37e00a..d734134 100644 --- a/internal/lua/adapters/kimicode.lua +++ b/internal/lua/adapters/kimicode.lua @@ -95,7 +95,42 @@ function adapter.transform_stream_chunk(raw_chunk) unified.reasoning_content = delta.reasoning_content end if delta.tool_calls then - unified.tool_calls = delta.tool_calls + local tcs = {} + for _, tc in ipairs(delta.tool_calls) do + -- OpenAI 流式格式: {function:{name,arguments}, id, type, index} + -- homed StreamChunk.ToolCalls 期望扁平格式: {id, type, name, raw_arguments} + local fn = tc["function"] + local name = (type(fn) == "table" and fn.name) or tc.name or "" + local raw_args = "" + if type(fn) == "table" and type(fn.arguments) == "string" then + raw_args = fn.arguments + elseif type(tc.arguments) == "string" then + raw_args = tc.arguments + end + -- 不能按 name 过滤:OpenAI 流式分片中后续块 name 为空但携带 arguments + -- accumulateStream 按 index 累积并在 flushToolCall 时校验 name + table.insert(tcs, { + id = tc.id or "", + type = tc.type or "function", + name = name, + raw_arguments = raw_args, + -- ★ 必须透传上游 index(键名是 stream_index,不是 index)。 + -- + -- 内核按 stream_index 分桶累积同一轮多个 tool_call 的分片 + -- (process.go:347 `idx := tc.StreamIndex`)。缺了这一项, + -- 所有分片的 StreamIndex 都是缺省 0 ⇒ 全部并进同一个桶 ⇒ + -- argsRaw 混拼 ⇒ 每个工具都报"参数不是合法 JSON", + -- 而工具一次都没真跑过。 + -- + -- 单工具调用时上游 index 恒为 0,缺省也是 0,所以这个问题 + -- 在生产上长期不显形 —— 直到模型一轮发多个工具才炸。 + -- + -- 续传分片(只有 arguments、没有 name)尤其依赖它: + -- 那种分片除了 index 没有任何可归位的依据。 + stream_index = tc.index or 0 + }) + end + unified.tool_calls = tcs end return json.encode(unified) end diff --git a/internal/lua/adapters/mistral.lua b/internal/lua/adapters/mistral.lua index b4f7a70..940395f 100644 --- a/internal/lua/adapters/mistral.lua +++ b/internal/lua/adapters/mistral.lua @@ -85,10 +85,48 @@ function adapter.transform_stream_chunk(raw_chunk) if not chunk.choices or #chunk.choices == 0 then return "" end local delta = chunk.choices[1].delta or {} local fr = chunk.choices[1].finish_reason - return json.encode({ + local unified = { content = delta.content or "", done = (fr ~= nil) - }) + } + if delta.reasoning_content then + unified.reasoning_content = delta.reasoning_content + end + -- ★ 必须处理流式 tool_calls —— 此前只透 content/done,导致 deepseek 源 + -- 在**流式**模式下工具调用全部丢失,模型调不动任何工具且无任何报错。 + -- + -- 为什么难发现:非流式路径(transform_response)是好的,所以端到端 + -- 手工测试也过;而内核的 tool call 循环默认走流式。 + -- 功能判据(core 包的批内测试)直接构造 Go 结构体,绕过适配器。 + -- + -- 形态与 openai.lua 一致:OpenAI 兼容流式格式 + -- {function:{name,arguments}, id, type, index} → homed 扁平结构 + -- {id, type, name, raw_arguments, stream_index}。 + if delta.tool_calls then + local tcs = {} + for _, tc in ipairs(delta.tool_calls) do + local fn = tc["function"] + local name = (type(fn) == "table" and fn.name) or tc.name or "" + local raw_args = "" + if type(fn) == "table" and type(fn.arguments) == "string" then + raw_args = fn.arguments + elseif type(tc.arguments) == "string" then + raw_args = tc.arguments + end + -- 不能按 name 过滤:流式续传片 name 为空但携带 arguments, + -- 内核 accumulateStream 按 stream_index 分桶并累积 + table.insert(tcs, { + id = tc.id or "", + type = tc.type or "function", + name = name, + raw_arguments = raw_args, + -- 透传上游分片 index:并行多工具调用时内核按它区分归属桶 + stream_index = tc.index or 0 + }) + end + unified.tool_calls = tcs + end + return json.encode(unified) end return adapter diff --git a/internal/lua/adapters/ollama.lua b/internal/lua/adapters/ollama.lua index 4e13d71..fe3a4e8 100644 --- a/internal/lua/adapters/ollama.lua +++ b/internal/lua/adapters/ollama.lua @@ -81,15 +81,28 @@ function adapter.transform_stream_chunk(raw_chunk) end if chunk.message.tool_calls then local tools = {} - for _, tc in ipairs(chunk.message.tool_calls) do + for i, tc in ipairs(chunk.message.tool_calls) do + -- ★ 必须是**扁平**结构(name / raw_arguments 在顶层)且键名是 + -- stream_index —— homed 的 agentAPI.ToolCall 按 json tag 反序列化: + -- · 嵌套 ["function"]={...} ⇒ Go 侧取不到 name/raw_arguments(零值) + -- · 键名写 index ⇒ StreamIndex 取零值 ⇒ 多个分片并到同一个桶, + -- argsRaw 混拼 ⇒ 每个工具报"参数不是合法 JSON"而一个都没真跑 + -- 两种都是**静默**失效,所以这里逐项对齐。 + local fn = tc["function"] + local name = (type(fn) == "table" and fn.name) or tc.name or "" + local args = "{}" + if type(fn) == "table" and fn.arguments ~= nil then + args = fn.arguments + elseif type(tc.arguments) == "string" then + args = tc.arguments + end + -- ollama 的 tool_calls **整条一次发完**(不分片),所以序号即下标 table.insert(tools, { - index = #tools, - id = tc.id or ("call_" .. #tools), + id = tc.id or ("call_" .. i), type = "function", - ["function"] = { - name = tc["function"] and tc["function"].name or "", - arguments = tc["function"] and (tc["function"].arguments or "{}") or "{}" - } + name = name, + raw_arguments = args, + stream_index = (tc.index or (i - 1)) }) end unified.tool_calls = tools diff --git a/internal/lua/adapters/server.lua b/internal/lua/adapters/server.lua index aeba948..c5697d9 100644 --- a/internal/lua/adapters/server.lua +++ b/internal/lua/adapters/server.lua @@ -95,7 +95,42 @@ function adapter.transform_stream_chunk(raw_chunk) unified.reasoning_content = delta.reasoning_content end if delta.tool_calls then - unified.tool_calls = delta.tool_calls + local tcs = {} + for _, tc in ipairs(delta.tool_calls) do + -- OpenAI 流式格式: {function:{name,arguments}, id, type, index} + -- homed StreamChunk.ToolCalls 期望扁平格式: {id, type, name, raw_arguments} + local fn = tc["function"] + local name = (type(fn) == "table" and fn.name) or tc.name or "" + local raw_args = "" + if type(fn) == "table" and type(fn.arguments) == "string" then + raw_args = fn.arguments + elseif type(tc.arguments) == "string" then + raw_args = tc.arguments + end + -- 不能按 name 过滤:OpenAI 流式分片中后续块 name 为空但携带 arguments + -- accumulateStream 按 index 累积并在 flushToolCall 时校验 name + table.insert(tcs, { + id = tc.id or "", + type = tc.type or "function", + name = name, + raw_arguments = raw_args, + -- ★ 必须透传上游 index(键名是 stream_index,不是 index)。 + -- + -- 内核按 stream_index 分桶累积同一轮多个 tool_call 的分片 + -- (process.go:347 `idx := tc.StreamIndex`)。缺了这一项, + -- 所有分片的 StreamIndex 都是缺省 0 ⇒ 全部并进同一个桶 ⇒ + -- argsRaw 混拼 ⇒ 每个工具都报"参数不是合法 JSON", + -- 而工具一次都没真跑过。 + -- + -- 单工具调用时上游 index 恒为 0,缺省也是 0,所以这个问题 + -- 在生产上长期不显形 —— 直到模型一轮发多个工具才炸。 + -- + -- 续传分片(只有 arguments、没有 name)尤其依赖它: + -- 那种分片除了 index 没有任何可归位的依据。 + stream_index = tc.index or 0 + }) + end + unified.tool_calls = tcs end return json.encode(unified) end diff --git a/internal/lua/anthropic_stream_test.go b/internal/lua/anthropic_stream_test.go new file mode 100644 index 0000000..deedd12 --- /dev/null +++ b/internal/lua/anthropic_stream_test.go @@ -0,0 +1,66 @@ +package lua + +import ( + "encoding/json" + "testing" +) + +// TestAnthropicAdapterEmitsFlatToolCallsWithStreamIndex 用 **Anthropic 协议** +// 的形态喂分片,验证输出是**扁平**结构且键名是 stream_index。 +// +// ★ 与 OpenAI 族的判据分开,原因有二: +// +// 1. 协议不同:Anthropic 用 content_block_start / content_block_delta + +// input_json_delta,不是 OpenAI 的 delta.tool_calls。共用一个 fixture +// 会因"不适用该 chunk"而跳过 —— 看着绿,实则没测。 +// 2. 它此前是**两处都错**:发嵌套 `["function"]={...}`,且键名用 `index` +// 而非 `stream_index`。两种都是**静默**失效(Go 侧按 json tag 取值,取不到 +// 就是零值,没有报错): +// · 嵌套 ⇒ name / raw_arguments 取零值 ⇒ flush 时判 "无 name" 丢弃 +// · 键名 index ⇒ StreamIndex 取零值 ⇒ 多分片并桶 ⇒ argsRaw 混拼 +func TestAnthropicAdapterEmitsFlatToolCallsWithStreamIndex(t *testing.T) { + vm := NewVM(t.TempDir()) + loadBundled(t, vm, "anthropic") + + // 首片:content_block_start,声明工具名 + start := `{"type":"content_block_start","index":2,` + + `"content_block":{"type":"tool_use","id":"toolu_01","name":"Read"}}` + // 续传片:content_block_delta + input_json_delta + delta := `{"type":"content_block_delta","index":2,` + + `"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"a\"}"}}` + + for i, f := range []string{start, delta} { + out, err := vm.CallTransformStreamChunk("anthropic", f) + if err != nil { + t.Fatalf("第 %d 片: %v", i, err) + } + var u struct { + ToolCalls []map[string]interface{} `json:"tool_calls"` + } + if json.Unmarshal([]byte(out), &u) != nil { + t.Fatalf("第 %d 片输出非法: %s", i, out) + } + if len(u.ToolCalls) == 0 { + t.Fatalf("第 %d 片没有 tool_calls: %s", i, out) + } + tc := u.ToolCalls[0] + // 必须是扁平:name / raw_arguments 在顶层,不能藏在 ["function"] 里 + if _, nested := tc["function"]; nested { + t.Errorf("第 %d 片仍是**嵌套**形态 %v —— Go 侧 agentAPI.ToolCall 没有 "+ + "function 字段,name/raw_arguments 会取零值(静默)", i, tc) + } + if _, has := tc["name"]; !has { + t.Errorf("第 %d 片缺顶层 name 键: %v", i, tc) + } + if _, has := tc["raw_arguments"]; !has { + t.Errorf("第 %d 片缺顶层 raw_arguments 键: %v", i, tc) + } + // 键名必须是 stream_index,不是 index + if si, ok := tc["stream_index"]; !ok { + t.Errorf("第 %d 片缺 stream_index 键: %v —— 键名写成 index 的话内核取零值,"+ + "多个分片并到同一个桶、参数混拼(静默)", i, tc) + } else if int(si.(float64)) != 2 { + t.Errorf("第 %d 片 stream_index = %v,应为 2", i, si) + } + } +} diff --git a/internal/lua/ollama_stream_test.go b/internal/lua/ollama_stream_test.go new file mode 100644 index 0000000..c8d2738 --- /dev/null +++ b/internal/lua/ollama_stream_test.go @@ -0,0 +1,55 @@ +package lua + +import ( + "encoding/json" + "testing" +) + +// TestOllamaAdapterEmitsFlatToolCallsWithStreamIndex 用 **Ollama 协议** +// 的形态喂分片,验证输出是扁平结构且键名是 stream_index。 +// +// ollama 的 tool_calls 是**整条一次发完**(不分片),所以 stream_index 取 +// 数组下标。它此前发的是嵌套 ["function"]={...} + index 键名 —— 两种都是 +// **静默**失效:Go 侧 agentAPI.ToolCall 没有 function 字段(取零值), +// 键名 index 也不会映射到 StreamIndex(取零值)。 +func TestOllamaAdapterEmitsFlatToolCallsWithStreamIndex(t *testing.T) { + vm := NewVM(t.TempDir()) + loadBundled(t, vm, "ollama") + + chunk := `{"message":{"content":"","tool_calls":[` + + `{"id":"c0","function":{"name":"Read","arguments":"{\"p\":1}"}},` + + `{"id":"c1","function":{"name":"Write","arguments":"{\"p\":2}"}}]},` + + `"done":false}` + out, err := vm.CallTransformStreamChunk("ollama", chunk) + if err != nil { + t.Fatalf("transform_stream_chunk: %v", err) + } + var u struct { + ToolCalls []map[string]interface{} `json:"tool_calls"` + } + if json.Unmarshal([]byte(out), &u) != nil { + t.Fatalf("输出非法: %s", out) + } + if len(u.ToolCalls) != 2 { + t.Fatalf("应有 2 个 tool_call,实际 %d: %s", len(u.ToolCalls), out) + } + for i, tc := range u.ToolCalls { + if _, nested := tc["function"]; nested { + t.Errorf("[%d] 仍是**嵌套**形态 %v —— Go 侧没有 function 字段,"+ + "name/raw_arguments 取零值(静默)", i, tc) + } + if _, has := tc["name"]; !has { + t.Errorf("[%d] 缺顶层 name: %v", i, tc) + } + if _, has := tc["raw_arguments"]; !has { + t.Errorf("[%d] 缺顶层 raw_arguments: %v", i, tc) + } + si, ok := tc["stream_index"] + if !ok { + t.Errorf("[%d] 缺 stream_index: %v —— 键名写成 index 的话内核取零值,"+ + "多分片并桶、参数混拼(静默)", i, tc) + } else if int(si.(float64)) != i { + t.Errorf("[%d] stream_index = %v,应为 %d", i, si, i) + } + } +} diff --git a/internal/lua/openai_family_stream_test.go b/internal/lua/openai_family_stream_test.go new file mode 100644 index 0000000..830149b --- /dev/null +++ b/internal/lua/openai_family_stream_test.go @@ -0,0 +1,64 @@ +package lua + +import ( + "encoding/json" + "testing" +) + +// openAICompatibleStreamAdapters 是「OpenAI 兼容流式协议」那一族适配器。 +// +// 它们的 transform_stream_chunk 曾只透 content/done,**完全不处理 +// tool_calls**(那部分只存在于 transform_response 即非流式路径)。后果: +// 流式模式下工具调用全部丢失,模型调不动任何工具,且没有任何报错。 +// +// 为什么难发现:非流式路径是好的 ⇒ 手工端到端测试也过;内核的 tool call +// 循环默认走流式 ⇒ 实际不可用;core 包的批内判据直接构造 Go 结构体, +// 绕过适配器 ⇒ 测不到这一层。 +// +// ★ 本判据按**协议族**组织而不是逐个适配器:这几个文件的流式函数逐字相同, +// +// 逐个写判据只是复制粘贴,且漏掉一个就少一个保护。 +var openAICompatibleStreamAdapters = []string{"openai", "deepseek", "github", "groq", "mistral"} + +func TestOpenAICompatibleFamilyHandlesStreamToolCalls(t *testing.T) { + // 两个 tool_call,各一片:首片带 name、续传片只带 arguments + frags := []string{ + `{"id":"c","choices":[{"index":0,"delta":{"tool_calls":[` + + `{"index":0,"id":"t0","type":"function","function":{"name":"cmd_run","arguments":""}}]}}]}`, + `{"id":"c","choices":[{"index":0,"delta":{"tool_calls":[` + + `{"index":1,"function":{"arguments":"{\"command\":\"x\"}"}}]}}]}`, + } + for _, name := range openAICompatibleStreamAdapters { + t.Run(name, func(t *testing.T) { + vm := NewVM(t.TempDir()) + loadBundled(t, vm, name) + for i, f := range frags { + out, err := vm.CallTransformStreamChunk(name, f) + if err != nil { + t.Fatalf("第 %d 片: %v", i, err) + } + var u struct { + ToolCalls []struct { + StreamIndex int `json:"stream_index"` + Name string `json:"name"` + RawArguments string `json:"raw_arguments"` + } `json:"tool_calls"` + } + if json.Unmarshal([]byte(out), &u) != nil { + t.Fatalf("第 %d 片输出非法: %s", i, out) + } + if len(u.ToolCalls) == 0 { + t.Errorf("第 %d 片:%s 的流式路径**丢掉了 tool_call**\n"+ + " 输出:%s\n"+ + " ⇒ 该源在流式模式下无法调用任何工具,且无任何报错。", + i, name, out) + continue + } + if u.ToolCalls[0].StreamIndex != i { + t.Errorf("第 %d 片 stream_index = %d,应为 %d —— 缺它会让内核"+ + "把多个分片并到同一个桶,参数混拼成非法 JSON", i, u.ToolCalls[0].StreamIndex, i) + } + } + }) + } +}