From 9c43e1c36756ada44d229f5133303ac73ba9c6c8 Mon Sep 17 00:00:00 2001 From: Hanako <216696112+gkd2323c@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:25:20 +0800 Subject: [PATCH] fix(responses): merge parallel function calls into one assistant message Devin rejects a history sequence where several assistant messages each carry a single toolCall followed by their results. The OpenAI Responses protocol emits each call as its own function_call item, and the adapter turned every item into a separate AssistantMessage, so a turn with parallel tool calls produced exactly that illegal sequence and upstream answered: invalid_argument: There is an issue with this request, please try a different model. Devin expects all tool calls issued in one generation to sit in a single assistant message with a toolCalls array. Append a function_call to the preceding assistant message when that message is a tool-use turn, instead of starting a new one. The guard on StopReason == StopReasonToolUse keeps text-only assistant turns from absorbing a later call, so serial tool-call history is unchanged. Fixes #7 --- internal/api/openai/responses/request.go | 15 ++++++- internal/api/openai/responses/request_test.go | 41 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/internal/api/openai/responses/request.go b/internal/api/openai/responses/request.go index a55612b..f5159ad 100644 --- a/internal/api/openai/responses/request.go +++ b/internal/api/openai/responses/request.go @@ -157,8 +157,21 @@ func appendInputItem(context *llm.RequestMessages, raw json.RawMessage) error { return err } arguments := json.RawMessage(item.Arguments) + call := llm.ToolCall{ID: item.CallID, Name: item.Name, Arguments: arguments} + // Devin 要求同一次生成发出的多个工具调用落在同一条 assistant 消息里: + // “两条 assistant 各带一个 toolCall,后面跟两个结果” 会被上游判为非法序列并 + // 回 invalid_argument(github.com/leookun/devin-2api/issues/7)。Responses + // 协议把每次调用拆成独立 item,这里并回上一条仍是 tool-use 的消息。 + if last := len(context.Messages) - 1; last >= 0 { + if assistant, ok := context.Messages[last].(llm.AssistantMessage); ok && + assistant.StopReason == llm.StopReasonToolUse { + assistant.Content = append(assistant.Content, call) + context.Messages[last] = assistant + return nil + } + } context.Messages = append(context.Messages, llm.AssistantMessage{ - Content: []llm.Content{llm.ToolCall{ID: item.CallID, Name: item.Name, Arguments: arguments}}, + Content: []llm.Content{call}, StopReason: llm.StopReasonToolUse, TimestampMS: time.Now().UnixMilli(), }) diff --git a/internal/api/openai/responses/request_test.go b/internal/api/openai/responses/request_test.go index 0bc3627..601406e 100644 --- a/internal/api/openai/responses/request_test.go +++ b/internal/api/openai/responses/request_test.go @@ -55,6 +55,47 @@ func TestDecodeRequestBuildsConversationContext(t *testing.T) { } } +// TestDecodeRequestMergesParallelFunctionCalls 验证并行工具调用并入同一条 assistant 消息。 +// Devin 上游拒绝“多条 assistant 各带一个 toolCall,后面跟多个结果”的序列(issue #7), +// 合并后 assistant.Content 应持有全部调用,且每个 call_id 仍能解析出工具名。 +func TestDecodeRequestMergesParallelFunctionCalls(t *testing.T) { + request, err := DecodeRequest([]byte(`{ + "model": "gpt-test", + "input": [ + {"type":"message","role":"user","content":[{"type":"input_text","text":"查时间和天气"}]}, + {"type":"function_call","call_id":"call-1","name":"get_time","arguments":"{}"}, + {"type":"reasoning","id":"rs-1","summary":[]}, + {"type":"function_call","call_id":"call-2","name":"get_weather","arguments":"{\"city\":\"Beijing\"}"}, + {"type":"function_call_output","call_id":"call-1","output":"18:30"}, + {"type":"function_call_output","call_id":"call-2","output":"22C"} + ], + "tools": [{"type":"function","name":"get_time","parameters":{"type":"object"}}, + {"type":"function","name":"get_weather","parameters":{"type":"object"}}] +}`)) + if err != nil { + t.Fatal(err) + } + if len(request.Context.Messages) != 4 { + t.Fatalf("message count = %d, want 4 (parallel calls must merge into one assistant)", len(request.Context.Messages)) + } + assistant, ok := request.Context.Messages[1].(llm.AssistantMessage) + if !ok { + t.Fatalf("message[1] type = %T, want llm.AssistantMessage", request.Context.Messages[1]) + } + if len(assistant.Content) != 2 { + t.Fatalf("assistant content count = %d, want 2", len(assistant.Content)) + } + for index, want := range []string{"get_time", "get_weather"} { + call, ok := assistant.Content[index].(llm.ToolCall) + if !ok || call.Name != want { + t.Fatalf("content[%d] = %#v, want tool %s", index, assistant.Content[index], want) + } + } + if err := request.Context.Validate(); err != nil { + t.Fatalf("context validation error = %v", err) + } +} + // TestDecodeRequestAcceptsStringInput 验证紧凑字符串输入会转换为用户文字消息。 func TestDecodeRequestAcceptsStringInput(t *testing.T) { request, err := DecodeRequest([]byte(`{"model":"gpt-test","input":"hello"}`))