mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-09 21:04:49 +08:00
### 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)
212 lines
5.5 KiB
Go
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
|
|
}
|