Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

AgentExperience.NET captures what an AI agent actually tried, verifies whether it worked, and turns the result into an auditable lesson that future runs can reuse safely. It sits between [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) (MAF) execution and durable storage, without replacing either.

> **Status: early development.** Epic 1 (capture and explain agent experience) is implemented and tested. Epic 2 has started: Experience Records can be stored in PostgreSQL. Retrieval, injection, and governance are planned (see [Roadmap](#roadmap)). Nothing is published to NuGet yet, and APIs may change.
> **Status: early development.** Epic 1 (capture and explain agent experience) is implemented and tested. Epic 2 has started: Experience Records can be stored in PostgreSQL and moved through their lifecycle with atomic, audited commits. Retrieval, injection, and governance are planned (see [Roadmap](#roadmap)). Nothing is published to NuGet yet, and APIs may change.

## Why

Expand All @@ -31,6 +31,7 @@ AgentExperience.NET records observable evidence (tool calls, results, errors, ve
| Auditable, template-based reflections traceable to evidence IDs | `AgentExperience.Core` |
| MAF adapter: captures ordinary, streaming, failed, and cancelled runs plus tool calls, without altering results | `AgentExperience.MicrosoftAgentFramework` |
| PostgreSQL Experience Record store: create, get, and scoped query; host authorization checked before database access; exact scope matching in SQL | `AgentExperience.Storage.Postgres` |
| Atomic audited lifecycle commits: the event and the record's projection in one transaction, idempotent by event ID, revision-checked, with append-only history | `AgentExperience.Core`, `AgentExperience.Storage.Postgres` |
| Journaled schema migrations: embedded scripts applied once, one transaction per script, serialized across processes by an advisory lock | `AgentExperience.Storage.Postgres` |

## Quick look
Expand Down Expand Up @@ -66,12 +67,12 @@ See the [adapter README](src/AgentExperience.MicrosoftAgentFramework/README.md)
```
src/
AgentExperience.Abstractions/ domain contracts and ports (BCL only)
AgentExperience.Core/ sanitization, capture, verification, reflection
AgentExperience.Core/ sanitization, capture, verification, reflection, lifecycle transitions
AgentExperience.MicrosoftAgentFramework/ MAF adapter (pinned Microsoft.Agents.AI 1.20.0)
AgentExperience.Storage.Postgres/ PostgreSQL Experience Record store and schema migrator (pinned Npgsql 10.0.3, dbup-postgresql 7.0.1, dbup-core 6.1.1)
tests/
AgentExperience.Abstractions.Tests/ contract and dependency-boundary tests
AgentExperience.Core.Tests/ sanitizer, capture, verification, reflection tests
AgentExperience.Core.Tests/ sanitizer, capture, verification, reflection, lifecycle tests
AgentExperience.MicrosoftAgentFramework.Tests/ real ChatClientAgent runs against a scripted fake model
AgentExperience.Storage.Postgres.Tests/ store tests, mostly against a PostgreSQL container
AgentExperience.CompatibilityProof/ executable proofs for MAF hooks, context providers, pgvector, redaction
Expand All @@ -89,17 +90,17 @@ dotnet build
dotnet test
```

Unit and MAF adapter tests run in memory, with no network, database, or model credentials. `AgentExperience.CompatibilityProof` and the `PostgresExperienceRecordStoreTests` and `ExperienceSchemaMigratorTests` in `AgentExperience.Storage.Postgres.Tests` start a PostgreSQL/pgvector container through Testcontainers, so they need Docker. If Testcontainers' Ryuk container fails to start under your local Docker setup, set `TESTCONTAINERS_RYUK_DISABLED=true`. To skip the container-backed tests:
Unit and MAF adapter tests run in memory, with no network, database, or model credentials. `AgentExperience.CompatibilityProof` and the `PostgresExperienceRecordStoreTests`, `PostgresLifecycleCommitTests`, and `ExperienceSchemaMigratorTests` in `AgentExperience.Storage.Postgres.Tests` start a PostgreSQL/pgvector container through Testcontainers, so they need Docker. If Testcontainers' Ryuk container fails to start under your local Docker setup, set `TESTCONTAINERS_RYUK_DISABLED=true`. To skip the container-backed tests:

```bash
dotnet test --filter "FullyQualifiedName!~CompatibilityProof&FullyQualifiedName!~PostgresExperienceRecordStoreTests&FullyQualifiedName!~ExperienceSchemaMigratorTests"
dotnet test --filter "FullyQualifiedName!~CompatibilityProof&FullyQualifiedName!~PostgresExperienceRecordStoreTests&FullyQualifiedName!~PostgresLifecycleCommitTests&FullyQualifiedName!~ExperienceSchemaMigratorTests"
```

## Roadmap

1. **Capture and explain agent experience** ✅ contracts, sanitization, capture, verification, reflection, MAF adapter
2. **Reuse relevant experience:** PostgreSQL persistence (Experience Record store in place), hybrid text and vector retrieval, historical-reference injection into MAF
3. **Govern experience safely:** sharing grants, audited lifecycle transitions, evidence-based confidence updates
2. **Reuse relevant experience:** PostgreSQL persistence and atomic audited lifecycle commits (in place), hybrid text and vector retrieval, historical-reference injection into MAF
3. **Govern experience safely:** sharing grants, the remaining lifecycle transitions, evidence-based confidence updates
4. **Operate and measure the learning loop:** OpenTelemetry instrumentation, an end-to-end demo, measured reuse against a baseline, data deletion and expiry

Full requirements and acceptance criteria are in [`_sdlc/planning-artifacts/epics.md`](_sdlc/planning-artifacts/epics.md).
Expand Down
129 changes: 128 additions & 1 deletion src/AgentExperience.Abstractions/ExperienceRecordStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,73 @@ Task<ExperienceRecordQueryResult> QueryAsync(
AuthorizationContext authorization,
ExperienceRecordQuery query,
CancellationToken cancellationToken);

/// <summary>
/// Appends <paramref name="lifecycleEvent"/> and updates the record's projection
/// (<see cref="ExperienceRecord.Status"/>, <see cref="ExperienceRecord.Revision"/>,
/// <see cref="ExperienceRecord.UpdatedAt"/>) in one transaction: both writes commit together or
/// neither does. The store persists the decision exactly as given and never derives a status, a
/// score, or a counter of its own -- deciding which transition is legal belongs to Core.
/// </summary>
/// <remarks>
/// <para>
/// <b>Idempotency.</b> <see cref="LifecycleEvent.EventId"/> is the idempotency key. Replaying an
/// event whose stored fields (including its scope) are identical returns the original outcome --
/// <see cref="ExperienceStoreOutcome.Committed"/> with the revision that commit produced -- and
/// writes nothing. A stored <see cref="LifecycleEvent.EventId"/> with any differing field is
/// <see cref="ExperienceStoreOutcome.Conflict"/>, whichever scope owns it, and writes nothing.
/// </para>
/// <para>
/// <b>Concurrency.</b> <see cref="LifecycleEvent.ExpectedRevision"/> must equal the record's
/// current <see cref="ExperienceRecord.Revision"/>. A successful commit sets the revision to
/// <see cref="LifecycleEvent.ExpectedRevision"/> + 1. Any other value is
/// <see cref="ExperienceStoreOutcome.StaleRevision"/> and writes nothing, so two commits racing
/// from the same revision never both apply.
/// </para>
/// <para>
/// <b>Prior-status guard.</b> When <see cref="LifecycleEvent.PriorStatus"/> is non-null it must
/// also equal the record's stored <see cref="ExperienceRecord.Status"/>, matched in the same
/// statement as the revision. That is what keeps Core's transition table enforced against real
/// state rather than against what the caller asserted, and keeps a stored event from recording a
/// prior status the record never had. A mismatch is
/// <see cref="ExperienceStoreOutcome.StatusMismatch"/>, carries the stored status, and writes
/// nothing. A <see langword="null"/> <see cref="LifecycleEvent.PriorStatus"/> (a record's first
/// event) skips the status match.
/// </para>
/// </remarks>
/// <param name="authorization">What the host has established the caller may do.</param>
/// <param name="scope">The exact request scope the record must lie in. Never treated as authority.</param>
/// <param name="lifecycleEvent">The transition Core decided, already stamped.</param>
/// <param name="cancellationToken">Cancels the operation.</param>
/// <returns>
/// <see cref="ExperienceStoreOutcome.Committed"/>, <see cref="ExperienceStoreOutcome.StaleRevision"/>,
/// <see cref="ExperienceStoreOutcome.StatusMismatch"/>,
/// <see cref="ExperienceStoreOutcome.NotFound"/> (missing, or in another scope),
/// <see cref="ExperienceStoreOutcome.Conflict"/>, <see cref="ExperienceStoreOutcome.Invalid"/>, or
/// <see cref="ExperienceStoreOutcome.Denied"/>.
/// </returns>
Task<ExperienceLifecycleCommitResult> CommitLifecycleEventAsync(
AuthorizationContext authorization,
Scope scope,
LifecycleEvent lifecycleEvent,
CancellationToken cancellationToken);

/// <summary>
/// Reads one record's lifecycle history within exactly <paramref name="scope"/>: its current
/// <see cref="ExperienceRecord.Revision"/> plus every appended event, oldest first. A record that
/// exists in a different scope is indistinguishable from a missing one
/// (<see cref="ExperienceStoreOutcome.NotFound"/>). Events are never deleted or rewritten.
/// </summary>
/// <param name="authorization">What the host has established the caller may do.</param>
/// <param name="scope">The exact request scope to read within.</param>
/// <param name="experienceId">The record whose history to read. Must not be <see cref="Guid.Empty"/>.</param>
/// <param name="cancellationToken">Cancels the operation.</param>
/// <returns><see cref="ExperienceStoreOutcome.Found"/> (possibly with no events), <see cref="ExperienceStoreOutcome.NotFound"/>, <see cref="ExperienceStoreOutcome.Invalid"/>, or <see cref="ExperienceStoreOutcome.Denied"/>.</returns>
Task<ExperienceRecordHistoryResult> GetHistoryAsync(
AuthorizationContext authorization,
Scope scope,
Guid experienceId,
CancellationToken cancellationToken);
}

/// <summary>
Expand Down Expand Up @@ -100,8 +167,33 @@ public enum ExperienceStoreOutcome
/// <summary>The request was malformed. See the result's validation errors. No storage was accessed.</summary>
Invalid,

/// <summary>A record with the same ID already exists in some scope. The stored record is unchanged and not revealed.</summary>
/// <summary>
/// A record with the same ID already exists in some scope, or a lifecycle event with the same
/// <see cref="LifecycleEvent.EventId"/> is already stored with differing fields. Nothing was
/// written and the stored state is unchanged and not revealed.
/// </summary>
Conflict,

/// <summary>
/// A lifecycle event and its projection update were committed together. The record's
/// <see cref="ExperienceRecord.Revision"/> is now <see cref="LifecycleEvent.ExpectedRevision"/> + 1.
/// An identical replay reports this same outcome without writing again.
/// </summary>
Committed,

/// <summary>
/// The lifecycle event's <see cref="LifecycleEvent.ExpectedRevision"/> did not equal the record's
/// current <see cref="ExperienceRecord.Revision"/>, so newer state was not overwritten. Nothing
/// was written.
/// </summary>
StaleRevision,

/// <summary>
/// The lifecycle event's <see cref="LifecycleEvent.PriorStatus"/> did not equal the record's stored
/// <see cref="ExperienceRecord.Status"/>, so the transition was decided against state the record was
/// not in. Nothing was written, and the result carries the stored status to re-decide against.
/// </summary>
StatusMismatch,
}

/// <summary>
Expand Down Expand Up @@ -142,6 +234,41 @@ public sealed record ExperienceRecordQueryResult(
IReadOnlyList<ExperienceRecord> Records,
IReadOnlyList<StoreValidationError> Errors);

/// <summary>
/// The result of <see cref="IExperienceRecordStore.CommitLifecycleEventAsync"/>.
/// </summary>
/// <param name="Outcome">What happened.</param>
/// <param name="Revision">
/// The record's <see cref="ExperienceRecord.Revision"/> after a
/// <see cref="ExperienceStoreOutcome.Committed"/> commit (or after the original commit, when this call
/// was an identical replay); the record's current revision on
/// <see cref="ExperienceStoreOutcome.StaleRevision"/>, so the caller can retry against it; otherwise 0.
/// </param>
/// <param name="CurrentStatus">
/// The record's stored <see cref="ExperienceRecord.Status"/> when <see cref="Outcome"/> is
/// <see cref="ExperienceStoreOutcome.StatusMismatch"/>, so the caller can re-decide the transition
/// against the state the record is actually in; otherwise <see langword="null"/>.
/// </param>
/// <param name="Errors">Every validation error when <see cref="Outcome"/> is <see cref="ExperienceStoreOutcome.Invalid"/>; otherwise empty.</param>
public sealed record ExperienceLifecycleCommitResult(
ExperienceStoreOutcome Outcome,
long Revision,
ExperienceStatus? CurrentStatus,
IReadOnlyList<StoreValidationError> Errors);

/// <summary>
/// The result of <see cref="IExperienceRecordStore.GetHistoryAsync"/>.
/// </summary>
/// <param name="Outcome">What happened.</param>
/// <param name="Revision">The record's current <see cref="ExperienceRecord.Revision"/> when <see cref="Outcome"/> is <see cref="ExperienceStoreOutcome.Found"/>; otherwise 0.</param>
/// <param name="Events">The record's lifecycle events, oldest first, when <see cref="Outcome"/> is <see cref="ExperienceStoreOutcome.Found"/>; otherwise empty.</param>
/// <param name="Errors">Every validation error when <see cref="Outcome"/> is <see cref="ExperienceStoreOutcome.Invalid"/>; otherwise empty.</param>
public sealed record ExperienceRecordHistoryResult(
ExperienceStoreOutcome Outcome,
long Revision,
IReadOnlyList<LifecycleEvent> Events,
IReadOnlyList<StoreValidationError> Errors);

/// <summary>
/// Thrown by an <see cref="IExperienceRecordStore"/> implementation when storage infrastructure
/// fails (database unavailable, driver error, timeout) or a stored record cannot be read (for
Expand Down
11 changes: 6 additions & 5 deletions src/AgentExperience.Abstractions/LifecycleEvent.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
namespace AgentExperience.Abstractions;

/// <summary>
/// The canonical lifecycle states an Experience Record can occupy. Ownership of the state
/// machine and valid transitions belongs to a later story; this package fixes only the shape of
/// the enum and the event that carries transitions between its values.
/// The canonical lifecycle states an Experience Record can occupy. This package fixes only the shape
/// of the enum and the event that carries transitions between its values; ownership of the state
/// machine and which transitions are valid belongs to Core's lifecycle service.
/// </summary>
public enum ExperienceStatus
{
Expand Down Expand Up @@ -35,8 +35,9 @@ public enum ExperienceStatus
/// <summary>
/// An append-only record of a single lifecycle state transition for an Experience Record.
/// Lifecycle changes are events first; current state is a projection derived from them. This
/// package defines only the event's data shape — transition validity rules belong to a later
/// story.
/// package defines only the event's data shape. Which transitions are valid is owned by Core's
/// lifecycle service, and the store enforces that decision against real state by matching
/// <see cref="PriorStatus"/> and <see cref="ExpectedRevision"/> when it applies the event.
/// </summary>
/// <param name="EventId">Unique identifier for this lifecycle event.</param>
/// <param name="ExperienceRecordId">The Experience Record this event applies to.</param>
Expand Down
Loading
Loading