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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ jobs:
dotnet build samples/AgentMemory.Sample.MinimalAgent/AgentMemory.Sample.MinimalAgent.csproj -c Release --no-restore
dotnet build samples/AgentMemory.Sample.BlendedAgent/AgentMemory.Sample.BlendedAgent.csproj -c Release --no-restore
dotnet build samples/AgentMemory.Sample.McpHost/AgentMemory.Sample.McpHost.csproj -c Release --no-restore
dotnet build tools/AgentMemory.McpHost/AgentMemory.McpHost.csproj -c Release --no-restore
dotnet build samples/AgentMemory.Sample.ShoppingAssistant/AgentMemory.Sample.ShoppingAssistant.csproj -c Release --no-restore
dotnet build samples/AgentMemory.Sample.NamsAgent/AgentMemory.Sample.NamsAgent.csproj -c Release --no-restore
dotnet build samples/AspireDemo/AspireDemo.AppHost/AspireDemo.AppHost.csproj -c Release --no-restore
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ jobs:
dotnet build samples/AgentMemory.Sample.MinimalAgent/AgentMemory.Sample.MinimalAgent.csproj -c Release --no-restore
dotnet build samples/AgentMemory.Sample.BlendedAgent/AgentMemory.Sample.BlendedAgent.csproj -c Release --no-restore
dotnet build samples/AgentMemory.Sample.McpHost/AgentMemory.Sample.McpHost.csproj -c Release --no-restore
dotnet build tools/AgentMemory.McpHost/AgentMemory.McpHost.csproj -c Release --no-restore
dotnet build samples/AgentMemory.Sample.ShoppingAssistant/AgentMemory.Sample.ShoppingAssistant.csproj -c Release --no-restore
dotnet build samples/AspireDemo/AspireDemo.AppHost/AspireDemo.AppHost.csproj -c Release --no-restore
dotnet build samples/AspireDemo/AspireDemo.DemoApp/AspireDemo.DemoApp.csproj -c Release --no-restore
Expand Down
1 change: 1 addition & 0 deletions AgentMemory.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
<Folder Name="/tools/">
<Project Path="tools/AgentMemory.Cli/AgentMemory.Cli.csproj" />
<Project Path="tools/AgentMemory.LongMemEval/AgentMemory.LongMemEval.csproj" />
<Project Path="tools/AgentMemory.McpHost/AgentMemory.McpHost.csproj" />
<Project Path="tools/AgentMemory.TckBridge/AgentMemory.TckBridge.csproj" />
<Project Path="tools/AgentMemory.TckBridge.Nams/AgentMemory.TckBridge.Nams.csproj" />
</Folder>
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Procedural memory: a reasoning trace can be promoted to a reusable procedure.** `TraceKind`
(`Episode` by default) marks a trace as a procedure; `trace_kind_idx` makes it seekable; and
task-similarity search takes an opt-in `proceduresOnly` filter, **null by default** so existing
Cypher is byte-identical.

A trace and a procedure are the same record read two ways: an episode says what happened *once*,
a procedure says what to do *next time* — they differ by retrieval key.

**The load-bearing part is the retention exemption.** `PruneSessionTraces` orders by `started_at`
with age as its *only* criterion and fires on every trace creation once `MaxTracesPerSession` is
set — so without it a promoted procedure is deleted by recency and the capability does not exist.
The exemption is NULL-safe in both directions: a trace written before `trace_kind` existed is still
prunable (or a retention cap silently stops capping) and still visible to an episode filter.

New migration `0011_trace_kind.cypher` brings existing databases to parity.

- **Live recall can now honour a fact's valid-time window** — `RecallOptions.ValidTime`
(`Ignore` by default, so nothing changes unless you ask).

Expand Down
6 changes: 4 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@
still-widely-deployed .NET 8 LTS use the library without adopting a newer runtime; net10.0 keeps
pace with the newest release. Verified with real builds and executed tests on all three TFMs, not
just compiled. Scoped the same way as the packaging metadata below, plus excluding the four
non-packable tools/ console apps (Cli, LongMemEval, TckBridge, TckBridge.Nams), which stay single-targeted. -->
<PropertyGroup Condition="$(MSBuildProjectName.StartsWith('AgentMemory')) and !$(MSBuildProjectName.Contains('.Tests')) and !$(MSBuildProjectName.Contains('.Sample')) and '$(MSBuildProjectName)' != 'AgentMemory.Cli' and '$(MSBuildProjectName)' != 'AgentMemory.LongMemEval' and '$(MSBuildProjectName)' != 'AgentMemory.TckBridge' and '$(MSBuildProjectName)' != 'AgentMemory.TckBridge.Nams'">
non-packable tools/ console apps (Cli, LongMemEval, TckBridge, TckBridge.Nams) and the McpHost global tool, which stay
single-targeted: a DotnetTool package resolves one framework at install time, so multi-targeting
it only inflates the package. -->
<PropertyGroup Condition="$(MSBuildProjectName.StartsWith('AgentMemory')) and !$(MSBuildProjectName.Contains('.Tests')) and !$(MSBuildProjectName.Contains('.Sample')) and '$(MSBuildProjectName)' != 'AgentMemory.Cli' and '$(MSBuildProjectName)' != 'AgentMemory.LongMemEval' and '$(MSBuildProjectName)' != 'AgentMemory.TckBridge' and '$(MSBuildProjectName)' != 'AgentMemory.TckBridge.Nams' and '$(MSBuildProjectName)' != 'AgentMemory.McpHost'">
<TargetFramework></TargetFramework>
<TargetFrameworks>net10.0;net9.0;net8.0</TargetFrameworks>
</PropertyGroup>
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ graph TD
| **Purpose** | Domain contracts — all models, interfaces, and configuration types shared across the system |
| **Dependencies** | **Microsoft.Extensions.AI.Abstractions** 10.8.0 (approved, D-AR2-1) — .NET BCL otherwise (multi-targets net8.0/net9.0/net10.0) |
| **MUST NOT reference** | Neo4j.Driver, Microsoft.Agents.*, any GraphRAG SDK, any MCP SDK, any NuGet package **except** Microsoft.Extensions.AI.Abstractions |
| **Key types** | 52 domain records (Conversation, Message, Entity, Fact, Preference, Relationship, MemoryHistoryQuery, MemoryHistoryRecord, ReasoningTrace, ReasoningStep, ToolCall, ToolCallStats, IngestionItemOutcome, MemoryContextRankedItem, MemoryContextSectionDiagnostics, UnifiedExtractionResult, etc.), 41 service interfaces (incl. `IMemoryIsolationPolicy`, `IUnifiedMemoryExtractor`, and `IMultiSessionUnifiedMemoryExtractor`), 11 repository interfaces, 16 configuration types (incl. `MemoryRankingOptions`, `MemoryIsolationOptions`), 26 enums (incl. `MemoryProfile`, `RankingIntent`, `DuplicateStatus`, `EntityMatchType`, `MemoryNodeKind`, `MemoryOperationAccess`, `MemoryIsolationMode`, `IngestionStatus`, `IngestionStage`, `IngestionItemStatus`, `MemoryItemKind`, `IngestionFailureMode`, `MemoryTrustLevel`, `AssistantContentMode`, `TemporalValidityMode`) |
| **Key types** | 52 domain records (Conversation, Message, Entity, Fact, Preference, Relationship, MemoryHistoryQuery, MemoryHistoryRecord, ReasoningTrace, ReasoningStep, ToolCall, ToolCallStats, IngestionItemOutcome, MemoryContextRankedItem, MemoryContextSectionDiagnostics, UnifiedExtractionResult, etc.), 41 service interfaces (incl. `IMemoryIsolationPolicy`, `IUnifiedMemoryExtractor`, and `IMultiSessionUnifiedMemoryExtractor`), 11 repository interfaces, 16 configuration types (incl. `MemoryRankingOptions`, `MemoryIsolationOptions`), 26 enums (incl. `MemoryProfile`, `RankingIntent`, `DuplicateStatus`, `EntityMatchType`, `MemoryNodeKind`, `MemoryOperationAccess`, `MemoryIsolationMode`, `IngestionStatus`, `IngestionStage`, `IngestionItemStatus`, `MemoryItemKind`, `IngestionFailureMode`, `MemoryTrustLevel`, `AssistantContentMode`, `TemporalValidityMode`, `TraceKind`, `ExtractionProvenanceMode`) |

**Namespace structure:**
```
Expand Down
4 changes: 4 additions & 0 deletions eng/release-packages.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ AgentMemory.Enrichment|src/AgentMemory.Enrichment/AgentMemory.Enrichment.csproj
AgentMemory.Extraction.AzureLanguage|src/AgentMemory.Extraction.AzureLanguage/AgentMemory.Extraction.AzureLanguage.csproj
AgentMemory.Extraction.Llm|src/AgentMemory.Extraction.Llm/AgentMemory.Extraction.Llm.csproj
AgentMemory.McpServer|src/AgentMemory.McpServer/AgentMemory.McpServer.csproj
# The one packable project outside src/: a DotnetTool, not a library. It is not referenced by any
# eng/package-consumers/ project because a tool package cannot be a PackageReference -- it is
# installed, not consumed -- so the consumer-install verification does not and should not cover it.
AgentMemory.McpHost|tools/AgentMemory.McpHost/AgentMemory.McpHost.csproj
AgentMemory.McpServer.Nams|src/AgentMemory.McpServer.Nams/AgentMemory.McpServer.Nams.csproj
AgentMemory.Nams|src/AgentMemory.Nams/AgentMemory.Nams.csproj
AgentMemory.Neo4j|src/AgentMemory.Neo4j/AgentMemory.Neo4j.csproj
Expand Down
43 changes: 43 additions & 0 deletions src/AgentMemory.Abstractions/Domain/Extraction/ExtractedFact.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,47 @@ public sealed record ExtractedFact
/// Optional end of validity period.
/// </summary>
public DateTimeOffset? ValidUntil { get; init; }

/// <summary>
/// The conversational role of the turn this fact was derived from (<c>"user"</c>,
/// <c>"assistant"</c>, …), or <see langword="null"/> when the extractor did not report one.
/// </summary>
/// <remarks>
/// <para>
/// Trust is otherwise stamped <b>once per extraction request</b> and applied to every item in the
/// batch, so a batch containing both a user's statement and a claim the model itself made records
/// them identically. That is tolerable only while assistant content is not extracted at all — which
/// is the shipped default (<c>AssistantContentMode.Ignore</c>) — and stops being tolerable the
/// moment it is switched on, because the enum's central distinction between a user's claim and the
/// model's own would be lost at exactly the point it first carries weight.
/// </para>
/// <para>
/// <b>Null is the meaningful value, and it means "unchanged".</b> Extractors populate this only when
/// assistant content is being extracted, so at defaults it is null everywhere and persistence
/// applies the request's trust level exactly as it always did. It is a self-report by the model
/// rather than a derived fact — a per-item source binding would need per-item provenance, which the
/// batch-level <c>EXTRACTED_FROM</c> edge does not yet carry — so it may only <i>refine</i> a trust
/// stamp, never relax the guarantees around it.
/// </para>
/// </remarks>
public string? SourceRole { get; init; }

/// <summary>
/// The 1-based turn number this fact was stated in, or <see langword="null"/> when the extractor
/// did not report one.
/// </summary>
/// <remarks>
/// <para>
/// Populated only under <see cref="Options.ExtractionProvenanceMode.PerItem"/>, which numbers the
/// turns in the extraction transcript and asks which one stated each item. It resolves to a single
/// source message, replacing the batch-level link in which a fact points at a mean of 12 messages
/// and as many as 30 — a breadth that makes any attribution metric derived from the edge true by
/// construction.
/// </para>
/// <para>
/// Out of range or absent falls back to the batch links. Coarse provenance is recoverable; missing
/// provenance is not, and a hallucinated turn number must not be able to erase the real answer.
/// </para>
/// </remarks>
public int? SourceTurn { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,15 @@ public sealed record ExtractedPreference
/// Confidence score (0.0 to 1.0).
/// </summary>
public double Confidence { get; init; } = 1.0;

/// <inheritdoc cref="ExtractedFact.SourceRole"/>
/// <remarks>
/// Preferences carry this for the same reason facts do, and arguably a stronger one: a preference
/// the <i>assistant</i> attributed to the user ("you seem to prefer …") becomes a durable statement
/// about that user, and is indistinguishable after the fact from one the user actually stated.
/// </remarks>
public string? SourceRole { get; init; }

/// <inheritdoc cref="ExtractedFact.SourceTurn"/>
public int? SourceTurn { get; init; }
}
11 changes: 10 additions & 1 deletion src/AgentMemory.Abstractions/Domain/Reasoning/ReasoningTrace.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace AgentMemory.Abstractions.Domain;
namespace AgentMemory.Abstractions.Domain;

/// <summary>
/// Represents a reasoning trace for a task or agent run.
Expand Down Expand Up @@ -51,6 +51,15 @@ public sealed record ReasoningTrace
/// </summary>
public string? OwnerId { get; init; }

/// <summary>
/// Whether this trace is an ordinary episode or a promoted, reusable procedure.
/// </summary>
/// <remarks>
/// Defaults to <see cref="TraceKind.Episode"/>, which is what every existing trace is, so nothing
/// changes for a store written before this existed.
/// </remarks>
public TraceKind Kind { get; init; } = TraceKind.Episode;

/// <summary>
/// Additional metadata.
/// </summary>
Expand Down
37 changes: 37 additions & 0 deletions src/AgentMemory.Abstractions/Domain/Reasoning/TraceKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
namespace AgentMemory.Abstractions.Domain;

/// <summary>
/// What a stored <see cref="ReasoningTrace"/> is <b>for</b>.
/// </summary>
/// <remarks>
/// <para>
/// A trace and a procedure are the same underlying record read two ways. An <b>episode</b> says what
/// happened once — retrieved by when it happened, read as a claim. A <b>procedure</b> says what to do
/// next time — retrieved by similarity of the <i>task</i>, and replayed as steps. Same incident,
/// different retrieval key, which is what makes them different kinds rather than one kind named twice.
/// </para>
/// <para>
/// <b>Named <c>trace_kind</c> on the node, never <c>kind</c>.</b> <c>kind</c> already means
/// "audit-node discriminator" both here and upstream, and overloading a property whose meaning is
/// shared with another implementation is the changed-semantics hazard that a schema-parity check
/// exists to catch. A new property is ungated by the parity verifier; a new label or edge is not.
/// </para>
/// </remarks>
public enum TraceKind
{
/// <summary>
/// An ordinary recorded episode. The default, and what every existing trace is.
/// </summary>
Episode = 0,

/// <summary>
/// A trace promoted to a reusable procedure: retrievable by task similarity and exempt from
/// recency-based retention pruning.
/// </summary>
/// <remarks>
/// The exemption is not a nicety. <c>PruneSessionTraces</c> orders by <c>started_at</c> with age as
/// its <b>only</b> criterion and fires on every trace creation once a per-session cap is set — so
/// without it, promotion is silently undone by recency and the capability does not exist.
/// </remarks>
Procedure = 1,
}
24 changes: 24 additions & 0 deletions src/AgentMemory.Abstractions/Options/ExtractionOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,30 @@ public sealed class ExtractionOptions
/// <see cref="MemoryTrustLevel.ApplicationTrusted"/>.
/// </summary>
public MemoryTrustLevel DefaultTrustLevel { get; set; } = MemoryTrustLevel.UserProvided;

/// <summary>
/// Whether a newly written fact about a <b>functional</b> relation supersedes the earlier
/// assertion it replaces, instead of accumulating beside it (M1).
/// </summary>
/// <remarks>
/// <para>
/// Off by default. It changes what live recall returns — a superseded fact drops out of it — and
/// every recorded measurement was taken with append-only writes, so a default flip would move
/// results with no setting changed.
/// </para>
/// <para>
/// <b>Only relations the vocabulary declares functional are eligible</b> (<c>lives in</c>,
/// <c>works at</c>, …). A person likes many things and attends many events; superseding a
/// multi-valued predicate would close a true fact. Undeclared predicates, including any the
/// extractor invents, are treated as multi-valued and are never superseded.
/// </para>
/// <para>
/// Non-destructive: losers keep their content, gain <c>invalidated_at</c> and a
/// <c>:SUPERSEDED_BY</c> edge, and stay visible to as-of recall. Requires a store implementing
/// <c>IFactRepository.FindSupersededCandidatesAsync</c>; one that does not simply keeps appending.
/// </para>
/// </remarks>
public bool SupersedeReplacedFacts { get; set; }
}

/// <summary>Controls which matching strategies are used for entity resolution.</summary>
Expand Down
42 changes: 42 additions & 0 deletions src/AgentMemory.Abstractions/Options/ExtractionProvenanceMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace AgentMemory.Abstractions.Options;

/// <summary>
/// How precisely a stored memory is bound to the conversation turn it came from.
/// </summary>
/// <remarks>
/// <para>
/// <c>EXTRACTED_FROM</c> is written per <b>ingestion batch</b>: every item extracted from a call is
/// linked to every message that call saw. Measured on the evaluation corpus, a single fact links to a
/// mean of <b>12</b> source messages and as many as 30. That is broader than the field's other
/// implementations — upstream binds a mention to one message with character offsets, Zep binds to one
/// episode — and being broader <i>and</i> coarser is the worst of both, because <b>any attribution
/// metric derived from that edge is satisfied by construction and can never fail</b>: ask "is the
/// source of this fact among its linked messages?" and the answer is yes for a batch of thirty.
/// </para>
/// <para>
/// This is opt-in and defaults to <see cref="Batch"/> for one reason: <see cref="PerItem"/> numbers the
/// turns in the extraction transcript and asks the model which one stated each item, so it changes the
/// prompt <b>and</b> the rendered conversation. Prompt bytes are fingerprinted into every measured run
/// here, and a default that moved them would silently invalidate every sealed base.
/// </para>
/// </remarks>
public enum ExtractionProvenanceMode
{
/// <summary>
/// Link every extracted item to every message the extraction call saw. The behaviour that shipped,
/// and the one every recorded measurement was taken under.
/// </summary>
Batch = 0,

/// <summary>
/// Ask the model which turn stated each fact and preference, and link only that message.
/// </summary>
/// <remarks>
/// Applies to facts and preferences, not entities — deliberately. A fact asserts one claim made in
/// one statement, so binding it to thirty messages is a loss of information. An <c>Entity</c> node
/// is a <i>merged identity</i> that legitimately appears across many turns, so narrowing it to a
/// single turn would be wrong rather than precise. An unreported or out-of-range turn falls back to
/// the batch links: coarse provenance is recoverable, missing provenance is not.
/// </remarks>
PerItem = 1,
}
27 changes: 27 additions & 0 deletions src/AgentMemory.Abstractions/Repositories/IFactRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,33 @@ public interface IFactRepository
/// </summary>
Task<bool> SupersedeAsync(string loserFactId, string winnerFactId, MemoryScope? scope = null, CancellationToken cancellationToken = default);

/// <summary>
/// The <b>live</b> facts asserting a different object for the same subject and predicate as
/// <paramref name="winnerFactId"/> — the ones a newly written fact about a functional relation
/// replaces (M1 write-time supersession). Never returns the winner itself.
/// </summary>
/// <remarks>
/// <para>
/// Matching is on the canonical <c>subject_key</c>/<c>predicate_key</c>/<c>object_key</c>, the same
/// keys the write path MERGEs on, so a restatement in different words is recognised as the same
/// assertion rather than accumulating beside it.
/// </para>
/// <para>
/// <b>Default: none.</b> A store that has not implemented this simply does not perform write-time
/// supersession — the append behaviour it already had. Returning nothing is the only safe default:
/// throwing would break every third-party repository on a feature they never opted into, and there
/// is no store-agnostic way to answer the question correctly.
/// </para>
/// </remarks>
Task<IReadOnlyList<Fact>> FindSupersededCandidatesAsync(
string winnerFactId,
string subject,
string predicate,
string @object,
MemoryScope? scope = null,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<Fact>>([]);

/// <summary>
/// Finds an existing fact matching the subject-predicate-object triple. When <paramref name="scope"/>
/// is supplied (R1) the lookup is confined to the owner's own and (optionally) shared facts. Null
Expand Down
Loading