fix(adapters): stop dropping non-streaming tool calls (agent loops died on turn 2)

Four adapters handled tool_calls in transform_stream_chunk but lost them in
transform_response, so any NON-streaming tool-using conversation broke on its
second request: the client received finish_reason:"tool_calls" with no
tool_calls payload, replayed an assistant message whose function
name/arguments were empty, and the upstream rejected the next turn with

    400 invalid tool_call function, function/name/arguments cannot be empty

The production audit trail shows 46 such failures on sensenova alone.

- sensenova.lua: forward message.tool_calls, decoding the arguments JSON string
  into an object as the unified shape expects.
- gemini.lua: collect functionCall parts from candidates[].content.parts. Also
  correct finish_reason, since Gemini reports "STOP" even when it emitted a
  function call and clients keyed on it treat that as a finished answer.
- ollama.lua: the field was initialized to an empty table and never filled;
  fill it and likewise correct done_reason "stop" -> "tool_calls".

trae is a different failure with the same symptom: trae-local-api's OpenAI
endpoint (/v1/chat/completions, src/server.js:353) never reads the request's
`tools` array — only its Anthropic endpoint does — so the relayed model is never
told the tool schema and instead PRINTS a <tool_call>{...}</tool_call> block into
content, leaving message.tool_calls null and finish_reason "stop". An OpenAI
client sees an ordinary completion and its agent loop ends mid-conversation.
trae.lua now recovers the structured call from that text, strips the block from
user-visible content, and corrects finish_reason. Both tag spellings
(<tool_call>/<toolcall>, the latter is what the same codebase's Anthropic prompt
asks for) and all three argument key names (arguments/params/input) are accepted.
This is a defensive fallback: fixing the upstream shim to honour `tools` remains
the real fix, since the model still guesses parameter names.

Tests: TestNonStreamToolCallsPreserved covers all ten OpenAI-shaped adapters,
TestGeminiNonStreamToolCalls and TestOllamaNonStreamToolCalls cover their native
shapes, TestTraeTextToolCallRecovery covers both tag spellings, prose around the
block, and asserts a plain text answer never gains tool_calls.

Verified end-to-end against mock upstreams reproducing each shape: a full
two-round agent loop (tool call -> tool result -> final answer) now completes for
both the structured and the text-emitted variants.
This commit is contained in:
JianFeeeee
2026-08-31 10:23:35 +08:00
parent 2ebc01e03b
commit 2e3d5b79ad
7 changed files with 408 additions and 8 deletions

View File

@ -81,16 +81,42 @@ function adapter.transform_response(raw_body)
if resp.candidates and #resp.candidates > 0 then
local cand = resp.candidates[1]
local tools = {}
if cand.content and cand.content.parts then
for _, part in ipairs(cand.content.parts) do
if part.text then
unified.content = unified.content .. part.text
elseif part.functionCall then
-- Non-streaming tool calls used to be dropped here while
-- transform_stream_chunk handled them, so a non-streaming
-- agent turn looked like a plain text answer and the tool
-- loop died. Gemini's args are already an object.
local args = part.functionCall.args
if type(args) == "string" then
local aok, decoded = pcall(json.decode, args)
args = aok and decoded or {}
elseif type(args) ~= "table" then
args = {}
end
table.insert(tools, {
id = part.functionCall.id or ("call_" .. #tools),
type = "function",
name = part.functionCall.name or "",
arguments = args
})
end
end
end
if cand.finishReason then
unified.finish_reason = cand.finishReason
end
if #tools > 0 then
unified.tool_calls = tools
-- Gemini reports finishReason "STOP" even when it emitted a
-- functionCall; clients keyed on finish_reason would treat that as
-- a completed answer and never run the tool.
unified.finish_reason = "tool_calls"
end
end
return json.encode(unified)

View File

@ -58,13 +58,39 @@ function adapter.transform_response(raw_body)
local unified = {
content = "",
finish_reason = resp.done_reason or "",
tool_calls = {},
-- key must be token_usage to match Go's UnifiedResponse json tag
token_usage = { prompt = p, completion = c, total = p + c }
}
if resp.message then
unified.content = resp.message.content or ""
-- Non-streaming tool calls were previously dropped: the field was
-- initialized to an empty table and never filled, while
-- transform_stream_chunk handled them. A non-streaming agent turn thus
-- looked like a plain answer and the tool loop stopped.
if type(resp.message.tool_calls) == "table" and #resp.message.tool_calls > 0 then
local tcs = {}
for _, tc in ipairs(resp.message.tool_calls) do
local fn = tc["function"] or {}
-- Ollama sends arguments as an object already
local args = fn.arguments
if type(args) == "string" then
local aok, decoded = pcall(json.decode, args)
args = aok and decoded or {}
elseif type(args) ~= "table" then
args = {}
end
table.insert(tcs, {
id = tc.id or ("call_" .. #tcs),
type = tc.type or "function",
name = fn.name or "",
arguments = args
})
end
unified.tool_calls = tcs
-- Ollama reports done_reason "stop" alongside tool calls
unified.finish_reason = "tool_calls"
end
end
return json.encode(unified)

View File

@ -61,6 +61,34 @@ function adapter.transform_response(raw_body)
-- sensenova-6.8-flash-lite 用 reasoning 字段而不是 reasoning_content
unified.reasoning_content = ch.message.reasoning
end
-- Tool calls MUST be forwarded. Dropping them while keeping
-- finish_reason="tool_calls" makes the client replay an assistant
-- message whose function name/arguments are empty, and sensenova
-- then rejects the next turn with
-- 400 invalid tool_call function, function/name/arguments cannot be empty
-- i.e. a tool-using conversation dies on its second request.
if type(ch.message.tool_calls) == "table" and #ch.message.tool_calls > 0 then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local fn = tc["function"] or {}
-- arguments arrives as a JSON *string* on the wire; the
-- unified shape expects a decoded object.
local args = fn.arguments
if type(args) == "string" then
local args_ok, decoded = pcall(json.decode, args)
args = args_ok and decoded or {}
elseif type(args) ~= "table" then
args = {}
end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = fn.name,
arguments = args
})
end
unified.tool_calls = tcs
end
end
unified.finish_reason = ch.finish_reason or ""
end

View File

@ -25,6 +25,78 @@ function adapter.transform_request(raw_body)
return json.encode(req)
end
-- parse_text_tool_calls extracts tool calls that an upstream emitted as PLAIN
-- TEXT instead of using the OpenAI tool_calls field.
--
-- trae-local-api's OpenAI endpoint (/v1/chat/completions) does not read the
-- request's `tools` array at all, so the relayed model is never told the tool
-- schema; it falls back to printing
-- <tool_call>
-- {"name": "get_weather", "arguments": {"location": "北京"}}
-- </tool_call>
-- into message.content, leaves message.tool_calls null, and reports
-- finish_reason="stop". A client following the OpenAI contract therefore never
-- sees a tool call: the agent loop terminates unexpectedly mid-conversation
-- (and a hand-written replay produces an empty function name next turn).
--
-- Both tag spellings are accepted: the same codebase's Anthropic endpoint
-- instructs models to emit <toolcall>, and models mix the two. Key names vary
-- too (arguments / params / input), so all are tried.
--
-- Returns (tool_calls_array_or_nil, content_with_blocks_removed).
local function parse_text_tool_calls(content)
if type(content) ~= "string" or content == "" then return nil, content end
if not (content:find("<tool_call", 1, true) or content:find("<toolcall", 1, true)) then
return nil, content
end
local tcs = {}
local idx = 0
local function collect(pattern)
for payload in content:gmatch(pattern) do
local ok, obj = pcall(json.decode, payload)
if ok and type(obj) == "table" then
-- some models wrap it as {"function":{"name":..,"arguments":..}}
local fn = obj["function"]
local name = obj.name or (type(fn) == "table" and fn.name) or nil
if name then
local args = obj.arguments or obj.params or obj.input
if args == nil and type(fn) == "table" then
args = fn.arguments or fn.params
end
if type(args) == "string" then
local aok, decoded = pcall(json.decode, args)
args = aok and decoded or {}
elseif type(args) ~= "table" then
args = {}
end
idx = idx + 1
table.insert(tcs, {
id = obj.id or ("call_text_" .. idx),
type = "function",
name = name,
arguments = args
})
end
end
end
end
-- `<tag ...>` allows attributes; %s* handles the "</tool_call >" spacing
-- that trae-local-api's own prompt example uses.
collect("<tool_call[^>]*>%s*(.-)%s*</tool_call%s*>")
collect("<toolcall[^>]*>%s*(.-)%s*</toolcall%s*>")
if #tcs == 0 then return nil, content end
-- drop the blocks from user-visible content; keep any surrounding prose
local stripped = content:gsub("<tool_call[^>]*>%s*.-%s*</tool_call%s*>", "")
stripped = stripped:gsub("<toolcall[^>]*>%s*.-%s*</toolcall%s*>", "")
stripped = stripped:gsub("^%s+", ""):gsub("%s+$", "")
return tcs, stripped
end
function adapter.transform_response(raw_body)
-- trae-local-api 偶发在非流式请求中返回 SSE 格式数据,
-- 表现为多个 data: {...} 行或混合了 reasoning_chunk 等。
@ -71,15 +143,21 @@ function adapter.transform_response(raw_body)
if ch.message.reasoning_content then
unified.reasoning_content = ch.message.reasoning_content
end
if type(ch.message.tool_calls) == "table" then
if type(ch.message.tool_calls) == "table" and #ch.message.tool_calls > 0 then
local tcs = {}
for _, tc in ipairs(ch.message.tool_calls) do
local args_ok, args = pcall(json.decode, tc["function"].arguments)
if not args_ok then args = {} end
local fn = tc["function"] or {}
local args = fn.arguments
if type(args) == "string" then
local args_ok, decoded = pcall(json.decode, args)
args = args_ok and decoded or {}
elseif type(args) ~= "table" then
args = {}
end
table.insert(tcs, {
id = tc.id,
type = tc.type or "function",
name = tc["function"].name,
name = fn.name,
arguments = args
})
end
@ -87,6 +165,22 @@ function adapter.transform_response(raw_body)
end
end
unified.finish_reason = ch.finish_reason or ""
-- Fallback: trae-local-api's OpenAI endpoint drops the request's
-- `tools` array entirely, so the relayed model is never told the tool
-- schema and instead PRINTS a <tool_call>{...}</tool_call> block into
-- content, leaving message.tool_calls null and finish_reason="stop".
-- A client following the OpenAI contract then sees a normal completion
-- and its agent loop terminates mid-conversation. Recover the
-- structured call so the loop can continue.
if unified.tool_calls == nil then
local recovered, cleaned = parse_text_tool_calls(unified.content)
if recovered then
unified.tool_calls = recovered
unified.content = cleaned
unified.finish_reason = "tool_calls"
end
end
end
return json.encode(unified)