mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-08 00:18:12 +08:00
414 lines
14 KiB
Go
414 lines
14 KiB
Go
// Package graph provides state inspection API for compiled graphs.
|
|
//
|
|
// This corresponds to Python LangGraph's get_state() / update_state() /
|
|
// get_state_history() on PregelProtocol.
|
|
package graph
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"ragflow/internal/harness/graph/channels"
|
|
"ragflow/internal/harness/graph/checkpoint"
|
|
"ragflow/internal/harness/graph/constants"
|
|
"ragflow/internal/harness/graph/types"
|
|
)
|
|
|
|
// StateSnapshot represents the state of the graph at a particular checkpoint.
|
|
// This mirrors Python's langgraph.types.StateSnapshot.
|
|
type StateSnapshot struct {
|
|
// Values are the current values of channels (i.e., the graph state).
|
|
Values map[string]interface{} `json:"values"`
|
|
// Next are the names of nodes to execute next.
|
|
Next []string `json:"next,omitempty"`
|
|
// Config is the RunnableConfig used to fetch this snapshot.
|
|
Config *types.RunnableConfig `json:"config"`
|
|
// Metadata associated with this snapshot.
|
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
// CreatedAt is the timestamp of snapshot creation.
|
|
CreatedAt time.Time `json:"created_at"`
|
|
// ParentConfig is the config that can fetch the parent snapshot, if any.
|
|
ParentConfig *types.RunnableConfig `json:"parent_config,omitempty"`
|
|
// Tasks are the pending tasks at this snapshot.
|
|
Tasks []Task `json:"tasks,omitempty"`
|
|
// Interrupts that were pending at this checkpoint.
|
|
Interrupts []*types.Interrupt `json:"interrupts,omitempty"`
|
|
}
|
|
|
|
// StateUpdate describes an update to apply to the graph state.
|
|
// This mirrors Python's StateUpdate tuple.
|
|
type StateUpdate struct {
|
|
Values map[string]interface{} // state values to write
|
|
AsNode string // node name to write as (empty = "resume")
|
|
CheckID string // checkpoint ID to target (empty = latest)
|
|
ThreadID string // thread ID to target
|
|
}
|
|
|
|
// StateInspector provides state inspection and manipulation for compiled graphs.
|
|
// Implemented by compiledGraph and CompiledStateGraph.
|
|
type StateInspector interface {
|
|
// GetState retrieves the state at the given config.
|
|
// When config contains only thread_id, returns the latest state.
|
|
// When config also contains checkpoint_id, returns that specific state.
|
|
GetState(ctx context.Context, config *types.RunnableConfig) (*StateSnapshot, error)
|
|
|
|
// GetStateHistory returns an iterator of state snapshots for the given config,
|
|
// starting from the most recent and going backward.
|
|
GetStateHistory(ctx context.Context, config *types.RunnableConfig, limit int, before *types.RunnableConfig) ([]*StateSnapshot, error)
|
|
|
|
// UpdateState applies updates to the graph state at the given config.
|
|
// This enables manual state injection (time travel, interrupt resolution).
|
|
// Returns the config for the new checkpoint created by the update.
|
|
UpdateState(ctx context.Context, config *types.RunnableConfig, update *StateUpdate) (*types.RunnableConfig, error)
|
|
|
|
// ForkThread clones a checkpoint from one thread to another.
|
|
// sourceCheckpointID: empty = latest checkpoint in source thread.
|
|
ForkThread(ctx context.Context, sourceThreadID, newThreadID string, sourceCheckpointID string) (*types.RunnableConfig, error)
|
|
}
|
|
|
|
// Ensure compiledGraph implements StateInspector.
|
|
var _ StateInspector = (*compiledGraph)(nil)
|
|
|
|
// GetState retrieves the graph state at the given configuration point.
|
|
func (cg *compiledGraph) GetState(ctx context.Context, config *types.RunnableConfig) (*StateSnapshot, error) {
|
|
if cg.checkpointer == nil {
|
|
return nil, fmt.Errorf("checkpointer is required for GetState, configure with WithCheckpointer during Compile")
|
|
}
|
|
|
|
cp, err := cg.getCheckpointer()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cpConfig := buildCheckpointerConfig(config)
|
|
cpData, err := cp.Get(ctx, cpConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get checkpoint: %w", err)
|
|
}
|
|
if cpData == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
// Build channel registry from graph channels and restore from checkpoint data.
|
|
registry := channels.NewRegistry()
|
|
for name, ch := range cg.graph.GetChannels() {
|
|
if chImpl, ok := ch.(channels.Channel); ok {
|
|
registry.Register(name, chImpl.Copy())
|
|
}
|
|
}
|
|
filtered := make(map[string]interface{})
|
|
for key, val := range cpData {
|
|
if _, ok := registry.Get(key); ok {
|
|
filtered[key] = val
|
|
}
|
|
}
|
|
if len(filtered) > 0 {
|
|
if err := registry.RestoreFromCheckpoint(filtered); err != nil {
|
|
return nil, fmt.Errorf("failed to restore from checkpoint: %w", err)
|
|
}
|
|
}
|
|
|
|
// Build current values.
|
|
values, _ := registry.GetValues()
|
|
|
|
// Determine next tasks.
|
|
nextNodes := cg.determineNextFromCheckpoint(cpData)
|
|
|
|
return &StateSnapshot{
|
|
Values: values,
|
|
Next: nextNodes,
|
|
Config: config,
|
|
Metadata: extractMeta(cpData),
|
|
CreatedAt: time.Now(),
|
|
}, nil
|
|
}
|
|
|
|
// GetStateHistory returns the sequence of state snapshots for the thread.
|
|
func (cg *compiledGraph) GetStateHistory(ctx context.Context, config *types.RunnableConfig, limit int, before *types.RunnableConfig) ([]*StateSnapshot, error) {
|
|
if cg.checkpointer == nil {
|
|
return nil, fmt.Errorf("checkpointer is required for GetStateHistory")
|
|
}
|
|
|
|
cp, err := cg.getCheckpointer()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cpConfig := buildCheckpointerConfig(config)
|
|
entries, err := cp.List(ctx, cpConfig, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list checkpoints: %w", err)
|
|
}
|
|
|
|
snapshots := make([]*StateSnapshot, 0, len(entries))
|
|
for _, entry := range entries {
|
|
// Build a config that points to this specific checkpoint.
|
|
cpID, _ := entry[constants.ConfigKeyCheckpointID].(string)
|
|
snapConfig := &types.RunnableConfig{}
|
|
if config != nil {
|
|
snapConfig = &types.RunnableConfig{}
|
|
if config.Configurable != nil {
|
|
snapConfig.Configurable = make(map[string]interface{}, len(config.Configurable))
|
|
for k, v := range config.Configurable {
|
|
snapConfig.Configurable[k] = v
|
|
}
|
|
}
|
|
}
|
|
if snapConfig.Configurable == nil {
|
|
snapConfig.Configurable = make(map[string]interface{})
|
|
}
|
|
if cpID != "" {
|
|
snapConfig.Configurable[constants.ConfigKeyCheckpointID] = cpID
|
|
}
|
|
|
|
// Get full state for this checkpoint.
|
|
snap, err := cg.GetState(ctx, snapConfig)
|
|
if err != nil {
|
|
// Skip entries we can't parse.
|
|
continue
|
|
}
|
|
if snap != nil {
|
|
if createdAt, ok := entry["created_at"].(time.Time); ok {
|
|
snap.CreatedAt = createdAt
|
|
}
|
|
if meta, ok := entry["metadata"].(map[string]interface{}); ok {
|
|
snap.Metadata = meta
|
|
}
|
|
snapshots = append(snapshots, snap)
|
|
}
|
|
}
|
|
return snapshots, nil
|
|
}
|
|
|
|
// UpdateState applies state updates at the given checkpoint/thread and creates a new checkpoint.
|
|
func (cg *compiledGraph) UpdateState(ctx context.Context, config *types.RunnableConfig, update *StateUpdate) (*types.RunnableConfig, error) {
|
|
if cg.checkpointer == nil {
|
|
return nil, fmt.Errorf("checkpointer is required for UpdateState")
|
|
}
|
|
|
|
cp, err := cg.getCheckpointer()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// 1. Get the current checkpoint at the target config.
|
|
cpConfig := buildCheckpointerConfig(config)
|
|
if update.CheckID != "" {
|
|
cpConfig[constants.ConfigKeyCheckpointID] = update.CheckID
|
|
}
|
|
cpData, err := cp.Get(ctx, cpConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get checkpoint for update: %w", err)
|
|
}
|
|
if cpData == nil {
|
|
return nil, fmt.Errorf("no checkpoint found for the given config")
|
|
}
|
|
|
|
// 2. Apply the update values to the checkpoint data.
|
|
asNode := update.AsNode
|
|
if asNode == "" {
|
|
asNode = "resume"
|
|
}
|
|
for key, val := range update.Values {
|
|
cpData[key] = val
|
|
}
|
|
|
|
// 3. Determine new thread ID and parent checkpoint ID.
|
|
// Note: Do NOT inject metadata keys (like __update_as_node__) into cpData,
|
|
// because inline Pregel will try to restore them as channels.
|
|
newThreadID := update.ThreadID
|
|
if newThreadID == "" {
|
|
if id, ok := cpConfig[constants.ConfigKeyThreadID].(string); ok {
|
|
newThreadID = id
|
|
}
|
|
}
|
|
if newThreadID == "" {
|
|
return nil, fmt.Errorf("thread_id is required for UpdateState")
|
|
}
|
|
parentID, _ := cpConfig[constants.ConfigKeyCheckpointID].(string)
|
|
|
|
newConfig := map[string]interface{}{
|
|
constants.ConfigKeyThreadID: newThreadID,
|
|
"parent_checkpoint_id": parentID,
|
|
constants.ConfigKeyCheckpointID: "",
|
|
}
|
|
if err := cp.Put(ctx, newConfig, cpData); err != nil {
|
|
return nil, fmt.Errorf("failed to save updated checkpoint: %w", err)
|
|
}
|
|
|
|
// 4. Return the config for the new checkpoint.
|
|
listCfg := map[string]interface{}{
|
|
constants.ConfigKeyThreadID: newThreadID,
|
|
}
|
|
entries, err := cp.List(ctx, listCfg, 1)
|
|
if err == nil && len(entries) > 0 {
|
|
newCPID, _ := entries[0][constants.ConfigKeyCheckpointID].(string)
|
|
result := types.NewRunnableConfig()
|
|
if config != nil && config.Configurable != nil {
|
|
result.Configurable = make(map[string]interface{}, len(config.Configurable))
|
|
for k, v := range config.Configurable {
|
|
result.Configurable[k] = v
|
|
}
|
|
}
|
|
if result.Configurable == nil {
|
|
result.Configurable = make(map[string]interface{})
|
|
}
|
|
result.Configurable[constants.ConfigKeyCheckpointID] = newCPID
|
|
result.Configurable[constants.ConfigKeyThreadID] = newThreadID
|
|
return result, nil
|
|
}
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
// getCheckpointer performs a safe type assertion on the stored checkpointer.
|
|
// Returns a BaseCheckpointer or a descriptive error if the cast fails.
|
|
func (cg *compiledGraph) getCheckpointer() (checkpoint.BaseCheckpointer, error) {
|
|
cp, ok := cg.checkpointer.(checkpoint.BaseCheckpointer)
|
|
if !ok {
|
|
return nil, fmt.Errorf("checkpointer is not a BaseCheckpointer (got %T)", cg.checkpointer)
|
|
}
|
|
return cp, nil
|
|
}
|
|
|
|
// buildCheckpointerConfig builds a checkpointer config from a RunnableConfig.
|
|
func buildCheckpointerConfig(config *types.RunnableConfig) map[string]interface{} {
|
|
cpConfig := make(map[string]interface{})
|
|
if config != nil && config.Configurable != nil {
|
|
if tid, ok := config.Configurable[constants.ConfigKeyThreadID]; ok {
|
|
cpConfig[constants.ConfigKeyThreadID] = tid
|
|
}
|
|
if cpid, ok := config.Configurable[constants.ConfigKeyCheckpointID]; ok {
|
|
cpConfig[constants.ConfigKeyCheckpointID] = cpid
|
|
}
|
|
if ns, ok := config.Configurable[constants.ConfigKeyCheckpointNS]; ok {
|
|
cpConfig[constants.ConfigKeyCheckpointNS] = ns
|
|
}
|
|
}
|
|
return cpConfig
|
|
}
|
|
|
|
// extractMeta extracts metadata from checkpoint data.
|
|
func extractMeta(cpData map[string]interface{}) map[string]interface{} {
|
|
meta := make(map[string]interface{})
|
|
if v, ok := cpData["__step__"]; ok {
|
|
meta["step"] = v
|
|
}
|
|
if v, ok := cpData["__last_completed_node__"]; ok {
|
|
meta["last_completed_node"] = v
|
|
}
|
|
return meta
|
|
}
|
|
|
|
// determineNextFromCheckpoint reads the checkpoint and checkpoint data
|
|
// to determine which nodes would run next.
|
|
func (cg *compiledGraph) determineNextFromCheckpoint(cpData map[string]interface{}) []string {
|
|
// Return nil because the stored __last_completed_node__ is already
|
|
// finished, not pending. Full edge replay is not yet implemented.
|
|
return nil
|
|
}
|
|
|
|
// ---- CompiledStateGraph also implements StateInspector ----
|
|
|
|
var _ StateInspector = (*CompiledStateGraph)(nil)
|
|
|
|
// GetState delegates to the underlying compiledGraph.
|
|
func (csg *CompiledStateGraph) GetState(ctx context.Context, config *types.RunnableConfig) (*StateSnapshot, error) {
|
|
return csg.compiledGraph.GetState(ctx, config)
|
|
}
|
|
|
|
// GetStateHistory delegates to the underlying compiledGraph.
|
|
func (csg *CompiledStateGraph) GetStateHistory(ctx context.Context, config *types.RunnableConfig, limit int, before *types.RunnableConfig) ([]*StateSnapshot, error) {
|
|
return csg.compiledGraph.GetStateHistory(ctx, config, limit, before)
|
|
}
|
|
|
|
// UpdateState delegates to the underlying compiledGraph.
|
|
func (csg *CompiledStateGraph) UpdateState(ctx context.Context, config *types.RunnableConfig, update *StateUpdate) (*types.RunnableConfig, error) {
|
|
return csg.compiledGraph.UpdateState(ctx, config, update)
|
|
}
|
|
|
|
// ---- Task type used in StateSnapshot ----
|
|
|
|
// Task represents a pending or completed task for state inspection.
|
|
type Task struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Error error `json:"error,omitempty"`
|
|
Interrupt interface{} `json:"interrupt,omitempty"`
|
|
State interface{} `json:"state,omitempty"`
|
|
Result interface{} `json:"result,omitempty"`
|
|
}
|
|
|
|
// CheckpointConflictError indicates a checkpoint version conflict during UpdateState.
|
|
type CheckpointConflictError struct {
|
|
Message string
|
|
}
|
|
|
|
func (e *CheckpointConflictError) Error() string {
|
|
return e.Message
|
|
}
|
|
|
|
// ForkThread clones a checkpoint from one thread to another.
|
|
// This enables "time travel fork": creating a new thread whose initial
|
|
// state is a copy of the given checkpoint. Returns the RunnableConfig
|
|
// for the new thread.
|
|
//
|
|
// To use: invoke on a compiledGraph with a checkpointer configured.
|
|
// sourceCheckpointID: empty = latest checkpoint in source thread.
|
|
func (cg *compiledGraph) ForkThread(ctx context.Context, sourceThreadID, newThreadID string, sourceCheckpointID string) (*types.RunnableConfig, error) {
|
|
if cg.checkpointer == nil {
|
|
return nil, fmt.Errorf("checkpointer is required for ForkThread")
|
|
}
|
|
|
|
cp, err := cg.getCheckpointer()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 1. Read source checkpoint.
|
|
cpConfig := map[string]interface{}{
|
|
constants.ConfigKeyThreadID: sourceThreadID,
|
|
}
|
|
if sourceCheckpointID != "" {
|
|
cpConfig[constants.ConfigKeyCheckpointID] = sourceCheckpointID
|
|
}
|
|
cpData, err := cp.Get(ctx, cpConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get source checkpoint: %w", err)
|
|
}
|
|
if cpData == nil {
|
|
return nil, fmt.Errorf("no checkpoint found for thread %s", sourceThreadID)
|
|
}
|
|
|
|
// 2. Write to new thread as a fresh checkpoint (no parent).
|
|
newConfig := map[string]interface{}{
|
|
constants.ConfigKeyThreadID: newThreadID,
|
|
}
|
|
if err := cp.Put(ctx, newConfig, cpData); err != nil {
|
|
return nil, fmt.Errorf("failed to write forked checkpoint: %w", err)
|
|
}
|
|
|
|
// 3. Return config pointing to the new thread.
|
|
entries, err := cp.List(ctx, newConfig, 1)
|
|
if err != nil || len(entries) == 0 {
|
|
return &types.RunnableConfig{
|
|
Configurable: map[string]interface{}{
|
|
constants.ConfigKeyThreadID: newThreadID,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
newCPID, _ := entries[0][constants.ConfigKeyCheckpointID].(string)
|
|
result := types.NewRunnableConfig()
|
|
result.Configurable = make(map[string]interface{})
|
|
result.Configurable[constants.ConfigKeyThreadID] = newThreadID
|
|
if newCPID != "" {
|
|
result.Configurable[constants.ConfigKeyCheckpointID] = newCPID
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func init() {
|
|
_ = (*CheckpointConflictError)(nil)
|
|
}
|