mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-23 00:46:42 +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>
245 lines
8.2 KiB
TypeScript
245 lines
8.2 KiB
TypeScript
// DSL bridge — single wire shape for the agent canvas.
|
|
//
|
|
// The RAGFlow agent DSL has exactly one canonical wire shape, used
|
|
// for every operation (PUT/GET/create/export/import):
|
|
//
|
|
// {
|
|
// "globals": {...},
|
|
// "graph": { "nodes": [...], "edges": [...] }, // React-Flow
|
|
// "variables": {...},
|
|
// "components": { "<Name>:<UUID>": { // execution topology
|
|
// "downstream": [...], "upstream": [...],
|
|
// "obj": { "component_name": "Name", "params": {...} }
|
|
// }},
|
|
// "path": [...], "retrieval": {...}, "history": [...]
|
|
// }
|
|
//
|
|
// `graph` is React-Flow's layout surface (positions, source/target
|
|
// handles). `components` is the topology the engine executes. The
|
|
// front-end rebuilds `components` from `graph` on every save so the
|
|
// two stay in lockstep; the back-end reads `components` only and
|
|
// ignores `graph`.
|
|
//
|
|
// `importDsl` reads the canonical `graph` block from a parsed
|
|
// import file. `_layout` is intentionally NOT consumed here — it
|
|
// was a one-shot import-time hint that historical v1 export files
|
|
// used to carry canvas positions, and it has not been a wire
|
|
// contract field since the v1/v2 split was removed. A payload with
|
|
// `_layout` but no `graph` (or with neither) falls through to the
|
|
// empty seed.
|
|
|
|
import { Edge } from '@xyflow/react';
|
|
|
|
import { DataflowOperator, EmptyDsl, Operator } from '@/constants/agent';
|
|
import {
|
|
DSL,
|
|
DSLComponents,
|
|
GlobalVariableType,
|
|
IOperator,
|
|
RAGFlowNodeType,
|
|
} from '@/interfaces/database/agent';
|
|
import { DataflowEmptyDsl } from '@/pages/agent/empty-dsl';
|
|
|
|
import { buildDslComponentsByGraph, buildDslGlobalVariables } from '../utils';
|
|
|
|
const LEGACY_ITERATION_NODE_TYPE = 'group';
|
|
const ITERATION_NODE_TYPE = 'iterationNode';
|
|
|
|
const normalizeGraphNodes = (nodes: RAGFlowNodeType[]): RAGFlowNodeType[] =>
|
|
nodes.map((node) => {
|
|
if (
|
|
node?.data?.label === Operator.Iteration &&
|
|
node.type === LEGACY_ITERATION_NODE_TYPE
|
|
) {
|
|
return {
|
|
...node,
|
|
type: ITERATION_NODE_TYPE,
|
|
};
|
|
}
|
|
|
|
return node;
|
|
});
|
|
|
|
// ─── Public API ─────────────────────────────────────────────────────────
|
|
|
|
/** Initial empty DSL for a new canvas. `isAgent` picks agent vs dataflow seed. */
|
|
export const initialEmptyDsl = (isAgent: boolean): DSL =>
|
|
isAgent ? (EmptyDsl as unknown as DSL) : (DataflowEmptyDsl as unknown as DSL);
|
|
|
|
/**
|
|
* Convert a parsed JSON object from a user-uploaded file into a
|
|
* renderable DSL. Caller must have already parsed the file (we take
|
|
* the object, not the string). `isAgent` is the form-level flag, not
|
|
* re-inferred here.
|
|
*
|
|
* Reads the canonical `graph` block from a parsed import file.
|
|
* `raw.graph.nodes` must be present and non-empty for the input
|
|
* to be rendered as-is; anything else falls through to the empty
|
|
* seed (an empty canvas).
|
|
*
|
|
* `_layout` is NOT read — that field was a one-shot import-time
|
|
* hint in historical v1 export files and is no longer part of the
|
|
* wire contract. A payload that carries `_layout` (and nothing
|
|
* else) is treated the same as an empty file.
|
|
*/
|
|
export const importDsl = (
|
|
rawParsed: Record<string, any>,
|
|
isAgent: boolean,
|
|
): DSL => {
|
|
const seed = isAgent ? EmptyDsl : DataflowEmptyDsl;
|
|
|
|
// Single precedence level: `raw.graph.nodes` is the canonical
|
|
// wire shape. Every DSL the back-end returns has a populated
|
|
// `graph` block, so anything else (a `_layout`-only payload from
|
|
// a stale test fixture, a `components`-only payload from a
|
|
// third-party tool, an empty file) falls through to the empty
|
|
// seed.
|
|
let graph: { nodes: RAGFlowNodeType[]; edges: Edge[] };
|
|
let components: DSLComponents;
|
|
|
|
if (Array.isArray(rawParsed?.graph?.nodes)) {
|
|
const rawEdges = rawParsed.graph.edges;
|
|
const edges: Edge[] = Array.isArray(rawEdges) ? rawEdges : [];
|
|
graph = {
|
|
nodes: normalizeGraphNodes(rawParsed.graph.nodes as RAGFlowNodeType[]),
|
|
edges,
|
|
};
|
|
components =
|
|
(rawParsed.components as DSLComponents | undefined) ??
|
|
(buildDslComponentsByGraph(
|
|
graph.nodes,
|
|
graph.edges,
|
|
seed.components as DSLComponents,
|
|
) as DSLComponents);
|
|
} else {
|
|
graph = { nodes: [], edges: [] };
|
|
components = seed.components as DSLComponents;
|
|
}
|
|
|
|
return {
|
|
...seed,
|
|
graph,
|
|
components,
|
|
retrieval: rawParsed.retrieval ?? seed.retrieval,
|
|
history: rawParsed.history ?? seed.history,
|
|
path: rawParsed.path ?? seed.path,
|
|
variables: rawParsed.variables ?? seed.variables,
|
|
globals: rawParsed.globals ?? seed.globals,
|
|
} as unknown as DSL;
|
|
};
|
|
|
|
/**
|
|
* Convert a server-returned DSL into the React-Flow `{nodes, edges}`
|
|
* shape the store consumes. Reads `dsl.graph` only; absent
|
|
* `graph.nodes` returns an empty canvas (see function body for
|
|
* the rationale).
|
|
*/
|
|
export const dslToGraph = (
|
|
dsl: DSL,
|
|
): { nodes: RAGFlowNodeType[]; edges: Edge[] } => {
|
|
// Single source of truth: server always populates `graph`, so a
|
|
// server-returned dsl that lacks `graph.nodes` is treated as
|
|
// empty (the back-end should never produce such a payload;
|
|
// treating it as empty keeps the canvas from blowing up if a
|
|
// historical row slips through). No components-only fallback —
|
|
// the strict-graph import contract in `importDsl` ensures the
|
|
// top-level DSL always carries `graph`.
|
|
const graphNodes = dsl?.graph?.nodes;
|
|
if (Array.isArray(graphNodes) && graphNodes.length > 0) {
|
|
const rawEdges = dsl?.graph?.edges;
|
|
return {
|
|
nodes: normalizeGraphNodes(graphNodes as RAGFlowNodeType[]),
|
|
edges: (Array.isArray(rawEdges) ? rawEdges : []) as Edge[],
|
|
};
|
|
}
|
|
return { nodes: [], edges: [] };
|
|
};
|
|
|
|
/**
|
|
* Build a fresh DSL from the current React-Flow state. Emits
|
|
* `graph` + `components` plus a spread of the previous DSL for any
|
|
* untouched fields (`messages`, `path`, `retrieval`, etc.).
|
|
*/
|
|
export const graphToDsl = (
|
|
currentNodes: RAGFlowNodeType[],
|
|
currentEdges: Edge[],
|
|
oldDsl: DSL,
|
|
globalVariables?: Record<string, GlobalVariableType>,
|
|
): DSL => {
|
|
const filteredNodes = currentNodes.filter(
|
|
(n) => n.data?.label !== Operator.Placeholder,
|
|
);
|
|
const filteredEdges = currentEdges.filter((edge) => {
|
|
const s = currentNodes.find((n) => n.id === edge.source);
|
|
const t = currentNodes.find((n) => n.id === edge.target);
|
|
return (
|
|
s?.data?.label !== Operator.Placeholder &&
|
|
t?.data?.label !== Operator.Placeholder
|
|
);
|
|
});
|
|
|
|
const dslComponents = buildDslComponentsByGraph(
|
|
filteredNodes,
|
|
filteredEdges,
|
|
oldDsl?.components ?? {},
|
|
);
|
|
const globals = buildDslGlobalVariables(
|
|
oldDsl ?? ({} as DSL),
|
|
globalVariables,
|
|
);
|
|
|
|
return {
|
|
...oldDsl,
|
|
...globals,
|
|
graph: { nodes: filteredNodes, edges: filteredEdges },
|
|
components: dslComponents,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Build a downloadable JSON for the export button. Returns the
|
|
* conventional wire shape (`graph` + `components` + `globals` +
|
|
* `variables` + spread of any untouched fields). The caller is
|
|
* responsible for stripping sensitive fields (api_key) before
|
|
* handing the result to the file writer.
|
|
*/
|
|
export const exportDsl = (
|
|
currentNodes: RAGFlowNodeType[],
|
|
currentEdges: Edge[],
|
|
oldDsl: DSL,
|
|
globalVariables?: Record<string, GlobalVariableType>,
|
|
): Record<string, any> => {
|
|
return graphToDsl(
|
|
currentNodes,
|
|
currentEdges,
|
|
oldDsl,
|
|
globalVariables,
|
|
) as Record<string, any>;
|
|
};
|
|
|
|
/**
|
|
* Detect whether an imported JSON is a dataflow canvas. Looks at
|
|
* both shapes (v1 components / v2 graph.nodes) for the dataflow
|
|
* markers ("File" begin + "Parser"). Defaults to `true` (agent)
|
|
* when ambiguous.
|
|
*/
|
|
export const inferIsAgentFromImport = (raw: Record<string, any>): boolean => {
|
|
const graph = raw?.graph;
|
|
if (graph && Array.isArray(graph.nodes)) {
|
|
const labels = (graph.nodes as any[]).map((n: any) => n?.data?.label);
|
|
if (
|
|
labels.includes(DataflowOperator.Begin) &&
|
|
labels.includes(DataflowOperator.Parser)
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
// No `graph` block — treat as agent. The strict-graph
|
|
// import contract in `importDsl` already handles the
|
|
// empty-payload case elsewhere.
|
|
return true;
|
|
};
|
|
|
|
export type { IOperator };
|