Files
ragflow/internal/harness/replay/diff.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

212 lines
5.5 KiB
Go

package replay
import (
"context"
"ragflow/internal/harness/events"
)
// DiffResult contains the comparison of two execution traces.
type DiffResult struct {
// LeftTraceID identifies the left (reference) trace.
LeftTraceID string
// RightTraceID identifies the right (candidate) trace.
RightTraceID string
// MissingInRight are events present in the left trace but absent in the right.
MissingInRight []*events.Event
// MissingInLeft are events present in the right trace but absent in the left.
MissingInLeft []*events.Event
// Mismatched are events that exist in both traces but have different payloads.
Mismatched []EventMismatch
// StateDiff captures differences in state transitions.
StateDiff map[string]StateDiff
// ToolCallDiff captures differences in tool invocations.
ToolCallDiff []ToolCallDiff
// LLMResponseDiff captures differences in LLM responses.
LLMResponseDiff []LLMResponseDiff
// FinalOutputDiff is the difference in the final output (empty when identical).
FinalOutputDiff string
}
// EventMismatch describes a single event-level difference between two traces.
type EventMismatch struct {
Clock uint64
LeftEvent *events.Event
RightEvent *events.Event
Field string
LeftValue string
RightValue string
}
// StateDiff describes a difference in state at a specific point.
type StateDiff struct {
Clock uint64
Key string
LeftValue any
RightValue any
}
// ToolCallDiff describes a difference in a tool invocation between two traces.
type ToolCallDiff struct {
Index int
ToolName string
LeftResult any
RightResult any
LeftError string
RightError string
}
// LLMResponseDiff describes a difference in an LLM response between two traces.
type LLMResponseDiff struct {
Index int
LeftContent string
RightContent string
}
// Diff compares two execution traces from the same event store.
// It identifies events that are present in one trace but not the other,
// and events that exist in both but differ in content.
func Diff(ctx context.Context, left, right events.EventLog, leftTraceID, rightTraceID string) (*DiffResult, error) {
result := &DiffResult{
LeftTraceID: leftTraceID,
RightTraceID: rightTraceID,
StateDiff: make(map[string]StateDiff),
}
// Collect events from both traces.
leftEvents, err := readAllEvents(ctx, left, leftTraceID)
if err != nil {
return nil, err
}
rightEvents, err := readAllEvents(ctx, right, rightTraceID)
if err != nil {
return nil, err
}
// Build lookup maps.
leftByClock := make(map[uint64]*events.Event)
for _, ev := range leftEvents {
leftByClock[ev.Clock] = ev
}
rightByClock := make(map[uint64]*events.Event)
for _, ev := range rightEvents {
rightByClock[ev.Clock] = ev
}
// Collect all clock values.
allClocks := make(map[uint64]bool)
for _, ev := range leftEvents {
allClocks[ev.Clock] = true
}
for _, ev := range rightEvents {
allClocks[ev.Clock] = true
}
// Compare event by event.
for clock := range allClocks {
leftEv, leftOk := leftByClock[clock]
rightEv, rightOk := rightByClock[clock]
switch {
case leftOk && !rightOk:
result.MissingInRight = append(result.MissingInRight, leftEv)
case !leftOk && rightOk:
result.MissingInLeft = append(result.MissingInLeft, rightEv)
case leftOk && rightOk:
// Both exist — compare.
if leftEv.Type != rightEv.Type {
result.Mismatched = append(result.Mismatched, EventMismatch{
Clock: clock,
LeftEvent: leftEv,
RightEvent: rightEv,
Field: "type",
LeftValue: string(leftEv.Type),
RightValue: string(rightEv.Type),
})
}
if leftEv.Hash != rightEv.Hash {
result.Mismatched = append(result.Mismatched, EventMismatch{
Clock: clock,
LeftEvent: leftEv,
RightEvent: rightEv,
Field: "payload",
LeftValue: leftEv.Hash[:16],
RightValue: rightEv.Hash[:16],
})
}
// Categorise by event type.
switch leftEv.Type {
case events.EventLLMCallEnd:
result.LLMResponseDiff = append(result.LLMResponseDiff, LLMResponseDiff{
Index: len(result.LLMResponseDiff),
LeftContent: extractContent(leftEv),
RightContent: extractContent(rightEv),
})
case events.EventToolCallResult:
result.ToolCallDiff = append(result.ToolCallDiff, ToolCallDiff{
Index: len(result.ToolCallDiff),
ToolName: extractToolName(leftEv),
})
case events.EventStateWrite:
if leftEv.Node != "" {
result.StateDiff[leftEv.Node] = StateDiff{
Clock: clock,
Key: leftEv.Node,
}
}
}
}
}
return result, nil
}
// readAllEvents reads all events for a trace from the store.
func readAllEvents(ctx context.Context, store events.EventLog, traceID string) ([]*events.Event, error) {
iter := store.Stream(ctx, events.EventFilter{TraceID: traceID})
defer iter.Close()
var result []*events.Event
for {
ev, ok := iter.Next(ctx)
if !ok {
break
}
result = append(result, ev)
}
return result, nil
}
// extractContent extracts the Content field from an LLMCallPayload event.
func extractContent(ev *events.Event) string {
if ev.Payload == nil {
return ""
}
var payload events.LLMCallPayload
if err := jsonUnmarshal(ev.Payload, &payload); err != nil {
return ""
}
return payload.Content
}
// extractToolName extracts the ToolName field from a ToolCallPayload event.
func extractToolName(ev *events.Event) string {
if ev.Payload == nil {
return ""
}
var payload events.ToolCallPayload
if err := jsonUnmarshal(ev.Payload, &payload); err != nil {
return ""
}
return payload.ToolName
}