mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-19 16:39:15 +00:00
fix(opencode): never drop an assistant turn that carries tool_calls
An agent client (pi) serialises an assistant turn whose content is only [thinking, toolCall] as content:[] with tool_calls. The multimodal-strip pass treated an empty content array as 'nothing left, drop the whole message' and discarded the tool_calls with it. The next message is that call's tool result, so it arrived orphaned: the model saw a result for a call it had never made and re-issued the same call on every turn — an endless repeated-tool-call loop. Reproduced against a capture sink: content:[] lost tool_calls, while content:"" and content:null kept them. Only messages with neither usable content NOR a tool call now get dropped. Content that collapses to empty but still has tool_calls or a tool_call_id is emitted as "" instead. Test: TestOpenCodeKeepsToolCallWithEmptyContent (plus a negative control that an image-only message without tool calls is still dropped).
This commit is contained in:
@ -34,8 +34,10 @@ end
|
||||
|
||||
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
|
||||
-- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part
|
||||
-- (image_url / input_audio / file 等)一律剥离;因此失去全部 content 的
|
||||
-- 消息整条丢弃,避免上游 "unknown variant `image_url`, expected `text`"。
|
||||
-- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息
|
||||
-- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游
|
||||
-- "unknown variant `image_url`, expected `text`");带工具调用的必须保留,
|
||||
-- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。
|
||||
-- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder:
|
||||
-- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游
|
||||
-- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。
|
||||
@ -72,7 +74,24 @@ function adapter.transform_request(raw_body)
|
||||
end
|
||||
end
|
||||
if #parts == 0 then
|
||||
drop = true
|
||||
-- Content collapsed to nothing after stripping unsupported
|
||||
-- parts. A message that still carries a tool call must
|
||||
-- NEVER be dropped: the very next message is its tool
|
||||
-- result, and dropping the call orphans that result. The
|
||||
-- model then sees a result for a call it never made and
|
||||
-- re-issues the same tool call on every turn (observed as
|
||||
-- an infinite "repeated tool call" loop).
|
||||
--
|
||||
-- This is the common shape for agent clients: an assistant
|
||||
-- turn whose content is only [thinking, toolCall] serialises
|
||||
-- to content:[] with tool_calls — exactly the case that used
|
||||
-- to vanish here. Emit "" instead, which zen accepts.
|
||||
if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0)
|
||||
or msg.tool_call_id ~= nil then
|
||||
msg.content = ""
|
||||
else
|
||||
drop = true
|
||||
end
|
||||
else
|
||||
msg.content = parts
|
||||
end
|
||||
|
||||
@ -221,6 +221,70 @@ func TestOpenCodeStripsMultiModalParts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenCodeKeepsToolCallWithEmptyContent pins the fix for an infinite
|
||||
// "repeated tool call" loop: an assistant turn whose content serialises to an
|
||||
// empty ARRAY while carrying tool_calls must survive the multimodal strip.
|
||||
// Dropping it orphans the following tool result, so the model never sees the
|
||||
// call it already made and re-issues it on every turn.
|
||||
func TestOpenCodeKeepsToolCallWithEmptyContent(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
|
||||
body := `{"model":"x","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"},
|
||||
{"role":"user","content":"now summarize"}
|
||||
]}`
|
||||
out, err := vm.Transform("opencode", "transform_request", body)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_request: %v", err)
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"tool_calls"`
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatalf("unmarshal: %v (%s)", err, out)
|
||||
}
|
||||
if len(req.Messages) != 4 {
|
||||
t.Fatalf("expected 4 messages (tool call must not be dropped), got %d: %s", len(req.Messages), out)
|
||||
}
|
||||
asst := req.Messages[1]
|
||||
if asst.Role != "assistant" || len(asst.ToolCalls) != 1 || asst.ToolCalls[0].ID != "call_abc" {
|
||||
t.Fatalf("assistant tool_calls lost: %s", out)
|
||||
}
|
||||
if string(asst.Content) != `""` {
|
||||
t.Fatalf("empty content must be rewritten to \"\", got %s", asst.Content)
|
||||
}
|
||||
if req.Messages[2].Role != "tool" || req.Messages[2].ToolCallID != "call_abc" {
|
||||
t.Fatalf("tool result must stay paired with its call: %s", out)
|
||||
}
|
||||
|
||||
// Negative control: a message with neither text nor tool calls is still dropped.
|
||||
only := `{"model":"x","messages":[
|
||||
{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://ex.com/a.png"}}]},
|
||||
{"role":"user","content":"keep me"}
|
||||
]}`
|
||||
out2, err := vm.Transform("opencode", "transform_request", only)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Count(out2, `"role"`); got != 1 {
|
||||
t.Fatalf("image-only message without tool calls must still be dropped, got %d: %s", got, out2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHeadersCustomHook(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user