mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-06-29 23:41:12 +08:00
## Summary
Aligns the **Go agent runtime/canvas/components/tools** behavior with
the **Python `agent/` implementation** so the same stored canvas DSL
produces the same execution result on either side. Every component,
tool, and runtime primitive in `internal/agent/` is now driven by the
same semantics as its Python counterpart — variable resolution, template
substitution, control flow, error reporting, retry/cancel, and stream
event shapes.
The **retrieval component is the one explicit exception** in this PR. It
is being reworked in a separate change and is excluded from this
alignment pass; the wrapper slot (`universe_a_wrappers.go →
newRetrievalComponent`) is preserved.
## Scope of alignment
### Components (all aligned with `agent/component/`)
`Begin` · `Message` · `LLM` (incl. ChatTemplateKwargs,
MessageHistoryWindowSize, VisualFiles, Cite, OutputStructure,
JSONOutput, TopP, MaxRetries, DelayAfterError, credentials) · `Agent`
(react + tool artifact capture + `Reset()` interface-assert) · `Switch`
(12/12 operators, Python-equivalent semantics) · `Categorize` · `Invoke`
· `Iteration` · `Loop` (macro-expansion through `workflowx.AddLoopNode`)
· `UserFillUp` (Python-equivalent interrupt/resume via eino
`compose.Interrupt`/`ResumeWithData`) · `FillUp` · `DataOperations` ·
`ListOperations` · `StringTransform` · `VariableAggregator` ·
`VariableAssigner` · `Browser` (full stagehand runtime parity) ·
`DocsGenerator` · `ExcelProcessor`.
### Tools (all aligned with `agent/tools/`)
`Retrieval` (wrapper slot only — logic out of scope) · `MCPToolAdapter`
(streamable-HTTP) · `CodeExec` (sandbox bridge with
`code_exec_contract.go` matching Python contract) · `AkShare` · `ArXiv`
· `Crawler` · `DeepL` · `DuckDuckGo` · `Email` · `ExeSQL` · `GitHub` ·
`Google` · `GoogleScholar` · `Jin10` · `PubMed` · `QWeather` · `SearXNG`
· `Tavily` · `Tushare` · `Wencai` · `Wikipedia` · `YahooFinance` —
uniform `eino tool.InvokableTool` interface, SSRF protection, shared
HTTP client.
### Canvas execution engine (`internal/agent/canvas/`)
Aligned with Python's `agent/canvas.py`:
- **Scheduler** (`scheduler.go`): state pre/post handlers, node lambdas,
per-component timeout resolver (4-level: per-class env → per-class table
→ uniform env → 600s fallback), `legacyNoOpNames`.
- **Loop subgraph** (`loop_subgraph.go`): Python-equivalent
`AddLoopNode` macro expansion + condition translation.
- **Multibranch** (`multibranch.go`): `Switch` / `Categorize` routing
via `compose.NewGraphMultiBranch` — same branch selection semantics as
Python.
- **Parallel subgraph** (`parallel_subgraph.go`): matches Python's
parallel fan-out contract.
- **Interrupt/Resume** (`interrupt_resume.go`): `UserFillUpNodeBody` /
`IsInterruptError` / `ExtractInterruptContexts` — replaces the
deprecated Python sentinel chain with eino's native interrupt API,
preserving the same external behavior.
- **Checkpoint** (`checkpoint_store.go`): `RedisCheckPointStore`
Get/Set/Delete, with business metadata (status / canvas_id /
parent_run_id) on a parallel Redis Hash.
- **RunTracker** (`run_tracker.go`): Start / MarkSucceeded / MarkFailed
/ MarkCancelled / AttachCheckpoint — same lifecycle as the Python run
record.
- **Cancel** (`cancel.go`): Redis pub/sub watch.
- **Stream** (`stream.go`): SSE channel with `messages` / `waiting` /
`errors` / `done` events, same shape as Python's `agent.canvas.RunEvent`
payload.
### DSL bridge (`internal/agent/dsl/`)
- `normalize.go`: v1↔v2 collapsed into a single wire format — Python and
Go consume the same stored JSON.
- `reset.go`: per-run state reset matches Python's `Canvas.reset()`
semantics.
- Testdata mirrors Python's `agent_msg.json` / `all.json` / etc.
### Runtime (`internal/agent/runtime/`)
- `CanvasState` / `NewCanvasState` / `GetVar` / `SetVar` / `ReadVars`:
same `{{cpn_id@param}}` resolution model.
- `ResolveTemplate` (regex fast path + gonja fallback) — Python
Jinja-style semantics.
- `selector.go`, `metrics.go`, `component.go`: shared runtime contracts.
## Out of scope (intentionally)
- **`Retrieval` component logic** — wrapped only; full parity lands in a
follow-up PR.
- **Frontend** — only minor dsl-bridge / canvas UX fixes ride along.
- **CLI / admin / model registry** — orthogonal to agent behavior.
## How alignment is verified
`internal/service/agent_run_e2e_test.go` exercises the **full production
chain** against real Python-shaped DSL fixtures:
```
loadCanvasForUser → versionDAO.GetLatest → decodeCanvasFromDSL →
canvas.Compile → cc.Workflow.Invoke → answer extraction
```
using in-memory SQLite + miniredis (no Docker). Covers:
- `TestRunAgent_RealCanvas_BeginMessage` — happy path, `{{sys.query}}`
resolution
- `TestRunAgent_RealCanvas_WaitForUserResume` — two-run resume cycle
(Python-equivalent)
- `TestRunAgent_RealCanvas_CompileFails` — unknown component name →
sanitized error (Python-equivalent)
- `TestRunAgent_RealCanvas_InvokeFails` — unresolvable template ref
(Python-equivalent)
- `TestRunAgent_RunTracker_AttachCheckpoint_CallSequence` —
Start→AttachCheckpoint→MarkSucceeded lifecycle
`internal/handler/agent_test.go` — SSE streaming parity (`Content-Type:
text/event-stream`, `data: {…}\n\n`, trailing `data: [DONE]\n\n`,
OpenAI-compatible non-stream `choices`).
`internal/agent/canvas/fixture_compile_test.go` + per-component tests
pin the Python-equivalent outputs.
```
go test -count=1 -v -run 'TestRunAgent_RealCanvas|TestRunAgent_RunTracker' ./internal/service/
```
## Design reference
`docs/develop/agent-go-port-design.md` (1329 lines, last cross-checked
2026-06-17) — module layout, per-component / per-tool inventory,
corner-case catalogue, and the actionable backlog (Section 14, including
the retrieval alignment follow-up).
---------
Co-authored-by: Claude <noreply@anthropic.com>
1209 lines
38 KiB
Go
1209 lines
38 KiB
Go
//
|
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"ragflow/internal/engine/redis"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"ragflow/internal/agent/canvas"
|
|
"ragflow/internal/common"
|
|
"ragflow/internal/dao"
|
|
"ragflow/internal/entity"
|
|
"ragflow/internal/service"
|
|
|
|
dslpkg "ragflow/internal/agent/dsl"
|
|
)
|
|
|
|
// AgentHandler agent handler
|
|
// fileUploader is the subset of FileService used by agent handlers.
|
|
type agentFileService interface {
|
|
UploadFile(tenantID, parentID string, files []*multipart.FileHeader) ([]map[string]interface{}, error)
|
|
DownloadAgentFile(tenantID, location string) ([]byte, error)
|
|
}
|
|
|
|
// chatAgentService is the subset of AgentService used by the chat-completion
|
|
// endpoints (AgentChatCompletions, RunAgent). Kept as a separate interface so
|
|
// handler tests can inject a fake RunAgent without standing up the full
|
|
// AgentService (DB DAOs, eino runner, etc.). The production wiring in
|
|
// 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, userInput string) (<-chan canvas.RunEvent, error)
|
|
}
|
|
|
|
// AgentHandler agent handler
|
|
type AgentHandler struct {
|
|
agentService *service.AgentService
|
|
chatRunner chatAgentService
|
|
fileService agentFileService
|
|
}
|
|
|
|
// NewAgentHandler create agent handler
|
|
|
|
func NewAgentHandler(agentService *service.AgentService, fileService *service.FileService) *AgentHandler {
|
|
return &AgentHandler{
|
|
agentService: agentService,
|
|
chatRunner: agentService,
|
|
fileService: fileService,
|
|
}
|
|
}
|
|
|
|
// ListAgents lists agent canvases for the current user.
|
|
// @Summary List Agents
|
|
// @Description List agent canvases accessible to the current user (Home dashboard tile)
|
|
// @Tags agents
|
|
// @Produce json
|
|
// @Param keywords query string false "Filter by title keyword"
|
|
// @Param page query int false "Page number (0 = no pagination)"
|
|
// @Param page_size query int false "Items per page (0 = no pagination)"
|
|
// @Param orderby query string false "Order-by field (default: create_time)"
|
|
// @Param desc query bool false "Descending order (default: true)"
|
|
// @Param owner_ids query string false "Comma-separated owner IDs to filter (default: all authorised tenants)"
|
|
// @Param canvas_category query string false "Canvas category (default: agent_canvas)"
|
|
// @Success 200 {object} service.ListAgentsResponse
|
|
// @Router /api/v1/agents [get]
|
|
func (h *AgentHandler) ListAgents(c *gin.Context) {
|
|
user, errorCode, errorMessage := GetUser(c)
|
|
if errorCode != common.CodeSuccess {
|
|
jsonError(c, errorCode, errorMessage)
|
|
return
|
|
}
|
|
|
|
keywords := c.Query("keywords")
|
|
canvasCategory := c.Query("canvas_category")
|
|
|
|
page := 0
|
|
if v := c.Query("page"); v != "" {
|
|
if p, err := strconv.Atoi(v); err == nil && p > 0 {
|
|
page = p
|
|
}
|
|
}
|
|
|
|
pageSize := 0
|
|
if v := c.Query("page_size"); v != "" {
|
|
if ps, err := strconv.Atoi(v); err == nil && ps > 0 {
|
|
pageSize = ps
|
|
}
|
|
}
|
|
|
|
orderby := c.DefaultQuery("orderby", "create_time")
|
|
|
|
desc := true
|
|
if v := c.Query("desc"); v != "" {
|
|
desc = strings.ToLower(v) != "false"
|
|
}
|
|
|
|
var ownerIDs []string
|
|
if raw := c.Query("owner_ids"); raw != "" {
|
|
for _, id := range strings.Split(raw, ",") {
|
|
id = strings.TrimSpace(id)
|
|
if id != "" {
|
|
ownerIDs = append(ownerIDs, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
result, code, err := h.agentService.ListAgents(
|
|
user.ID,
|
|
keywords,
|
|
page,
|
|
pageSize,
|
|
orderby,
|
|
desc,
|
|
ownerIDs,
|
|
canvasCategory,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": code,
|
|
"data": false,
|
|
"message": err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": result,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// mapAgentError normalises service-layer errors onto the existing
|
|
// {code, data, message} response envelope used by every other handler.
|
|
//
|
|
// Three classes:
|
|
// - service.ErrAgentNotOwner -> "Only the owner..." (DELETE only, 103)
|
|
// - dao.ErrUserCanvasNotFound -> "Make sure you have permission..." (103)
|
|
// - service.ErrAgentStorageError -> "Internal storage error" (500)
|
|
//
|
|
// The first two surface as OPERATING_ERROR(103) so the front-end
|
|
// cannot use the response code to enumerate other users' canvases
|
|
// (IDOR mitigation). ErrAgentStorageError maps to SERVER_ERROR(500)
|
|
// with a sanitized message — the raw DAO / DB error string MUST
|
|
// NOT reach the client (DSNs, table names, gorm stack frames would
|
|
// otherwise leak). Everything else becomes CodeDataError so the
|
|
// front-end can surface the message verbatim.
|
|
func mapAgentError(err error) (common.ErrorCode, string) {
|
|
if err == nil {
|
|
return common.CodeSuccess, ""
|
|
}
|
|
if errors.Is(err, service.ErrAgentNotOwner) {
|
|
return common.CodeOperatingError, "Only the owner of the agent is authorized for this operation."
|
|
}
|
|
if errors.Is(err, dao.ErrUserCanvasNotFound) ||
|
|
errors.Is(err, dao.ErrUserCanvasVersionNotFound) {
|
|
return common.CodeOperatingError, "Make sure you have permission to access the agent."
|
|
}
|
|
if errors.Is(err, service.ErrAgentStorageError) {
|
|
return common.CodeServerError, "Internal storage error while accessing the agent."
|
|
}
|
|
return common.CodeDataError, err.Error()
|
|
}
|
|
|
|
// CreateAgent creates a new agent canvas.
|
|
// @Summary Create Agent
|
|
// @Tags agents
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body service.CreateAgentRequest true "agent create request"
|
|
// @Success 200 {object} entity.UserCanvas
|
|
// @Router /api/v1/agents [post]
|
|
func (h *AgentHandler) CreateAgent(c *gin.Context) {
|
|
user, errorCode, errorMessage := GetUser(c)
|
|
if errorCode != common.CodeSuccess {
|
|
jsonError(c, errorCode, errorMessage)
|
|
return
|
|
}
|
|
var req service.CreateAgentRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
jsonError(c, common.CodeArgumentError, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
req.UserID = user.ID
|
|
row, code, err := h.agentService.CreateAgent(c.Request.Context(), &req)
|
|
if err != nil {
|
|
jsonError(c, code, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": row,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// GetAgent returns one canvas by ID.
|
|
// @Summary Get Agent
|
|
// @Tags agents
|
|
// @Produce json
|
|
// @Param canvas_id path string true "canvas id"
|
|
// @Success 200 {object} entity.UserCanvas
|
|
// @Router /api/v1/agents/{canvas_id} [get]
|
|
func (h *AgentHandler) GetAgent(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
row, err := h.agentService.GetAgent(c.Request.Context(), user.ID, canvasID)
|
|
if err != nil {
|
|
ec, em := mapAgentError(err)
|
|
jsonError(c, ec, em)
|
|
return
|
|
}
|
|
// Defensive: any historical v1 / Go-v2-only row in user_canvas.dsl
|
|
// is rendered as a missing graph by the front-end. Normalize in
|
|
// place (NormalizeForCanvas is a no-op when graph.nodes is set) so
|
|
// the response is always renderable without a migration.
|
|
if row != nil {
|
|
row.DSL = dslpkg.NormalizeForCanvas(row.DSL)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": row,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// updateAgentRequest is the wire shape for PUT /api/v1/agents/:canvas_id.
|
|
type updateAgentRequest struct {
|
|
DSL entity.JSONMap `json:"dsl"`
|
|
}
|
|
|
|
// UpdateAgent writes a new draft DSL to the canvas (no version created).
|
|
// @Summary Update Agent (Draft)
|
|
// @Tags agents
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param canvas_id path string true "canvas id"
|
|
// @Param request body updateAgentRequest true "draft DSL payload"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Router /api/v1/agents/{canvas_id} [put]
|
|
func (h *AgentHandler) UpdateAgent(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
var req updateAgentRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
jsonError(c, common.CodeArgumentError, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
if err := h.agentService.UpdateAgent(c.Request.Context(), user.ID, canvasID, req.DSL); err != nil {
|
|
ec, em := mapAgentError(err)
|
|
jsonError(c, ec, em)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": true,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// DeleteAgent removes the canvas and cascades to its versions.
|
|
// @Summary Delete Agent
|
|
// @Tags agents
|
|
// @Produce json
|
|
// @Param canvas_id path string true "canvas id"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Router /api/v1/agents/{canvas_id} [delete]
|
|
func (h *AgentHandler) DeleteAgent(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
if err := h.agentService.DeleteAgent(c.Request.Context(), user.ID, canvasID); err != nil {
|
|
ec, em := mapAgentError(err)
|
|
jsonError(c, ec, em)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": true,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// ListTemplates lists every canvas template available to authenticated users.
|
|
// @Summary List Agent Templates
|
|
// @Description List the catalogue of canvas templates that authenticated users can clone.
|
|
// @Tags agents
|
|
// @Produce json
|
|
// @Security ApiKeyAuth
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Router /api/v1/agents/templates [get]
|
|
func (h *AgentHandler) ListTemplates(c *gin.Context) {
|
|
if _, errorCode, errorMessage := GetUser(c); errorCode != common.CodeSuccess {
|
|
jsonError(c, errorCode, errorMessage)
|
|
return
|
|
}
|
|
|
|
templates, err := h.agentService.ListTemplates()
|
|
if err != nil {
|
|
jsonInternalError(c, err)
|
|
return
|
|
}
|
|
if templates == nil {
|
|
// Ensure the JSON payload is always a list, never null.
|
|
templates = []*entity.CanvasTemplate{}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": templates,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// RunAgent returns an SSE stream of execution events. The Phase 5 stub emits
|
|
// a single "Phase 5 wiring pending" event and closes; the real eino run
|
|
// loop will replace the channel source in service.AgentService.RunAgent.
|
|
// @Summary Run Agent (SSE)
|
|
// @Tags agents
|
|
// @Produce text/event-stream
|
|
// @Param canvas_id path string true "canvas id"
|
|
// @Param version query string false "version id (default: latest)"
|
|
// @Success 200 {string} string "SSE: data: {...}\\n\\n"
|
|
// @Router /api/v1/agents/{canvas_id}/run [post]
|
|
func (h *AgentHandler) RunAgent(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
version := c.Query("version")
|
|
sessionID := c.Query("session_id")
|
|
userInput := readUserInput(c)
|
|
|
|
events, err := h.chatRunner.RunAgent(c.Request.Context(), user.ID, canvasID, sessionID, version, userInput)
|
|
if err != nil {
|
|
ec, em := mapAgentError(err)
|
|
jsonError(c, ec, em)
|
|
return
|
|
}
|
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
|
c.Writer.Header().Set("Connection", "keep-alive")
|
|
flusher, _ := c.Writer.(http.Flusher)
|
|
for ev := range events {
|
|
writeRunEventSSE(c.Writer, flusher, ev)
|
|
}
|
|
}
|
|
|
|
// readUserInput extracts the user_input field from the JSON body if
|
|
// present, otherwise from the ?user_input= query string. An empty body
|
|
// (no body sent) is treated as "" so the resume cycle still works
|
|
// when the client only passes ?session_id=...&user_input=... on the URL.
|
|
func readUserInput(c *gin.Context) string {
|
|
if c.Request.ContentLength > 0 {
|
|
var body struct {
|
|
UserInput string `json:"user_input"`
|
|
Query string `json:"query"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err == nil {
|
|
if body.UserInput != "" {
|
|
return body.UserInput
|
|
}
|
|
if body.Query != "" {
|
|
return body.Query
|
|
}
|
|
if body.Message != "" {
|
|
return body.Message
|
|
}
|
|
}
|
|
}
|
|
return c.Query("user_input")
|
|
}
|
|
|
|
// writeRunEventSSE writes one canvas.RunEvent as an SSE frame in the
|
|
// Python envelope format (same as writeChatCompletionSSE):
|
|
//
|
|
// data:{"event":"<ev.Type>","message_id":"...","created_at":...,"task_id":"...","session_id":"...","data":<ev.Data>}
|
|
//
|
|
// The "done" type emits `data: [DONE]\n\n`.
|
|
func writeRunEventSSE(w io.Writer, flusher http.Flusher, ev canvas.RunEvent) {
|
|
if ev.Type == "done" {
|
|
fmt.Fprintf(w, "data: [DONE]\n\n")
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
return
|
|
}
|
|
data := ev.Data
|
|
if data == "" {
|
|
data = "{}"
|
|
}
|
|
envelope := sseEnvelope(ev.Type, ev.MessageID, ev.CreatedAt, ev.TaskID, ev.SessionID, data)
|
|
fmt.Fprintf(w, "data: %s\n\n", envelope)
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
|
|
// sanitiseRunEventError passes through the error event payload
|
|
// unchanged. The runner serialises canvas.ErrorEvent ({"message": ...})
|
|
// before push, so when the payload round-trips through JSON the
|
|
// message field is already preserved. Heuristic sanitisation is
|
|
// disabled until the runner tags error events with a "kind"
|
|
// field — without that, blanket rewriting every error to
|
|
// "Internal storage error while accessing the agent." hides the
|
|
// real failure from the front-end and the user (v3.6.1 diagnostic
|
|
// regression: every canvas run failure surfaced as the same opaque
|
|
// string).
|
|
func sanitiseRunEventError(data string) string {
|
|
if data == "" {
|
|
return `{"message":"Unknown agent runtime error"}`
|
|
}
|
|
return data
|
|
}
|
|
|
|
// CancelAgent signals the in-flight run to stop.
|
|
// @Summary Cancel Agent Run
|
|
// @Tags agents
|
|
// @Produce json
|
|
// @Param canvas_id path string true "canvas id"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Router /api/v1/agents/{canvas_id}/run [delete]
|
|
func (h *AgentHandler) CancelAgent(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
if err := h.agentService.CancelAgent(c.Request.Context(), user.ID, canvasID); err != nil {
|
|
ec, em := mapAgentError(err)
|
|
jsonError(c, ec, em)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": true,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// publishAgentRequest is the wire shape for POST /api/v1/agents/:canvas_id/publish.
|
|
type publishAgentRequest struct {
|
|
Title *string `json:"title,omitempty"`
|
|
Description *string `json:"description,omitempty"`
|
|
DSL entity.JSONMap `json:"dsl,omitempty"`
|
|
}
|
|
|
|
// PublishAgent creates a new immutable version row and marks the parent canvas as released.
|
|
// @Summary Publish Agent Version
|
|
// @Tags agents
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param canvas_id path string true "canvas id"
|
|
// @Param request body publishAgentRequest true "publish payload"
|
|
// @Success 200 {object} entity.UserCanvasVersion
|
|
// @Router /api/v1/agents/{canvas_id}/publish [post]
|
|
func (h *AgentHandler) PublishAgent(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
var req publishAgentRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
jsonError(c, common.CodeArgumentError, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
row, err := h.agentService.PublishAgent(c.Request.Context(), user.ID, canvasID, &service.PublishAgentRequest{
|
|
Title: req.Title,
|
|
Description: req.Description,
|
|
DSL: req.DSL,
|
|
})
|
|
if err != nil {
|
|
ec, em := mapAgentError(err)
|
|
jsonError(c, ec, em)
|
|
return
|
|
}
|
|
if row != nil {
|
|
row.DSL = dslpkg.NormalizeForCanvas(row.DSL)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": row,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// ListVersions returns every version of a canvas, newest first.
|
|
// @Summary List Agent Versions
|
|
// @Tags agents
|
|
// @Produce json
|
|
// @Param canvas_id path string true "canvas id"
|
|
// @Success 200 {array} entity.UserCanvasVersion
|
|
// @Router /api/v1/agents/{canvas_id}/versions [get]
|
|
func (h *AgentHandler) ListVersions(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
rows, err := h.agentService.ListVersions(c.Request.Context(), user.ID, canvasID)
|
|
if err != nil {
|
|
ec, em := mapAgentError(err)
|
|
jsonError(c, ec, em)
|
|
return
|
|
}
|
|
if rows == nil {
|
|
rows = []*entity.UserCanvasVersion{}
|
|
}
|
|
for _, row := range rows {
|
|
if row == nil {
|
|
continue
|
|
}
|
|
row.DSL = dslpkg.NormalizeForCanvas(row.DSL)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": rows,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// GetVersion returns a single version.
|
|
// @Summary Get Agent Version
|
|
// @Tags agents
|
|
// @Produce json
|
|
// @Param canvas_id path string true "canvas id"
|
|
// @Param version_id path string true "version id"
|
|
// @Success 200 {object} entity.UserCanvasVersion
|
|
// @Router /api/v1/agents/{canvas_id}/versions/{version_id} [get]
|
|
func (h *AgentHandler) GetVersion(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
versionID := c.Param("version_id")
|
|
row, err := h.agentService.GetVersion(c.Request.Context(), user.ID, canvasID, versionID)
|
|
if err != nil {
|
|
ec, em := mapAgentError(err)
|
|
jsonError(c, ec, em)
|
|
return
|
|
}
|
|
if row != nil {
|
|
row.DSL = dslpkg.NormalizeForCanvas(row.DSL)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": row,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// DeleteVersion removes a single version by id.
|
|
// @Summary Delete Agent Version
|
|
// @Tags agents
|
|
// @Produce json
|
|
// @Param canvas_id path string true "canvas id"
|
|
// @Param version_id path string true "version id"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Router /api/v1/agents/{canvas_id}/versions/{version_id} [delete]
|
|
func (h *AgentHandler) DeleteVersion(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
versionID := c.Param("version_id")
|
|
if err := h.agentService.DeleteVersion(c.Request.Context(), user.ID, canvasID, versionID); err != nil {
|
|
ec, em := mapAgentError(err)
|
|
jsonError(c, ec, em)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": true,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// --- PR2: missing routes wired up to the existing service layer ---
|
|
|
|
// ListAgentTemplates GET /api/v1/agents/templates
|
|
func (h *AgentHandler) ListAgentTemplates(c *gin.Context) {
|
|
if _, code, msg := GetUser(c); code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
rows, err := h.agentService.ListTemplates()
|
|
if err != nil {
|
|
jsonError(c, common.CodeServerError, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": rows,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// Prompts GET /api/v1/agents/prompts — returns the four hardcoded
|
|
// authoring guidelines the agent UI surfaces. The Python agent API
|
|
// returns these from a module-level constant; we keep the same shape.
|
|
func (h *AgentHandler) Prompts(c *gin.Context) {
|
|
if _, code, msg := GetUser(c); code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": gin.H{
|
|
"task_analysis": "As an AI agent designer, your role is to engage users by understanding their objectives and creating effective agent designs. Begin by analyzing the user's request to determine the appropriate actions.",
|
|
"output_format": "For each agent you create, detail its components and explain how they collaborate to achieve the user's goal.",
|
|
"citation_guidelines": "If the agent uses external sources, cite them in the final output. Use the format: [index] document_id, which corresponds to the document identifier in the database.",
|
|
"few_shots_examples": "<example/>",
|
|
},
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// ListAgentTags GET /api/v1/agents/tags — out of scope (no test depends on
|
|
// it); return 501 to keep the surface honest.
|
|
func (h *AgentHandler) ListAgentTags(c *gin.Context) {
|
|
if _, code, msg := GetUser(c); code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": []string{},
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// UpdateAgentTags PUT /api/v1/agents/:canvas_id/tags
|
|
func (h *AgentHandler) UpdateAgentTags(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
var body struct {
|
|
Tags interface{} `json:"tags"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
|
|
jsonError(c, common.CodeArgumentError, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
ok, errCode, errMsg := h.agentService.UpdateAgentTags(user.ID, canvasID, body.Tags)
|
|
if !ok {
|
|
jsonError(c, errCode, errMsg.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": true,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// ListAgentSessions GET /api/v1/agents/:canvas_id/sessions
|
|
func (h *AgentHandler) ListAgentSessions(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "0"))
|
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "0"))
|
|
keywords := c.Query("keywords")
|
|
fromDate := c.Query("from_date")
|
|
toDate := c.Query("to_date")
|
|
orderby := c.DefaultQuery("orderby", "create_time")
|
|
desc := c.DefaultQuery("desc", "true") != "false"
|
|
sessionID := c.Query("id")
|
|
expUserID := c.Query("user_id")
|
|
includeDSL := c.Query("dsl") == "true"
|
|
|
|
resp, code, err := h.agentService.ListAgentSessions(user.ID, user.ID, canvasID, service.ListAgentSessionsRequest{
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
Keywords: keywords,
|
|
FromDate: fromDate,
|
|
ToDate: toDate,
|
|
OrderBy: orderby,
|
|
Desc: desc,
|
|
SessionID: sessionID,
|
|
UserID: user.ID,
|
|
ExpUserID: expUserID,
|
|
IncludeDSL: includeDSL,
|
|
})
|
|
if err != nil {
|
|
jsonError(c, code, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": resp.Data,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// CreateAgentSession POST /api/v1/agents/:canvas_id/sessions
|
|
func (h *AgentHandler) CreateAgentSession(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
var body struct {
|
|
Name string `json:"name"`
|
|
Source string `json:"source"`
|
|
DSL json.RawMessage `json:"dsl"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
|
|
jsonError(c, common.CodeArgumentError, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
row, code, err := h.agentService.CreateAgentSession(&service.CreateAgentSessionRequest{
|
|
UserID: user.ID,
|
|
AgentID: canvasID,
|
|
Name: body.Name,
|
|
Source: body.Source,
|
|
DSL: body.DSL,
|
|
})
|
|
if err != nil {
|
|
jsonError(c, code, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": row,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// GetAgentSession GET /api/v1/agents/:canvas_id/sessions/:session_id
|
|
func (h *AgentHandler) GetAgentSession(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
sessionID := c.Param("session_id")
|
|
row, code, err := h.agentService.GetAgentSession(user.ID, canvasID, sessionID)
|
|
if err != nil {
|
|
jsonError(c, code, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": row,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// DeleteAgentSession DELETE /api/v1/agents/:canvas_id/sessions[/:session_id]
|
|
//
|
|
// Path parameter disambiguation:
|
|
// - /sessions/:session_id -> single item delete
|
|
// - /sessions?ids=a,b -> batch delete (delete_all when ids is empty)
|
|
func (h *AgentHandler) DeleteAgentSession(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
sessionID := c.Param("session_id")
|
|
if sessionID != "" {
|
|
ok, code, err := h.agentService.DeleteAgentSessionItem(user.ID, canvasID, sessionID)
|
|
if err != nil {
|
|
jsonError(c, code, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": ok,
|
|
"message": "success",
|
|
})
|
|
return
|
|
}
|
|
idsParam := c.Query("ids")
|
|
deleteAll := c.Query("delete_all") == "true"
|
|
var ids []string
|
|
if idsParam != "" {
|
|
for _, id := range strings.Split(idsParam, ",") {
|
|
if id = strings.TrimSpace(id); id != "" {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
}
|
|
result, code, err := h.agentService.DeleteAgentSessions(user.ID, canvasID, ids, deleteAll)
|
|
if err != nil {
|
|
jsonError(c, code, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": result,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// AgentChatCompletions POST /api/v1/agents/chat/completions
|
|
//
|
|
// Runs the canvas against `agent_id` and streams the result as SSE.
|
|
//
|
|
// Behaviour matches the Python reference at
|
|
// api/db/services/canvas_service.py:313 (`completion()`):
|
|
//
|
|
// - Non-openai path: always streams SSE — one `data: {...}\n\n` frame per
|
|
// canvas RunEvent, terminated by `data: [DONE]\n\n`. The `stream` field
|
|
// is ignored on this path because Python's `completion()` always yields
|
|
// SSE frames regardless of the flag.
|
|
// - Openai-compatible path: requires `messages` (a non-empty list with at
|
|
// least one user message is needed to derive the question). The full
|
|
// OpenAI wire framing (delta + reference + token counts — see
|
|
// `completion_openai` at api/db/services/canvas_service.py:378-479) is
|
|
// still a Phase 5 TODO; until then the openai-compat branches return a
|
|
// hardcoded "hello" stub so the validation contracts keep passing.
|
|
type agentChatCompletionsRequest struct {
|
|
AgentID string `json:"agent_id"`
|
|
Query string `json:"query"`
|
|
SessionID string `json:"session_id"`
|
|
Stream bool `json:"stream"`
|
|
OpenAICompat bool `json:"openai-compatible"`
|
|
Model string `json:"model"`
|
|
Messages []map[string]interface{} `json:"messages"`
|
|
ReturnTrace bool `json:"return_trace"`
|
|
}
|
|
|
|
// extractLastUserContent returns the content of the last message in
|
|
// `messages` whose role is "user", or "" if none is found. Mirrors the
|
|
// Python derivation in api/apps/restful_apis/agent_api.py:1258 that drives
|
|
// `completion_openai` when the request uses the openai-compatible wire
|
|
// format but no top-level `query` is supplied.
|
|
func extractLastUserContent(messages []map[string]interface{}) string {
|
|
for i := len(messages) - 1; i >= 0; i-- {
|
|
role, _ := messages[i]["role"].(string)
|
|
if role != "user" {
|
|
continue
|
|
}
|
|
if c, _ := messages[i]["content"].(string); c != "" {
|
|
return c
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
var req agentChatCompletionsRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
jsonError(c, common.CodeArgumentError, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
if req.AgentID == "" {
|
|
jsonError(c, common.CodeArgumentError, "`agent_id` is required.")
|
|
return
|
|
}
|
|
if req.OpenAICompat && len(req.Messages) == 0 {
|
|
jsonError(c, common.CodeDataError, "at least one message is required in openai-compatible mode.")
|
|
return
|
|
}
|
|
|
|
// TODO(phase5-openai-framing): the openai-compat branches below are
|
|
// stubs. They keep the existing "choices"-shape contract for the
|
|
// openai-compat tests, but the production wire format must mirror
|
|
// api/db/services/canvas_service.py:378-479 (`completion_openai`):
|
|
// per-token `delta.content`, cumulative token counts, `[DONE]`
|
|
// terminator, `reference` attached to the final choice. Land that
|
|
// once the chat path needs to interop with OpenAI clients.
|
|
if req.OpenAICompat {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"choices": []map[string]interface{}{
|
|
{"message": gin.H{"content": "hello"}},
|
|
},
|
|
"message": "success",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Real canvas run — derive userInput from `query` first, then fall
|
|
// back to the last user message (covers the front-end that posts
|
|
// running_hint_text without a top-level `query`).
|
|
userInput := req.Query
|
|
if userInput == "" {
|
|
userInput = extractLastUserContent(req.Messages)
|
|
}
|
|
|
|
events, err := h.chatRunner.RunAgent(c.Request.Context(), user.ID, req.AgentID, req.SessionID, "", userInput)
|
|
if err != nil {
|
|
ec, em := mapAgentError(err)
|
|
jsonError(c, ec, em)
|
|
return
|
|
}
|
|
|
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
|
c.Writer.Header().Set("Connection", "keep-alive")
|
|
flusher, _ := c.Writer.(http.Flusher)
|
|
// SSE wire format mirrors Python's `completion()` at
|
|
// api/db/services/canvas_service.py:368: each canvas event is one
|
|
// `data: <json>\n\n` frame, and the channel close is signalled by
|
|
// `data: [DONE]\n\n`. We do NOT emit an `event:` line — the
|
|
// front-end's `use-send-message.ts` parser feeds each `data:` line
|
|
// directly into JSON.parse and breaks on the `e` of `event:`
|
|
// (browser console: "SyntaxError: Unexpected token 'e', \"event:
|
|
// mes\"…"). The richer `writeRunEventSSE` helper still owns the
|
|
// /api/v1/agents/{id}/run endpoint's wire format — see
|
|
// writeRunEventSSE at agent.go for that path.
|
|
for ev := range events {
|
|
writeChatCompletionSSE(c.Writer, flusher, ev)
|
|
}
|
|
}
|
|
|
|
// writeChatCompletionSSE emits one canvas.RunEvent in the
|
|
// Python-shaped chat-completion SSE envelope:
|
|
//
|
|
// data:{"event":"<ev.Type>","message_id":"<ev.MessageID>","created_at":<ev.CreatedAt>,"task_id":"<ev.TaskID>","session_id":"<ev.SessionID>","data":<ev.Data>}
|
|
//
|
|
// The special "done" type sends `data: [DONE]\n\n` (no JSON envelope).
|
|
func writeChatCompletionSSE(w io.Writer, flusher http.Flusher, ev canvas.RunEvent) {
|
|
if ev.Type == "done" {
|
|
fmt.Fprint(w, "data: [DONE]\n\n")
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
return
|
|
}
|
|
data := ev.Data
|
|
if data == "" {
|
|
data = "{}"
|
|
}
|
|
envelope := sseEnvelope(ev.Type, ev.MessageID, ev.CreatedAt, ev.TaskID, ev.SessionID, data)
|
|
fmt.Fprintf(w, "data: %s\n\n", envelope)
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
|
|
// sseEnvelope builds the Python-shaped SSE JSON payload:
|
|
//
|
|
// {"event":"<typ>","message_id":"<mid>","created_at":<ts>,"task_id":"<tid>","session_id":"<sid>","data":<raw>}
|
|
func sseEnvelope(typ, mid string, ts int64, tid, sid, rawData string) string {
|
|
return fmt.Sprintf(
|
|
`{"event":%q,"message_id":%q,"created_at":%d,"task_id":%q,"session_id":%q,"data":%s}`,
|
|
typ, mid, ts, tid, sid, rawData,
|
|
)
|
|
}
|
|
|
|
// RerunAgent POST /api/v1/agents/rerun — requires id, dsl, and
|
|
// component_id. The Python agent API uses PipelineOperationLogService
|
|
// and the dataflow queue, none of which the Go port has implemented
|
|
// yet; we keep the validation envelope (101 with the "required
|
|
// argument are missing" message) so the test contract is satisfied,
|
|
// and accept the request when all three fields are present.
|
|
func (h *AgentHandler) RerunAgent(c *gin.Context) {
|
|
if _, code, msg := GetUser(c); code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
var body struct {
|
|
ID string `json:"id"`
|
|
DSL map[string]interface{} `json:"dsl"`
|
|
ComponentID string `json:"component_id"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
|
|
jsonError(c, common.CodeArgumentError, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
missing := make([]string, 0, 3)
|
|
if body.ID == "" {
|
|
missing = append(missing, "id")
|
|
}
|
|
if body.DSL == nil {
|
|
missing = append(missing, "dsl")
|
|
}
|
|
if body.ComponentID == "" {
|
|
missing = append(missing, "component_id")
|
|
}
|
|
if len(missing) > 0 {
|
|
jsonError(c, common.CodeArgumentError,
|
|
"required argument are missing: "+strings.Join(missing, ",")+"; ")
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": true,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// TestDBConnection POST /api/v1/agents/test_db_connection
|
|
func (h *AgentHandler) TestDBConnection(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
var req service.TestDBConnectionRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
jsonError(c, common.CodeArgumentError, "Invalid request: "+err.Error())
|
|
return
|
|
}
|
|
code, err := h.agentService.TestDBConnection(user.ID, &req)
|
|
if err != nil {
|
|
jsonError(c, code, err.Error())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": true,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// GetAgentLogs GET /api/v1/agents/:canvas_id/logs/:message_id
|
|
//
|
|
// Reads "{agent_id}-{message_id}-logs" from Redis (same key format
|
|
// used by the Python agent API in api/apps/restful_apis/agent_api.py
|
|
// line 920). Missing key returns an empty dict so the test contract
|
|
// `data is dict` and `code == 0` are both satisfied.
|
|
func (h *AgentHandler) GetAgentLogs(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
messageID := c.Param("message_id")
|
|
ok, errCode, errMsg := h.checkCanvasAccessForHandler(c, user.ID, canvasID)
|
|
if !ok {
|
|
jsonError(c, errCode, errMsg)
|
|
return
|
|
}
|
|
|
|
key := fmt.Sprintf("%s-%s-logs", canvasID, messageID)
|
|
payload, rerr := redis.Get().Get(key)
|
|
data := map[string]interface{}{}
|
|
if rerr == nil && payload != "" {
|
|
_ = json.Unmarshal([]byte(payload), &data)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": data,
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// GetAgentWebhookLogs GET /api/v1/agents/:canvas_id/webhook/logs
|
|
//
|
|
// The Python agent API returns 102 "Canvas not found." when the agent
|
|
// id does not resolve to a canvas owned by the caller (see
|
|
// api/apps/restful_apis/agent_api.py webhook_trace). We replicate
|
|
// that envelope here so the front-end poll does not surface a 500
|
|
// for unknown / foreign canvas ids.
|
|
func (h *AgentHandler) GetAgentWebhookLogs(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
ok, err := h.agentService.CheckCanvasAccess(user.ID, canvasID)
|
|
if err != nil || !ok {
|
|
// CheckCanvasAccess now surfaces ErrUserCanvasNotFound when
|
|
// the canvas row is missing; the Python envelope is
|
|
// indistinguishable for missing vs foreign, so collapse
|
|
// both into 102 "Canvas not found." here.
|
|
if err != nil && !errors.Is(err, dao.ErrUserCanvasNotFound) {
|
|
jsonError(c, common.CodeServerError, err.Error())
|
|
return
|
|
}
|
|
jsonError(c, common.CodeDataError, "Canvas not found.")
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": gin.H{
|
|
"events": []interface{}{},
|
|
"finished": false,
|
|
"next_since_ts": 0,
|
|
},
|
|
"message": "success",
|
|
})
|
|
}
|
|
|
|
// checkCanvasAccessForHandler is the shared 103 envelope helper for
|
|
// PR2 routes that need to call service.CheckCanvasAccess and surface
|
|
// the access-denied envelope with the same shape the existing
|
|
// loadCanvasForUser-based handlers use.
|
|
func (h *AgentHandler) checkCanvasAccessForHandler(c *gin.Context, userID, canvasID string) (bool, common.ErrorCode, string) {
|
|
ok, err := h.agentService.CheckCanvasAccess(userID, canvasID)
|
|
if err != nil {
|
|
// The Python agent API uses @_require_canvas_access_async on
|
|
// /sessions and /logs/:message_id, which folds "canvas does
|
|
// not exist" into the same 103 access envelope as a
|
|
// permission mismatch. Surface the same shape here so
|
|
// callers probing unknown ids do not get a 500 record not
|
|
// found that breaks the front-end.
|
|
if errors.Is(err, dao.ErrUserCanvasNotFound) {
|
|
return false, common.CodeOperatingError, "Make sure you have permission to access the agent."
|
|
}
|
|
return false, common.CodeServerError, err.Error()
|
|
}
|
|
if !ok {
|
|
return false, common.CodeOperatingError, "Make sure you have permission to access the agent."
|
|
}
|
|
return true, common.CodeSuccess, ""
|
|
}
|
|
|
|
// ResetAgent clears the per-run state of a canvas (history, retrieval,
|
|
// memory, path) and zeroes every "sys.*" / "env.*" global. Mirrors
|
|
// POST /api/v1/agents/:canvas_id/reset from the Python backend at
|
|
// api/apps/restful_apis/agent_api.py:992 — but unlike the Python
|
|
// implementation this handler does not sync a Canvas replica.
|
|
// `api.apps.services.canvas_replica_service.CanvasReplicaService` is
|
|
// the Python Redis-backed runtime replica (distributed lock + 3h TTL);
|
|
// it is intentionally NOT ported to Go. The Go agent port runs every
|
|
// agent through eino's compose.Workflow.Invoke, which is reconstructed
|
|
// from the DSL on each run, so the replica's read-side acceleration
|
|
// is unnecessary and its write-side adds an out-of-band DB/cache sync
|
|
// for no benefit. UpdateAgent / CreateAgent / RerunAgent follow the
|
|
// same convention — DSL write only, no Redis replica. See the
|
|
// "canvas-replica-not-porting" project memory for the design rationale.
|
|
//
|
|
// The reset DSL is returned in the response body so the front-end
|
|
// can render the new state without an extra GET, matching the
|
|
// Python handler's `return get_json_result(data=dsl)` line.
|
|
// @Summary Reset Agent
|
|
// @Tags agents
|
|
// @Produce json
|
|
// @Param canvas_id path string true "canvas id"
|
|
// @Success 200 {object} map[string]interface{}
|
|
// @Router /api/v1/agents/{canvas_id}/reset [post]
|
|
func (h *AgentHandler) ResetAgent(c *gin.Context) {
|
|
user, code, msg := GetUser(c)
|
|
if code != common.CodeSuccess {
|
|
jsonError(c, code, msg)
|
|
return
|
|
}
|
|
canvasID := c.Param("canvas_id")
|
|
dsl, err := h.agentService.ResetAgent(c.Request.Context(), user.ID, canvasID)
|
|
if err != nil {
|
|
ec, em := mapAgentError(err)
|
|
jsonError(c, ec, em)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": common.CodeSuccess,
|
|
"data": dsl,
|
|
"message": "success",
|
|
})
|
|
}
|