Skip to content

Commit a81355c

Browse files
franklinicclaude
andcommitted
feat(agentic): Phase 4 — trajectory capture (self-improvement foundation) (#1544)
The raw material every self-improvement mechanism learns from: - AgentTrajectory: a captured run (transcript, final answer, iterations, usage, agent name, optional evaluator-assigned Reward, metadata). - ITrajectoryStore (Add/Get/GetAll/Query/Clear) + InMemoryTrajectoryStore. - TracingAgent<T> (IAgent<T>): transparently records each run of any wrapped agent (executor/supervisor/swarm/memory) to the store without changing behavior; supports per-experiment metadata. - 5 tests (green net10.0 + net471): records runs, accumulates + queryable, metadata, get-by-id + reward annotation, clear. net471 nullability fix on GetAsync (Task.FromResult<AgentTrajectory?>). No null-forgiving. Stacked on Phase 3 (#1552). Next: eval harness scoring trajectories with the existing reward models, then learned routing / prompt-opt / online LoRA. Part of epic #1544 (Phase 4). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9dcb7b7 commit a81355c

5 files changed

Lines changed: 378 additions & 0 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
using AiDotNet.Agentic.Models;
2+
3+
// Disambiguate from the legacy AiDotNet.PromptEngineering.Templates.ChatMessage (global using).
4+
using ChatMessage = AiDotNet.Agentic.Models.ChatMessage;
5+
6+
namespace AiDotNet.Agentic.SelfImproving;
7+
8+
/// <summary>
9+
/// A captured record of one agent run — the structured "trajectory" the self-improving layer learns from.
10+
/// It pairs what the agent did (the full message transcript, the final answer, step count, token usage) with
11+
/// an optional quality <see cref="Reward"/> assigned later by an evaluator.
12+
/// </summary>
13+
/// <remarks>
14+
/// <para>
15+
/// Trajectories are the raw material for every self-improvement mechanism: continuous evaluation scores them,
16+
/// learned routers/tool policies train on them, prompt optimizers measure prompts against them, and
17+
/// reward-filtered trajectories become fine-tuning (LoRA) data. Capturing a run is non-invasive — a
18+
/// <see cref="TracingAgent{T}"/> wraps any agent and records each run without changing its behavior.
19+
/// </para>
20+
/// <para><b>For Beginners:</b> Think of this as the flight recorder for an agent: it saves exactly what was
21+
/// said and done on a run, plus (once graded) how good the outcome was. Collect many of these and the system
22+
/// can learn what works and get better over time.
23+
/// </para>
24+
/// </remarks>
25+
public sealed class AgentTrajectory
26+
{
27+
/// <summary>
28+
/// Initializes a new trajectory.
29+
/// </summary>
30+
/// <param name="id">The unique id for this trajectory.</param>
31+
/// <param name="agentName">The name of the agent that produced the run.</param>
32+
/// <param name="messages">The full conversation transcript the run produced.</param>
33+
/// <param name="finalText">The agent's final answer.</param>
34+
/// <param name="iterations">The number of model calls the run took.</param>
35+
/// <param name="usage">Aggregate token usage, when available.</param>
36+
/// <param name="reward">An optional quality score (assigned by an evaluator). <c>null</c> until graded.</param>
37+
/// <param name="metadata">Optional key/value metadata.</param>
38+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="id"/>, <paramref name="agentName"/>, <paramref name="messages"/>, or <paramref name="finalText"/> is <c>null</c>.</exception>
39+
public AgentTrajectory(
40+
string id,
41+
string agentName,
42+
IReadOnlyList<ChatMessage> messages,
43+
string finalText,
44+
int iterations,
45+
ChatUsage? usage = null,
46+
double? reward = null,
47+
IReadOnlyDictionary<string, string>? metadata = null)
48+
{
49+
Guard.NotNullOrWhiteSpace(id);
50+
Guard.NotNull(agentName);
51+
Guard.NotNull(messages);
52+
Guard.NotNull(finalText);
53+
Id = id;
54+
AgentName = agentName;
55+
Messages = messages;
56+
FinalText = finalText;
57+
Iterations = iterations;
58+
Usage = usage;
59+
Reward = reward;
60+
Metadata = metadata;
61+
}
62+
63+
/// <summary>Gets the unique id for this trajectory.</summary>
64+
public string Id { get; }
65+
66+
/// <summary>Gets the name of the agent that produced the run.</summary>
67+
public string AgentName { get; }
68+
69+
/// <summary>Gets the full conversation transcript the run produced.</summary>
70+
public IReadOnlyList<ChatMessage> Messages { get; }
71+
72+
/// <summary>Gets the agent's final answer.</summary>
73+
public string FinalText { get; }
74+
75+
/// <summary>Gets the number of model calls the run took.</summary>
76+
public int Iterations { get; }
77+
78+
/// <summary>Gets aggregate token usage, or <c>null</c> when not reported.</summary>
79+
public ChatUsage? Usage { get; }
80+
81+
/// <summary>
82+
/// Gets or sets the quality score for this trajectory, assigned by an evaluator (higher is better), or
83+
/// <c>null</c> while ungraded.
84+
/// </summary>
85+
public double? Reward { get; set; }
86+
87+
/// <summary>Gets optional key/value metadata, or <c>null</c> when none was supplied.</summary>
88+
public IReadOnlyDictionary<string, string>? Metadata { get; }
89+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
namespace AiDotNet.Agentic.SelfImproving;
2+
3+
/// <summary>
4+
/// Stores captured <see cref="AgentTrajectory"/> records so the self-improving layer can replay, evaluate,
5+
/// and learn from past agent runs.
6+
/// </summary>
7+
/// <remarks>
8+
/// <para><b>For Beginners:</b> This is the logbook of everything the agents have done. Other parts of the
9+
/// system read from it to grade past runs and figure out how to do better next time.
10+
/// </para>
11+
/// </remarks>
12+
public interface ITrajectoryStore
13+
{
14+
/// <summary>
15+
/// Adds a trajectory and returns its id.
16+
/// </summary>
17+
/// <param name="trajectory">The trajectory to store.</param>
18+
/// <param name="cancellationToken">Token used to cancel the operation.</param>
19+
/// <returns>The stored trajectory's id.</returns>
20+
Task<string> AddAsync(AgentTrajectory trajectory, CancellationToken cancellationToken = default);
21+
22+
/// <summary>
23+
/// Gets a trajectory by id, or <c>null</c> when not found.
24+
/// </summary>
25+
/// <param name="id">The trajectory id.</param>
26+
/// <param name="cancellationToken">Token used to cancel the operation.</param>
27+
Task<AgentTrajectory?> GetAsync(string id, CancellationToken cancellationToken = default);
28+
29+
/// <summary>
30+
/// Returns all stored trajectories, oldest first.
31+
/// </summary>
32+
/// <param name="cancellationToken">Token used to cancel the operation.</param>
33+
Task<IReadOnlyList<AgentTrajectory>> GetAllAsync(CancellationToken cancellationToken = default);
34+
35+
/// <summary>
36+
/// Returns the trajectories matching a predicate, oldest first.
37+
/// </summary>
38+
/// <param name="predicate">The filter to apply.</param>
39+
/// <param name="cancellationToken">Token used to cancel the operation.</param>
40+
Task<IReadOnlyList<AgentTrajectory>> QueryAsync(
41+
Func<AgentTrajectory, bool> predicate,
42+
CancellationToken cancellationToken = default);
43+
44+
/// <summary>
45+
/// Removes all stored trajectories.
46+
/// </summary>
47+
/// <param name="cancellationToken">Token used to cancel the operation.</param>
48+
Task ClearAsync(CancellationToken cancellationToken = default);
49+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
namespace AiDotNet.Agentic.SelfImproving;
2+
3+
/// <summary>
4+
/// A process-local <see cref="ITrajectoryStore"/> that keeps captured runs in memory. Ideal for tests and
5+
/// single-process self-improvement loops; contents are lost when the process exits.
6+
/// </summary>
7+
/// <remarks>
8+
/// <para><b>For Beginners:</b> The simplest logbook, kept in RAM — fast and zero-config, but not saved to
9+
/// disk. Swap in a durable store when you need trajectories to persist across restarts.
10+
/// </para>
11+
/// </remarks>
12+
public sealed class InMemoryTrajectoryStore : ITrajectoryStore
13+
{
14+
private readonly object _gate = new();
15+
private readonly List<AgentTrajectory> _trajectories = new();
16+
17+
/// <inheritdoc/>
18+
public Task<string> AddAsync(AgentTrajectory trajectory, CancellationToken cancellationToken = default)
19+
{
20+
Guard.NotNull(trajectory);
21+
lock (_gate)
22+
{
23+
_trajectories.Add(trajectory);
24+
}
25+
26+
return Task.FromResult(trajectory.Id);
27+
}
28+
29+
/// <inheritdoc/>
30+
public Task<AgentTrajectory?> GetAsync(string id, CancellationToken cancellationToken = default)
31+
{
32+
Guard.NotNullOrWhiteSpace(id);
33+
lock (_gate)
34+
{
35+
AgentTrajectory? match = _trajectories.FirstOrDefault(t => string.Equals(t.Id, id, StringComparison.Ordinal));
36+
return Task.FromResult<AgentTrajectory?>(match);
37+
}
38+
}
39+
40+
/// <inheritdoc/>
41+
public Task<IReadOnlyList<AgentTrajectory>> GetAllAsync(CancellationToken cancellationToken = default)
42+
{
43+
lock (_gate)
44+
{
45+
IReadOnlyList<AgentTrajectory> all = new List<AgentTrajectory>(_trajectories);
46+
return Task.FromResult(all);
47+
}
48+
}
49+
50+
/// <inheritdoc/>
51+
public Task<IReadOnlyList<AgentTrajectory>> QueryAsync(
52+
Func<AgentTrajectory, bool> predicate,
53+
CancellationToken cancellationToken = default)
54+
{
55+
Guard.NotNull(predicate);
56+
lock (_gate)
57+
{
58+
IReadOnlyList<AgentTrajectory> matches = _trajectories.Where(predicate).ToList();
59+
return Task.FromResult(matches);
60+
}
61+
}
62+
63+
/// <inheritdoc/>
64+
public Task ClearAsync(CancellationToken cancellationToken = default)
65+
{
66+
lock (_gate)
67+
{
68+
_trajectories.Clear();
69+
}
70+
71+
return Task.CompletedTask;
72+
}
73+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
using AiDotNet.Agentic.Agents;
2+
using AiDotNet.Agentic.Models;
3+
4+
// Disambiguate from the legacy AiDotNet.PromptEngineering.Templates.ChatMessage (global using).
5+
using ChatMessage = AiDotNet.Agentic.Models.ChatMessage;
6+
7+
namespace AiDotNet.Agentic.SelfImproving;
8+
9+
/// <summary>
10+
/// Wraps any <see cref="IAgent{T}"/> and records each run as an <see cref="AgentTrajectory"/> in a
11+
/// <see cref="ITrajectoryStore"/>, without altering the agent's behavior. This is how the self-improving
12+
/// layer collects the experience it later evaluates and learns from.
13+
/// </summary>
14+
/// <typeparam name="T">The numeric type shared across the agent stack.</typeparam>
15+
/// <remarks>
16+
/// <para>
17+
/// Tracing is transparent and composable: the wrapper returns exactly what the inner agent returns and can
18+
/// itself wrap (or be wrapped by) memory, supervisor, or swarm agents. Capture failures never affect the
19+
/// run — the result is returned regardless of whether the store accepts the trajectory.
20+
/// </para>
21+
/// <para><b>For Beginners:</b> Put this around an agent and every time it runs, a copy of what happened is
22+
/// filed away for later study. The agent itself behaves no differently; you just end up with a logbook.
23+
/// </para>
24+
/// </remarks>
25+
public sealed class TracingAgent<T> : IAgent<T>
26+
{
27+
private readonly IAgent<T> _inner;
28+
private readonly ITrajectoryStore _store;
29+
private readonly IReadOnlyDictionary<string, string>? _metadata;
30+
31+
/// <summary>
32+
/// Initializes a new tracing wrapper.
33+
/// </summary>
34+
/// <param name="inner">The agent whose runs are recorded.</param>
35+
/// <param name="store">The store that receives each trajectory.</param>
36+
/// <param name="metadata">Optional metadata attached to every captured trajectory (e.g., experiment id).</param>
37+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="inner"/> or <paramref name="store"/> is <c>null</c>.</exception>
38+
public TracingAgent(IAgent<T> inner, ITrajectoryStore store, IReadOnlyDictionary<string, string>? metadata = null)
39+
{
40+
Guard.NotNull(inner);
41+
Guard.NotNull(store);
42+
_inner = inner;
43+
_store = store;
44+
_metadata = metadata;
45+
}
46+
47+
/// <inheritdoc/>
48+
public string Name => _inner.Name;
49+
50+
/// <inheritdoc/>
51+
public string Description => _inner.Description;
52+
53+
/// <inheritdoc/>
54+
public async Task<AgentRunResult> RunAsync(
55+
IReadOnlyList<ChatMessage> messages,
56+
CancellationToken cancellationToken = default)
57+
{
58+
var result = await _inner.RunAsync(messages, cancellationToken).ConfigureAwait(false);
59+
60+
var trajectory = new AgentTrajectory(
61+
Guid.NewGuid().ToString("N"),
62+
result.AgentName is { } name && name.Trim().Length > 0 ? name : _inner.Name,
63+
result.Messages,
64+
result.FinalText,
65+
result.Iterations,
66+
result.Usage,
67+
reward: null,
68+
_metadata);
69+
70+
await _store.AddAsync(trajectory, cancellationToken).ConfigureAwait(false);
71+
72+
return result;
73+
}
74+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using System.Threading.Tasks;
4+
using AiDotNet.Agentic.Agents;
5+
using AiDotNet.Agentic.Models;
6+
using AiDotNet.Agentic.SelfImproving;
7+
using AiDotNetTests.UnitTests.Agentic.Agents;
8+
using Xunit;
9+
10+
namespace AiDotNetTests.UnitTests.Agentic.SelfImproving
11+
{
12+
public class TrajectoryCaptureTests
13+
{
14+
private static AgentExecutor<double> Agent(string name, string answer)
15+
{
16+
var client = ScriptedChatClient<double>.Sequence(ChatResponses.Text(answer));
17+
return new AgentExecutor<double>(client, tools: null, new AgentExecutorOptions { Name = name });
18+
}
19+
20+
[Fact(Timeout = 60000)]
21+
public async Task TracingAgent_RecordsEachRun()
22+
{
23+
var store = new InMemoryTrajectoryStore();
24+
var traced = new TracingAgent<double>(Agent("writer", "Hello."), store);
25+
26+
var result = await traced.RunAsync(new[] { ChatMessage.User("hi") });
27+
28+
Assert.Equal("Hello.", result.FinalText); // behavior unchanged
29+
30+
var all = await store.GetAllAsync();
31+
Assert.Single(all);
32+
Assert.Equal("writer", all[0].AgentName);
33+
Assert.Equal("Hello.", all[0].FinalText);
34+
Assert.Contains(all[0].Messages, m => m.Role == ChatRole.User && m.Text == "hi");
35+
Assert.Null(all[0].Reward);
36+
}
37+
38+
[Fact(Timeout = 60000)]
39+
public async Task TracingAgent_AccumulatesAcrossRuns_AndIsQueryable()
40+
{
41+
var store = new InMemoryTrajectoryStore();
42+
await new TracingAgent<double>(Agent("a", "one"), store).RunAsync(new[] { ChatMessage.User("x") });
43+
await new TracingAgent<double>(Agent("b", "two"), store).RunAsync(new[] { ChatMessage.User("y") });
44+
45+
Assert.Equal(2, (await store.GetAllAsync()).Count);
46+
47+
var onlyB = await store.QueryAsync(t => t.AgentName == "b");
48+
Assert.Single(onlyB);
49+
Assert.Equal("two", onlyB[0].FinalText);
50+
}
51+
52+
[Fact(Timeout = 60000)]
53+
public async Task TracingAgent_AttachesMetadata()
54+
{
55+
var store = new InMemoryTrajectoryStore();
56+
var metadata = new Dictionary<string, string> { ["experiment"] = "exp-1" };
57+
var traced = new TracingAgent<double>(Agent("a", "ok"), store, metadata);
58+
59+
await traced.RunAsync(new[] { ChatMessage.User("x") });
60+
61+
var all = await store.GetAllAsync();
62+
Assert.NotNull(all[0].Metadata);
63+
Assert.Equal("exp-1", all[0].Metadata["experiment"]);
64+
}
65+
66+
[Fact(Timeout = 60000)]
67+
public async Task Store_GetById_And_RewardAnnotation()
68+
{
69+
var store = new InMemoryTrajectoryStore();
70+
var traced = new TracingAgent<double>(Agent("a", "answer"), store);
71+
await traced.RunAsync(new[] { ChatMessage.User("x") });
72+
73+
var id = (await store.GetAllAsync())[0].Id;
74+
var fetched = await store.GetAsync(id);
75+
Assert.NotNull(fetched);
76+
77+
// An evaluator grades the trajectory; the annotation is visible on subsequent reads.
78+
fetched.Reward = 0.75;
79+
var again = await store.GetAsync(id);
80+
Assert.NotNull(again);
81+
Assert.Equal(0.75, again.Reward);
82+
}
83+
84+
[Fact(Timeout = 60000)]
85+
public async Task Store_Clear_Empties()
86+
{
87+
var store = new InMemoryTrajectoryStore();
88+
await new TracingAgent<double>(Agent("a", "ok"), store).RunAsync(new[] { ChatMessage.User("x") });
89+
await store.ClearAsync();
90+
Assert.Empty(await store.GetAllAsync());
91+
}
92+
}
93+
}

0 commit comments

Comments
 (0)