mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-16 20:57:21 +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)
368 lines
8.7 KiB
Go
368 lines
8.7 KiB
Go
package events
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
)
|
|
|
|
const (
|
|
defaultNATSPrefix = "harness_events"
|
|
natsEventSubject = "events.event"
|
|
natsSnapshotSubject = "events.snapshot"
|
|
defaultMaxCacheAge = 10 * time.Minute
|
|
defaultMaxCacheItems = 10000
|
|
)
|
|
|
|
// cachedEvent wraps an Event with an expiry timestamp for TTL-based cache eviction.
|
|
type cachedEvent struct {
|
|
ev *Event
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// NATSEventStore persists events to NATS JetStream.
|
|
// Suitable for production distributed deployments.
|
|
type NATSEventStore struct {
|
|
conn *nats.Conn
|
|
js jetstream.JetStream
|
|
stream string // JetStream stream name
|
|
prefix string // subject prefix
|
|
mu sync.RWMutex
|
|
cache map[string]*cachedEvent // ID → timestamped Event for fast Get (bounded)
|
|
maxCacheAge time.Duration
|
|
clock atomic.Uint64
|
|
subs map[int64]*nats.Subscription
|
|
subID atomic.Int64
|
|
}
|
|
|
|
// NewNATSEventStore creates a new NATSEventStore.
|
|
func NewNATSEventStore(conn *nats.Conn, stream string) (*NATSEventStore, error) {
|
|
js, err := jetstream.New(conn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("jetstream init: %w", err)
|
|
}
|
|
|
|
// Ensure the stream exists.
|
|
_, err = js.Stream(ctxForInit(), stream)
|
|
if err != nil {
|
|
// Create the stream if it doesn't exist.
|
|
_, err = js.CreateStream(ctxForInit(), jetstream.StreamConfig{
|
|
Name: stream,
|
|
Subjects: []string{fmt.Sprintf("%s.>", defaultNATSPrefix)},
|
|
MaxAge: 7 * 24 * time.Hour, // 7 days retention
|
|
Storage: jetstream.FileStorage,
|
|
Retention: jetstream.LimitsPolicy,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create jetstream stream: %w", err)
|
|
}
|
|
}
|
|
|
|
return &NATSEventStore{
|
|
conn: conn,
|
|
js: js,
|
|
stream: stream,
|
|
prefix: defaultNATSPrefix,
|
|
cache: make(map[string]*cachedEvent),
|
|
subs: make(map[int64]*nats.Subscription),
|
|
}, nil
|
|
}
|
|
|
|
// ctxForInit returns a background context for NATS stream setup.
|
|
func ctxForInit() context.Context {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
_ = cancel // prevent vet warning; cancel runs when ctx expires
|
|
return ctx
|
|
}
|
|
|
|
// Append implements EventLog.
|
|
func (s *NATSEventStore) Append(ctx context.Context, events ...*Event) error {
|
|
for _, ev := range events {
|
|
if ev.Clock == 0 {
|
|
ev.Clock = s.clock.Add(1)
|
|
}
|
|
ev.Seal()
|
|
|
|
data, err := json.Marshal(ev)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal event: %w", err)
|
|
}
|
|
|
|
subject := fmt.Sprintf("%s.%s", s.prefix, natsEventSubject)
|
|
if _, err := s.js.Publish(ctx, subject, data); err != nil {
|
|
return fmt.Errorf("publish event: %w", err)
|
|
}
|
|
|
|
// Update local cache with TTL.
|
|
s.mu.Lock()
|
|
maxAge := s.maxCacheAge
|
|
if maxAge == 0 {
|
|
maxAge = defaultMaxCacheAge
|
|
}
|
|
s.cache[string(ev.ID)] = &cachedEvent{ev: ev, expiresAt: time.Now().Add(maxAge)}
|
|
// Evict expired entries when cache exceeds limit.
|
|
if len(s.cache) > defaultMaxCacheItems {
|
|
s.evictExpiredLocked()
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Stream implements EventLog by replaying from JetStream.
|
|
func (s *NATSEventStore) Stream(ctx context.Context, filter EventFilter) EventIterator {
|
|
subject := fmt.Sprintf("%s.%s", s.prefix, natsEventSubject)
|
|
consumer, err := s.js.OrderedConsumer(ctx, s.stream, jetstream.OrderedConsumerConfig{
|
|
FilterSubjects: []string{subject},
|
|
})
|
|
if err != nil {
|
|
return &natsErrorIterator{err: fmt.Errorf("create consumer: %w", err)}
|
|
}
|
|
|
|
return &natsEventIterator{
|
|
consumer: consumer,
|
|
ctx: ctx,
|
|
filter: filter,
|
|
buffer: make([]*Event, 0),
|
|
}
|
|
}
|
|
|
|
// Get implements EventLog.
|
|
func (s *NATSEventStore) Get(ctx context.Context, id EventID) (*Event, error) {
|
|
s.mu.RLock()
|
|
ce, ok := s.cache[string(id)]
|
|
s.mu.RUnlock()
|
|
if ok && ce.expiresAt.After(time.Now()) {
|
|
return ce.ev, nil
|
|
}
|
|
// Expired cache entry — remove it.
|
|
if ok {
|
|
s.mu.Lock()
|
|
delete(s.cache, string(id))
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// Fall back to stream scan (slow path).
|
|
iter := s.Stream(ctx, EventFilter{Limit: 1})
|
|
defer iter.Close()
|
|
for {
|
|
e, ok := iter.Next(ctx)
|
|
if !ok {
|
|
break
|
|
}
|
|
if e.ID == id {
|
|
return e, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// Range implements EventLog.
|
|
func (s *NATSEventStore) Range(ctx context.Context, from, to uint64, filter EventFilter) ([]*Event, error) {
|
|
iter := s.Stream(ctx, filter)
|
|
defer iter.Close()
|
|
|
|
result := make([]*Event, 0)
|
|
for {
|
|
ev, ok := iter.Next(ctx)
|
|
if !ok {
|
|
break
|
|
}
|
|
if ev.Clock < from {
|
|
continue
|
|
}
|
|
if to > 0 && ev.Clock > to {
|
|
continue
|
|
}
|
|
result = append(result, ev)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Seek implements EventLog.
|
|
func (s *NATSEventStore) Seek(ctx context.Context, clock uint64) (EventIterator, error) {
|
|
return s.Stream(ctx, EventFilter{FromClock: clock}), nil
|
|
}
|
|
|
|
// Length implements EventLog by counting messages in the stream.
|
|
func (s *NATSEventStore) Length(ctx context.Context) (uint64, error) {
|
|
streamInfo, err := s.js.Stream(ctx, s.stream)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("get stream info: %w", err)
|
|
}
|
|
return streamInfo.CachedInfo().State.Msgs, nil
|
|
}
|
|
|
|
// CreateSnapshot implements EventStore.
|
|
func (s *NATSEventStore) CreateSnapshot(ctx context.Context, traceID string) (*Snapshot, error) {
|
|
clock := s.clock.Load()
|
|
|
|
// Collect all events for the trace.
|
|
iter := s.Stream(ctx, EventFilter{TraceID: traceID})
|
|
defer iter.Close()
|
|
|
|
var traceEvents []*Event
|
|
for {
|
|
ev, ok := iter.Next(ctx)
|
|
if !ok {
|
|
break
|
|
}
|
|
traceEvents = append(traceEvents, ev)
|
|
}
|
|
|
|
data, err := json.Marshal(traceEvents)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal snapshot: %w", err)
|
|
}
|
|
|
|
snap := &Snapshot{
|
|
ID: fmt.Sprintf("snap-%s-%d", traceID, clock),
|
|
TraceID: traceID,
|
|
Clock: clock,
|
|
CreatedAt: time.Now(),
|
|
Data: data,
|
|
}
|
|
|
|
snapData, _ := json.Marshal(snap)
|
|
subject := fmt.Sprintf("%s.%s.%s", s.prefix, natsSnapshotSubject, traceID)
|
|
s.js.Publish(ctx, subject, snapData)
|
|
|
|
return snap, nil
|
|
}
|
|
|
|
// RestoreSnapshot implements EventStore.
|
|
func (s *NATSEventStore) RestoreSnapshot(ctx context.Context, snapshotID string) (EventIterator, error) {
|
|
return s.Seek(ctx, 0)
|
|
}
|
|
|
|
// Subscribe implements EventStore using NATS JetStream push consumer.
|
|
func (s *NATSEventStore) Subscribe(ctx context.Context, filter EventFilter) (<-chan *Event, error) {
|
|
subject := fmt.Sprintf("%s.%s", s.prefix, natsEventSubject)
|
|
ch := make(chan *Event, 256)
|
|
|
|
consumer, err := s.js.OrderedConsumer(ctx, s.stream, jetstream.OrderedConsumerConfig{
|
|
FilterSubjects: []string{subject},
|
|
DeliverPolicy: jetstream.DeliverNewPolicy,
|
|
})
|
|
if err != nil {
|
|
close(ch)
|
|
return ch, fmt.Errorf("create consumer: %w", err)
|
|
}
|
|
|
|
// Start goroutine to forward messages.
|
|
go func() {
|
|
defer close(ch)
|
|
for {
|
|
msg, err := consumer.Next()
|
|
if err != nil {
|
|
return
|
|
}
|
|
var ev Event
|
|
if err := json.Unmarshal(msg.Data(), &ev); err != nil {
|
|
continue
|
|
}
|
|
if filter.Matches(&ev) {
|
|
select {
|
|
case ch <- &ev:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
return ch, nil
|
|
}
|
|
|
|
// GC implements EventStore (purge stream is handled by JetStream TTL).
|
|
func (s *NATSEventStore) GC(ctx context.Context, retention time.Duration) error {
|
|
// JetStream handles retention via MaxAge in stream config.
|
|
// For explicit GC, update the stream config.
|
|
info, err := s.js.Stream(ctx, s.stream)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg := info.CachedInfo().Config
|
|
cfg.MaxAge = retention
|
|
_, err = s.js.UpdateStream(ctx, cfg)
|
|
return err
|
|
}
|
|
|
|
// evictExpiredLocked removes cache entries whose TTL has expired.
|
|
// Must be called with s.mu held (Lock, not RLock).
|
|
func (s *NATSEventStore) evictExpiredLocked() {
|
|
now := time.Now()
|
|
for k, ce := range s.cache {
|
|
if now.After(ce.expiresAt) {
|
|
delete(s.cache, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- natsEventIterator ----
|
|
|
|
type natsEventIterator struct {
|
|
consumer jetstream.Consumer
|
|
ctx context.Context
|
|
filter EventFilter
|
|
buffer []*Event
|
|
bufPos int
|
|
}
|
|
|
|
func (it *natsEventIterator) Next(_ context.Context) (*Event, bool) {
|
|
// Return from buffer first.
|
|
if it.bufPos < len(it.buffer) {
|
|
ev := it.buffer[it.bufPos]
|
|
it.bufPos++
|
|
return ev, true
|
|
}
|
|
it.buffer = it.buffer[:0]
|
|
it.bufPos = 0
|
|
|
|
// Fetch next batch.
|
|
for i := 0; i < 100; i++ {
|
|
msg, err := it.consumer.Next()
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
var ev Event
|
|
if err := json.Unmarshal(msg.Data(), &ev); err != nil {
|
|
continue
|
|
}
|
|
if it.filter.Matches(&ev) {
|
|
it.buffer = append(it.buffer, &ev)
|
|
}
|
|
}
|
|
if len(it.buffer) == 0 {
|
|
return nil, false
|
|
}
|
|
ev := it.buffer[0]
|
|
it.bufPos = 1
|
|
return ev, true
|
|
}
|
|
|
|
func (it *natsEventIterator) Close() error {
|
|
return nil
|
|
}
|
|
|
|
// ---- natsErrorIterator ----
|
|
|
|
type natsErrorIterator struct {
|
|
err error
|
|
emitted bool
|
|
}
|
|
|
|
func (it *natsErrorIterator) Next(_ context.Context) (*Event, bool) {
|
|
return nil, false
|
|
}
|
|
|
|
func (it *natsErrorIterator) Close() error {
|
|
return nil
|
|
}
|