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"}`))