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:
Hz_
2026-07-17 15:53:58 +08:00
committed by GitHub
parent 7336c27814
commit 1b77e3ebcd
28 changed files with 2363 additions and 174 deletions

View File

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

View File

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

View File

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

View File

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