fix(agent): accept 1D files wire shape in chat/completions (#18197)

This commit is contained in:
euvre
2026-08-12 23:26:54 -07:00
committed by GitHub
parent 827ec11525
commit f240568a78
2 changed files with 94 additions and 32 deletions

View File

@@ -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{

View File

@@ -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