diff --git a/internal/lua/adapters/gemini.lua b/internal/lua/adapters/gemini.lua index 96fbf36..2da7489 100644 --- a/internal/lua/adapters/gemini.lua +++ b/internal/lua/adapters/gemini.lua @@ -7,6 +7,10 @@ adapter.headers = {} -- Gemini API: POST /v1/models/{model}:generateContent -- Auth: API key in query param ?key=XXX or Authorization: Bearer XXX +-- +-- 请求方向必须把 OpenAI 的工具调用翻译成 Gemini 的 functionCall / +-- functionResponse,否则 agent 回放的历史里助手那一轮的调用会凭空消失, +-- 紧随其后的工具结果就成了「无来源」的孤立结果,模型只能反复重发同一个调用。 function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) if not ok then return raw_body end @@ -14,16 +18,23 @@ function adapter.transform_request(raw_body) -- 将 OpenAI 风格 content(字符串或 [{type:*}] 数组)拆成 Gemini parts local function to_parts(content) if type(content) == "string" then + if content == "" then return {} end return { { text = content } } end local parts = {} for _, p in ipairs(content or {}) do - if p.type == "text" then - table.insert(parts, { text = p.text }) - elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then - local mt, b64 = string.match(p.image_url.url, "^data:([^,]+);base64,(.+)$") - if b64 then - table.insert(parts, { inline_data = { mime_type = mt or "image/png", data = b64 } }) + if type(p) == "string" then + if p ~= "" then table.insert(parts, { text = p }) end + elseif type(p) == "table" then + if p.type == "text" then + if p.text ~= nil and p.text ~= "" then + table.insert(parts, { text = p.text }) + end + elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then + local mt, b64 = string.match(p.image_url.url, "^data:([^,]+);base64,(.+)$") + if b64 then + table.insert(parts, { inline_data = { mime_type = mt or "image/png", data = b64 } }) + end end end end @@ -31,11 +42,59 @@ function adapter.transform_request(raw_body) end local contents = {} + local system = "" + local call_names = {} -- tool_call_id -> 函数名(functionResponse 只认名字) + for _, m in ipairs(req.messages or {}) do - table.insert(contents, { - role = (m.role == "assistant") and "model" or m.role, - parts = to_parts(m.content) - }) + local role = m.role or "user" + + if role == "system" then + if type(m.content) == "string" then + system = system .. m.content .. "\n" + end + + elseif role == "assistant" then + local parts = to_parts(m.content) + if type(m.tool_calls) == "table" then + for _, tc in ipairs(m.tool_calls) do + if type(tc) == "table" then + local fn = tc["function"] or {} + local args = fn.arguments + if type(args) == "string" and args ~= "" then + local aok, decoded = pcall(json.decode, args) + args = aok and decoded or {} + elseif type(args) ~= "table" then + args = {} + end + if tc.id ~= nil then call_names[tc.id] = fn.name or "" end + table.insert(parts, { + functionCall = { name = fn.name or "", args = args } + }) + end + end + end + -- Gemini 不接受空的 parts 数组;无可发送内容的轮次直接跳过 + if #parts > 0 then + table.insert(contents, { role = "model", parts = parts }) + end + + elseif role == "tool" then + local name = call_names[m.tool_call_id] or m.name or "" + local text = type(m.content) == "string" and m.content or "" + table.insert(contents, { + role = "user", + parts = { { functionResponse = { + name = name, + response = { content = text }, + } } } + }) + + else + local parts = to_parts(m.content) + if #parts > 0 then + table.insert(contents, { role = "user", parts = parts }) + end + end end local gemini_req = { @@ -46,6 +105,30 @@ function adapter.transform_request(raw_body) } } + if system ~= "" then + gemini_req.systemInstruction = { parts = { { text = system } } } + end + + -- tools -> functionDeclarations + if type(req.tools) == "table" then + local decls = {} + for _, t in ipairs(req.tools) do + if type(t) == "table" and type(t["function"]) == "table" then + local fn = t["function"] + local params = fn.parameters + if type(params) ~= "table" then params = { type = "object", properties = {} } end + table.insert(decls, { + name = fn.name or "", + description = fn.description or "", + parameters = params, + }) + end + end + if #decls > 0 then + gemini_req.tools = { { functionDeclarations = decls } } + end + end + if req.stream then gemini_req.stream = true end diff --git a/internal/lua/adapters/ollama.lua b/internal/lua/adapters/ollama.lua index 541c343..6e13b7e 100644 --- a/internal/lua/adapters/ollama.lua +++ b/internal/lua/adapters/ollama.lua @@ -6,6 +6,10 @@ adapter.endpoint = "/api/chat" adapter.headers = {} -- Ollama API 格式:{ model, messages, stream, options:{temperature,num_predict} } +-- 工具调用必须一并翻译:Ollama 的 assistant 消息用 tool_calls(arguments 是对象 +-- 而非 JSON 字符串),工具结果用 tool 角色 + tool_name。此前这里只复制了 +-- role/content,助手那轮的调用和 tool 消息的归属全部丢失,模型看到无来源的 +-- 工具结果就只能反复重发同一个调用。 function adapter.transform_request(raw_body) local ok, req = pcall(json.decode, raw_body) if not ok then return raw_body end @@ -22,6 +26,7 @@ function adapter.transform_request(raw_body) -- 转换 messages 格式(Ollama messages 支持 images base64 数组) if req.messages then local msgs = {} + local call_names = {} -- tool_call_id -> 函数名 for _, m in ipairs(req.messages) do local text, images if type(m.content) == "string" then @@ -30,22 +35,61 @@ function adapter.transform_request(raw_body) text = "" images = {} for _, p in ipairs(m.content or {}) do - if p.type == "text" then - text = text .. (p.text or "") - elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then - local b64 = string.match(p.image_url.url, "^data:[^,]+;base64,(.+)$") - if b64 then table.insert(images, b64) end + if type(p) == "table" then + if p.type == "text" then + text = text .. (p.text or "") + elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then + local b64 = string.match(p.image_url.url, "^data:[^,]+;base64,(.+)$") + if b64 then table.insert(images, b64) end + end end end if #images == 0 then images = nil end end local msg = { role = m.role, content = text } if images then msg.images = images end + + -- 助手轮的工具调用:arguments 转成对象 + if type(m.tool_calls) == "table" and #m.tool_calls > 0 then + local tcs = {} + for _, tc in ipairs(m.tool_calls) do + if type(tc) == "table" then + local fn = tc["function"] or {} + local args = fn.arguments + if type(args) == "string" and args ~= "" then + local aok, decoded = pcall(json.decode, args) + args = aok and decoded or {} + elseif type(args) ~= "table" then + args = {} + end + if tc.id ~= nil then call_names[tc.id] = fn.name or "" end + table.insert(tcs, { + ["function"] = { name = fn.name or "", arguments = args } + }) + end + end + if #tcs > 0 then msg.tool_calls = tcs end + end + + -- 工具结果:Ollama 用 tool_name 标识归属(不认 tool_call_id) + if m.role == "tool" then + local name = call_names[m.tool_call_id] or m.name or "" + if m.tool_call_id ~= nil then + msg.tool_call_id = m.tool_call_id + end + if name ~= "" then msg.tool_name = name end + end + table.insert(msgs, msg) end ollama_req.messages = msgs end + -- tools 透传(Ollama 的 shape 与 OpenAI 一致) + if type(req.tools) == "table" and #req.tools > 0 then + ollama_req.tools = req.tools + end + return json.encode(ollama_req) end diff --git a/internal/lua/toolcall_preservation_test.go b/internal/lua/toolcall_preservation_test.go new file mode 100644 index 0000000..f4f7c9f --- /dev/null +++ b/internal/lua/toolcall_preservation_test.go @@ -0,0 +1,120 @@ +package lua + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestAdaptersPreserveToolCalls is the fleet-wide guard for the +// "orphaned tool result" class of bug: when an agent client replays a turn +// whose assistant message carries tool_calls, EVERY adapter must forward both +// the call and the attribution of its result. Dropping the call (or the +// tool_call_id / tool_name that ties the result to it) makes the model see a +// result for a call it never made and re-issue the same call forever. +// +// The input deliberately uses content:[] — the shape most agent clients emit +// for a tool-calling assistant turn with no text. +func TestAdaptersPreserveToolCalls(t *testing.T) { + vm := NewVM(freshAdapterDir(t)) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + defer vm.Stop() + + body := `{"model":"m","messages":[ + {"role":"user","content":"read /tmp/x"}, + {"role":"assistant","content":[],"tool_calls":[{"id":"call_abc","type":"function","function":{"name":"read","arguments":"{\"path\":\"/tmp/x\"}"}}]}, + {"role":"tool","tool_call_id":"call_abc","content":"hello world"}, + {"role":"user","content":"summarize"} + ]}` + const ( + callID = "call_abc" + fnName = "read" + toolOut = "hello world" + lastUser = "summarize" + ) + + // Adapters that identify a tool result by the call id. + byID := []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral", + "sensenova", "trae", "agentrouter", "opencode", "anthropic"} + + for _, a := range byID { + out, err := vm.Transform(a, "transform_request", body) + if err != nil { + t.Errorf("%s: transform_request: %v", a, err) + continue + } + if !strings.Contains(out, callID) { + t.Errorf("%s dropped the tool call id %q: %s", a, callID, out) + } + if !strings.Contains(out, fnName) { + t.Errorf("%s dropped the function name %q: %s", a, fnName, out) + } + if !strings.Contains(out, toolOut) { + t.Errorf("%s dropped the tool result: %s", a, out) + } + if !strings.Contains(out, lastUser) { + t.Errorf("%s dropped the final user turn: %s", a, out) + } + } + + // Ollama identifies the result with tool_name (its API has no tool_call_id + // on assistant turns) and takes arguments as an object, not a JSON string. + out, err := vm.Transform("ollama", "transform_request", body) + if err != nil { + t.Fatalf("ollama: %v", err) + } + for _, want := range []string{`"tool_calls"`, fnName, `"tool_name"`, toolOut} { + if !strings.Contains(out, want) { + t.Errorf("ollama missing %s: %s", want, out) + } + } + + // Gemini identifies the result by function NAME and has no call id in its + // wire format; both directions must be present as functionCall / + // functionResponse parts, and the system role must move to + // systemInstruction rather than sitting in contents. + gBody := `{"model":"m","messages":[ + {"role":"system","content":"be brief"}, + {"role":"user","content":"read /tmp/x"}, + {"role":"assistant","content":[],"tool_calls":[{"id":"call_abc","type":"function","function":{"name":"read","arguments":"{\"path\":\"/tmp/x\"}"}}]}, + {"role":"tool","tool_call_id":"call_abc","content":"hello world"} + ]}` + gout, err := vm.Transform("gemini", "transform_request", gBody) + if err != nil { + t.Fatalf("gemini: %v", err) + } + for _, want := range []string{`"functionCall"`, `"functionResponse"`, fnName, toolOut, `"systemInstruction"`} { + if !strings.Contains(gout, want) { + t.Errorf("gemini missing %s: %s", want, gout) + } + } + // No message may keep the OpenAI-only roles inside contents. + var greq struct { + Contents []struct { + Role string `json:"role"` + } `json:"contents"` + } + if err := json.Unmarshal([]byte(gout), &greq); err != nil { + t.Fatalf("gemini unmarshal: %v (%s)", err, gout) + } + for _, c := range greq.Contents { + if c.Role != "user" && c.Role != "model" { + t.Errorf("gemini contents carry an invalid role %q: %s", c.Role, gout) + } + } + + // Negative control: a text-only turn must still be forwarded verbatim. + plain := `{"model":"m","messages":[{"role":"user","content":"just text"}]}` + for _, a := range append(byID, "ollama", "gemini") { + pout, err := vm.Transform(a, "transform_request", plain) + if err != nil { + t.Errorf("%s plain: %v", a, err) + continue + } + if !strings.Contains(pout, "just text") { + t.Errorf("%s dropped plain text: %s", a, pout) + } + } +} diff --git a/internal/types/types.go b/internal/types/types.go index c6fc550..6ff4178 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -3,6 +3,7 @@ package types import ( + "bytes" "encoding/json" "errors" "strings" @@ -61,6 +62,39 @@ type ChatMessage struct { func StringContent(s string) json.RawMessage { b, _ := json.Marshal(s); return b } +// isEmptyJSONArray reports whether raw is the literal empty JSON array []. +func isEmptyJSONArray(raw json.RawMessage) bool { + t := bytes.TrimSpace(raw) + return len(t) == 2 && t[0] == '[' && t[1] == ']' +} + +// UnmarshalJSON decodes a chat message, normalising an empty-array content +// ("content":[]) to an empty string and dropping an empty tool_calls array. +// +// Why this exists: Lua adapters json.decode the request and re-encode it, and +// an empty Lua table is indistinguishable from an empty JSON array — the +// encoder emits {} for both. Agent clients serialise an assistant turn that +// carries tool_calls and no text as content:[], so every pass-through adapter +// turned it into content:{} — a shape that is not valid OpenAI (content is +// string | array of parts | null) and that real upstreams reject with +// "400 invalid arguments". Normalising at the decode boundary fixes every +// adapter at once, including ones added later. +func (m *ChatMessage) UnmarshalJSON(b []byte) error { + type alias ChatMessage + var a alias + if err := json.Unmarshal(b, &a); err != nil { + return err + } + *m = ChatMessage(a) + if isEmptyJSONArray(m.Content) { + m.Content = StringContent("") + } + if isEmptyJSONArray(m.ToolCalls) { + m.ToolCalls = nil + } + return nil +} + type ToolCall struct { ID string `json:"id"` Type string `json:"type"` diff --git a/internal/types/types_test.go b/internal/types/types_test.go index bb4e8a1..4c758dc 100644 --- a/internal/types/types_test.go +++ b/internal/types/types_test.go @@ -68,3 +68,52 @@ func TestTokenUsageMarshalCacheFields(t *testing.T) { t.Logf("DeepSeek JSON: %s", string(b)) t.Logf("OpenAI JSON: %s", string(b2)) } + +// TestChatMessageNormalizesEmptyArrayContent pins the fix for the malformed +// content:{} that pass-through adapters produced for an assistant turn carrying +// tool_calls with no text. Lua adapters cannot tell an empty JSON array from an +// empty object, so the decode boundary normalises it for every adapter at once. +func TestChatMessageNormalizesEmptyArrayContent(t *testing.T) { + var msg ChatMessage + raw := `{"role":"assistant","content":[],"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read","arguments":"{}"}}]}` + if err := json.Unmarshal([]byte(raw), &msg); err != nil { + t.Fatal(err) + } + if string(msg.Content) != `""` { + t.Fatalf("empty-array content must normalise to \"\", got %s", msg.Content) + } + if len(msg.ToolCalls) == 0 { + t.Fatal("tool_calls must survive normalisation") + } + + // A non-empty content array must be left byte-identical (multimodal path). + var mm ChatMessage + multi := `{"role":"user","content":[{"type":"text","text":"hi"}]}` + if err := json.Unmarshal([]byte(multi), &mm); err != nil { + t.Fatal(err) + } + if string(mm.Content) != `[{"type":"text","text":"hi"}]` { + t.Fatalf("multimodal content must pass through untouched, got %s", mm.Content) + } + + // A plain string content is untouched. + var sm ChatMessage + if err := json.Unmarshal([]byte(`{"role":"user","content":"plain"}`), &sm); err != nil { + t.Fatal(err) + } + if string(sm.Content) != `"plain"` { + t.Fatalf("string content must pass through untouched, got %s", sm.Content) + } + + // An omitted content stays omitted (omitempty semantics preserved). + var om ChatMessage + if err := json.Unmarshal([]byte(`{"role":"assistant","tool_calls":[]}`), &om); err != nil { + t.Fatal(err) + } + if om.Content != nil { + t.Fatalf("absent content must stay absent, got %s", om.Content) + } + if om.ToolCalls != nil { + t.Fatalf("empty tool_calls array must be dropped, got %s", om.ToolCalls) + } +}