Add files via upload

This commit is contained in:
公明
2026-07-06 11:34:50 +08:00
committed by GitHub
parent 2764732cdf
commit f6412150cf
19 changed files with 1603 additions and 102 deletions
+28 -4
View File
@@ -9,15 +9,39 @@ import (
// compileAgentSubgraph wraps an Agent canvas node as an Eino subgraph (AddGraphNode best practice).
func compileAgentSubgraph(_ context.Context, node graphNode) (compose.AnyGraph, error) {
n := node
innerID := n.ID + "__agent"
prepareID := n.ID + "__agent_prepare"
executeID := n.ID + "__agent_execute"
finalizeID := n.ID + "__agent_finalize"
g := compose.NewGraph[WorkflowNodeOutput, WorkflowNodeOutput]()
_ = g.AddLambdaNode(innerID, compose.InvokableLambda(func(runCtx context.Context, _ WorkflowNodeOutput) (WorkflowNodeOutput, error) {
_ = g.AddLambdaNode(prepareID, compose.InvokableLambda(func(_ context.Context, input WorkflowNodeOutput) (WorkflowNodeOutput, error) {
if input == nil {
input = WorkflowNodeOutput{}
}
input["agent_subgraph_stage"] = "prepare"
input["agent_node_id"] = n.ID
return input, nil
}))
_ = g.AddLambdaNode(executeID, compose.InvokableLambda(func(runCtx context.Context, _ WorkflowNodeOutput) (WorkflowNodeOutput, error) {
return runWorkflowNodeLambda(runCtx, n)
}))
if err := g.AddEdge(compose.START, innerID); err != nil {
_ = g.AddLambdaNode(finalizeID, compose.InvokableLambda(func(_ context.Context, output WorkflowNodeOutput) (WorkflowNodeOutput, error) {
if output == nil {
output = WorkflowNodeOutput{}
}
output["agent_subgraph_stage"] = "finalize"
output["agent_node_id"] = n.ID
return output, nil
}))
if err := g.AddEdge(compose.START, prepareID); err != nil {
return nil, err
}
if err := g.AddEdge(innerID, compose.END); err != nil {
if err := g.AddEdge(prepareID, executeID); err != nil {
return nil, err
}
if err := g.AddEdge(executeID, finalizeID); err != nil {
return nil, err
}
if err := g.AddEdge(finalizeID, compose.END); err != nil {
return nil, err
}
return g, nil
+12
View File
@@ -52,20 +52,32 @@ func resolveBinding(b FieldBinding, state *WorkflowLocalState) any {
field = "output"
}
if from == "" || from == "previous" || from == "prev" {
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
return evalJSONPathValue(state.LastOutput, field)
}
if field == "output" && state.LastOutput != nil {
return state.LastOutput["output"]
}
return valueFromPath("previous."+field, state)
}
if from == "inputs" || from == "input" {
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
return evalJSONPathValue(state.Inputs, field)
}
if field == "" {
return state.Inputs
}
return valueFromPath("inputs."+field, state)
}
if from == "outputs" {
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
return evalJSONPathValue(state.Outputs, field)
}
return valueFromPath("outputs."+field, state)
}
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
return evalJSONPathValue(valueFromPath(from, state), field)
}
return valueFromPath(from+"."+field, state)
}
+173
View File
@@ -0,0 +1,173 @@
package workflow
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
)
type DryRunResult struct {
Outputs map[string]any `json:"outputs"`
NodeOutputs map[string]map[string]any `json:"nodeOutputs"`
Executed []string `json:"executed"`
Skipped []string `json:"skipped"`
Trace []map[string]any `json:"trace"`
Metrics map[string]any `json:"metrics"`
ReplayScript []map[string]any `json:"replayScript"`
}
func DryRunGraphJSON(ctx context.Context, graphJSON string, inputs map[string]any) (*DryRunResult, error) {
g, err := parseGraph(graphJSON)
if err != nil {
return nil, err
}
idx := indexGraph(g)
if err := validateGraphDefinition(g, idx); err != nil {
return nil, err
}
in := make(map[string]interface{}, len(inputs))
for k, v := range inputs {
in[k] = v
}
if _, ok := in["message"]; !ok {
in["message"] = ""
}
state := newWorkflowLocalState(in, "dry-run")
rt := &workflowRuntime{runID: "dry-run", idx: idx, state: state}
trace := []map[string]any{}
executedIDs := map[string]bool{}
queue := findStartNodeIDs(idx)
for len(queue) > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
nodeID := queue[0]
queue = queue[1:]
if executedIDs[nodeID] {
continue
}
node := idx.nodes[nodeID]
if !dryRunPredecessorsReady(idx, nodeID, executedIDs) {
queue = append(queue, nodeID)
continue
}
if err := prepareNodeInputState(rt, node); err != nil {
return nil, err
}
started := time.Now()
out, proceed, status, errText := dryRunNode(node, state)
out["duration_ms"] = time.Since(started).Milliseconds()
out["status"] = status
state.NodeOutputs[node.ID] = out
state.LastOutput = out
executedIDs[nodeID] = true
if status == "skipped" {
state.Skipped = append(state.Skipped, firstNonEmpty(node.Label, node.ID))
} else {
state.Executed = append(state.Executed, firstNonEmpty(node.Label, node.ID))
}
trace = append(trace, map[string]any{
"nodeId": node.ID,
"label": firstNonEmpty(node.Label, node.ID),
"type": node.Type,
"status": status,
"error": errText,
"output": out,
"previous": state.LastOutput,
})
if !proceed {
continue
}
for edgeIdx, edge := range idx.outgoing[nodeID] {
if edgeAllowed(edge, node, edgeIdx, state) {
queue = append(queue, edge.Target)
}
}
}
for id, node := range idx.nodes {
if !executedIDs[id] {
state.Skipped = append(state.Skipped, firstNonEmpty(node.Label, id))
}
}
return &DryRunResult{
Outputs: state.Outputs,
NodeOutputs: state.NodeOutputs,
Executed: state.Executed,
Skipped: state.Skipped,
Trace: trace,
Metrics: state.Metrics,
ReplayScript: buildReplayScript(trace),
}, nil
}
func dryRunPredecessorsReady(idx *graphIndex, nodeID string, executed map[string]bool) bool {
for _, edge := range idx.incoming[nodeID] {
if !executed[edge.Source] {
return false
}
}
return true
}
func dryRunNode(node graphNode, state *WorkflowLocalState) (map[string]any, bool, string, string) {
switch strings.ToLower(strings.TrimSpace(node.Type)) {
case "start":
return startOutputMap(node, state.Inputs["message"], state.Inputs["conversationId"], state.Inputs["projectId"]), true, "completed", ""
case "condition":
expr := cfgString(node.Config, "expression")
matched := evalCondition(expr, state)
return conditionOutputMap(node, expr, matched), true, "completed", ""
case "output":
key := cfgString(node.Config, "output_key")
value := resolveOutputSourceBinding(node.Config, state)
if static := cfgString(node.Config, "static_value"); static != "" {
value = static
}
state.Outputs[key] = value
return outputNodeOutputMap(node, key, value), true, "completed", ""
case "end":
value := resolveOutputSourceBinding(node.Config, state)
if b, ok := parseFieldBinding(node.Config, "result_binding"); ok {
value = resolveBinding(b, state)
}
return endOutputMap(node, value), false, "completed", ""
case "tool":
args, err := resolveToolArguments(node.Config, state)
if err != nil {
errText := fmt.Sprintf("工具参数不是合法 JSON%v", err)
return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"error": errText}), false, "failed", errText
}
return toolOutputMap(node, "[dry-run] tool call skipped", cfgString(node.Config, "tool_name"), args, "dry-run", false), true, "simulated", ""
case "agent":
mode := firstNonEmpty(cfgString(node.Config, "agent_mode"), "eino_single")
response := "[dry-run] agent execution skipped"
if key := cfgString(node.Config, "output_key"); key != "" {
state.Outputs[key] = response
}
return agentOutputMap(node, response, mode, nil), true, "simulated", ""
case "hitl":
prompt := resolveHITLPromptBinding(node.Config, state)
return hitlOutputMap(node, "simulated", prompt, prompt, firstNonEmpty(cfgString(node.Config, "reviewer"), "human"), true), true, "simulated", ""
default:
return outputMap(envelope("unknown", node.ID, node.Type, "skipped", ""), map[string]any{"reason": "未知节点类型"}), true, "skipped", "未知节点类型"
}
}
func buildReplayScript(trace []map[string]any) []map[string]any {
out := make([]map[string]any, 0, len(trace))
for i, step := range trace {
raw, _ := json.Marshal(step["output"])
out = append(out, map[string]any{
"step": i + 1,
"nodeId": step["nodeId"],
"type": step["type"],
"status": step["status"],
"output": string(raw),
})
}
return out
}
+36 -7
View File
@@ -62,10 +62,13 @@ func extractAwaitingHITL(err error, art *compiledArtifact, runID string, args Ru
label := firstNonEmpty(node.Label, nodeID)
if args.DB != nil {
pending := map[string]any{
"nodeId": nodeID,
"label": label,
"prompt": prompt,
"reviewer": cfgString(node.Config, "reviewer"),
"nodeId": nodeID,
"label": label,
"prompt": prompt,
"reviewer": cfgString(node.Config, "reviewer"),
"checkpointId": runID,
"interrupt": workflowInterruptMetadata(info),
"resumePayload": map[string]any{"approved": "bool", "comment": "string"},
}
pendingJSON, _ := json.Marshal(pending)
_ = args.DB.SetWorkflowRunAwaitingHITL(runID, nodeID, string(pendingJSON))
@@ -90,6 +93,30 @@ func extractAwaitingHITL(err error, art *compiledArtifact, runID string, args Ru
}
}
func workflowInterruptMetadata(info *compose.InterruptInfo) map[string]any {
if info == nil {
return map[string]any{}
}
before := append([]string(nil), info.BeforeNodes...)
return map[string]any{
"beforeNodes": before,
"resumeTarget": firstString(before),
"address": map[string]any{
"kind": "compose_interrupt",
"beforeNodes": before,
"path": strings.Join(before, "/"),
},
"raw": fmt.Sprintf("%+v", info),
}
}
func firstString(values []string) string {
if len(values) == 0 {
return ""
}
return values[0]
}
func nextHITLNodeID(info *compose.InterruptInfo, hitlIDs []string) string {
if info != nil && len(info.BeforeNodes) > 0 {
for _, id := range info.BeforeNodes {
@@ -176,9 +203,9 @@ func ResumeWorkflowRun(ctx context.Context, args RunArgs, runID string, approved
if err != nil {
if IsAwaitingHITL(err) {
return &RunResult{
RunID: runID,
Status: "awaiting_hitl",
Response: fmt.Sprintf("工作流在节点「%s」等待下一次人工确认。", err.(*AwaitingHITLError).NodeID),
RunID: runID,
Status: "awaiting_hitl",
Response: fmt.Sprintf("工作流在节点「%s」等待下一次人工确认。", err.(*AwaitingHITLError).NodeID),
AwaitingHITL: true,
}, nil
}
@@ -194,6 +221,7 @@ func ResumeWorkflowRun(ctx context.Context, args RunArgs, runID string, approved
"workflowRunId": runID,
"status": "completed",
"outputs": state.Outputs,
"metrics": state.Metrics,
"executedNodes": state.Executed,
"skippedNodes": state.Skipped,
"engine": "eino_workflow",
@@ -206,6 +234,7 @@ func ResumeWorkflowRun(ctx context.Context, args RunArgs, runID string, approved
"workflowRunId": runID,
"workflowId": wf.ID,
"outputs": state.Outputs,
"metrics": state.Metrics,
"response": response,
"engine": "eino_workflow",
})
+132
View File
@@ -3,6 +3,7 @@ package workflow
import (
"context"
"path/filepath"
"strings"
"testing"
"cyberstrike-ai/internal/config"
@@ -58,6 +59,137 @@ func TestValidateGraphJSON_linear(t *testing.T) {
}
}
func TestValidateGraphJSON_rejectsInvalidGraphs(t *testing.T) {
tests := []struct {
name string
graph string
wantErr string
}{
{
name: "start with incoming edge",
graph: `{
"nodes": [
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
{"id": "agent-1", "type": "agent", "label": "Agent", "position": {"x": 0, "y": 80}, "config": {"instruction": "noop"}},
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 160}, "config": {"output_key": "result"}}
],
"edges": [
{"id": "e1", "source": "start-1", "target": "agent-1"},
{"id": "e2", "source": "agent-1", "target": "start-1"},
{"id": "e3", "source": "agent-1", "target": "out-1"}
]
}`,
wantErr: "开始节点",
},
{
name: "output with outgoing edge",
graph: `{
"nodes": [
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 80}, "config": {"output_key": "result"}},
{"id": "end-1", "type": "end", "label": "结束", "position": {"x": 0, "y": 160}, "config": {}}
],
"edges": [
{"id": "e1", "source": "start-1", "target": "out-1"},
{"id": "e2", "source": "out-1", "target": "end-1"}
]
}`,
wantErr: "不能有出边",
},
{
name: "tool without name",
graph: `{
"nodes": [
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
{"id": "tool-1", "type": "tool", "label": "工具", "position": {"x": 0, "y": 80}, "config": {}},
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 160}, "config": {"output_key": "result"}}
],
"edges": [
{"id": "e1", "source": "start-1", "target": "tool-1"},
{"id": "e2", "source": "tool-1", "target": "out-1"}
]
}`,
wantErr: "必须选择 MCP 工具",
},
{
name: "condition with too many branches",
graph: `{
"nodes": [
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
{"id": "cond-1", "type": "condition", "label": "判断", "position": {"x": 0, "y": 80}, "config": {"expression": "{{inputs.message}}"}},
{"id": "out-1", "type": "output", "label": "输出1", "position": {"x": -80, "y": 160}, "config": {"output_key": "a"}},
{"id": "out-2", "type": "output", "label": "输出2", "position": {"x": 0, "y": 160}, "config": {"output_key": "b"}},
{"id": "out-3", "type": "output", "label": "输出3", "position": {"x": 80, "y": 160}, "config": {"output_key": "c"}}
],
"edges": [
{"id": "e1", "source": "start-1", "target": "cond-1"},
{"id": "e2", "source": "cond-1", "target": "out-1"},
{"id": "e3", "source": "cond-1", "target": "out-2"},
{"id": "e4", "source": "cond-1", "target": "out-3"}
]
}`,
wantErr: "1 到 2 条出边",
},
{
name: "orphan node",
graph: `{
"nodes": [
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 80}, "config": {"output_key": "result"}},
{"id": "agent-1", "type": "agent", "label": "孤岛", "position": {"x": 200, "y": 80}, "config": {"instruction": "noop"}}
],
"edges": [
{"id": "e1", "source": "start-1", "target": "out-1"}
]
}`,
wantErr: "不可达",
},
{
name: "cycle",
graph: `{
"nodes": [
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
{"id": "agent-1", "type": "agent", "label": "Agent1", "position": {"x": 0, "y": 80}, "config": {"instruction": "noop", "output_key": "a1"}},
{"id": "agent-2", "type": "agent", "label": "Agent2", "position": {"x": 0, "y": 160}, "config": {"instruction": "noop", "output_key": "a2"}},
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 240}, "config": {"output_key": "result"}}
],
"edges": [
{"id": "e1", "source": "start-1", "target": "agent-1"},
{"id": "e2", "source": "agent-1", "target": "agent-2"},
{"id": "e3", "source": "agent-2", "target": "agent-1"},
{"id": "e4", "source": "agent-2", "target": "out-1"}
]
}`,
wantErr: "环路",
},
{
name: "output without key",
graph: `{
"nodes": [
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 80}, "config": {}}
],
"edges": [
{"id": "e1", "source": "start-1", "target": "out-1"}
]
}`,
wantErr: "输出变量名",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateGraphJSON(context.Background(), tt.graph)
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
}
})
}
}
func TestCompileEngine_linear(t *testing.T) {
ctx := context.Background()
SetCheckpointDir(t.TempDir())
+14 -11
View File
@@ -17,16 +17,16 @@ type compiledArtifact struct {
// Engine compiles and caches Eino Workflow artifacts.
type Engine struct {
mu sync.RWMutex
cache map[string]*compiledArtifact
cpStore compose.CheckPointStore
cpStoreMu sync.Once
cpStoreErr error
mu sync.RWMutex
cache map[string]*compiledArtifact
cpStore compose.CheckPointStore
cpStoreMu sync.Once
cpStoreErr error
checkpointDir string
}
var defaultEngine = &Engine{
cache: make(map[string]*compiledArtifact),
cache: make(map[string]*compiledArtifact),
checkpointDir: "data/workflow-checkpoints",
}
@@ -69,11 +69,8 @@ func ValidateGraphJSON(ctx context.Context, graphJSON string) error {
return err
}
idx := indexGraph(g)
if len(findStartNodeIDs(idx)) == 0 {
return fmt.Errorf("工作流缺少可执行的起点节点")
}
if !hasTerminalNode(idx) {
return fmt.Errorf("工作流至少需要一个无出边的终点或 output/end 节点")
if err := validateGraphDefinition(g, idx); err != nil {
return err
}
_, err = defaultEngine.compile(ctx, g)
return err
@@ -120,6 +117,9 @@ func (e *Engine) compile(ctx context.Context, g *graphDef) (*compiledArtifact, e
return nil, err
}
idx := indexGraph(g)
if err := validateGraphDefinition(g, idx); err != nil {
return nil, err
}
hitlIDs := collectHITLNodeIDs(idx)
compileOpts := []compose.GraphCompileOption{
compose.WithGraphName("CyberStrikeWorkflow"),
@@ -219,6 +219,9 @@ func runWorkflowNodeLambda(runCtx context.Context, n graphNode) (WorkflowNodeOut
if localRT == nil {
return nil, fmt.Errorf("workflow runtime missing in context")
}
if err := prepareNodeInputState(localRT, n); err != nil {
return nil, err
}
result, proceed, err := executeNode(runCtx, localRT.args, localRT.runID, n, localRT.state)
if err != nil {
return nil, err
+4 -4
View File
@@ -4,11 +4,11 @@ import "errors"
// AwaitingHITLError indicates the workflow paused before a HITL node for human approval.
type AwaitingHITLError struct {
RunID string
NodeID string
RunID string
NodeID string
NodeLabel string
Prompt string
Reviewer string
Prompt string
Reviewer string
}
func (e *AwaitingHITLError) Error() string {
+186
View File
@@ -0,0 +1,186 @@
package workflow
import (
"fmt"
"regexp"
"strconv"
"strings"
)
var expressionOps = []string{">=", "<=", "==", "!=", " contains ", " matches ", ">", "<"}
var jsonFuncRe = regexp.MustCompile(`^(jsonpath|jq)\((.*),\s*(['"][^'"]+['"])\)$`)
var jsonFuncFindRe = regexp.MustCompile(`(jsonpath|jq)\([^)]*\)`)
var singleTemplateVarRe = regexp.MustCompile(`^\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}$`)
func validateConditionExpression(expr string) error {
expr = strings.TrimSpace(expr)
if expr == "" {
return fmt.Errorf("条件表达式不能为空")
}
for _, part := range splitBoolExpr(expr, "||") {
for _, atom := range splitBoolExpr(part, "&&") {
if err := validateConditionAtom(atom); err != nil {
return err
}
}
}
return nil
}
func validateConditionAtom(expr string) error {
expr = strings.TrimSpace(expr)
if expr == "" {
return fmt.Errorf("条件表达式存在空片段")
}
if strings.Count(expr, "{{") != strings.Count(expr, "}}") {
return fmt.Errorf("条件表达式模板括号不匹配: %s", expr)
}
if err := validateJSONFunctions(expr); err != nil {
return err
}
if left, right, ok := splitExpressionAtom(expr, " matches "); ok {
if strings.TrimSpace(left) == "" || strings.TrimSpace(right) == "" {
return fmt.Errorf("matches 表达式两侧不能为空: %s", expr)
}
pattern := cleanComparable(resolveStaticTemplate(right))
if _, err := regexp.Compile(pattern); err != nil {
return fmt.Errorf("matches 正则非法: %w", err)
}
return nil
}
for _, op := range expressionOps {
if op == " matches " {
continue
}
if left, right, ok := splitExpressionAtom(expr, op); ok {
if strings.TrimSpace(left) == "" || strings.TrimSpace(right) == "" {
return fmt.Errorf("表达式 %q 两侧不能为空: %s", strings.TrimSpace(op), expr)
}
return nil
}
}
return nil
}
func evalCondition(expr string, state *WorkflowLocalState) bool {
expr = strings.TrimSpace(expr)
if expr == "" {
return true
}
orParts := splitBoolExpr(expr, "||")
for _, orPart := range orParts {
andOK := true
for _, atom := range splitBoolExpr(orPart, "&&") {
if !evalConditionAtom(atom, state) {
andOK = false
break
}
}
if andOK {
return true
}
}
return false
}
func evalConditionAtom(expr string, state *WorkflowLocalState) bool {
expr = strings.TrimSpace(expr)
for _, op := range expressionOps {
if left, right, ok := splitExpressionAtom(expr, op); ok {
left = strings.TrimSpace(fmt.Sprint(resolveExpressionOperand(left, state)))
right = strings.TrimSpace(fmt.Sprint(resolveExpressionOperand(right, state)))
switch strings.TrimSpace(op) {
case "==":
return cleanComparable(left) == cleanComparable(right)
case "!=":
return cleanComparable(left) != cleanComparable(right)
case ">":
return compareNumeric(left, right, func(a, b float64) bool { return a > b })
case ">=":
return compareNumeric(left, right, func(a, b float64) bool { return a >= b })
case "<":
return compareNumeric(left, right, func(a, b float64) bool { return a < b })
case "<=":
return compareNumeric(left, right, func(a, b float64) bool { return a <= b })
case "contains":
return strings.Contains(cleanComparable(left), cleanComparable(right))
case "matches":
matched, _ := regexp.MatchString(cleanComparable(right), cleanComparable(left))
return matched
}
}
}
resolved := strings.TrimSpace(fmt.Sprint(resolveExpressionOperand(expr, state)))
v := strings.ToLower(cleanComparable(resolved))
return v != "" && v != "false" && v != "0" && v != "null"
}
func splitBoolExpr(expr, sep string) []string {
parts := strings.Split(expr, sep)
out := make([]string, 0, len(parts))
for _, part := range parts {
if s := strings.TrimSpace(part); s != "" {
out = append(out, s)
}
}
if len(out) == 0 {
return []string{strings.TrimSpace(expr)}
}
return out
}
func splitExpressionAtom(expr, op string) (string, string, bool) {
if strings.TrimSpace(op) == "contains" || strings.TrimSpace(op) == "matches" {
idx := strings.Index(expr, op)
if idx < 0 {
return "", "", false
}
return expr[:idx], expr[idx+len(op):], true
}
idx := strings.Index(expr, op)
if idx < 0 {
return "", "", false
}
return expr[:idx], expr[idx+len(op):], true
}
func compareNumeric(left, right string, cmp func(float64, float64) bool) bool {
a, errA := strconv.ParseFloat(cleanComparable(left), 64)
b, errB := strconv.ParseFloat(cleanComparable(right), 64)
if errA != nil || errB != nil {
return false
}
return cmp(a, b)
}
func resolveStaticTemplate(s string) string {
return templateVarRe.ReplaceAllString(s, "value")
}
func resolveExpressionOperand(raw string, state *WorkflowLocalState) any {
raw = strings.TrimSpace(raw)
if m := jsonFuncRe.FindStringSubmatch(raw); len(m) == 4 {
inputExpr := strings.TrimSpace(m[2])
path := strings.Trim(m[3], `"'`)
input := resolveExpressionOperand(inputExpr, state)
return evalJSONPathValue(input, path)
}
if m := singleTemplateVarRe.FindStringSubmatch(raw); len(m) == 2 {
return valueFromPath(m[1], state)
}
return resolveTemplate(raw, state)
}
func validateJSONFunctions(expr string) error {
for _, candidate := range jsonFuncFindRe.FindAllString(expr, -1) {
candidate = strings.TrimSpace(candidate)
m := jsonFuncRe.FindStringSubmatch(candidate)
if len(m) != 4 {
return fmt.Errorf("JSONPath/JQ 函数格式应为 jsonpath(value, \"$.path\") 或 jq(value, \".path\")")
}
if err := validateJSONPathSyntax(strings.Trim(m[3], `"'`)); err != nil {
return err
}
}
return nil
}
+107
View File
@@ -0,0 +1,107 @@
package workflow
import (
"context"
"testing"
)
func TestEvalCondition_extendedOperators(t *testing.T) {
state := newWorkflowLocalState(map[string]interface{}{"score": 9, "message": "status: ok"}, "run-expr")
state.LastOutput = map[string]any{"output": "asset-123.example.com"}
tests := []string{
"{{inputs.score}} >= 9",
"{{inputs.message}} contains ok",
"{{previous.output}} matches ^asset-[0-9]+\\.example\\.com$",
"{{inputs.score}} > 5 && {{inputs.message}} contains status",
}
for _, expr := range tests {
if err := validateConditionExpression(expr); err != nil {
t.Fatalf("validate %q: %v", expr, err)
}
if !evalCondition(expr, state) {
t.Fatalf("evalCondition(%q) = false, want true", expr)
}
}
}
func TestEvalCondition_jsonPathAndJQSafeSubset(t *testing.T) {
state := newWorkflowLocalState(map[string]interface{}{
"payload": map[string]any{
"risk": 9,
"items": []any{
map[string]any{"name": "first"},
},
},
}, "run-jsonpath")
state.LastOutput = map[string]any{"output": `{"status":"ok","score":7}`}
tests := []string{
`jsonpath({{inputs.payload}}, "$.risk") >= 8`,
`jq({{inputs.payload}}, ".items[0].name") == first`,
`jsonpath({{previous.output}}, "$.status") == ok`,
}
for _, expr := range tests {
if err := validateConditionExpression(expr); err != nil {
t.Fatalf("validate %q: %v", expr, err)
}
if !evalCondition(expr, state) {
t.Fatalf("evalCondition(%q) = false, want true", expr)
}
}
}
func TestMergeUpstreamOutputs_allMerge(t *testing.T) {
got := mergeUpstreamOutputs(JoinAllMerge, []map[string]any{
{"output": "a", "left": 1},
{"output": "b", "right": 2},
})
if got["kind"] != "join" || got["strategy"] != JoinAllMerge {
t.Fatalf("join metadata = %#v", got)
}
values, ok := got["output"].([]any)
if !ok || len(values) != 2 || values[0] != "a" || values[1] != "b" {
t.Fatalf("merged output = %#v", got["output"])
}
if got["left"] != 1 || got["right"] != 2 {
t.Fatalf("merged fields = %#v", got)
}
}
func TestMergeUpstreamOutputs_firstNonEmpty(t *testing.T) {
got := mergeUpstreamOutputs(JoinFirstNonEmpty, []map[string]any{
{"output": ""},
{"output": "winner"},
})
if got["output"] != "winner" {
t.Fatalf("output = %#v, want winner", got["output"])
}
}
func TestDryRunGraphJSON_simulatesUnsafeNodes(t *testing.T) {
graph := `{
"nodes": [
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
{"id": "agent-1", "type": "agent", "label": "Agent", "position": {"x": 0, "y": 80}, "config": {"instruction": "noop", "output_key": "agent_result"}},
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 160}, "config": {"output_key": "result", "source_binding": {"from": "outputs", "field": "agent_result"}}}
],
"edges": [
{"id": "e1", "source": "start-1", "target": "agent-1"},
{"id": "e2", "source": "agent-1", "target": "out-1"}
]
}`
result, err := DryRunGraphJSON(nilContext(), graph, map[string]any{"message": "hello"})
if err != nil {
t.Fatalf("DryRunGraphJSON: %v", err)
}
if got := result.Outputs["result"]; got != "[dry-run] agent execution skipped" {
t.Fatalf("result output = %#v", got)
}
if len(result.Trace) != 3 {
t.Fatalf("trace len = %d, want 3", len(result.Trace))
}
}
func nilContext() context.Context {
return context.Background()
}
+117
View File
@@ -0,0 +1,117 @@
package workflow
import (
"fmt"
"strings"
)
const (
JoinAllMerge = "all_merge"
JoinLastByCanvas = "last_by_canvas"
JoinFirstNonEmpty = "first_non_empty"
JoinFailFast = "fail_fast"
)
var allowedJoinStrategies = map[string]bool{
JoinAllMerge: true,
JoinLastByCanvas: true,
JoinFirstNonEmpty: true,
JoinFailFast: true,
}
func joinStrategy(node graphNode) string {
strategy := strings.ToLower(cfgString(node.Config, "join_strategy"))
if strategy == "" {
return JoinAllMerge
}
return strategy
}
func prepareNodeInputState(rt *workflowRuntime, node graphNode) error {
if rt == nil || rt.idx == nil || rt.state == nil {
return nil
}
incoming := rt.idx.incoming[node.ID]
if len(incoming) <= 1 {
return nil
}
strategy := joinStrategy(node)
if !allowedJoinStrategies[strategy] {
return fmt.Errorf("节点「%s」使用了未知汇聚策略: %s", firstNonEmpty(node.Label, node.ID), strategy)
}
upstreams := make([]map[string]any, 0, len(incoming))
for _, edge := range incoming {
out := rt.state.NodeOutputs[edge.Source]
if out == nil {
continue
}
if isFailedNodeOutput(out) && strategy == JoinFailFast {
return fmt.Errorf("上游节点「%s」失败,汇聚策略 fail_fast 中止", edge.Source)
}
upstreams = append(upstreams, out)
}
if len(upstreams) == 0 {
return nil
}
rt.state.LastOutput = mergeUpstreamOutputs(strategy, upstreams)
return nil
}
func mergeUpstreamOutputs(strategy string, upstreams []map[string]any) map[string]any {
switch strategy {
case JoinLastByCanvas:
return cloneNodeOutput(upstreams[len(upstreams)-1])
case JoinFirstNonEmpty:
for _, out := range upstreams {
if !isEmptyOutputValue(out["output"]) {
return cloneNodeOutput(out)
}
}
return cloneNodeOutput(upstreams[0])
default:
merged := map[string]any{
"kind": "join",
"strategy": strategy,
"upstreams": upstreams,
}
values := make([]any, 0, len(upstreams))
for _, out := range upstreams {
values = append(values, out["output"])
for k, v := range out {
if _, exists := merged[k]; !exists {
merged[k] = v
}
}
}
merged["output"] = values
return merged
}
}
func cloneNodeOutput(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func isEmptyOutputValue(v any) bool {
if v == nil {
return true
}
return strings.TrimSpace(fmt.Sprint(v)) == ""
}
func isFailedNodeOutput(out map[string]any) bool {
if out == nil {
return false
}
if v, ok := out["error"]; ok && strings.TrimSpace(fmt.Sprint(v)) != "" {
return true
}
if v, ok := out["is_error"]; ok {
return strings.EqualFold(fmt.Sprint(v), "true")
}
return false
}
+115
View File
@@ -0,0 +1,115 @@
package workflow
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
func evalJSONPathValue(input any, path string) any {
path = strings.TrimSpace(path)
if path == "" || path == "$" || path == "." {
return input
}
if strings.HasPrefix(path, "$.") {
path = strings.TrimPrefix(path, "$.")
} else if strings.HasPrefix(path, ".") {
path = strings.TrimPrefix(path, ".")
} else if strings.HasPrefix(path, "$") {
path = strings.TrimPrefix(path, "$")
}
cur := normalizeJSONInput(input)
for _, token := range parseJSONPathTokens(path) {
if token == "" {
continue
}
switch v := cur.(type) {
case map[string]any:
cur = v[token]
case []any:
idx, err := strconv.Atoi(token)
if err != nil || idx < 0 || idx >= len(v) {
return ""
}
cur = v[idx]
default:
return ""
}
}
if cur == nil {
return ""
}
return cur
}
func normalizeJSONInput(input any) any {
switch v := input.(type) {
case string:
var decoded any
if err := json.Unmarshal([]byte(v), &decoded); err == nil {
return decoded
}
return v
case []byte:
var decoded any
if err := json.Unmarshal(v, &decoded); err == nil {
return decoded
}
return string(v)
default:
return input
}
}
func parseJSONPathTokens(path string) []string {
var tokens []string
var buf strings.Builder
for i := 0; i < len(path); i++ {
ch := path[i]
switch ch {
case '.':
if buf.Len() > 0 {
tokens = append(tokens, buf.String())
buf.Reset()
}
case '[':
if buf.Len() > 0 {
tokens = append(tokens, buf.String())
buf.Reset()
}
j := i + 1
for j < len(path) && path[j] != ']' {
j++
}
if j <= len(path) {
token := strings.Trim(path[i+1:j], `"' `)
tokens = append(tokens, token)
i = j
}
default:
buf.WriteByte(ch)
}
}
if buf.Len() > 0 {
tokens = append(tokens, buf.String())
}
return tokens
}
func validateJSONPathSyntax(path string) error {
path = strings.TrimSpace(path)
if path == "" {
return fmt.Errorf("JSONPath 不能为空")
}
if !strings.HasPrefix(path, "$") && !strings.HasPrefix(path, ".") {
return fmt.Errorf("JSONPath/JQ 路径必须以 $ 或 . 开头")
}
if strings.Contains(path, "..") || strings.ContainsAny(path, "*?()|") {
return fmt.Errorf("仅支持安全路径子集,不支持通配符、递归或表达式")
}
if strings.Count(path, "[") != strings.Count(path, "]") {
return fmt.Errorf("JSONPath 方括号不匹配")
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
package workflow
import (
"fmt"
"strconv"
)
func accumulateWorkflowMetric(state *WorkflowLocalState, key string, delta any) {
if state == nil {
return
}
if state.Metrics == nil {
state.Metrics = make(map[string]any)
}
current := numericMetric(state.Metrics[key])
state.Metrics[key] = current + numericMetric(delta)
}
func numericMetric(v any) float64 {
switch n := v.(type) {
case int:
return float64(n)
case int32:
return float64(n)
case int64:
return float64(n)
case float32:
return float64(n)
case float64:
return n
case string:
f, _ := strconv.ParseFloat(n, 64)
return f
default:
f, _ := strconv.ParseFloat(fmt.Sprint(v), 64)
return f
}
}
func collectAgentMetrics(state *WorkflowLocalState, data interface{}) {
m, ok := data.(map[string]interface{})
if !ok || state == nil {
return
}
for _, key := range []string{"prompt_tokens", "completion_tokens", "total_tokens", "cost", "input_tokens", "output_tokens"} {
if v, ok := m[key]; ok {
accumulateWorkflowMetric(state, key, v)
}
}
if usage, ok := m["usage"].(map[string]interface{}); ok {
for _, key := range []string{"prompt_tokens", "completion_tokens", "total_tokens", "input_tokens", "output_tokens"} {
if v, ok := usage[key]; ok {
accumulateWorkflowMetric(state, key, v)
}
}
}
}
+23 -1
View File
@@ -18,12 +18,21 @@ func executeNode(ctx context.Context, args RunArgs, runID string, node graphNode
label = node.ID
}
nodeRunID := uuid.NewString()
startedAt := time.Now()
incomingCount := 0
if rt := workflowRuntimeFrom(ctx); rt != nil && rt.idx != nil {
incomingCount = len(rt.idx.incoming[node.ID])
}
input := map[string]any{
"nodeId": node.ID,
"nodeType": node.Type,
"label": label,
"inputs": state.Inputs,
"previous": state.LastOutput,
"join": map[string]any{
"strategy": joinStrategy(node),
"incoming": incomingCount,
},
}
inputJSON, _ := json.Marshal(input)
if err := args.DB.CreateWorkflowNodeRun(&database.WorkflowNodeRun{
@@ -32,7 +41,7 @@ func executeNode(ctx context.Context, args RunArgs, runID string, node graphNode
NodeID: node.ID,
Status: "running",
InputJSON: string(inputJSON),
StartedAt: time.Now(),
StartedAt: startedAt,
}); err != nil {
return nil, false, err
}
@@ -47,6 +56,18 @@ func executeNode(ctx context.Context, args RunArgs, runID string, node graphNode
}
result, proceed, status, errText := runBuiltinNode(ctx, args, node, state)
duration := time.Since(startedAt)
if result == nil {
result = map[string]any{}
}
result["duration_ms"] = duration.Milliseconds()
result["finished_at"] = time.Now().Format(time.RFC3339Nano)
result["status"] = status
accumulateWorkflowMetric(state, "node_count", 1)
accumulateWorkflowMetric(state, "duration_ms", duration.Milliseconds())
if strings.EqualFold(node.Type, "tool") {
accumulateWorkflowMetric(state, "tool_call_count", 1)
}
outputJSON, _ := json.Marshal(result)
if err := args.DB.FinishWorkflowNodeRun(nodeRunID, status, string(outputJSON), errText); err != nil {
return nil, false, err
@@ -64,6 +85,7 @@ func executeNode(ctx context.Context, args RunArgs, runID string, node graphNode
"nodeType": node.Type,
"label": label,
"status": status,
"durationMs": duration.Milliseconds(),
"output": result,
}
progressMsg := fmt.Sprintf("节点完成:%s%s", label, status)
+17 -38
View File
@@ -13,18 +13,11 @@ func runBuiltinNode(ctx context.Context, args RunArgs, node graphNode, state *Wo
cfg := node.Config
switch strings.ToLower(strings.TrimSpace(node.Type)) {
case "start":
out := map[string]any{
"output": state.Inputs["message"],
"message": state.Inputs["message"],
"conversationId": state.Inputs["conversationId"],
"projectId": state.Inputs["projectId"],
}
return out, true, "completed", ""
return startOutputMap(node, state.Inputs["message"], state.Inputs["conversationId"], state.Inputs["projectId"]), true, "completed", ""
case "condition":
expr := cfgString(cfg, "expression")
ok := evalCondition(expr, state)
out := map[string]any{"output": ok, "condition": expr, "matched": ok}
return out, true, "completed", ""
return conditionOutputMap(node, expr, ok), true, "completed", ""
case "output":
key := cfgString(cfg, "output_key")
if key == "" {
@@ -37,13 +30,13 @@ func runBuiltinNode(ctx context.Context, args RunArgs, node graphNode, state *Wo
value = resolveOutputSourceBinding(cfg, state)
}
state.Outputs[key] = value
return map[string]any{"output": value, "outputs": map[string]any{key: value}}, true, "completed", ""
return outputNodeOutputMap(node, key, value), true, "completed", ""
case "end":
value := resolveOutputSourceBinding(cfg, state)
if b, ok := parseFieldBinding(cfg, "result_binding"); ok {
value = resolveBinding(b, state)
}
return map[string]any{"output": value}, false, "completed", ""
return endOutputMap(node, value), false, "completed", ""
case "tool":
return runToolNode(ctx, args, node, state)
case "agent":
@@ -52,7 +45,8 @@ func runBuiltinNode(ctx context.Context, args RunArgs, node graphNode, state *Wo
return runHITLNode(args, node, state)
default:
reason := "未知节点类型"
return map[string]any{"output": "", "skipped": true, "reason": reason, "node_type": node.Type}, true, "skipped", reason
out := outputMap(envelope("unknown", node.ID, node.Type, "skipped", ""), map[string]any{"skipped": true, "reason": reason})
return out, true, "skipped", reason
}
}
@@ -60,16 +54,16 @@ func runToolNode(ctx context.Context, args RunArgs, node graphNode, state *Workf
toolName := cfgString(node.Config, "tool_name")
if toolName == "" {
errText := "工具节点未选择 MCP 工具"
return map[string]any{"output": "", "error": errText}, false, "failed", errText
return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"error": errText}), false, "failed", errText
}
if args.Agent == nil {
errText := "工具节点执行失败:Agent 为空"
return map[string]any{"output": "", "tool_name": toolName, "error": errText}, false, "failed", errText
return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"tool_name": toolName, "error": errText}), false, "failed", errText
}
toolArgs, err := resolveToolArguments(node.Config, state)
if err != nil {
errText := fmt.Sprintf("工具参数不是合法 JSON%v", err)
return map[string]any{"output": "", "tool_name": toolName, "error": errText}, false, "failed", errText
return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"tool_name": toolName, "error": errText}), false, "failed", errText
}
if args.Progress != nil {
args.Progress("workflow_tool_start", fmt.Sprintf("调用工具:%s", toolName), map[string]any{
@@ -81,7 +75,7 @@ func runToolNode(ctx context.Context, args RunArgs, node graphNode, state *Workf
result, err := args.Agent.ExecuteMCPToolForConversation(ctx, args.ConversationID, toolName, toolArgs)
if err != nil {
errText := err.Error()
return map[string]any{"output": "", "tool_name": toolName, "arguments": toolArgs, "error": errText}, false, "failed", errText
return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"tool_name": toolName, "arguments": toolArgs, "error": errText}), false, "failed", errText
}
output := ""
executionID := ""
@@ -91,13 +85,7 @@ func runToolNode(ctx context.Context, args RunArgs, node graphNode, state *Workf
executionID = result.ExecutionID
isError = result.IsError
}
out := map[string]any{
"output": output,
"tool_name": toolName,
"arguments": toolArgs,
"execution_id": executionID,
"is_error": isError,
}
out := toolOutputMap(node, output, toolName, toolArgs, executionID, isError)
if key := cfgString(node.Config, "output_key"); key != "" {
state.Outputs[key] = output
}
@@ -114,7 +102,7 @@ func runToolNode(ctx context.Context, args RunArgs, node graphNode, state *Workf
func runAgentNode(ctx context.Context, args RunArgs, node graphNode, state *WorkflowLocalState) (map[string]any, bool, string, string) {
if args.AppCfg == nil || args.Agent == nil {
errText := "Agent 节点执行失败:应用配置或 Agent 为空"
return map[string]any{"output": "", "error": errText}, false, "failed", errText
return outputMap(envelope("agent", node.ID, node.Type, "failed", ""), map[string]any{"error": errText}), false, "failed", errText
}
mode := strings.ToLower(cfgString(node.Config, "agent_mode"))
if mode == "" {
@@ -167,7 +155,7 @@ func runAgentNode(ctx context.Context, args RunArgs, node graphNode, state *Work
if err != nil {
errText := err.Error()
state.MainIterationOffset += state.SegmentMaxIteration
return map[string]any{"output": "", "mode": mode, "error": errText}, false, "failed", errText
return outputMap(envelope("agent", node.ID, node.Type, "failed", ""), map[string]any{"mode": mode, "error": errText}), false, "failed", errText
}
state.MainIterationOffset += state.SegmentMaxIteration
response := ""
@@ -189,11 +177,7 @@ func runAgentNode(ctx context.Context, args RunArgs, node graphNode, state *Work
if key := cfgString(node.Config, "output_key"); key != "" {
state.Outputs[key] = response
}
return map[string]any{
"output": response,
"mode": mode,
"mcp_execution_ids": mcpIDs,
}, true, "completed", ""
return agentOutputMap(node, response, mode, mcpIDs), true, "completed", ""
}
func buildAgentNodeMessage(node graphNode, state *WorkflowLocalState, upstreamInput string) string {
@@ -221,6 +205,7 @@ func workflowAgentProgress(progress agent.ProgressCallback, state *WorkflowLocal
return
default:
enrichWorkflowAgentEventData(data, state, node)
collectAgentMetrics(state, data)
if eventType == "iteration" {
applyWorkflowMainIterationOffset(data, state)
}
@@ -302,7 +287,7 @@ func runHITLNode(args RunArgs, node graphNode, state *WorkflowLocalState) (map[s
}
}
}
return map[string]any{"output": "", "prompt": prompt, "approved": false, "mode": "interactive"}, false, "failed", reason
return hitlOutputMap(node, "failed", "", prompt, reviewer, false), false, "failed", reason
}
if args.Progress != nil {
args.Progress("workflow_hitl_checkpoint", "人工确认节点已通过", map[string]any{
@@ -313,11 +298,5 @@ func runHITLNode(args RunArgs, node graphNode, state *WorkflowLocalState) (map[s
"approved": true,
})
}
return map[string]any{
"output": prompt,
"prompt": prompt,
"reviewer": reviewer,
"approved": true,
"mode": "interactive",
}, true, "completed", ""
return hitlOutputMap(node, "completed", prompt, prompt, reviewer, true), true, "completed", ""
}
+2
View File
@@ -189,6 +189,7 @@ func RunRoleBoundWorkflow(ctx context.Context, args RunArgs) (*RunResult, error)
"workflowRunId": runID,
"status": "completed",
"outputs": state.Outputs,
"metrics": state.Metrics,
"executedNodes": state.Executed,
"skippedNodes": state.Skipped,
"engine": "eino_workflow",
@@ -204,6 +205,7 @@ func RunRoleBoundWorkflow(ctx context.Context, args RunArgs) (*RunResult, error)
"workflowRunId": runID,
"workflowId": wf.ID,
"outputs": state.Outputs,
"metrics": state.Metrics,
"response": response,
"engine": "eino_workflow",
})
+6 -23
View File
@@ -20,6 +20,7 @@ type WorkflowLocalState struct {
NodeOutputs map[string]map[string]any `json:"nodeOutputs,omitempty"`
NodeProceed map[string]bool `json:"nodeProceed,omitempty"`
LastOutput map[string]any `json:"lastOutput,omitempty"`
Metrics map[string]any `json:"metrics,omitempty"`
Executed []string `json:"executed,omitempty"`
Skipped []string `json:"skipped,omitempty"`
WorkflowRunID string `json:"workflowRunId,omitempty"`
@@ -33,10 +34,11 @@ func newWorkflowLocalState(inputs map[string]interface{}, runID string) *Workflo
in[k] = v
}
return &WorkflowLocalState{
Inputs: in,
Outputs: make(map[string]any),
NodeOutputs: make(map[string]map[string]any),
NodeProceed: make(map[string]bool),
Inputs: in,
Outputs: make(map[string]any),
NodeOutputs: make(map[string]map[string]any),
NodeProceed: make(map[string]bool),
Metrics: make(map[string]any),
WorkflowRunID: runID,
}
}
@@ -91,25 +93,6 @@ func valueFromPath(path string, state *WorkflowLocalState) any {
return cur
}
func evalCondition(expr string, state *WorkflowLocalState) bool {
expr = strings.TrimSpace(expr)
if expr == "" {
return true
}
resolved := strings.TrimSpace(resolveTemplate(expr, state))
switch {
case strings.Contains(resolved, "!="):
parts := strings.SplitN(resolved, "!=", 2)
return cleanComparable(parts[0]) != cleanComparable(parts[1])
case strings.Contains(resolved, "=="):
parts := strings.SplitN(resolved, "==", 2)
return cleanComparable(parts[0]) == cleanComparable(parts[1])
default:
v := strings.ToLower(cleanComparable(resolved))
return v != "" && v != "false" && v != "0" && v != "null"
}
}
func cleanComparable(s string) string {
s = strings.TrimSpace(s)
s = strings.Trim(s, `"'`)
+150
View File
@@ -0,0 +1,150 @@
package workflow
type NodeOutputEnvelope struct {
Kind string `json:"kind"`
NodeID string `json:"node_id"`
NodeType string `json:"node_type"`
Status string `json:"status"`
Output any `json:"output"`
}
type StartOutput struct {
NodeOutputEnvelope
Message any `json:"message"`
ConversationID any `json:"conversationId"`
ProjectID any `json:"projectId"`
}
type ConditionOutput struct {
NodeOutputEnvelope
Condition string `json:"condition"`
Matched bool `json:"matched"`
}
type ToolOutput struct {
NodeOutputEnvelope
ToolName string `json:"tool_name"`
Arguments map[string]any `json:"arguments"`
ExecutionID string `json:"execution_id"`
IsError bool `json:"is_error"`
}
type AgentOutput struct {
NodeOutputEnvelope
Mode string `json:"mode"`
MCPExecutionIDs []string `json:"mcp_execution_ids"`
}
type HITLOutput struct {
NodeOutputEnvelope
Prompt string `json:"prompt"`
Reviewer string `json:"reviewer"`
Approved bool `json:"approved"`
Mode string `json:"mode"`
}
type OutputNodeOutput struct {
NodeOutputEnvelope
OutputKey string `json:"output_key"`
Outputs map[string]any `json:"outputs"`
}
func envelope(kind, nodeID, nodeType, status string, output any) NodeOutputEnvelope {
return NodeOutputEnvelope{Kind: kind, NodeID: nodeID, NodeType: nodeType, Status: status, Output: output}
}
func outputMap(env NodeOutputEnvelope, extra map[string]any) map[string]any {
out := map[string]any{
"kind": env.Kind,
"node_id": env.NodeID,
"node_type": env.NodeType,
"status": env.Status,
"output": env.Output,
"typed": env,
}
for k, v := range extra {
out[k] = v
}
return out
}
func startOutputMap(node graphNode, message, conversationID, projectID any) map[string]any {
typed := StartOutput{
NodeOutputEnvelope: envelope("start", node.ID, node.Type, "completed", message),
Message: message,
ConversationID: conversationID,
ProjectID: projectID,
}
return outputMap(typed.NodeOutputEnvelope, map[string]any{
"message": typed.Message,
"conversationId": typed.ConversationID,
"projectId": typed.ProjectID,
"typed": typed,
})
}
func conditionOutputMap(node graphNode, expr string, matched bool) map[string]any {
typed := ConditionOutput{
NodeOutputEnvelope: envelope("condition", node.ID, node.Type, "completed", matched),
Condition: expr,
Matched: matched,
}
return outputMap(typed.NodeOutputEnvelope, map[string]any{"condition": expr, "matched": matched, "typed": typed})
}
func outputNodeOutputMap(node graphNode, key string, value any) map[string]any {
typed := OutputNodeOutput{
NodeOutputEnvelope: envelope("output", node.ID, node.Type, "completed", value),
OutputKey: key,
Outputs: map[string]any{key: value},
}
return outputMap(typed.NodeOutputEnvelope, map[string]any{"output_key": key, "outputs": typed.Outputs, "typed": typed})
}
func endOutputMap(node graphNode, value any) map[string]any {
typed := envelope("end", node.ID, node.Type, "completed", value)
return outputMap(typed, nil)
}
func toolOutputMap(node graphNode, output string, toolName string, args map[string]any, executionID string, isError bool) map[string]any {
typed := ToolOutput{
NodeOutputEnvelope: envelope("tool", node.ID, node.Type, "completed", output),
ToolName: toolName,
Arguments: args,
ExecutionID: executionID,
IsError: isError,
}
return outputMap(typed.NodeOutputEnvelope, map[string]any{
"tool_name": toolName,
"arguments": args,
"execution_id": executionID,
"is_error": isError,
"typed": typed,
})
}
func agentOutputMap(node graphNode, response, mode string, mcpIDs []string) map[string]any {
typed := AgentOutput{
NodeOutputEnvelope: envelope("agent", node.ID, node.Type, "completed", response),
Mode: mode,
MCPExecutionIDs: mcpIDs,
}
return outputMap(typed.NodeOutputEnvelope, map[string]any{"mode": mode, "mcp_execution_ids": mcpIDs, "typed": typed})
}
func hitlOutputMap(node graphNode, status string, output string, prompt string, reviewer string, approved bool) map[string]any {
typed := HITLOutput{
NodeOutputEnvelope: envelope("hitl", node.ID, node.Type, status, output),
Prompt: prompt,
Reviewer: reviewer,
Approved: approved,
Mode: "interactive",
}
return outputMap(typed.NodeOutputEnvelope, map[string]any{
"prompt": prompt,
"reviewer": reviewer,
"approved": approved,
"mode": "interactive",
"typed": typed,
})
}
+366
View File
@@ -0,0 +1,366 @@
package workflow
import (
"fmt"
"strconv"
"strings"
)
var allowedWorkflowNodeTypes = map[string]bool{
"start": true,
"tool": true,
"agent": true,
"condition": true,
"hitl": true,
"output": true,
"end": true,
}
func validateGraphDefinition(g *graphDef, idx *graphIndex) error {
if g == nil || idx == nil {
return fmt.Errorf("工作流图为空")
}
if err := validateNodeIDsAndTypes(g); err != nil {
return err
}
if err := validateEdges(g, idx); err != nil {
return err
}
if err := validateNodeTopology(idx); err != nil {
return err
}
if err := validateNodeConfigs(idx); err != nil {
return err
}
if err := validateDAG(idx); err != nil {
return err
}
if err := validateReachability(idx); err != nil {
return err
}
return nil
}
func validateNodeIDsAndTypes(g *graphDef) error {
seen := make(map[string]bool, len(g.Nodes))
for _, node := range g.Nodes {
id := strings.TrimSpace(node.ID)
if id == "" {
return fmt.Errorf("工作流存在空节点 ID")
}
if seen[id] {
return fmt.Errorf("工作流存在重复节点 ID: %s", id)
}
seen[id] = true
nodeType := strings.ToLower(strings.TrimSpace(node.Type))
if nodeType == "" {
return fmt.Errorf("节点「%s」缺少节点类型", id)
}
if !allowedWorkflowNodeTypes[nodeType] {
return fmt.Errorf("节点「%s」使用了未知节点类型: %s", id, node.Type)
}
}
return nil
}
func validateEdges(g *graphDef, idx *graphIndex) error {
seen := make(map[string]bool, len(g.Edges))
for _, edge := range g.Edges {
if id := strings.TrimSpace(edge.ID); id != "" {
if seen[id] {
return fmt.Errorf("工作流存在重复连线 ID: %s", id)
}
seen[id] = true
}
source := strings.TrimSpace(edge.Source)
target := strings.TrimSpace(edge.Target)
if source == "" || target == "" {
return fmt.Errorf("工作流存在源或目标为空的连线")
}
if source == target {
return fmt.Errorf("连线「%s」不能自环", firstNonEmpty(edge.ID, source))
}
if _, ok := idx.nodes[source]; !ok {
return fmt.Errorf("连线「%s」引用了不存在的源节点: %s", firstNonEmpty(edge.ID, source), source)
}
if _, ok := idx.nodes[target]; !ok {
return fmt.Errorf("连线「%s」引用了不存在的目标节点: %s", firstNonEmpty(edge.ID, target), target)
}
}
return nil
}
func validateNodeTopology(idx *graphIndex) error {
starts := explicitStartNodeIDs(idx)
if len(starts) == 0 {
return fmt.Errorf("工作流至少需要一个开始节点")
}
outputs := outputNodeIDs(idx)
if len(outputs) == 0 {
return fmt.Errorf("工作流至少需要一个输出节点")
}
for id, node := range idx.nodes {
inDegree := len(idx.incoming[id])
outDegree := len(idx.outgoing[id])
nodeType := strings.ToLower(strings.TrimSpace(node.Type))
switch nodeType {
case "start":
if inDegree > 0 {
return fmt.Errorf("开始节点「%s」不能有入边", firstNonEmpty(node.Label, id))
}
if outDegree == 0 {
return fmt.Errorf("开始节点「%s」至少需要一条出边", firstNonEmpty(node.Label, id))
}
case "output", "end":
if outDegree > 0 {
return fmt.Errorf("%s 节点「%s」不能有出边", displayNodeType(nodeType), firstNonEmpty(node.Label, id))
}
if inDegree == 0 {
return fmt.Errorf("%s 节点「%s」至少需要一条入边", displayNodeType(nodeType), firstNonEmpty(node.Label, id))
}
default:
if inDegree == 0 {
return fmt.Errorf("节点「%s」不可达:非开始节点必须有入边", firstNonEmpty(node.Label, id))
}
if outDegree == 0 {
return fmt.Errorf("节点「%s」没有出边;请连接到 output/end 节点", firstNonEmpty(node.Label, id))
}
}
}
return nil
}
func validateNodeConfigs(idx *graphIndex) error {
for id, node := range idx.nodes {
label := firstNonEmpty(node.Label, id)
switch strings.ToLower(strings.TrimSpace(node.Type)) {
case "tool":
if cfgString(node.Config, "tool_name") == "" {
return fmt.Errorf("工具节点「%s」必须选择 MCP 工具", label)
}
if err := validateToolConfig(node); err != nil {
return err
}
case "agent":
if cfgString(node.Config, "instruction") == "" {
if _, ok := parseFieldBinding(node.Config, "input_binding"); !ok {
return fmt.Errorf("Agent 节点「%s」必须填写节点指令或输入绑定", label)
}
}
if cfgString(node.Config, "output_key") == "" {
return fmt.Errorf("Agent 节点「%s」必须填写输出变量名", label)
}
case "condition":
if cfgString(node.Config, "expression") == "" {
return fmt.Errorf("条件节点「%s」必须填写表达式", label)
}
if err := validateConditionExpression(cfgString(node.Config, "expression")); err != nil {
return fmt.Errorf("条件节点「%s」表达式非法: %w", label, err)
}
if n := len(idx.outgoing[id]); n < 1 || n > 2 {
return fmt.Errorf("条件节点「%s」需要 1 到 2 条出边(是/否)", label)
}
if err := validateConditionBranchLabels(idx, id, node); err != nil {
return err
}
case "output":
if cfgString(node.Config, "output_key") == "" {
return fmt.Errorf("输出节点「%s」必须填写输出变量名", label)
}
}
if err := validateJoinConfig(idx, id, node); err != nil {
return err
}
if hasConditionalOutgoingEdges(idx, id) {
if err := validateConditionalOutgoingEdges(idx, id, node); err != nil {
return err
}
}
}
return nil
}
func validateConditionalOutgoingEdges(idx *graphIndex, nodeID string, node graphNode) error {
unconditional := 0
for _, edge := range idx.outgoing[nodeID] {
cond := firstNonEmpty(cfgString(edge.Config, "condition"), cfgString(edge.Config, "expression"))
if cond != "" {
if err := validateConditionExpression(cond); err != nil {
return fmt.Errorf("节点「%s」的连线条件非法: %w", firstNonEmpty(node.Label, nodeID), err)
}
}
if cond == "" {
unconditional++
}
}
if unconditional > 1 {
return fmt.Errorf("节点「%s」的条件出边最多只能有一条默认分支", firstNonEmpty(node.Label, nodeID))
}
return nil
}
func validateToolConfig(node graphNode) error {
rawArgs := cfgString(node.Config, "arguments")
if rawArgs != "" {
if _, err := resolveToolArguments(node.Config, &WorkflowLocalState{}); err != nil {
return fmt.Errorf("工具节点「%s」参数 JSON 非法: %w", firstNonEmpty(node.Label, node.ID), err)
}
}
if timeout := cfgString(node.Config, "timeout_seconds"); timeout != "" {
if _, err := parsePositiveInt(timeout); err != nil {
return fmt.Errorf("工具节点「%s」超时时间必须是正整数", firstNonEmpty(node.Label, node.ID))
}
}
return nil
}
func validateJoinConfig(idx *graphIndex, nodeID string, node graphNode) error {
strategy := joinStrategy(node)
if !allowedJoinStrategies[strategy] {
return fmt.Errorf("节点「%s」使用了未知汇聚策略: %s", firstNonEmpty(node.Label, nodeID), strategy)
}
if len(idx.incoming[nodeID]) > 1 && strategy == "" {
return fmt.Errorf("节点「%s」有多个上游时必须声明汇聚策略", firstNonEmpty(node.Label, nodeID))
}
return nil
}
func validateConditionBranchLabels(idx *graphIndex, nodeID string, node graphNode) error {
seen := map[string]bool{}
for _, edge := range idx.outgoing[nodeID] {
hint := conditionBranchHint(edge)
if hint == "" {
return fmt.Errorf("条件节点「%s」的出边必须标记为是/否或 true/false", firstNonEmpty(node.Label, nodeID))
}
if seen[hint] {
return fmt.Errorf("条件节点「%s」存在重复分支标签: %s", firstNonEmpty(node.Label, nodeID), hint)
}
seen[hint] = true
}
return nil
}
func validateDAG(idx *graphIndex) error {
color := make(map[string]int, len(idx.nodes))
var visit func(string) error
visit = func(id string) error {
switch color[id] {
case 1:
return fmt.Errorf("工作流存在环路,Workflow 编排必须是 DAG: %s", id)
case 2:
return nil
}
color[id] = 1
for _, edge := range idx.outgoing[id] {
if err := visit(edge.Target); err != nil {
return err
}
}
color[id] = 2
return nil
}
for id := range idx.nodes {
if err := visit(id); err != nil {
return err
}
}
return nil
}
func validateReachability(idx *graphIndex) error {
starts := explicitStartNodeIDs(idx)
reached := make(map[string]bool, len(idx.nodes))
queue := append([]string(nil), starts...)
for len(queue) > 0 {
id := queue[0]
queue = queue[1:]
if reached[id] {
continue
}
reached[id] = true
for _, edge := range idx.outgoing[id] {
queue = append(queue, edge.Target)
}
}
for id, node := range idx.nodes {
if !reached[id] {
return fmt.Errorf("节点「%s」不可达:没有从开始节点连通到该节点", firstNonEmpty(node.Label, id))
}
}
canReachTerminal := make(map[string]bool, len(idx.nodes))
visiting := make(map[string]bool, len(idx.nodes))
var reachesTerminal func(string) bool
reachesTerminal = func(id string) bool {
if canReachTerminal[id] {
return true
}
if visiting[id] {
return false
}
visiting[id] = true
node := idx.nodes[id]
nodeType := strings.ToLower(strings.TrimSpace(node.Type))
if nodeType == "output" || nodeType == "end" {
canReachTerminal[id] = true
visiting[id] = false
return true
}
for _, edge := range idx.outgoing[id] {
if reachesTerminal(edge.Target) {
canReachTerminal[id] = true
visiting[id] = false
return true
}
}
visiting[id] = false
return false
}
for id, node := range idx.nodes {
if !reachesTerminal(id) {
return fmt.Errorf("节点「%s」无法到达 output/end 终点", firstNonEmpty(node.Label, id))
}
}
return nil
}
func explicitStartNodeIDs(idx *graphIndex) []string {
var ids []string
for id, node := range idx.nodes {
if strings.EqualFold(node.Type, "start") {
ids = append(ids, id)
}
}
sortNodeIDsByCanvas(ids, idx.nodes)
return ids
}
func outputNodeIDs(idx *graphIndex) []string {
var ids []string
for id, node := range idx.nodes {
if strings.EqualFold(node.Type, "output") {
ids = append(ids, id)
}
}
sortNodeIDsByCanvas(ids, idx.nodes)
return ids
}
func displayNodeType(nodeType string) string {
switch strings.ToLower(strings.TrimSpace(nodeType)) {
case "output":
return "输出"
case "end":
return "结束"
default:
return nodeType
}
}
func parsePositiveInt(s string) (int, error) {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || n <= 0 {
return 0, fmt.Errorf("not positive integer")
}
return n, nil
}