From f240568a789eabcf2d2eed9f10c6e0e4a7f7cb3c Mon Sep 17 00:00:00 2001 From: euvre <93761161+euvre@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:26:54 -0700 Subject: [PATCH] fix(agent): accept 1D files wire shape in chat/completions (#18197) --- internal/handler/agent.go | 70 +++++++++++++++++++++------------- internal/handler/agent_test.go | 56 ++++++++++++++++++++++++--- 2 files changed, 94 insertions(+), 32 deletions(-) diff --git a/internal/handler/agent.go b/internal/handler/agent.go index 69421144ce..b3dac9f881 100644 --- a/internal/handler/agent.go +++ b/internal/handler/agent.go @@ -643,15 +643,11 @@ func respondWithDebugResult(c *gin.Context, result *task.PipelineResult, err err // (id/name/created_by); the raw bytes are fetched from the per-user downloads // bucket via the file service. When no usable file is present it returns a // default name and a nil slice so the pipeline can still run file-less. -func (h *AgentHandler) extractChatDebugFile(ctx context.Context, files [][]map[string]interface{}, user *entity.User) (string, []byte) { +func (h *AgentHandler) extractChatDebugFile(ctx context.Context, files []map[string]interface{}, user *entity.User) (string, []byte) { if len(files) == 0 { return "debug", nil } - fileList := files[0] - if len(fileList) == 0 { - return "debug", nil - } - fd := fileList[0] + fd := files[0] name, _ := fd["name"].(string) id, _ := fd["id"].(string) if id == "" { @@ -1079,16 +1075,44 @@ type agentChatCompletionsRequest struct { Model string `json:"model"` Messages []map[string]interface{} `json:"messages"` ReturnTrace bool `json:"return_trace"` - // Files carries the uploaded file references for a run. The RAGFlow web - // front-end wraps the file list one extra level (a list of per-turn file - // lists), so the wire shape is `[[{id, name, ...}]]`. This mirrors the - // Python agent_api.py:1611 contract `queue_dataflow(..., files[0], 0)`, - // where `files[0]` (the first inner list) is the set of files for this - // run. The dataflow debug extractor and the agent run path both unwrap - // the outer layer: `Files[0]` reaches the file dicts. The web contract - // is 2D, so a plain 1D `[]map[string]interface{}` would fail to decode - // and 400 the whole request. - Files [][]map[string]interface{} `json:"files"` + // Files carries the uploaded file references for a run, normalized to + // the 1D file-dict list. See agentFiles for the two accepted wire + // shapes. + Files agentFiles `json:"files"` +} + +// agentFiles carries the uploaded file references for a canvas run. The +// wire shape differs by caller: the agent chat front-end posts a 1D list +// of file dicts (`files: [{id, ...}]`, web use-send-agent-message.ts), +// while the dataflow debug front-end wraps it one extra level +// (`files: [[{id, ...}]]`, web use-run-dataflow.ts). Python accepts both +// because each consumer path only sees the shape its own front-end sends: +// the chat path iterates the 1D list directly (canvas.py get_files_async) +// and the dataflow debug branch takes `files[0]` (agent_api.py +// queue_dataflow call site). We normalize both to the 1D list here; for +// the 2D shape the first inner list wins, matching Python's `files[0]`. +type agentFiles []map[string]interface{} + +// UnmarshalJSON accepts both the 1D (`[{...}]`) and 2D (`[[{...}]]`) +// wire shapes described on agentFiles and normalizes to the 1D list. +func (f *agentFiles) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *f = nil + return nil + } + var oneD []map[string]interface{} + if err := json.Unmarshal(data, &oneD); err == nil { + *f = oneD + return nil + } + var twoD [][]map[string]interface{} + if err := json.Unmarshal(data, &twoD); err != nil { + return err + } + if len(twoD) > 0 { + *f = twoD[0] + } + return nil } // extractLastUserContent returns the content of the last message in @@ -1274,16 +1298,10 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) { req.SessionID = utility.GenerateToken() } - // The web contract wraps `files` one level ([[{...}]]); unwrap the - // outer layer so RunAgent receives the inner 1D file list, matching the - // Python canvas file component's `kwargs.get("file")[0]` unwrap. When no - // files are present (the common agent-chat case) pass nil so RunAgent's - // `len(files) > 0` guard sees an empty run. - var chatFiles []map[string]interface{} - if len(req.Files) > 0 { - chatFiles = req.Files[0] - } - events, err := h.chatRunner.RunAgent(c.Request.Context(), user.ID, req.AgentID, req.SessionID, "", userInput, chatFiles) + // req.Files is already normalized to the 1D file list by the + // agentFiles unmarshaler. A nil/empty list reaches RunAgent as-is so + // its `len(files) > 0` guard sees a file-less run. + events, err := h.chatRunner.RunAgent(c.Request.Context(), user.ID, req.AgentID, req.SessionID, "", userInput, req.Files) if err != nil { common.Warn("agent chat completions: RunAgent failed", append([]zap.Field{ diff --git a/internal/handler/agent_test.go b/internal/handler/agent_test.go index 1873868473..77ce4e9cb5 100644 --- a/internal/handler/agent_test.go +++ b/internal/handler/agent_test.go @@ -1244,12 +1244,11 @@ func (s *stubDocService) Accessible(_, _ string) bool { return s.accessible } -// TestAgentChatCompletions_FilesDeserialized verifies that when the -// JSON request body contains the web-contract 2D `files` field -// (`[[{...}]]`), the agentChatCompletionsRequest struct deserializes it -// and the inner file list reaches RunAgent. Mirrors Python's -// agent_api.py:1611 `queue_dataflow(..., files[0], 0)`, where the first -// inner list is the set of files for the run. +// TestAgentChatCompletions_FilesDeserialized verifies that the +// dataflow-debug 2D `files` shape (`[[{...}]]`, web use-run-dataflow.ts) +// still deserializes: the agentFiles unmarshaler normalizes it by taking +// the first inner list, matching Python's `files[0]` access in the +// dataflow debug branch (agent_api.py queue_dataflow call site). func TestAgentChatCompletions_FilesDeserialized(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -1288,6 +1287,51 @@ func TestAgentChatCompletions_FilesDeserialized(t *testing.T) { } } +// TestAgentChatCompletions_Files1DDeserialized pins the agent chat wire +// shape: the chat front-end posts `files` as a 1D list of file dicts +// (`[{...}]`, use-send-agent-message.ts). The previous 2D-only struct +// field rejected this with a 400 "cannot unmarshal object into Go struct +// field ... of type []map[string]interface {}", so sending a message +// with an uploaded image produced no agent response in the chat UI. +func TestAgentChatCompletions_Files1DDeserialized(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + body := `{ + "agent_id": "a1", + "query": "hi", + "files": [ + {"id": "file-1", "name": "photo.png", "mime_type": "image/png", "created_by": "u1"} + ] + }` + c.Request = httptest.NewRequest("POST", "/api/v1/agents/chat/completions", + strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Set("user", &entity.User{ID: "u1"}) + c.Set("user_id", "u1") + + var captured any + var capturedFiles []map[string]interface{} + runner := &captureChatRunner{captured: &captured, capturedFiles: &capturedFiles} + h := &AgentHandler{chatRunner: runner} + h.AgentChatCompletions(c) + + var resp map[string]interface{} + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if code, _ := resp["code"].(float64); code == float64(common.CodeArgumentError) { + t.Fatalf("1D files rejected: code=%v message=%v; want the request accepted", code, resp["message"]) + } + if len(capturedFiles) != 1 { + t.Fatalf("capturedFiles length = %d, want 1", len(capturedFiles)) + } + if id, _ := capturedFiles[0]["id"].(string); id != "file-1" { + t.Errorf("capturedFiles[0][\"id\"] = %q, want %q", id, "file-1") + } + if mime, _ := capturedFiles[0]["mime_type"].(string); mime != "image/png" { + t.Errorf("capturedFiles[0][\"mime_type\"] = %q, want %q", mime, "image/png") + } +} + // TestAgentChatCompletions_EmptyFilesNil verifies that when the JSON // request body does NOT include `files`, the handler passes nil to // RunAgent (no crash, no spurious slice). Mirrors Python's behavior