mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-07 04:58:03 +02:00
Add files via upload
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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
@@ -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,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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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", ""
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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, `"'`)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user