Skip to content
Merged
62 changes: 62 additions & 0 deletions src/Agentic/Graph/Checkpointing/GraphCheckpoint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
namespace AiDotNet.Agentic.Graph.Checkpointing;

/// <summary>
/// An immutable snapshot of a graph run at a point in time: which node runs next and the state as of
/// that point. Saved after each step so a run can be resumed, inspected, or replayed.
/// </summary>
/// <typeparam name="TState">The graph's state type.</typeparam>
/// <remarks>
/// <para>
/// A checkpoint records the <see cref="NextNode"/> (where execution will continue) plus the
/// <see cref="State"/> at that boundary, tagged with a <see cref="ThreadId"/> (the run/conversation) and a
/// monotonically increasing <see cref="Step"/>. The sequence of checkpoints for a thread is its history —
/// the basis for durable resume and time-travel.
/// </para>
/// <para><b>For Beginners:</b> Like a save-game. It remembers exactly where the graph was about to go
/// next and what the data looked like, so you can stop and pick up later (or rewind to an earlier save).
/// </para>
/// </remarks>
public sealed class GraphCheckpoint<TState>
{
/// <summary>
/// Initializes a new checkpoint.
/// </summary>
/// <param name="threadId">The run/thread this checkpoint belongs to.</param>
/// <param name="checkpointId">A unique id for this checkpoint within the thread.</param>
/// <param name="step">The zero-based step index (monotonically increasing within a thread).</param>
/// <param name="nextNode">The node that will execute next (or <see cref="GraphSpecialNodes.End"/> when complete).</param>
/// <param name="state">The state at this boundary.</param>
/// <exception cref="ArgumentNullException">Thrown when a required string is <c>null</c>.</exception>
/// <exception cref="ArgumentException">Thrown when a required string is empty/whitespace.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="step"/> is negative.</exception>
public GraphCheckpoint(string threadId, string checkpointId, int step, string nextNode, TState state)
{
Guard.NotNullOrWhiteSpace(threadId);
Guard.NotNullOrWhiteSpace(checkpointId);
Guard.NotNullOrWhiteSpace(nextNode);
Guard.NonNegative(step);
ThreadId = threadId;
CheckpointId = checkpointId;
Step = step;
NextNode = nextNode;
State = state;
}

/// <summary>Gets the run/thread this checkpoint belongs to.</summary>
public string ThreadId { get; }

/// <summary>Gets the unique id of this checkpoint within the thread.</summary>
public string CheckpointId { get; }

/// <summary>Gets the zero-based, monotonically increasing step index.</summary>
public int Step { get; }

/// <summary>Gets the node that will execute next (or <see cref="GraphSpecialNodes.End"/> when the run is complete).</summary>
public string NextNode { get; }

/// <summary>Gets the state captured at this boundary.</summary>
public TState State { get; }

/// <summary>Gets a value indicating whether this checkpoint represents a completed run.</summary>
public bool IsComplete => NextNode == GraphSpecialNodes.End;
}
53 changes: 53 additions & 0 deletions src/Agentic/Graph/Checkpointing/IGraphCheckpointer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace AiDotNet.Agentic.Graph.Checkpointing;

/// <summary>
/// Persists and retrieves <see cref="GraphCheckpoint{TState}"/>s, enabling durable resume and time-travel
/// for graph runs. Implementations back onto memory, SQLite, Postgres, Redis, etc.
/// </summary>
/// <typeparam name="TState">The graph's state type.</typeparam>
/// <remarks>
/// <para><b>For Beginners:</b> The save-game store. The graph asks it to save a checkpoint after each
/// step, to fetch the latest checkpoint when resuming, and to list the full history when rewinding.
/// </para>
/// </remarks>
public interface IGraphCheckpointer<TState>
{
/// <summary>
/// Saves (appends) a checkpoint.
/// </summary>
/// <param name="checkpoint">The checkpoint to persist.</param>
/// <param name="cancellationToken">Token used to cancel the operation.</param>
Task SaveAsync(GraphCheckpoint<TState> checkpoint, CancellationToken cancellationToken = default);

/// <summary>
/// Gets the most recent checkpoint for a thread, or <c>null</c> if the thread has none.
/// </summary>
/// <param name="threadId">The run/thread id.</param>
/// <param name="cancellationToken">Token used to cancel the operation.</param>
/// <returns>The latest checkpoint, or <c>null</c>.</returns>
Task<GraphCheckpoint<TState>?> GetLatestAsync(string threadId, CancellationToken cancellationToken = default);

/// <summary>
/// Gets a specific checkpoint by id (used for time-travel / replay from a past point).
/// </summary>
/// <param name="threadId">The run/thread id.</param>
/// <param name="checkpointId">The checkpoint id.</param>
/// <param name="cancellationToken">Token used to cancel the operation.</param>
/// <returns>The checkpoint, or <c>null</c> if not found.</returns>
Task<GraphCheckpoint<TState>?> GetAsync(string threadId, string checkpointId, CancellationToken cancellationToken = default);

/// <summary>
/// Gets the full <see cref="GraphCheckpoint{TState}"/> history for <paramref name="threadId"/> in
/// append (chronological) order — i.e. the order checkpoints were saved, by a persistent monotonic
/// sequence (e.g. the SQLite backend orders by <c>Seq ASC</c>), NOT by logical step number.
/// </summary>
/// <remarks>
/// Replay/time-travel relies on this chronological ordering: logical step values can repeat or branch
/// (loops, re-runs from an earlier checkpoint), so ordering by step would diverge across backends.
/// Implementations MUST return checkpoints oldest-first by save order.
/// </remarks>
/// <param name="threadId">The run/thread id.</param>
/// <param name="cancellationToken">Token used to cancel the operation.</param>
/// <returns>The chronologically-ordered history (possibly empty).</returns>
Task<IReadOnlyList<GraphCheckpoint<TState>>> GetHistoryAsync(string threadId, CancellationToken cancellationToken = default);
}
88 changes: 88 additions & 0 deletions src/Agentic/Graph/Checkpointing/InMemoryGraphCheckpointer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
namespace AiDotNet.Agentic.Graph.Checkpointing;

/// <summary>
/// An in-memory <see cref="IGraphCheckpointer{TState}"/> that keeps each thread's checkpoint history in a
/// dictionary. Suitable for tests, single-process runs, and as the default when no durable store is wired.
/// </summary>
/// <typeparam name="TState">The graph's state type.</typeparam>
/// <remarks>
/// <para>
/// Thread-safe via a per-instance lock. Because it stores the state object by reference, callers that use
/// a mutable reference type for <typeparamref name="TState"/> should treat the state as immutable per step
/// (return a new/copied instance from nodes) to get true snapshots; durable checkpointers serialize and so
/// snapshot inherently.
/// </para>
/// <para><b>For Beginners:</b> Keeps your save-games in memory. Great for tests and short-lived runs; for
/// durability across process restarts, use a database-backed checkpointer (coming in later slices).
/// </para>
/// </remarks>
public sealed class InMemoryGraphCheckpointer<TState> : IGraphCheckpointer<TState>
{
private readonly object _gate = new();
private readonly Dictionary<string, List<GraphCheckpoint<TState>>> _byThread = new(StringComparer.Ordinal);

/// <inheritdoc/>
public Task SaveAsync(GraphCheckpoint<TState> checkpoint, CancellationToken cancellationToken = default)
{
Guard.NotNull(checkpoint);
lock (_gate)
{
if (!_byThread.TryGetValue(checkpoint.ThreadId, out var list))
{
list = new List<GraphCheckpoint<TState>>();
_byThread[checkpoint.ThreadId] = list;
}

list.Add(checkpoint);
}

return Task.CompletedTask;
}

/// <inheritdoc/>
public Task<GraphCheckpoint<TState>?> GetLatestAsync(string threadId, CancellationToken cancellationToken = default)
{
Guard.NotNull(threadId);
lock (_gate)
{
if (_byThread.TryGetValue(threadId, out var list) && list.Count > 0)
{
return Task.FromResult<GraphCheckpoint<TState>?>(list[list.Count - 1]);
}
}

return Task.FromResult<GraphCheckpoint<TState>?>(null);
}

/// <inheritdoc/>
public Task<GraphCheckpoint<TState>?> GetAsync(string threadId, string checkpointId, CancellationToken cancellationToken = default)
{
Guard.NotNull(threadId);
Guard.NotNull(checkpointId);
lock (_gate)
{
if (_byThread.TryGetValue(threadId, out var list))
{
// Return the NEWEST checkpoint with this id (history is append-ordered), matching the durable
// backends which order by descending sequence — so re-saving an id resolves to the latest.
var match = list.LastOrDefault(cp => cp.CheckpointId == checkpointId);
return Task.FromResult<GraphCheckpoint<TState>?>(match);
}
}

return Task.FromResult<GraphCheckpoint<TState>?>(null);
}

/// <inheritdoc/>
public Task<IReadOnlyList<GraphCheckpoint<TState>>> GetHistoryAsync(string threadId, CancellationToken cancellationToken = default)
{
Guard.NotNull(threadId);
lock (_gate)
{
IReadOnlyList<GraphCheckpoint<TState>> history = _byThread.TryGetValue(threadId, out var list)
? list.ToList()
: new List<GraphCheckpoint<TState>>();
return Task.FromResult(history);
}
}
}
Loading
Loading