mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-19 22:21:04 +08:00
fix(go-agent): preserve canvas system state across turns (#17010)
## Summary - Preserve request-scoped system variables such as files and user IDs during Canvas execution. - Persist conversation history, turn counts, and tool memory in the session DSL across turns. - Parse agent uploads into `sys.files` and align system variable rendering with Python. ## Testing - `bash build.sh --test ./internal/agent/...` - `bash build.sh --test ./internal/service/...` <img width="1896" height="1232" alt="image" src="https://github.com/user-attachments/assets/b420cd97-53c3-470f-a3e1-d39cea26a213" />
This commit is contained in:
@@ -65,7 +65,7 @@ type agentFileService interface {
|
||||
// NewAgentHandler assigns the concrete *service.AgentService — which
|
||||
// satisfies this interface because its RunAgent signature matches.
|
||||
type chatAgentService interface {
|
||||
RunAgent(ctx context.Context, userID, canvasID, sessionID, version string, userInput any) (<-chan canvas.RunEvent, error)
|
||||
RunAgent(ctx context.Context, userID, canvasID, sessionID, version string, userInput any, files []map[string]interface{}) (<-chan canvas.RunEvent, error)
|
||||
}
|
||||
|
||||
// documentAccessChecker is the minimal surface RerunAgent needs
|
||||
@@ -393,7 +393,7 @@ func (h *AgentHandler) RunAgent(c *gin.Context) {
|
||||
sessionID := c.Query("session_id")
|
||||
userInput := readUserInput(c)
|
||||
|
||||
events, err := h.chatRunner.RunAgent(c.Request.Context(), user.ID, canvasID, sessionID, version, userInput)
|
||||
events, err := h.chatRunner.RunAgent(c.Request.Context(), user.ID, canvasID, sessionID, version, userInput, nil)
|
||||
if err != nil {
|
||||
ec, em := mapAgentError(err)
|
||||
common.ResponseWithCodeData(c, ec, nil, em)
|
||||
@@ -848,6 +848,7 @@ type agentChatCompletionsRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []map[string]interface{} `json:"messages"`
|
||||
ReturnTrace bool `json:"return_trace"`
|
||||
Files []map[string]interface{} `json:"files"`
|
||||
}
|
||||
|
||||
// extractLastUserContent returns the content of the last message in
|
||||
@@ -1004,7 +1005,7 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
|
||||
}, userInputMeta(userInput)...)...,
|
||||
)
|
||||
|
||||
events, err := h.chatRunner.RunAgent(c.Request.Context(), user.ID, req.AgentID, req.SessionID, "", userInput)
|
||||
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{
|
||||
|
||||
@@ -715,7 +715,7 @@ type stubChatRunner struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubChatRunner) RunAgent(_ context.Context, _, _, _, _ string, _ any) (<-chan canvas.RunEvent, error) {
|
||||
func (s *stubChatRunner) RunAgent(_ context.Context, _, _, _, _ string, _ any, _ []map[string]interface{}) (<-chan canvas.RunEvent, error) {
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
@@ -933,15 +933,19 @@ func TestAgentChatCompletions_DerivesStructuredUserInputFromInputs(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// captureChatRunner records the userInput it was called with and
|
||||
// captureChatRunner records the userInput and files it was called with and
|
||||
// returns an empty (closed) channel. Used to assert on argument
|
||||
// derivation without exercising the runner.
|
||||
type captureChatRunner struct {
|
||||
captured *any
|
||||
captured *any
|
||||
capturedFiles *[]map[string]interface{}
|
||||
}
|
||||
|
||||
func (c *captureChatRunner) RunAgent(_ context.Context, _, _, _, _ string, userInput any) (<-chan canvas.RunEvent, error) {
|
||||
func (c *captureChatRunner) RunAgent(_ context.Context, _, _, _, _ string, userInput any, files []map[string]interface{}) (<-chan canvas.RunEvent, error) {
|
||||
*c.captured = userInput
|
||||
if c.capturedFiles != nil {
|
||||
*c.capturedFiles = files
|
||||
}
|
||||
ch := make(chan canvas.RunEvent)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
@@ -1188,3 +1192,70 @@ type stubDocService struct {
|
||||
func (s *stubDocService) Accessible(_, _ string) bool {
|
||||
return s.accessible
|
||||
}
|
||||
|
||||
// TestAgentChatCompletions_FilesDeserialized verifies that when the
|
||||
// JSON request body contains a `files` field, the
|
||||
// agentChatCompletionsRequest struct deserializes it correctly.
|
||||
// Mirrors Python's req.get("files", []) at agent_api.py:1313.
|
||||
func TestAgentChatCompletions_FilesDeserialized(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": "resume.txt", "mime_type": "text/plain", "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)
|
||||
|
||||
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 name, _ := capturedFiles[0]["name"].(string); name != "resume.txt" {
|
||||
t.Errorf("capturedFiles[0][\"name\"] = %q, want %q", name, "resume.txt")
|
||||
}
|
||||
mime, _ := capturedFiles[0]["mime_type"].(string)
|
||||
if mime != "text/plain" {
|
||||
t.Errorf("capturedFiles[0][\"mime_type\"] = %q, want %q", mime, "text/plain")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// where req.get("files", []) defaults to [].
|
||||
func TestAgentChatCompletions_EmptyFilesNil(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/api/v1/agents/chat/completions",
|
||||
strings.NewReader(`{"agent_id":"a1","query":"hi"}`))
|
||||
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)
|
||||
|
||||
if capturedFiles != nil {
|
||||
t.Errorf("capturedFiles = %v, want nil when files not in request", capturedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ func (f *waitFakeAgentService) DeleteAgent(context.Context, string, string) erro
|
||||
// RunAgent mimics service.AgentService.RunAgent for the test
|
||||
// driver. It loads the canvas (a no-op in tests), builds a RunFunc
|
||||
// from the supplied stub, and hands off to the orchestrator.
|
||||
func (f *waitFakeAgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID, version string, userInput any) (<-chan canvas.RunEvent, error) {
|
||||
func (f *waitFakeAgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID, version string, userInput any, _ []map[string]interface{}) (<-chan canvas.RunEvent, error) {
|
||||
_ = ctx
|
||||
_ = userID
|
||||
_ = version
|
||||
@@ -151,7 +151,7 @@ func waitForUserRoutes(svc *waitFakeAgentService) *gin.Engine {
|
||||
canvasID := c.Param("canvas_id")
|
||||
sessionID := c.Query("session_id")
|
||||
userInput := c.Query("user_input")
|
||||
events, err := svc.RunAgent(c.Request.Context(), "user-wait", canvasID, sessionID, "", userInput)
|
||||
events, err := svc.RunAgent(c.Request.Context(), "user-wait", canvasID, sessionID, "", userInput, nil)
|
||||
if err != nil {
|
||||
// We never expect a non-nil err from the fake,
|
||||
// but be defensive.
|
||||
|
||||
@@ -492,6 +492,29 @@ func TestAgentbotCompletion_ResumesSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentbotCompletion_BindsFileDescriptors(t *testing.T) {
|
||||
var capturedReq service.AgentbotCompletionRequest
|
||||
stub := &stubBotService{
|
||||
agentbotCompleteFn: func(ctx context.Context, tenantID, agentID string, req service.AgentbotCompletionRequest) (<-chan canvas.RunEvent, common.ErrorCode, error) {
|
||||
capturedReq = req
|
||||
ch := make(chan canvas.RunEvent)
|
||||
close(ch)
|
||||
return ch, common.CodeSuccess, nil
|
||||
},
|
||||
}
|
||||
r := botTestEngine(stub)
|
||||
_ = doJSON(r, http.MethodPost, "/api/v1/agentbots/a1/completions", `{
|
||||
"question":"hi",
|
||||
"files":[{"id":"upload-1","name":"notes.txt","mime_type":"text/plain","created_by":"user-1"}]
|
||||
}`)
|
||||
if len(capturedReq.Files) != 1 {
|
||||
t.Fatalf("files = %#v, want one descriptor", capturedReq.Files)
|
||||
}
|
||||
if capturedReq.Files[0]["id"] != "upload-1" || capturedReq.Files[0]["created_by"] != "user-1" {
|
||||
t.Fatalf("file descriptor = %#v", capturedReq.Files[0])
|
||||
}
|
||||
}
|
||||
|
||||
// ----- AgentbotInputs tests (criteria 21, 22, 23) -----
|
||||
|
||||
// TestAgentbotInputs_OK covers criterion 21.
|
||||
|
||||
Reference in New Issue
Block a user