Files
ragflow/web/src/pages/agent/utils/dsl-bridge.ts
Zhichang Yu 3f805a64f1 feat(agent): align Go agent behavior with Python (except retrieval component) (#16225)
## 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>
2026-06-22 11:58:29 +08:00

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 };