Files
ragflow/internal/harness/replay/fork.go
Yingfeng dd20561fca Feat: add event sourcing and replay to harness (#16326)
### Motivation

This PR evolves the harness from a pure execution runtime into an
**observable, replayable agent evaluation platform**. The current
`harness/graph` checkpoint mechanism is insufficient for true
event-sourced introspection—we need append-only event logs capturing
every tool call, state transition, memory write, and approval decision,
enabling deterministic replay, fork/diff, postmortem analysis, and
time-travel debugging.

### Key Design Goals

1. **Event-Sourced Execution Model**  
Replace coarse checkpoints with granular, append-only event logs. Every
operation becomes a durable event: tool invocation, state mutation,
memory update, human approval. This unlocks deterministic replay,
branching execution histories, and regression datasets derived directly
from production failures.

2. **First-Class Replay & Evaluation Loop**  
Replay is not an afterthought—it is a core primitive. A single live run
seeds an offline corpus that supports: repeated playback, model
substitution, tool result mocking, and strategy comparison. The harness
graduates from "executor" to "continuous evaluation platform" where
failed production traces convert directly into offline regression
suites.

3. **Operational Observability**  
   Beyond raw traces, expose metrics that prove stability over time:
   - Tool success / failure rates
   - Approval latency distributions
   - Retry frequencies
   - Checkpoint restore reliability
   - Memory retrieval quality
   - Cost per completed task
   - Fork replay pass rates

The underlying thesis: the bottleneck for most agent systems is not
execution capability, but the inability to **demonstrate continuous,
measurable improvement**.


### Type of change

- [x] New Feature (non-breaking change which adds functionality)
2026-07-06 23:31:54 +08:00

226 lines
6.5 KiB
Go

package replay
import (
"context"
"fmt"
"time"
"ragflow/internal/harness/events"
"ragflow/internal/harness/graph/checkpoint"
"ragflow/internal/harness/graph/constants"
"ragflow/internal/harness/graph/pregel"
"ragflow/internal/harness/graph/types"
)
// ForkContextKey is used to pass the ForkConfig's ModelOverride/ToolOverride
// through context to node-level wrappers during true replay.
// This is a spare key; the actual model/tool substitution during engine
// re-execution is done by the caller via agent-level middleware.
type ForkContextKey struct{}
// ForkConfig configures a fork operation.
type ForkConfig struct {
// Store is the event source for the original trace.
Store events.EventLog
// TraceID identifies the original trace to fork from.
TraceID string
// Point is the event ID at which to fork.
Point events.EventID
// Substitution strategies for the forked branch.
ModelOverride ModelOverrideFunc
ToolOverride ToolOverrideFunc
NewInput any
// ForkEngine is the actual graph engine to execute the forked branch.
// When set, Fork replays up to ForkPoint, builds a checkpoint from the
// events, saves it into a MemorySaver, and hands off to real execution.
// When nil, Fork replays deterministically from EventLog alone.
ForkEngine *pregel.Engine
// Checkpointer is the persistence backend to use when resuming the
// ForkEngine. When nil, a fresh MemorySaver is created.
Checkpointer checkpoint.BaseCheckpointer
// OutputStore receives events generated during the fork (nil = discard).
OutputStore events.EventLog
}
// ForkResult contains the result of a fork operation.
type ForkResult struct {
// ForkTraceID identifies the new fork trace.
ForkTraceID string
// ForkEvents generated during the forked execution.
ForkEvents []*events.Event
// ParentTraceID is the original trace that was forked.
ParentTraceID string
// ForkPoint is the event ID where the fork occurred.
ForkPoint events.EventID
// FinalState is the output state from the forked Engine execution.
// Only set when ForkEngine was used.
FinalState any
// Duration of the fork operation.
Duration time.Duration
}
// Fork creates a branched execution from a specified point in the trace.
// Events up to ForkPoint are replayed from the original store.
// After ForkPoint, if ForkEngine is set, execution hands off to the real
// graph engine via checkpoint resume; otherwise replay continues
// deterministically with overrides.
func (e *ReplayEngine) Fork(ctx context.Context, cfg *ForkConfig) (*ForkResult, error) {
start := time.Now()
// Use config store, falling back to engine store.
store := cfg.Store
if store == nil {
store = e.store
}
// Find the fork point event.
forkEvent, err := store.Get(ctx, cfg.Point)
if err != nil {
return nil, err
}
if forkEvent == nil {
return nil, errEventNotFound(cfg.Point)
}
// Read ALL events up to (but not including) the fork point.
// We need the complete event list to reconstruct the checkpoint.
filter := events.EventFilter{
TraceID: cfg.TraceID,
ToClock: forkEvent.Clock - 1,
}
iter := store.Stream(ctx, filter)
defer iter.Close()
var preForkEvents []*events.Event
for {
ev, ok := iter.Next(ctx)
if !ok {
break
}
preForkEvents = append(preForkEvents, ev)
}
result := &ForkResult{
ForkTraceID: cfg.TraceID + "_fork_" + string(cfg.Point),
ParentTraceID: cfg.TraceID,
ForkPoint: cfg.Point,
}
// Append fork marker event.
forkMarker := events.NewEvent(events.EventFork, 0)
forkMarker.TraceID = result.ForkTraceID
forkMarker.ParentID = cfg.Point
forkMarker.CausedBy = []events.EventID{cfg.Point}
forkMarker.Metadata["parent_trace"] = cfg.TraceID
forkMarker.Seal()
// Collect pre-fork events.
result.ForkEvents = append(result.ForkEvents, preForkEvents...)
result.ForkEvents = append(result.ForkEvents, forkMarker)
if cfg.OutputStore != nil {
if err := cfg.OutputStore.Append(ctx, result.ForkEvents...); err != nil {
return nil, err
}
}
// If a fork engine is provided, reconstruct checkpoint and resume.
if cfg.ForkEngine != nil {
forkResult, err := e.resumeFromCheckpoint(ctx, cfg, preForkEvents, forkMarker)
if err != nil {
return nil, fmt.Errorf("fork resume: %w", err)
}
result.FinalState = forkResult
}
result.Duration = time.Since(start)
return result, nil
}
// resumeFromCheckpoint reconstructs checkpoint state from pre-fork events and
// resumes the ForkEngine from that point. The engine runs the graph from the
// reconstructed state and returns the final output.
func (e *ReplayEngine) resumeFromCheckpoint(ctx context.Context, cfg *ForkConfig, preForkEvents []*events.Event, forkMarker *events.Event) (any, error) {
if cfg.ForkEngine == nil {
return nil, nil
}
threadID := cfg.TraceID
if threadID == "" {
threadID = "fork-" + string(cfg.Point)
}
// Build checkpoint map from pre-fork events.
cp, cpID := BuildCheckpoint(preForkEvents, threadID)
// Save checkpoint into a MemorySaver (or caller-provided checkpointer).
saver := cfg.Checkpointer
if saver == nil {
saver = checkpoint.NewMemorySaver()
}
if err := saver.Put(ctx, map[string]any{
constants.ConfigKeyThreadID: threadID,
constants.ConfigKeyCheckpointID: cpID,
}, cp); err != nil {
return nil, fmt.Errorf("save fork checkpoint: %w", err)
}
// Check if ForkEngine already has a checkpointer; if not, set it.
// We inject our own via WithCheckpointer option at Fork creation time
// by creating a new Engine wrapping the same graph.
// Configure the engine's runnable config to point at the checkpoint.
rc := types.NewRunnableConfig()
rc.ThreadID = threadID
rc.Set(constants.ConfigKeyThreadID, threadID)
rc.Set(constants.ConfigKeyCheckpointID, cpID)
// Run the ForkEngine with the resume config.
outputCh, errCh := cfg.ForkEngine.Run(ctx, nil, types.StreamModeValues)
// Drain outputCh for final state.
var finalState any
for result := range outputCh {
if se, ok := result.(*pregel.StreamEvent); ok {
if se.Type == pregel.EventTypeFinal {
if data, ok := se.Data.(map[string]any); ok {
if state, ok := data["state"]; ok {
finalState = state
}
}
}
}
}
if err := <-errCh; err != nil {
return nil, err
}
// If output store is set, record fork completion.
if cfg.OutputStore != nil {
forkEnd := events.NewEvent(events.EventGraphEnd, 0)
forkEnd.TraceID = cfg.TraceID + "_fork_" + string(cfg.Point)
forkEnd.Metadata["fork_replay"] = true
forkEnd.Seal()
_ = cfg.OutputStore.Append(ctx, forkEnd)
}
return finalState, nil
}
func errEventNotFound(id events.EventID) error {
return errorf("event not found: %s", id)
}