Skip to content
Open
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
196 changes: 169 additions & 27 deletions agents/Aevatar.GAgents.UserMemory/UserMemoryGAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ namespace Aevatar.GAgents.UserMemory;
/// Actor ID: <c>user-memory-{userId}</c> (user-scoped).
///
/// Eviction policy (runs inside <see cref="TransitionState"/>):
/// 1. When adding an entry that would exceed <see cref="MaxEntries"/>,
/// evict the oldest entry in the same category first.
/// 2. If no same-category entry remains, evict the globally oldest entry.
/// 1. Without a retention policy, preserve the legacy same-category-first behavior.
/// 2. With a policy, enforce the added category's cap before the global cap.
/// 3. At the global cap, higher-ranked categories are evicted first and rank ties
/// retain the legacy same-category-first order.
///
/// </summary>
[GAgent("user.memory")]
Expand All @@ -27,6 +28,8 @@ public sealed class UserMemoryGAgent : GAgentBase<UserMemoryState>, IProjectedAc
public static string ProjectionKind => "user-memory";

internal const int MaxEntries = 50;
private const int DefaultEvictionRank = 100;
private const int MaxEvictionRank = 1000;

[EventHandler(EndpointName = "addMemoryEntry")]
public async Task HandleAddUserMemoryEntry(AddUserMemoryEntryCommand command)
Expand Down Expand Up @@ -70,6 +73,31 @@ public async Task HandleClearUserMemoryEntries(ClearUserMemoryEntriesCommand com
await PersistDomainEventAsync(new MemoryEntriesClearedEvent());
}

[EventHandler(EndpointName = "replaceRetentionPolicy")]
public async Task HandleReplaceRetentionPolicy(ReplaceUserMemoryRetentionPolicyCommand command)
{
ArgumentNullException.ThrowIfNull(command);
if (command.ExpectedStateVersion < 0)
throw new InvalidOperationException("user_memory_expected_state_version_invalid");

var mutationId = NormalizeMutationId(command.MutationId);
var replacedEvent = BuildRetentionPolicyReplacedEvent(State, command, mutationId);
if (string.Equals(State.LastRetentionPolicyMutationId, mutationId, StringComparison.Ordinal))
{
if (PolicyMatchesState(State.RetentionPolicy, replacedEvent.Policy))
return;

throw new InvalidOperationException("user_memory_policy_mutation_conflict");
}

var currentVersion = EventSourcing?.CurrentVersion
?? throw new InvalidOperationException("user_memory_event_sourcing_unavailable");
if (command.ExpectedStateVersion != currentVersion)
throw new InvalidOperationException("user_memory_expected_state_version_conflict");

await PersistDomainEventAsync(replacedEvent);
}

protected override async Task OnActivateAsync(CancellationToken ct)
{
await base.OnActivateAsync(ct);
Expand All @@ -83,47 +111,116 @@ protected override UserMemoryState TransitionState(
.On<MemoryEntryAddedEvent>(ApplyAdded)
.On<MemoryEntryRemovedEvent>(ApplyRemoved)
.On<MemoryEntriesClearedEvent>(ApplyCleared)
.On<UserMemoryRetentionPolicyReplacedEvent>(ApplyRetentionPolicyReplaced)
.OrCurrent();
}

// Implement (issue #3528):
// Behavior: Policy replacement is validated and revisioned inside the owning actor state.
// Why this shape: Replay must derive retention from committed facts without external policy reads.
public static UserMemoryRetentionPolicyReplacedEvent BuildRetentionPolicyReplacedEvent(
UserMemoryState state,
ReplaceUserMemoryRetentionPolicyCommand command,
string? normalizedMutationId = null)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(command);

var policy = new UserMemoryRetentionPolicy
{
PolicyRevision = (state.RetentionPolicy?.PolicyRevision ?? 0) + 1,
};
policy.Rules.AddRange(NormalizeRules(command.Rules));
return new UserMemoryRetentionPolicyReplacedEvent
{
Policy = policy,
MutationId = normalizedMutationId ?? NormalizeMutationId(command.MutationId),
};
}

private static UserMemoryState ApplyAdded(
UserMemoryState state, MemoryEntryAddedEvent evt)
{
var next = state.Clone();
next.Entries.Add(evt.Entry.Clone());

// Eviction: enforce global cap.
// Priority: evict oldest in same category first, then globally oldest.
if (next.RetentionPolicy is not null)
EnforceCategoryCap(next, evt.Entry);

while (next.Entries.Count > MaxEntries)
{
var category = evt.Entry.Category;
var oldestSameCategory = next.Entries
.Where(e => e.Category == category
&& !string.Equals(e.Id, evt.Entry.Id, StringComparison.Ordinal))
.OrderBy(e => e.CreatedAtMs)
.FirstOrDefault();
var evicted = next.RetentionPolicy is null
? SelectLegacyEvictionCandidate(next.Entries, evt.Entry)
: SelectPolicyEvictionCandidate(next, evt.Entry);
if (evicted is null)
break;

if (oldestSameCategory is not null)
{
next.Entries.Remove(oldestSameCategory);
}
else
{
var globallyOldest = next.Entries
.Where(e => !string.Equals(e.Id, evt.Entry.Id, StringComparison.Ordinal))
.OrderBy(e => e.CreatedAtMs)
.FirstOrDefault();

if (globallyOldest is not null)
next.Entries.Remove(globallyOldest);
else
break;
}
next.Entries.Remove(evicted);
}

return next;
}

private static void EnforceCategoryCap(UserMemoryState state, UserMemoryEntryProto addedEntry)
{
var rule = state.RetentionPolicy!.Rules.FirstOrDefault(candidate =>
candidate.Category == addedEntry.Category);
if (rule is null || rule.MaxEntries == 0)
return;

while (state.Entries.Count(entry => entry.Category == addedEntry.Category) > rule.MaxEntries)
{
var oldest = state.Entries
.Where(entry => entry.Category == addedEntry.Category &&
!string.Equals(entry.Id, addedEntry.Id, StringComparison.Ordinal))
.OrderBy(entry => entry.CreatedAtMs)
.FirstOrDefault();
if (oldest is null)
break;

state.Entries.Remove(oldest);
}
}

// Implement (issue #3528):
// Behavior: Global eviction ranks categories, then preserves legacy ordering for equal ranks.
// Why this shape: It protects low-rank categories without changing tie behavior or evicting the new entry.
private static UserMemoryEntryProto? SelectPolicyEvictionCandidate(
UserMemoryState state,
UserMemoryEntryProto addedEntry)
{
var candidates = state.Entries
.Where(entry => !string.Equals(entry.Id, addedEntry.Id, StringComparison.Ordinal))
.ToArray();
if (candidates.Length == 0)
return null;

var highestRank = candidates.Max(entry => ResolveEvictionRank(state.RetentionPolicy!, entry.Category));
var highestRankCandidates = candidates
.Where(entry => ResolveEvictionRank(state.RetentionPolicy!, entry.Category) == highestRank)
.ToArray();
return SelectLegacyEvictionCandidate(highestRankCandidates, addedEntry);
}

private static UserMemoryEntryProto? SelectLegacyEvictionCandidate(
IEnumerable<UserMemoryEntryProto> entries,
UserMemoryEntryProto addedEntry) =>
entries
.Where(entry => entry.Category == addedEntry.Category &&
!string.Equals(entry.Id, addedEntry.Id, StringComparison.Ordinal))
.OrderBy(entry => entry.CreatedAtMs)
.FirstOrDefault()
?? entries
.Where(entry => !string.Equals(entry.Id, addedEntry.Id, StringComparison.Ordinal))
.OrderBy(entry => entry.CreatedAtMs)
.FirstOrDefault();

private static int ResolveEvictionRank(
UserMemoryRetentionPolicy policy,
UserMemoryCategory category) =>
policy.Rules.FirstOrDefault(rule => rule.Category == category)?.EvictionRank
?? DefaultEvictionRank;

private static UserMemoryState ApplyRemoved(
UserMemoryState state, MemoryEntryRemovedEvent evt)
{
Expand All @@ -145,6 +242,51 @@ private static UserMemoryState ApplyCleared(
return next;
}

private static UserMemoryState ApplyRetentionPolicyReplaced(
UserMemoryState state,
UserMemoryRetentionPolicyReplacedEvent evt)
{
var next = state.Clone();
next.RetentionPolicy = evt.Policy.Clone();
next.LastRetentionPolicyMutationId = evt.MutationId;
return next;
}

private static IReadOnlyList<UserMemoryCategoryRetentionRule> NormalizeRules(
IEnumerable<UserMemoryCategoryRetentionRule> rules)
{
var normalized = new List<UserMemoryCategoryRetentionRule>();
var categories = new HashSet<UserMemoryCategory>();
foreach (var rule in rules ?? [])
{
if (!Enum.IsDefined(rule.Category) || rule.Category == UserMemoryCategory.Unspecified)
throw new InvalidOperationException("user_memory_policy_category_invalid");
if (!categories.Add(rule.Category))
throw new InvalidOperationException("user_memory_policy_category_duplicate");
if (rule.MaxEntries is < 0 or > MaxEntries)
throw new InvalidOperationException("user_memory_policy_max_entries_invalid");
if (rule.EvictionRank is < 0 or > MaxEvictionRank)
throw new InvalidOperationException("user_memory_policy_eviction_rank_invalid");

normalized.Add(rule.Clone());
}

return normalized.OrderBy(static rule => rule.Category).ToArray();
}

private static bool PolicyMatchesState(
UserMemoryRetentionPolicy? statePolicy,
UserMemoryRetentionPolicy policy) =>
statePolicy is not null && statePolicy.Rules.SequenceEqual(policy.Rules);

private static string NormalizeMutationId(string? mutationId)
{
var normalized = mutationId?.Trim() ?? string.Empty;
if (normalized.Length == 0)
throw new InvalidOperationException("user_memory_policy_mutation_id_invalid");
return normalized;
}

private static void ValidateEntry(UserMemoryEntryProto? entry)
{
if (entry is null)
Expand Down
24 changes: 24 additions & 0 deletions agents/Aevatar.GAgents.UserMemory/user_memory_messages.proto
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,21 @@ message UserMemoryEntryProto {
int64 updated_at_ms = 6;
}

message UserMemoryCategoryRetentionRule {
UserMemoryCategory category = 1;
int32 max_entries = 2;
int32 eviction_rank = 3;
}

message UserMemoryRetentionPolicy {
repeated UserMemoryCategoryRetentionRule rules = 1;
int64 policy_revision = 2;
}

message UserMemoryState {
repeated UserMemoryEntryProto entries = 1;
UserMemoryRetentionPolicy retention_policy = 2;
string last_retention_policy_mutation_id = 3;
}

// ─── Commands ───
Expand All @@ -43,6 +56,12 @@ message RemoveUserMemoryEntryCommand {
message ClearUserMemoryEntriesCommand {
}

message ReplaceUserMemoryRetentionPolicyCommand {
repeated UserMemoryCategoryRetentionRule rules = 1;
int64 expected_state_version = 2;
string mutation_id = 3;
}

// ─── Events ───

message MemoryEntryAddedEvent {
Expand All @@ -55,3 +74,8 @@ message MemoryEntryRemovedEvent {

message MemoryEntriesClearedEvent {
}

message UserMemoryRetentionPolicyReplacedEvent {
UserMemoryRetentionPolicy policy = 1;
string mutation_id = 2;
}
10 changes: 6 additions & 4 deletions docs/canon/conversation-context-and-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ route helper 和测试不得假设它们相等,也不得从 actor ID 前缀推
| Execution state | 从 typed admission/start 到 terminal/reconciled;只覆盖该 turn、session 或 run | 只保留恢复、幂等和终态查询所需的 actor-owned waterline;例如 `RoleGAgent` 只跟踪有界的已完成 session,NyxID turn actor 只保留有界 delivery evidence | 执行 owner 在 terminal/delivery 已安全后按自己的 typed policy 清理;不得把 checkpoint 复制到 transcript 或 user memory |
| Prompt context | 单次 LLM call 或当前执行 turn | 受 message/token/character budget 限制;调用结束即可丢弃 | 构建它的执行模块;截断只改变下一次调用输入,不删除任何权威事实 |
| Conversation transcript | conversation 初始化后持续存在,并可继续 append | 按 #3141:所有 committed turns 在显式删除整个 conversation 前可查询;无 per-turn TTL、silent rolling eviction 或隐式 archive | `ChatConversationGAgent` 通过 typed whole-conversation deletion fact 清理;projection 只物化该事实 |
| User memory | 用户 scope 存续期间跨 conversation 存在 | 当前 actor 上限为 50 条;新增超限时优先淘汰同 category 最旧项,再淘汰全局最旧项;也可显式 remove/clear | `UserMemoryGAgent` command handler 内决定 eviction/remove/clear 并提交 event;prompt builder 和 query adapter不得清理 |
| User memory | 用户 scope 存续期间跨 conversation 存在 | actor 保持 50 条全局硬上限;未配置 retention policy 时沿用“同 category 最旧、再全局最旧”,配置后先执行新增 category 的 `max_entries` cap,再按 `eviction_rank` 从高到低执行全局 eviction,rank 相同时回退旧顺序;cap 不预留空位 | `UserMemoryGAgent` 通过带 revision、CAS 与 mutation 幂等的 typed policy command/event 持有 retention policy,并在状态转换内决定 eviction/remove/clear;prompt builder 和 query adapter 不得清理 |

如果未来需要 transcript archive、user-memory 向量检索或不同 retention,必须先定义新的
owner、typed lifecycle 和正式 query contract;不得在 prompt 截断或 query adapter 中静默实现。
Expand All @@ -59,9 +59,11 @@ read is redacted, tombstoned, expired, over budget, or temporarily unavailable.
`InitializeChatConversationCommand`、`AppendChatTurnCommand` 及其 domain events。
3. User memory 使用 `UserMemoryState`、typed `UserMemoryCategory`、typed
`UserMemorySource`,以及 `AddUserMemoryEntryCommand`、
`RemoveUserMemoryEntryCommand`、`ClearUserMemoryEntriesCommand`。actor 校验 command,
再提交 `MemoryEntryAddedEvent`、`MemoryEntryRemovedEvent` 或
`MemoryEntriesClearedEvent`;domain event 不再冒充 command。
`RemoveUserMemoryEntryCommand`、`ClearUserMemoryEntriesCommand` 与
`ReplaceUserMemoryRetentionPolicyCommand`。actor 校验 command,再提交
`MemoryEntryAddedEvent`、`MemoryEntryRemovedEvent`、`MemoryEntriesClearedEvent` 或
`UserMemoryRetentionPolicyReplacedEvent`;domain event 不再冒充 command,retention
policy 不从 conversation profile 或 query path 注入。
4. Prompt context 通过 typed LLM request/control 字段传递;`user_memory_prompt` 是已经
派生、受限长控制的 prompt 输入,不是 user-memory persistence contract。只有最终文本
拼装可以是字符串;category、source、identity、control 和 recovery policy 不得降级到
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,31 @@ public sealed record UserMemoryEntrySnapshot(
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);

public sealed record UserMemoryCategoryRetentionRule(
UserMemoryCategory Category,
int MaxEntries,
int EvictionRank);

public sealed record UserMemoryRetentionPolicy(
IReadOnlyList<UserMemoryCategoryRetentionRule> Rules);

public sealed record UserMemorySnapshot(
UserMemoryOwnerKey Owner,
long StateVersion,
IReadOnlyList<UserMemoryEntrySnapshot> Entries)
IReadOnlyList<UserMemoryEntrySnapshot> Entries,
UserMemoryRetentionPolicy? RetentionPolicy = null,
long PolicyRevision = 0)
{
public static UserMemorySnapshot Empty(UserMemoryOwnerKey owner) =>
new(owner, 0, []);
}

public sealed record ReplaceUserMemoryRetentionPolicy(
UserMemoryOwnerKey Owner,
IReadOnlyList<UserMemoryCategoryRetentionRule> Rules,
long ExpectedStateVersion,
string MutationId);

/// <summary>
/// Reads the per-user current-state replica materialized from committed
/// <c>UserMemoryGAgent</c> facts. Implementations must not activate actors,
Expand All @@ -57,3 +73,14 @@ public interface IUserMemoryQueryPort
{
Task<UserMemorySnapshot> GetAsync(CancellationToken ct = default);
}

/// <summary>
/// Replaces actor-owned user-memory retention policy through the standard
/// command dispatch path. Acceptance does not imply commit or read-model visibility.
/// </summary>
public interface IUserMemoryRetentionPolicyCommandPort
{
Task<UserConfigSaveReceipt> ReplaceAsync(
ReplaceUserMemoryRetentionPolicy command,
CancellationToken ct = default);
}
Loading
Loading