Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2d8eb5a3a | ||
|
|
de457f670a | ||
|
|
235f66c9ba | ||
|
|
62b23e6bfa |
No files matched your search
@@ -22,10 +22,11 @@ Go 言語(ジェネリクス)で書かれた、軽量・高性能・型安
|
||||
- **支持循环结构:** 突破了传统 DAG(有向无环图)工作流的限制,原生支持节点间的双向循环和条件路由。
|
||||
- **并发分支与短路取消:** 原生支持多个 Agent 节点的并发执行,基于 `context.WithCancel` 实现短路机制——任意分支出错立即取消其余兄弟 goroutine,避免算力空转。
|
||||
- **并发安全深拷贝:** 通过 `Engine.WithStateCloner` 注入自定义深拷贝函数,彻底消除含引用类型的状态在并发分支中的数据竞争。
|
||||
- **人机协同中断 (HITL):** 支持在特定节点前自动挂起,返回执行线程快照,等待人工修改状态或审批通过后一键恢复 (`Resume`)。
|
||||
- **人机协同中断 (HITL):** 支持在特定节点前自动挂起,返回执行线程快照,等待人工修改状态或审批通过后一键恢复 (`Resume`)。支持 `ErrNotPaused`、`ErrAlreadyFinished` 哨兵错误,便于精准判断异常。
|
||||
- **死循环硬性熔断:** 通过 `Engine.WithMaxSteps` 设置步数上限,图中的任何意外环路都会被明确报错而非无限阻塞。
|
||||
- **轻量级生命周期 Hooks:** `Engine.WithPreNodeHook` / `WithPostNodeHook` 提供节点执行前后的钩子,无需修改任何 `NodeFn`,即可接入日志、链路追踪(OpenTelemetry)或流式输出。
|
||||
- **开箱即用持久化:** 提供 `Checkpointer[S]` 接口与内置的 `FileCheckpointer[S]`,支持将进度保存为 JSON 文件,实现停机或重启后进度无损恢复。
|
||||
- **轻量级生命周期 Hooks(支持多重链式累加):** `Engine.WithPreNodeHook` / `WithPostNodeHook` 支持多次调用链式累加(不覆盖已有钩子),无需修改 `NodeFn`,极易解耦日志、链路追踪(OpenTelemetry)或流式输出等逻辑。
|
||||
- **编译期结构校验:** 图在编译 (`Compile`) 时,不仅会验证所有节点和边是否存在,还会严格检测同一节点是否冲突地定义了多种出边类型(如同时定义静态边、条件边与并发边),规避多义性逻辑隐患。
|
||||
- **开箱即用持久化与安全校验:** 提供 `Checkpointer[S]` 接口与内置的 `FileCheckpointer[S]`,采用“先写临时文件再原子重命名”防损坏机制,并对 `threadID` 进行路径穿越防御性校验,实现进程意外退出后进度安全无损地恢复。
|
||||
- **纯粹的高性能:** 基于内存流转,零外部依赖,极低的上下文切换开销。
|
||||
|
||||
### 快速开始
|
||||
@@ -46,7 +47,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
GopherGraph "github.com/unclesam-LY/GopherGraph"
|
||||
GopherGraph "github.com/unclesam-ly/GopherGraph"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -163,10 +164,11 @@ go test -v -race ./...
|
||||
- **Support for Cycles/Loops:** Unlike traditional DAG (Directed Acyclic Graph) engines, GopherGraph natively supports loops and dynamic routing based on agent evaluation.
|
||||
- **Parallel Branching with Short-Circuit Cancellation:** Run multiple agent nodes concurrently. Built on `context.WithCancel`, a failure in any branch immediately cancels all sibling goroutines, preventing wasted compute on LLM API calls.
|
||||
- **Deep-Clone for Concurrency Safety:** Inject a custom `StateCloner` via `Engine.WithStateCloner` to safely deep-copy reference types (slices, maps, pointers) before fan-out, eliminating data races at the root.
|
||||
- **Human-in-the-Loop (HITL):** Pause workflow execution *before* a designated node, capture a snapshot (`Thread`), modify the state, and `Resume` seamlessly.
|
||||
- **Human-in-the-Loop (HITL):** Pause workflow execution *before* a designated node, capture a snapshot (`Thread`), modify the state, and `Resume` seamlessly. Returns exported sentinel errors `ErrNotPaused` or `ErrAlreadyFinished` when resume pre-conditions are not met.
|
||||
- **Infinite Loop Circuit Breaker:** Set a hard step limit with `Engine.WithMaxSteps`. Any accidental cycle in the graph returns a clear error instead of blocking forever.
|
||||
- **Lightweight Lifecycle Hooks:** `Engine.WithPreNodeHook` / `WithPostNodeHook` fire before and after each node without touching any `NodeFn` signature — perfect for logging, OpenTelemetry tracing, or streaming output.
|
||||
- **State Persistence (Checkpointer):** Generic `Checkpointer[S]` interface with built-in `FileCheckpointer[S]` for saving and loading execution snapshots to/from JSON files.
|
||||
- **Lightweight Lifecycle Hooks (Additive Chaining):** `Engine.WithPreNodeHook` / `WithPostNodeHook` fire before and after each node without touching any `NodeFn` signature. Supports multiple chained registrations (additive) instead of overwriting, making it easy to decouple concerns like logging, tracing (OpenTelemetry), and streaming.
|
||||
- **Compile-Time Structural Validation:** During `Compile()`, the engine validates node/edge existence and strictly checks for outgoing edge conflicts on each node (e.g., preventing a node from simultaneously defining static, conditional, and parallel edges) to ensure unambiguous flow execution.
|
||||
- **State Persistence & Path Safety:** Generic `Checkpointer[S]` interface with built-in `FileCheckpointer[S]`. Uses an atomic write (temp-file write followed by rename) to prevent corruption, and includes path traversal validation on `threadID`.
|
||||
- **Zero-Dependency & High-Performance:** Written in pure Go with zero external dependencies, leveraging in-memory queues for lightning-fast orchestration.
|
||||
|
||||
### Quick Start
|
||||
@@ -187,7 +189,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
GopherGraph "github.com/unclesam-LY/GopherGraph"
|
||||
GopherGraph "github.com/unclesam-ly/GopherGraph"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -303,10 +305,11 @@ go test -v -race ./...
|
||||
- **ループと条件付き分岐のサポート:** 従来の DAG(有向非巡回グラフ)の制限を超え、エージェントの判定に基づく条件付きルートや双方向のループをサポート。
|
||||
- **並行処理とショートサーキットキャンセル:** `context.WithCancel` に基づく並行ブランチ実行を実装。いずれかのブランチでエラーが発生した瞬間に他の全 goroutine へキャンセルシグナルを伝播し、LLM API の無駄な呼び出しを防ぎます。
|
||||
- **データ競合を防ぐ深いコピー(DeepClone):** `Engine.WithStateCloner` でカスタムの深コピー関数を注入。参照型(スライス・マップ・ポインタ)を持つ状態でも、並行ブランチでのデータ競合を根本から解消します。
|
||||
- **Human-in-the-Loop (HITL) の一時停止と再開:** 特定のノードの実行前に処理を自動停止し、スナップショット(`Thread`)を返します。承認や状態修正の後にシームレスに処理を再開(`Resume`)できます。
|
||||
- **Human-in-the-Loop (HITL) の一時停止と再開:** 特定のノードの実行前に処理を自动停止し、スナップショット(`Thread`)を返します。承認や状態修正の後にシームレスに処理を再開(`Resume`)できます。再開判定用に `ErrNotPaused` や `ErrAlreadyFinished` といった明確なセンチネルエラーを提供しています。
|
||||
- **無限ループのサーキットブレーカー:** `Engine.WithMaxSteps` でステップ数の上限を設定。グラフ内に意図しないサイクルが発生しても、永久ブロックせず明確なエラーを返します。
|
||||
- **軽量なライフサイクルフック:** `Engine.WithPreNodeHook` / `WithPostNodeHook` により、`NodeFn` のシグネチャを一切変更せずにロギング・トレーシング・ストリーミング出力を実装できます。
|
||||
- **状態の永続化 (Checkpointer):** 抽象的な `Checkpointer[S]` インタフェースと、JSONファイルへの書き出し・読み込みを行う組み込みの `FileCheckpointer[S]` をサポート。サーバー再起動後の進捗復旧が可能。
|
||||
- **軽量なライフサイクルフック(複数チェーン登録対応):** `Engine.WithPreNodeHook` / `WithPostNodeHook` により、`NodeFn` のシグネチャを変更することなくロギング、トレーシング(OpenTelemetry)、ストリーミング出力を実装できます。**複数回呼び出すことで複数のフックを順番に実行(チェーン蓄積)**可能です。
|
||||
- **コンパイル時の構造検証:** `Compile()` 実行時に、すべてのノードや辺の存在をチェックするだけでなく、同一ノードに対する出辺タイプ(静的辺、条件付き辺、並行辺)の重複競合も厳密に検出し、意図しない曖昧な挙動を防ぎます。
|
||||
- **状態の永続化とパス安全性:** 抽象的な `Checkpointer[S]` インタフェースと、書き込み時の破損を防ぐアトミック書き込み(一時ファイルからリネーム)に対応した `FileCheckpointer[S]` を内蔵。`threadID` のパス・トラバーサル検証によりセキュアに状態の復旧が可能です。
|
||||
- **ピュア Go & 高性能:** 外部依存関係ゼロ。メモリ内チャネルを用いた高速なコンテキスト切り替え。
|
||||
|
||||
### クイックスタート
|
||||
@@ -327,7 +330,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
GopherGraph "github.com/unclesam-LY/GopherGraph"
|
||||
GopherGraph "github.com/unclesam-ly/GopherGraph"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
+32
-2
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Checkpointer 定义了持久化状态的接口,S 是工作流的强类型状态。
|
||||
@@ -31,8 +32,13 @@ func NewFileCheckpointer[S any](dir string) (*FileCheckpointer[S], error) {
|
||||
return &FileCheckpointer[S]{dir: dir}, nil
|
||||
}
|
||||
|
||||
// Save 将 Thread 序列化为 JSON 并写入本地文件
|
||||
// Save 将 Thread 序列化为 JSON 并写入本地文件。
|
||||
// 采用“先写临时文件再原子重命名”的方式,防止写入中途程序崩溃导致文件损坏。
|
||||
func (fc *FileCheckpointer[S]) Save(ctx context.Context, threadID string, thread *Thread[S]) error {
|
||||
if err := validateThreadID(threadID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(fc.dir, threadID+".json")
|
||||
|
||||
// 使用 MarshalIndent 方便人工阅读生成的 JSON 文件
|
||||
@@ -41,11 +47,23 @@ func (fc *FileCheckpointer[S]) Save(ctx context.Context, threadID string, thread
|
||||
return fmt.Errorf("failed to marshal thread: %w", err)
|
||||
}
|
||||
|
||||
return os.WriteFile(path, data, 0644)
|
||||
// 原子写入:先写临时文件,再 Rename 替换,确保断电/崩溃安全
|
||||
tmpPath := path + ".tmp"
|
||||
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpPath, path); err != nil {
|
||||
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load 从本地文件中读取 JSON 并反序列化为 Thread。
|
||||
func (fc *FileCheckpointer[S]) Load(ctx context.Context, threadID string) (*Thread[S], error) {
|
||||
if err := validateThreadID(threadID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
path := filepath.Join(fc.dir, threadID+".json")
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
@@ -60,3 +78,15 @@ func (fc *FileCheckpointer[S]) Load(ctx context.Context, threadID string) (*Thre
|
||||
|
||||
return &thread, nil
|
||||
}
|
||||
|
||||
// validateThreadID 校验 threadID 的合法性,防止路径穿越(path traversal)攻击。
|
||||
// threadID 中不允许出现目录分隔符或 ".." 序列。
|
||||
func validateThreadID(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("threadID must not be empty")
|
||||
}
|
||||
if strings.ContainsAny(id, "/\\") || strings.Contains(id, "..") {
|
||||
return fmt.Errorf("threadID %q contains invalid characters", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Package GopherGraph 是一个用 Go 语言编写的代码即图(Code-as-Graph)智能体编排引擎,
|
||||
// 设计灵感来源于 Python 生态的 LangGraph。
|
||||
//
|
||||
// 它利用 Go 1.18+ 的泛型机制,让工作流的上下文状态在编译期就具备强类型约束,
|
||||
// 并依靠 Go 原生的 Goroutines 和 Channels 实现极高并发的本地数据流转。
|
||||
//
|
||||
// # 核心概念
|
||||
//
|
||||
// - [Graph] 图构建器:通过 [NewGraph] 创建,使用 AddNode/AddEdge 系列方法声明节点和连线。
|
||||
// - [CompiledGraph] 编译后的只读图:通过 [Graph.Compile] 生成,可直接调用 Start/Resume 运行。
|
||||
// - [Engine] 增强包装器:通过 [NewEngine] 创建,在 CompiledGraph 基础上叠加
|
||||
// StateCloner(深拷贝)、MaxSteps(死循环熔断)、PreNodeHook/PostNodeHook(生命周期钩子)。
|
||||
// - [Thread] 执行快照:承载当前状态、下一节点、暂停/结束标记,可序列化用于断点恢复。
|
||||
// - [Checkpointer] 持久化接口:内置 [FileCheckpointer] 实现基于 JSON 文件的状态存储。
|
||||
//
|
||||
// # 零外部依赖
|
||||
//
|
||||
// GopherGraph 仅使用 Go 标准库,无任何第三方依赖。
|
||||
package GopherGraph
|
||||
@@ -37,7 +37,7 @@ func (g *Graph[S]) Compile() (*CompiledGraph[S], error) {
|
||||
return nil, fmt.Errorf("compile error: edge origin %q does not exist", from)
|
||||
}
|
||||
if _, exists := g.nodes[to]; !exists {
|
||||
return nil, fmt.Errorf("compile error: edge origin %q does not exist", to)
|
||||
return nil, fmt.Errorf("compile error: edge destination %q does not exist", to)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,16 +79,60 @@ func (g *Graph[S]) Compile() (*CompiledGraph[S], error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 返回编译好的只读图
|
||||
// 校验同一节点的出边冲突:不能同时存在于 edges、conditional、parallels 中
|
||||
for node := range g.nodes {
|
||||
conflictCount := 0
|
||||
if _, ok := g.edges[node]; ok {
|
||||
conflictCount++
|
||||
}
|
||||
if _, ok := g.conditional[node]; ok {
|
||||
conflictCount++
|
||||
}
|
||||
if _, ok := g.parallels[node]; ok {
|
||||
conflictCount++
|
||||
}
|
||||
if conflictCount > 1 {
|
||||
return nil, fmt.Errorf("compile error: node %q has conflicting outgoing edges (defined in multiple edge types simultaneously)", node)
|
||||
}
|
||||
}
|
||||
|
||||
// 返回编译好的只读图(防御性拷贝,阻止编译后修改 Graph 对 CompiledGraph 造成污染)
|
||||
nodesCopy := make(map[string]NodeFn[S], len(g.nodes))
|
||||
for k, v := range g.nodes {
|
||||
nodesCopy[k] = v
|
||||
}
|
||||
edgesCopy := make(map[string]string, len(g.edges))
|
||||
for k, v := range g.edges {
|
||||
edgesCopy[k] = v
|
||||
}
|
||||
conditionalCopy := make(map[string]RouterFn[S], len(g.conditional))
|
||||
for k, v := range g.conditional {
|
||||
conditionalCopy[k] = v
|
||||
}
|
||||
interruptsCopy := make(map[string]bool, len(g.interrupts))
|
||||
for k, v := range g.interrupts {
|
||||
interruptsCopy[k] = v
|
||||
}
|
||||
parallelsCopy := make(map[string]parallelStep[S], len(g.parallels))
|
||||
for k, v := range g.parallels {
|
||||
parallelsCopy[k] = v
|
||||
}
|
||||
|
||||
return &CompiledGraph[S]{
|
||||
nodes: g.nodes,
|
||||
edges: g.edges,
|
||||
conditional: g.conditional,
|
||||
interrupts: g.interrupts,
|
||||
parallels: g.parallels,
|
||||
nodes: nodesCopy,
|
||||
edges: edgesCopy,
|
||||
conditional: conditionalCopy,
|
||||
interrupts: interruptsCopy,
|
||||
parallels: parallelsCopy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ErrNotPaused 在调用 Resume 时线程未处于暂停状态时返回。可用于 errors.Is 判断。
|
||||
var ErrNotPaused = errors.New("cannot resume: thread is not paused")
|
||||
|
||||
// ErrAlreadyFinished 在调用 Resume 时线程已结束时返回。可用于 errors.Is 判断。
|
||||
var ErrAlreadyFinished = errors.New("cannot resume: thread is already finished")
|
||||
|
||||
// Start 从指定的起始节点开始运行图,并持续流转,直到遇到中断或运行结束
|
||||
func (cg *CompiledGraph[S]) Start(ctx context.Context, startNode string, initialState S) (*Thread[S], error) {
|
||||
thread := &Thread[S]{
|
||||
@@ -102,10 +146,10 @@ func (cg *CompiledGraph[S]) Start(ctx context.Context, startNode string, initial
|
||||
// Resume 恢复执行一个被暂停的线程,并允许注入外部修改后的状态数据(例如人工审批修改后的结果)
|
||||
func (cg *CompiledGraph[S]) Resume(ctx context.Context, thread *Thread[S], modifiedState S) (*Thread[S], error) {
|
||||
if !thread.IsPaused {
|
||||
return nil, errors.New("cannot resume: thread is not paused")
|
||||
return nil, ErrNotPaused
|
||||
}
|
||||
if thread.IsFinished {
|
||||
return nil, errors.New("cannot resume: thread is already finished")
|
||||
return nil, ErrAlreadyFinished
|
||||
}
|
||||
|
||||
// 注入人工修改后的状态,并解除暂停标记
|
||||
@@ -207,11 +251,6 @@ func (cg *CompiledGraph[S]) run(ctx context.Context, thread *Thread[S], opts run
|
||||
// 更新快照中的"下一个节点"
|
||||
thread.NextNode = nextNode
|
||||
|
||||
// 如果下一站是终点,直接进入下一次循环触发结束逻辑
|
||||
if nextNode == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 核心中断机制:如果即将进入的下一个节点被标记为了中断节点,则在此挂起
|
||||
if cg.interrupts[nextNode] {
|
||||
thread.IsPaused = true
|
||||
|
||||
+20
-12
@@ -76,7 +76,15 @@ func (e *Engine[S]) WithMaxSteps(n int) *Engine[S] {
|
||||
// 钩子函数接收到的 state 是节点执行【前】的状态快照,请勿修改(只读语义)。
|
||||
// 钩子中的 panic 会向上传播,建议在内部做好 recover。
|
||||
func (e *Engine[S]) WithPreNodeHook(fn HookFn[S]) *Engine[S] {
|
||||
e.preNodeHook = fn
|
||||
if e.preNodeHook == nil {
|
||||
e.preNodeHook = fn
|
||||
} else {
|
||||
prev := e.preNodeHook
|
||||
e.preNodeHook = func(ctx context.Context, name string, s S) {
|
||||
prev(ctx, name, s)
|
||||
fn(ctx, name, s)
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -89,7 +97,15 @@ func (e *Engine[S]) WithPreNodeHook(fn HookFn[S]) *Engine[S] {
|
||||
//
|
||||
// 钩子函数接收到的 state 是节点执行【后】更新过的状态,同为只读语义。
|
||||
func (e *Engine[S]) WithPostNodeHook(fn HookFn[S]) *Engine[S] {
|
||||
e.postNodeHook = fn
|
||||
if e.postNodeHook == nil {
|
||||
e.postNodeHook = fn
|
||||
} else {
|
||||
prev := e.postNodeHook
|
||||
e.postNodeHook = func(ctx context.Context, name string, s S) {
|
||||
prev(ctx, name, s)
|
||||
fn(ctx, name, s)
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -117,10 +133,10 @@ func (e *Engine[S]) Start(ctx context.Context, startNode string, initialState S)
|
||||
// 但会附加所有已注册的 Engine 增强选项(Hooks、MaxSteps、StateCloner)。
|
||||
func (e *Engine[S]) Resume(ctx context.Context, thread *Thread[S], modifiedState S) (*Thread[S], error) {
|
||||
if !thread.IsPaused {
|
||||
return nil, errNotPaused
|
||||
return nil, ErrNotPaused
|
||||
}
|
||||
if thread.IsFinished {
|
||||
return nil, errAlreadyFinished
|
||||
return nil, ErrAlreadyFinished
|
||||
}
|
||||
|
||||
thread.State = modifiedState
|
||||
@@ -129,12 +145,4 @@ func (e *Engine[S]) Resume(ctx context.Context, thread *Thread[S], modifiedState
|
||||
return e.graph.run(ctx, thread, e.opts())
|
||||
}
|
||||
|
||||
// 复用与 CompiledGraph.Resume 中相同的哨兵错误,避免魔法字符串重复
|
||||
var (
|
||||
errNotPaused = errStr("cannot resume: thread is not paused")
|
||||
errAlreadyFinished = errStr("cannot resume: thread is already finished")
|
||||
)
|
||||
|
||||
type errStr string
|
||||
|
||||
func (e errStr) Error() string { return string(e) }
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
GopherGraph "github.com/unclesam-LY/GopherGraph"
|
||||
GopherGraph "github.com/unclesam-ly/GopherGraph"
|
||||
)
|
||||
|
||||
// AgentState 是这个示例工作流的状态
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
GopherGraph "github.com/unclesam-LY/GopherGraph"
|
||||
GopherGraph "github.com/unclesam-ly/GopherGraph"
|
||||
)
|
||||
|
||||
// TranslationState 代表我们工作流的共享状态(State)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module github.com/unclesam-LY/GopherGraph
|
||||
module github.com/unclesam-ly/GopherGraph
|
||||
|
||||
go 1.26.2
|
||||
+223
@@ -452,3 +452,226 @@ func TestLifecycleHooks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResumeErrors 验证 Resume 对非暂停/已结束线程的错误路径
|
||||
func TestResumeErrors(t *testing.T) {
|
||||
g := NewGraph[TestState]()
|
||||
g.AddNode("A", func(ctx context.Context, s TestState) (TestState, error) {
|
||||
return s, nil
|
||||
})
|
||||
cg, err := g.Compile()
|
||||
if err != nil {
|
||||
t.Fatalf("编译图失败: %v", err)
|
||||
}
|
||||
|
||||
// 1. Resume 非暂停的线程
|
||||
thread := &Thread[TestState]{NextNode: "A"}
|
||||
_, err = cg.Resume(context.Background(), thread, TestState{})
|
||||
if !errors.Is(err, ErrNotPaused) {
|
||||
t.Errorf("期望 ErrNotPaused,实际: %v", err)
|
||||
}
|
||||
|
||||
// 2. Resume 已结束的线程
|
||||
thread2 := &Thread[TestState]{IsPaused: true, IsFinished: true}
|
||||
_, err = cg.Resume(context.Background(), thread2, TestState{})
|
||||
if !errors.Is(err, ErrAlreadyFinished) {
|
||||
t.Errorf("期望 ErrAlreadyFinished,实际: %v", err)
|
||||
}
|
||||
|
||||
// 3. Engine.Resume 也应返回相同的哨兵错误
|
||||
engine := NewEngine(cg)
|
||||
_, err = engine.Resume(context.Background(), &Thread[TestState]{NextNode: "A"}, TestState{})
|
||||
if !errors.Is(err, ErrNotPaused) {
|
||||
t.Errorf("Engine.Resume: 期望 ErrNotPaused,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompileValidationErrors 验证 Compile 对各种非法图结构的校验
|
||||
func TestCompileValidationErrors(t *testing.T) {
|
||||
// 1. 空图
|
||||
g1 := NewGraph[TestState]()
|
||||
_, err := g1.Compile()
|
||||
if err == nil || !strings.Contains(err.Error(), "no nodes") {
|
||||
t.Errorf("空图应报错: %v", err)
|
||||
}
|
||||
|
||||
// 2. 边指向不存在的节点
|
||||
g2 := NewGraph[TestState]()
|
||||
g2.AddNode("A", func(ctx context.Context, s TestState) (TestState, error) { return s, nil })
|
||||
g2.AddEdge("A", "nonexistent")
|
||||
_, err = g2.Compile()
|
||||
if err == nil || !strings.Contains(err.Error(), "destination") {
|
||||
t.Errorf("边指向不存在节点应报 destination 错误: %v", err)
|
||||
}
|
||||
|
||||
// 3. 中断节点不存在
|
||||
g3 := NewGraph[TestState]()
|
||||
g3.AddNode("A", func(ctx context.Context, s TestState) (TestState, error) { return s, nil })
|
||||
g3.AddInterrupt("ghost")
|
||||
_, err = g3.Compile()
|
||||
if err == nil || !strings.Contains(err.Error(), "interrupt") {
|
||||
t.Errorf("不存在的中断节点应报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompileEdgeConflict 验证同一节点不能同时存在于多种出边类型中
|
||||
func TestCompileEdgeConflict(t *testing.T) {
|
||||
g := NewGraph[TestState]()
|
||||
g.AddNode("A", func(ctx context.Context, s TestState) (TestState, error) { return s, nil })
|
||||
g.AddNode("B", func(ctx context.Context, s TestState) (TestState, error) { return s, nil })
|
||||
|
||||
// 同时为 A 设置静态边和条件路由
|
||||
g.AddEdge("A", "B")
|
||||
g.AddConditionalEdges("A", func(ctx context.Context, s TestState) (string, error) {
|
||||
return "B", nil
|
||||
})
|
||||
|
||||
_, err := g.Compile()
|
||||
if err == nil || !strings.Contains(err.Error(), "conflicting") {
|
||||
t.Errorf("出边冲突应报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouterReturnsNonExistentNode 验证路由函数返回不存在的节点名时的运行时错误
|
||||
func TestRouterReturnsNonExistentNode(t *testing.T) {
|
||||
g := NewGraph[TestState]()
|
||||
g.AddNode("start", func(ctx context.Context, s TestState) (TestState, error) { return s, nil })
|
||||
g.AddConditionalEdges("start", func(ctx context.Context, s TestState) (string, error) {
|
||||
return "nonexistent_node", nil
|
||||
})
|
||||
|
||||
cg, err := g.Compile()
|
||||
if err != nil {
|
||||
t.Fatalf("编译图失败: %v", err)
|
||||
}
|
||||
|
||||
_, err = cg.Start(context.Background(), "start", TestState{})
|
||||
if err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("路由到不存在节点应返回 runtime error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParallelWithStateCloner 验证 StateCloner 在并发分支中正确隔离引用类型
|
||||
func TestParallelWithStateCloner(t *testing.T) {
|
||||
g := NewGraph[TestState]()
|
||||
|
||||
g.AddNode("start", func(ctx context.Context, s TestState) (TestState, error) {
|
||||
s.Log = append(s.Log, "start")
|
||||
return s, nil
|
||||
})
|
||||
// 两个分支都向 Log 切片追加元素
|
||||
g.AddNode("branch1", func(ctx context.Context, s TestState) (TestState, error) {
|
||||
s.Log = append(s.Log, "branch1")
|
||||
s.Value = 10
|
||||
return s, nil
|
||||
})
|
||||
g.AddNode("branch2", func(ctx context.Context, s TestState) (TestState, error) {
|
||||
s.Log = append(s.Log, "branch2")
|
||||
s.Value = 20
|
||||
return s, nil
|
||||
})
|
||||
|
||||
g.AddParallelEdges("start", []string{"branch1", "branch2"}, "",
|
||||
func(ctx context.Context, parent TestState, branches []TestState) (TestState, error) {
|
||||
for _, b := range branches {
|
||||
parent.Value += b.Value
|
||||
}
|
||||
return parent, nil
|
||||
},
|
||||
)
|
||||
|
||||
cg, err := g.Compile()
|
||||
if err != nil {
|
||||
t.Fatalf("编译图失败: %v", err)
|
||||
}
|
||||
|
||||
// 使用 Engine + StateCloner 运行
|
||||
engine := NewEngine(cg).WithStateCloner(func(s TestState) TestState {
|
||||
clone := s
|
||||
clone.Log = append([]string{}, s.Log...)
|
||||
return clone
|
||||
})
|
||||
|
||||
thread, err := engine.Start(context.Background(), "start", TestState{})
|
||||
if err != nil {
|
||||
t.Fatalf("启动图失败: %v", err)
|
||||
}
|
||||
if thread.State.Value != 30 {
|
||||
t.Errorf("期望 Value 为 30,实际为 %d", thread.State.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHookChaining 验证多次调用 WithPreNodeHook/WithPostNodeHook 时钩子按顺序叠加
|
||||
func TestHookChaining(t *testing.T) {
|
||||
g := NewGraph[TestState]()
|
||||
g.AddNode("A", func(ctx context.Context, s TestState) (TestState, error) {
|
||||
return s, nil
|
||||
})
|
||||
cg, _ := g.Compile()
|
||||
|
||||
var log []string
|
||||
engine := NewEngine(cg).
|
||||
WithPreNodeHook(func(ctx context.Context, name string, s TestState) {
|
||||
log = append(log, "pre1:"+name)
|
||||
}).
|
||||
WithPreNodeHook(func(ctx context.Context, name string, s TestState) {
|
||||
log = append(log, "pre2:"+name)
|
||||
}).
|
||||
WithPostNodeHook(func(ctx context.Context, name string, s TestState) {
|
||||
log = append(log, "post1:"+name)
|
||||
}).
|
||||
WithPostNodeHook(func(ctx context.Context, name string, s TestState) {
|
||||
log = append(log, "post2:"+name)
|
||||
})
|
||||
|
||||
engine.Start(context.Background(), "A", TestState{})
|
||||
|
||||
expected := []string{"pre1:A", "pre2:A", "post1:A", "post2:A"}
|
||||
if len(log) != len(expected) {
|
||||
t.Fatalf("期望 %d 次 hook 触发,实际 %d 次: %v", len(expected), len(log), log)
|
||||
}
|
||||
for i, entry := range expected {
|
||||
if log[i] != entry {
|
||||
t.Errorf("第 %d 次 hook 期望 %q,实际 %q", i, entry, log[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckpointerThreadIDValidation 验证 FileCheckpointer 对恶意 threadID 的防御
|
||||
func TestCheckpointerThreadIDValidation(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
fc, err := NewFileCheckpointer[TestState](tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("创建 FileCheckpointer 失败: %v", err)
|
||||
}
|
||||
|
||||
thread := &Thread[TestState]{State: TestState{Value: 1}}
|
||||
ctx := context.Background()
|
||||
|
||||
// 空 ID
|
||||
if err := fc.Save(ctx, "", thread); err == nil {
|
||||
t.Error("空 threadID 应报错")
|
||||
}
|
||||
|
||||
// 路径穿越
|
||||
if err := fc.Save(ctx, "../escape", thread); err == nil {
|
||||
t.Error("包含 .. 的 threadID 应报错")
|
||||
}
|
||||
|
||||
// 目录分隔符
|
||||
if err := fc.Save(ctx, "sub/dir", thread); err == nil {
|
||||
t.Error("包含 / 的 threadID 应报错")
|
||||
}
|
||||
|
||||
// Load 同样校验
|
||||
_, err = fc.Load(ctx, "../escape")
|
||||
if err == nil {
|
||||
t.Error("Load 包含 .. 的 threadID 应报错")
|
||||
}
|
||||
|
||||
// 合法 ID 应成功
|
||||
if err := fc.Save(ctx, "valid-id_123", thread); err != nil {
|
||||
t.Errorf("合法 threadID 不应报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user