diff --git a/Agentstration.slnx b/Agentstration.slnx
index 277867e5..680e90d9 100644
--- a/Agentstration.slnx
+++ b/Agentstration.slnx
@@ -21,9 +21,15 @@
+
+
+
+
+
+
@@ -52,6 +58,7 @@
+
diff --git a/README.md b/README.md
index fe8c6ae3..7d577803 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,8 @@ The product is a modular monolith organized around a Management Plane, Runtime P
The autonomous Agentstration Extension Protocol SDK, conformance validator, CLI, samples, and standalone Inspector are staged in [`aep/`](aep/README.md). That directory has its own solution and build configuration so it can be moved into a dedicated repository without carrying Agentstration application projects.
+Governed Memory is explicit and Workspace-isolated. Direct startup uses the builtin SQLite store; the Aspire AppHost additionally starts the autonomous `Agentstration.Extensions.Memory.Sqlite` AEP reference provider. Storage-provider selection does not move retrieval policy or context assembly outside Agentstration. See [Memory and execution context](docs/memory-context.md).
+
## Quick start
Requirements: the .NET SDK version selected by [`global.json`](global.json) (currently .NET 10.0.300 or a compatible feature band).
diff --git a/aep/src/Agentstration.Aep.Abstractions/AepProtocol.cs b/aep/src/Agentstration.Aep.Abstractions/AepProtocol.cs
index 939533d7..3647eb59 100644
--- a/aep/src/Agentstration.Aep.Abstractions/AepProtocol.cs
+++ b/aep/src/Agentstration.Aep.Abstractions/AepProtocol.cs
@@ -10,6 +10,7 @@ public static class AepProtocol
public const string LegacyDiscoveryPath = "/.well-known/agentstration";
public const string HealthPath = "/aep/health";
public const string ModelProvidersPath = "/aep/model-providers";
+ public const string MemoryProvidersPath = "/aep/memory-providers";
public static JsonSerializerOptions JsonOptions { get; } = CreateJsonOptions();
@@ -25,6 +26,7 @@ public static class AepCapabilityNames
{
public const string Health = "aep.health";
public const string ModelProvider = "aep.model-provider";
+ public const string MemoryProvider = "aep.memory-provider";
public const string Tools = "aep.tools";
public const string Configuration = "aep.configuration";
}
@@ -47,7 +49,8 @@ public sealed record AepHealth(string Status, string? Details = null);
public sealed record AepContributions(
IReadOnlyList ModelProviders,
- IReadOnlyList? Tools = null);
+ IReadOnlyList? Tools = null,
+ IReadOnlyList? MemoryProviders = null);
public sealed record AepMcpDescriptor(IReadOnlyList Servers);
@@ -85,6 +88,13 @@ public static IReadOnlyList Validate(AepManifest descriptor)
if (string.IsNullOrWhiteSpace(tool.Mcp.Server) || !servers.Contains(tool.Mcp.Server))
errors.Add($"Tool contribution '{tool.Id}' references unknown MCP server '{tool.Mcp.Server}'.");
}
+ var memoryProviders = new HashSet(StringComparer.Ordinal);
+ foreach (var provider in descriptor.Contributions.MemoryProviders ?? [])
+ {
+ if (string.IsNullOrWhiteSpace(provider.Id)) errors.Add("Memory provider id is required.");
+ else if (!memoryProviders.Add(provider.Id)) errors.Add($"Memory provider '{provider.Id}' is duplicated.");
+ if (string.IsNullOrWhiteSpace(provider.DisplayName)) errors.Add($"Memory provider '{provider.Id}' displayName is required.");
+ }
return errors;
}
@@ -134,6 +144,38 @@ public sealed record AepModelDescriptor(
public sealed record AepProviderHealth(string Status, string? Details = null);
+public sealed record AepMemoryProviderDescriptor(
+ string Id,
+ string DisplayName,
+ AepMemoryProviderCapabilities Capabilities,
+ IReadOnlyDictionary? Metadata = null);
+
+public sealed record AepMemoryProviderCapabilities(
+ bool ExactScope = true,
+ bool Expiry = true,
+ bool Delete = true,
+ bool ClearScope = true,
+ bool PurgeExpired = true);
+
+public sealed record AepMemoryScope(string Kind, string Key);
+public sealed record AepMemoryProvenance(string SourceKind, string? SourceId, string Reason, Guid CreatedByPrincipalId);
+public sealed record AepMemoryRecord(
+ Guid Id,
+ Guid WorkspaceId,
+ AepMemoryScope Scope,
+ string Content,
+ IReadOnlyList Tags,
+ AepMemoryProvenance Provenance,
+ DateTimeOffset CreatedAt,
+ DateTimeOffset? ExpiresAt = null);
+public sealed record AepMemoryRecordRequest(Guid WorkspaceId, Guid RecordId);
+public sealed record AepMemoryGetResponse(AepMemoryRecord? Value);
+public sealed record AepMemoryListRequest(Guid WorkspaceId, AepMemoryScope? Scope, DateTimeOffset Now, int Skip, int Take);
+public sealed record AepMemoryListResponse(IReadOnlyList Value);
+public sealed record AepMemoryScopeRequest(Guid WorkspaceId, AepMemoryScope Scope);
+public sealed record AepMemoryPurgeRequest(Guid WorkspaceId, DateTimeOffset Now, int Take);
+public sealed record AepMemoryMutationResponse(int Affected);
+
public enum AepRole { System, User, Assistant, Tool }
public enum AepContentKind { Text, Image, File, Structured, ToolCall, ToolResult }
public enum AepFinishReason { Stop, Length, ToolCalls, ContentFilter, Error, Other }
diff --git a/aep/src/Agentstration.Aep.AspNetCore/AepServer.cs b/aep/src/Agentstration.Aep.AspNetCore/AepServer.cs
index 5d5beedd..10bf3cfc 100644
--- a/aep/src/Agentstration.Aep.AspNetCore/AepServer.cs
+++ b/aep/src/Agentstration.Aep.AspNetCore/AepServer.cs
@@ -21,6 +21,19 @@ Task GetHealthAsync(CancellationToken cancellationToken = def
Task.FromResult(new AepProviderHealth("available"));
}
+public interface IAepMemoryProvider
+{
+ AepMemoryProviderDescriptor Descriptor { get; }
+ Task GetHealthAsync(CancellationToken cancellationToken = default) =>
+ Task.FromResult(new AepProviderHealth("available"));
+ Task WriteAsync(AepMemoryRecord record, CancellationToken cancellationToken);
+ Task GetAsync(AepMemoryRecordRequest request, CancellationToken cancellationToken);
+ Task> ListAsync(AepMemoryListRequest request, CancellationToken cancellationToken);
+ Task DeleteAsync(AepMemoryRecordRequest request, CancellationToken cancellationToken);
+ Task ClearScopeAsync(AepMemoryScopeRequest request, CancellationToken cancellationToken);
+ Task PurgeExpiredAsync(AepMemoryPurgeRequest request, CancellationToken cancellationToken);
+}
+
public sealed class AepExtensionOptions
{
public AepExtensionIdentity Extension { get; set; } = new("agentstration.extension", "Agentstration extension", "1.0.0");
@@ -46,12 +59,15 @@ public static IServiceCollection AddAgentstrationAep(this IServiceCollection ser
public static IServiceCollection AddModelProvider(this IServiceCollection services)
where TProvider : class, IAepModelProvider => services.AddSingleton();
+ public static IServiceCollection AddMemoryProvider(this IServiceCollection services)
+ where TProvider : class, IAepMemoryProvider => services.AddSingleton();
+
public static IEndpointRouteBuilder MapAgentstrationAep(this IEndpointRouteBuilder endpoints)
{
- endpoints.MapGet(AepProtocol.DiscoveryPath, (IOptions options, IEnumerable providers) =>
- Results.Json(CreateManifest(options.Value, providers), AepProtocol.JsonOptions));
- endpoints.MapGet(AepProtocol.LegacyDiscoveryPath, (IOptions options, IEnumerable providers) =>
- Results.Json(CreateManifest(options.Value, providers), AepProtocol.JsonOptions));
+ endpoints.MapGet(AepProtocol.DiscoveryPath, (IOptions options, IEnumerable providers, IEnumerable memories) =>
+ Results.Json(CreateManifest(options.Value, providers, memories), AepProtocol.JsonOptions));
+ endpoints.MapGet(AepProtocol.LegacyDiscoveryPath, (IOptions options, IEnumerable providers, IEnumerable memories) =>
+ Results.Json(CreateManifest(options.Value, providers, memories), AepProtocol.JsonOptions));
endpoints.MapGet(AepProtocol.HealthPath, () => Results.Json(new AepHealth("available"), AepProtocol.JsonOptions));
endpoints.MapGet(AepProtocol.ModelProvidersPath, (IEnumerable providers) =>
Results.Json(providers.Select(value => value.Descriptor).ToArray(), AepProtocol.JsonOptions));
@@ -59,26 +75,37 @@ public static IEndpointRouteBuilder MapAgentstrationAep(this IEndpointRouteBuild
endpoints.MapPost($"{AepProtocol.ModelProvidersPath}/{{providerId}}/chat/stream", StreamAsync);
endpoints.MapGet($"{AepProtocol.ModelProvidersPath}/{{providerId}}/models", ListModelsAsync);
endpoints.MapGet($"{AepProtocol.ModelProvidersPath}/{{providerId}}/health", ProviderHealthAsync);
+ endpoints.MapGet(AepProtocol.MemoryProvidersPath, (IEnumerable providers) =>
+ Results.Json(providers.Select(value => value.Descriptor).ToArray(), AepProtocol.JsonOptions));
+ endpoints.MapGet($"{AepProtocol.MemoryProvidersPath}/{{providerId}}/health", MemoryHealthAsync);
+ endpoints.MapPost($"{AepProtocol.MemoryProvidersPath}/{{providerId}}/records", WriteMemoryAsync);
+ endpoints.MapPost($"{AepProtocol.MemoryProvidersPath}/{{providerId}}/records/get", GetMemoryAsync);
+ endpoints.MapPost($"{AepProtocol.MemoryProvidersPath}/{{providerId}}/records/query", ListMemoryAsync);
+ endpoints.MapPost($"{AepProtocol.MemoryProvidersPath}/{{providerId}}/records/delete", DeleteMemoryAsync);
+ endpoints.MapPost($"{AepProtocol.MemoryProvidersPath}/{{providerId}}/records/clear", ClearMemoryAsync);
+ endpoints.MapPost($"{AepProtocol.MemoryProvidersPath}/{{providerId}}/records/purge", PurgeMemoryAsync);
endpoints.MapHealthChecks("/health");
return endpoints;
}
public static IEndpointRouteBuilder MapAep(this IEndpointRouteBuilder endpoints) => endpoints.MapAgentstrationAep();
- private static AepManifest CreateManifest(AepExtensionOptions options, IEnumerable providers)
+ private static AepManifest CreateManifest(AepExtensionOptions options, IEnumerable providers, IEnumerable memories)
{
var modelProviders = providers.Select(value => value.Descriptor).ToArray();
+ var memoryProviders = memories.Select(value => value.Descriptor).ToArray();
var capabilities = new Dictionary(options.Capabilities, StringComparer.Ordinal)
{
[AepCapabilityNames.Health] = new("1.0", AepProtocol.HealthPath)
};
if (modelProviders.Length > 0) capabilities[AepCapabilityNames.ModelProvider] = new("1.0", AepProtocol.ModelProvidersPath);
+ if (memoryProviders.Length > 0) capabilities[AepCapabilityNames.MemoryProvider] = new("1.0", AepProtocol.MemoryProvidersPath);
if (options.Tools.Count > 0) capabilities[AepCapabilityNames.Tools] = new("1.0");
var descriptor = new AepManifest(
AepProtocol.Version,
options.Extension,
capabilities,
- new AepContributions(modelProviders, options.Tools.ToArray()),
+ new AepContributions(modelProviders, options.Tools.ToArray(), memoryProviders),
options.McpServers.Count == 0 ? null : new AepMcpDescriptor(options.McpServers.ToArray()));
var errors = AepDescriptorValidator.Validate(descriptor);
if (errors.Count > 0) throw new InvalidOperationException($"The AEP extension descriptor is invalid: {string.Join(" ", errors)}");
@@ -137,6 +164,59 @@ private static async Task StreamAsync(string providerId, AepChatRequest request,
private static IAepModelProvider? Find(IEnumerable providers, string id) =>
providers.FirstOrDefault(value => string.Equals(value.Descriptor.Id, id, StringComparison.OrdinalIgnoreCase));
+ private static IAepMemoryProvider? FindMemory(IEnumerable providers, string id) =>
+ providers.FirstOrDefault(value => string.Equals(value.Descriptor.Id, id, StringComparison.OrdinalIgnoreCase));
+
+ private static async Task MemoryHealthAsync(string providerId, IEnumerable providers, CancellationToken token)
+ {
+ var provider = FindMemory(providers, providerId);
+ return provider is null ? Error(404, "provider_unavailable", $"Memory provider '{providerId}' is not registered.")
+ : Results.Json(await provider.GetHealthAsync(token), AepProtocol.JsonOptions);
+ }
+
+ private static async Task WriteMemoryAsync(string providerId, AepMemoryRecord record, IEnumerable providers, CancellationToken token)
+ {
+ var provider = FindMemory(providers, providerId);
+ if (provider is null) return Error(404, "provider_unavailable", $"Memory provider '{providerId}' is not registered.");
+ await provider.WriteAsync(record, token);
+ return Results.NoContent();
+ }
+
+ private static async Task GetMemoryAsync(string providerId, AepMemoryRecordRequest request, IEnumerable providers, CancellationToken token)
+ {
+ var provider = FindMemory(providers, providerId);
+ if (provider is null) return Error(404, "provider_unavailable", $"Memory provider '{providerId}' is not registered.");
+ return Results.Json(new AepMemoryGetResponse(await provider.GetAsync(request, token)), AepProtocol.JsonOptions);
+ }
+
+ private static async Task ListMemoryAsync(string providerId, AepMemoryListRequest request, IEnumerable providers, CancellationToken token)
+ {
+ var provider = FindMemory(providers, providerId);
+ if (provider is null) return Error(404, "provider_unavailable", $"Memory provider '{providerId}' is not registered.");
+ return Results.Json(new AepMemoryListResponse(await provider.ListAsync(request, token)), AepProtocol.JsonOptions);
+ }
+
+ private static async Task DeleteMemoryAsync(string providerId, AepMemoryRecordRequest request, IEnumerable providers, CancellationToken token)
+ {
+ var provider = FindMemory(providers, providerId);
+ if (provider is null) return Error(404, "provider_unavailable", $"Memory provider '{providerId}' is not registered.");
+ return Results.Json(new AepMemoryMutationResponse(await provider.DeleteAsync(request, token) ? 1 : 0), AepProtocol.JsonOptions);
+ }
+
+ private static async Task ClearMemoryAsync(string providerId, AepMemoryScopeRequest request, IEnumerable providers, CancellationToken token)
+ {
+ var provider = FindMemory(providers, providerId);
+ if (provider is null) return Error(404, "provider_unavailable", $"Memory provider '{providerId}' is not registered.");
+ return Results.Json(new AepMemoryMutationResponse(await provider.ClearScopeAsync(request, token)), AepProtocol.JsonOptions);
+ }
+
+ private static async Task PurgeMemoryAsync(string providerId, AepMemoryPurgeRequest request, IEnumerable providers, CancellationToken token)
+ {
+ var provider = FindMemory(providers, providerId);
+ if (provider is null) return Error(404, "provider_unavailable", $"Memory provider '{providerId}' is not registered.");
+ return Results.Json(new AepMemoryMutationResponse(await provider.PurgeExpiredAsync(request, token)), AepProtocol.JsonOptions);
+ }
+
private static IResult Error(int status, string code, string message) =>
Results.Json(new AepErrorResponse(new AepError(code, message)), AepProtocol.JsonOptions, statusCode: status);
}
diff --git a/aep/src/Agentstration.Aep.Client/AepClient.cs b/aep/src/Agentstration.Aep.Client/AepClient.cs
index d48384b7..e269d5e5 100644
--- a/aep/src/Agentstration.Aep.Client/AepClient.cs
+++ b/aep/src/Agentstration.Aep.Client/AepClient.cs
@@ -19,7 +19,13 @@ public interface IAepModelProvidersClient
AepModelProviderClient CreateModelProvider(string providerId);
}
-public sealed class AepClient(HttpClient httpClient) : IAepClient, IAepModelProvidersClient
+public interface IAepMemoryProvidersClient
+{
+ Task> ListMemoryProvidersAsync(CancellationToken cancellationToken = default);
+ AepMemoryProviderClient CreateMemoryProvider(string providerId);
+}
+
+public sealed class AepClient(HttpClient httpClient) : IAepClient, IAepModelProvidersClient, IAepMemoryProvidersClient
{
public Task GetManifestAsync(CancellationToken cancellationToken = default) => DiscoverAsync(cancellationToken);
@@ -50,6 +56,50 @@ public async Task> ListModelProvidersA
public AepModelProviderClient CreateModelProvider(string providerId) => new(this, providerId);
+ public async Task> ListMemoryProvidersAsync(CancellationToken cancellationToken = default)
+ {
+ _ = await DiscoverAsync(cancellationToken);
+ using var response = await SendAsync(HttpMethod.Get, AepProtocol.MemoryProvidersPath, null, cancellationToken);
+ return await ReadAsync(response, cancellationToken);
+ }
+
+ public AepMemoryProviderClient CreateMemoryProvider(string providerId) => new(this, providerId);
+
+ internal async Task GetMemoryHealthAsync(string providerId, CancellationToken cancellationToken) =>
+ await SendMemoryAsync(providerId, HttpMethod.Get, "health", null, cancellationToken);
+
+ internal async Task WriteMemoryAsync(string providerId, AepMemoryRecord record, CancellationToken cancellationToken)
+ {
+ using var response = await SendMemoryResponseAsync(providerId, HttpMethod.Post, "records", record, cancellationToken);
+ }
+
+ internal async Task GetMemoryAsync(string providerId, AepMemoryRecordRequest request, CancellationToken cancellationToken) =>
+ (await SendMemoryAsync(providerId, HttpMethod.Post, "records/get", request, cancellationToken)).Value;
+
+ internal async Task> ListMemoryAsync(string providerId, AepMemoryListRequest request, CancellationToken cancellationToken) =>
+ (await SendMemoryAsync(providerId, HttpMethod.Post, "records/query", request, cancellationToken)).Value;
+
+ internal async Task DeleteMemoryAsync(string providerId, AepMemoryRecordRequest request, CancellationToken cancellationToken) =>
+ (await SendMemoryAsync(providerId, HttpMethod.Post, "records/delete", request, cancellationToken)).Affected;
+
+ internal async Task ClearMemoryScopeAsync(string providerId, AepMemoryScopeRequest request, CancellationToken cancellationToken) =>
+ (await SendMemoryAsync(providerId, HttpMethod.Post, "records/clear", request, cancellationToken)).Affected;
+
+ internal async Task PurgeMemoryAsync(string providerId, AepMemoryPurgeRequest request, CancellationToken cancellationToken) =>
+ (await SendMemoryAsync(providerId, HttpMethod.Post, "records/purge", request, cancellationToken)).Affected;
+
+ private async Task SendMemoryAsync(string providerId, HttpMethod method, string operation, object? body, CancellationToken cancellationToken)
+ {
+ using var response = await SendMemoryResponseAsync(providerId, method, operation, body, cancellationToken);
+ return await ReadAsync(response, cancellationToken);
+ }
+
+ private async Task SendMemoryResponseAsync(string providerId, HttpMethod method, string operation, object? body, CancellationToken cancellationToken)
+ {
+ _ = await DiscoverAsync(cancellationToken);
+ return await SendAsync(method, $"{AepProtocol.MemoryProvidersPath}/{Uri.EscapeDataString(providerId)}/{operation}", body, cancellationToken);
+ }
+
internal async Task ChatAsync(string providerId, AepChatRequest request, CancellationToken cancellationToken)
{
_ = await DiscoverAsync(cancellationToken);
@@ -146,6 +196,17 @@ public IAsyncEnumerable ChatStreamingAsync(AepChatRequest request
client.StreamAsync(providerId, request, cancellationToken);
}
+public sealed class AepMemoryProviderClient(AepClient client, string providerId)
+{
+ public Task GetHealthAsync(CancellationToken cancellationToken = default) => client.GetMemoryHealthAsync(providerId, cancellationToken);
+ public Task WriteAsync(AepMemoryRecord record, CancellationToken cancellationToken = default) => client.WriteMemoryAsync(providerId, record, cancellationToken);
+ public Task GetAsync(AepMemoryRecordRequest request, CancellationToken cancellationToken = default) => client.GetMemoryAsync(providerId, request, cancellationToken);
+ public Task> ListAsync(AepMemoryListRequest request, CancellationToken cancellationToken = default) => client.ListMemoryAsync(providerId, request, cancellationToken);
+ public async Task DeleteAsync(AepMemoryRecordRequest request, CancellationToken cancellationToken = default) => await client.DeleteMemoryAsync(providerId, request, cancellationToken) == 1;
+ public Task ClearScopeAsync(AepMemoryScopeRequest request, CancellationToken cancellationToken = default) => client.ClearMemoryScopeAsync(providerId, request, cancellationToken);
+ public Task PurgeExpiredAsync(AepMemoryPurgeRequest request, CancellationToken cancellationToken = default) => client.PurgeMemoryAsync(providerId, request, cancellationToken);
+}
+
public sealed class AepProtocolException(string code, string message, HttpStatusCode? statusCode = null, Exception? innerException = null)
: Exception(message, innerException)
{
diff --git a/aep/src/Agentstration.Aep.Validation/AepValidator.cs b/aep/src/Agentstration.Aep.Validation/AepValidator.cs
index acc77c18..22851aad 100644
--- a/aep/src/Agentstration.Aep.Validation/AepValidator.cs
+++ b/aep/src/Agentstration.Aep.Validation/AepValidator.cs
@@ -38,6 +38,13 @@ public async Task ValidateAsync(IAepClient client, Cancella
if (!capability.Key.StartsWith("aep.", StringComparison.Ordinal)) issues.Add(new("AEP020", $"Capability '{capability.Key}' is outside the AEP namespace.", AepValidationSeverity.Warning, $"capabilities.{capability.Key}"));
if (string.IsNullOrWhiteSpace(capability.Value.Version)) issues.Add(new("AEP021", $"Capability '{capability.Key}' has no version.", AepValidationSeverity.Error, $"capabilities.{capability.Key}.version"));
}
+ var memoryProviderIds = new HashSet(StringComparer.Ordinal);
+ foreach (var provider in manifest.Contributions.MemoryProviders ?? [])
+ {
+ if (string.IsNullOrWhiteSpace(provider.Id)) issues.Add(new("AEP030", "Memory provider id is required.", AepValidationSeverity.Error, "contributions.memoryProviders"));
+ else if (!memoryProviderIds.Add(provider.Id)) issues.Add(new("AEP031", $"Memory provider '{provider.Id}' is duplicated.", AepValidationSeverity.Error, "contributions.memoryProviders"));
+ if (string.IsNullOrWhiteSpace(provider.DisplayName)) issues.Add(new("AEP032", $"Memory provider '{provider.Id}' displayName is required.", AepValidationSeverity.Error, "contributions.memoryProviders"));
+ }
foreach (var descriptorIssue in AepDescriptorValidator.Validate(manifest)) issues.Add(new("AEP100", descriptorIssue, AepValidationSeverity.Error, "contributions.tools"));
try
{
diff --git a/docs/architecture.md b/docs/architecture.md
index 985223b5..2065bf70 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -102,14 +102,14 @@ Work.Storage.Sqlite -> Work storage abstractions + EF Core SQLite
| Identity | local accounts, Principal mapping, Workspace memberships/RBAC, bootstrap, account security, append-only security audit | external-account provisioning/linking, recovery, workload authentication |
| Workspaces | workspace and inbox lifecycle | teams, organizations, policies |
| Ingestion | text, JSON, multipart file, URL, hash deduplication | webhooks, email, connectors |
-| Memory | normalized content, summaries, categories, search contract | facts, relations, embeddings, conversations |
+| Memory/context | governed Agent/shared records in isolated SQLite storage, deterministic bounded retrieval, Runtime context assembly, explicit writes/deletes/expiry | semantic retrieval, policies, compaction, user-facing inspection |
| Routing | deterministic stateless decision | rule catalog and LLM router |
| Agents | management definitions plus isolated MAF runtime adapter | sessions, execution budgets, richer tool policies |
-| Workflows | normalize → analyze → remember | parallel, routing, handoff, supervisor, HITL |
+| Workflows | normalize → analyze → derived result | parallel, routing, handoff, supervisor, HITL |
| Scheduling | standalone polling worker | Quartz persistent scheduler |
| Tools | persisted ToolProvider/Tool resources, AEP contribution resolution, MCP schema catalog, and an Agentstration-owned runtime execution boundary before MCP `tools/call` | richer permissions, credentials, connection policies, and execution hooks |
| Notifications | internal notification record/event | email, Teams, webhook channels |
-| MCP | nine tools reusing application services | resources and authorization |
+| MCP | eight tools reusing application services | resources and authorization |
| Evaluation | deterministic `Microsoft.Extensions.AI.Evaluation` metrics and versioned content-workflow dataset | LLM-as-judge quality/safety evaluators and reports |
| Observability | OTel traces/metrics/log correlation tags | dashboards, SLOs, evaluation telemetry |
@@ -126,10 +126,7 @@ public interface IAgentRuntime
Task RunAsync(AgentExecutionRequest request, CancellationToken cancellationToken);
}
-public interface IMemoryStore { Task AddAsync(MemoryEntry entry, CancellationToken cancellationToken); }
-public interface IMemorySearch { Task> SearchAsync(WorkspaceId workspaceId, string query, int limit, CancellationToken cancellationToken); }
public interface IBlobStore { Task PutAsync(WorkspaceId workspaceId, string name, Stream content, CancellationToken cancellationToken); }
-public interface IEmbeddingStore { Task UpsertAsync(WorkspaceId workspaceId, Guid id, ReadOnlyMemory embedding, CancellationToken cancellationToken); }
public interface IScheduler { Task TriggerDueMissionsAsync(CancellationToken cancellationToken); }
```
@@ -137,12 +134,16 @@ Other important contracts are `IPlatformStore`, `IEventBus`, `IEventHandler`,
## Initial data model
-The executable model includes `Workspace`, `Inbox`, `Item`, `RawContent`, `NormalizedContent`, `MemoryEntry`, `Mission`, `MissionRun`, `Notification`, and `AuditEntry`. The wider model reserves `User`, `WorkspaceMember`, `AgentDefinition`, `AgentRun`, `WorkflowDefinition`, `WorkflowRun`, `Schedule`, and `ToolDefinition` for later increments.
+The executable model includes `Workspace`, `Inbox`, `Item`, `RawContent`, `NormalizedContent`, `ItemAnalysis`, `Mission`, `MissionRun`, `Notification`, `AuditEntry`, and the independently owned `MemoryRecord`. An item analysis is not agent memory merely because it was produced by AI.
+
+Memory storage is selected through canonical `MemoryProviderResource` declarations and reusable `MemoryProfileResource` policies. Runtime resolves the Agent's optional profile and provider before bounded retrieval. SQLite is builtin; external stores implement the versioned AEP `aep.memory-provider` capability. `Agentstration.Extensions.Memory.Sqlite` is the autonomous reference extension and Aspire wires it as an optional AEP provider while preserving the builtin direct-launch default. AEP never owns retrieval policy or context assembly, and record APIs are provider- and Workspace-scoped.
Every workspace-owned record carries `WorkspaceId`. Queries require it alongside the entity identifier. Runtime runs, Flow definitions and runs, Work items, events, queues, cancellation state, and artifacts preserve that scope end to end; storage identities are composite where identifiers may repeat across workspaces. HTTP scope comes from the authenticated request context rather than caller-controlled payload or query values, and background workers re-authorize the durable scope before execution. Key indexes in the PostgreSQL model cover `(WorkspaceId, Slug)`, `(WorkspaceId, InboxId, ContentHash)`, `(WorkspaceId, Status, CreatedAt)`, `(WorkspaceId, ItemId, CreatedAt)`, and `(WorkspaceId, MissionId, StartedAt)`. See ADR-0050.
Raw content is append-only from the workflow's perspective. Normalization and AI results are separate records. Content hash plus inbox scope provides ingestion idempotency.
+The governed Memory/context model, provider conformance contract, lifecycle, and V1 limitations are documented in [Memory and execution context](memory-context.md), ADR-0062, and ADR-0063.
+
## Main flows
### Content vertical
@@ -156,7 +157,7 @@ REST / UI / MCP
-> deterministic router
-> normalize
-> IAgentRuntime -> IChatClient
- -> MemoryEntry
+ -> ItemAnalysis
-> ItemProcessed
```
@@ -166,7 +167,7 @@ REST / UI / MCP
REST / UI / MCP / scheduler tick
-> MissionService
-> IObservationTool (demo sequence in MVP)
- -> MissionRun + observation MemoryEntry
+ -> MissionRun
-> compare previous observation
-> threshold satisfied and changed
-> Notification + NotificationRequested
@@ -340,7 +341,7 @@ SQLite schema evolution for the workspace-scope hardening increment is reset-onl
1. **Delivered foundation:** solution conventions, domain/application boundaries, local content store, API/UI/MCP, OTel, Aspire, and tests.
2. **Delivered management vertical:** direct agent definitions, deterministic compilation, immutable revisions, SQLite control-plane storage, deployments, ETags, concise REST API, and pagination.
3. **Delivered runtime vertical:** isolated Microsoft Agent Framework adapter, in-process/shared-host provisioners, runtime registry, periodic reconciliation, single-agent routing, execution, and standalone sample data.
-4. **Delivered content and monitoring verticals:** ingestion, memory/search, deterministic/OpenAI-compatible AI, missions, change detection, and internal notifications.
+4. **Delivered content and monitoring verticals:** ingestion, derived analysis data, deterministic/OpenAI-compatible AI, missions, change detection, and internal notifications.
5. **Delivered Work vertical:** domain-controlled lifecycle, typed identifiers, interactions, idempotent Runtime events, independent SQLite persistence, local execution gateway, canonical REST API, metrics, traces, and tests.
6. **Delivered Flow authoring vertical:** independent projects, typed seven-step graphs, draft revisions and ETags, structural/resource/expression validation, YAML/JSON source, immutable publication, visual authoring, Work references, OpenAPI, and SQLite.
7. **Delivered Flow Runtime vertical:** durable FlowRun contracts and event history, immutable draft/published snapshots, bounded sequential typed-graph execution, input validation, cancellation, SignalR replay, telemetry, and the Flow-centered console.
@@ -426,3 +427,5 @@ SQLite schema evolution for the workspace-scope hardening increment is reset-onl
- ADR-0059: Tool arguments require explicit bounded retention
- ADR-0060: Entry owns Workplace execution presentation
- ADR-0061: llama.cpp AEP provider and effective capability resolution
+- ADR-0062: Memory is governed state and Runtime assembles execution context
+- ADR-0063: Memory providers are Management bindings and AEP extends stores
diff --git a/docs/decisions/0062-memory-is-governed-state-and-runtime-assembles-context.md b/docs/decisions/0062-memory-is-governed-state-and-runtime-assembles-context.md
new file mode 100644
index 00000000..baafe8f8
--- /dev/null
+++ b/docs/decisions/0062-memory-is-governed-state-and-runtime-assembles-context.md
@@ -0,0 +1,39 @@
+# ADR-0062: Memory is governed state and Runtime assembles execution context
+
+## Status
+
+Accepted.
+
+## Context
+
+Agentstration already persisted several kinds of state that can be mistaken for memory: `ConversationMessage`, `InteractionContinuationContext`, Work results and artifacts, Flow and Runtime Runs, and opaque Microsoft Agent Framework checkpoints. The original content MVP also called generated summaries `MemoryEntry` and exposed keyword search over them. That name described an experiment, not durable information deliberately retained to influence a future Agent execution.
+
+Conflating these mechanisms would make retention, ownership, authorization, replay, and provider boundaries ambiguous. In particular, a MAF checkpoint is technical resume state, and an Agent resource/revision is desired state that a Run must never silently mutate.
+
+## Decision
+
+Memory is a dedicated Agentstration capability with provider-neutral domain, application, storage-abstraction, and local SQLite projects. A `MemoryRecord` is workspace-owned persisted data that may influence a future execution. It has one exact scope, content, tags, provenance, creator, creation time, and optional expiry.
+
+V1 supports two scope kinds:
+
+- `Agent`, keyed by the stable Agent UID;
+- `Shared`, keyed by an explicit workspace-local name.
+
+There is no implicit Workspace-wide scope and no `ContextGroup` resource. A named shared scope meets the multi-Agent sharing requirement without adding desired state or a separate lifecycle. Interaction and Work remain sources of provenance or execution context; they do not become Memory owners in V1.
+
+Reading and writing are separate decisions. Agent configuration may opt into bounded reads of its own scope and named shared scopes. Writes are only explicit API/application commands. Agent replies, prompts, Tool arguments/results, traces, conversations, and checkpoints are never captured automatically.
+
+Runtime owns `AgentExecutionContextAssembler`. It combines ordered provider-neutral conversation messages, explicit functional/Work context, and bounded Memory retrieval into distinct message blocks immediately before execution. Work and Flow supply inputs and projections but do not implement retrieval or storage. The MAF adapter only translates the already assembled messages and never owns Memory contracts.
+
+Memory data is runtime/user state and is never exported by Packs. Agent Memory read configuration is desired state and may later be portable in a Pack. AEP is unchanged.
+
+The old generic content `MemoryEntry` is removed. Its useful content-workflow result becomes the narrower item-owned `ItemAnalysis` model; nullable Mission ownership, generic kind/content fields, keyword search, JSON compatibility, and the old PostgreSQL table are removed. Prototype-generated local data is reset rather than migrated into governed Memory because its semantics and provenance are insufficient.
+
+## Consequences
+
+- Every query and mutation requires the canonical server-resolved `WorkspaceId`; record identity is composite with workspace ownership.
+- Dedicated `memory/read`, `memory/write`, and `memory/delete` permissions govern the REST and Runtime paths.
+- Retrieval is exact-scope, newest-first, deterministic, and bounded to 20 records in V1.
+- Expired records are excluded from reads and can be purged; individual delete and clear-scope are supported.
+- A future semantic or hybrid retriever can implement `IMemoryRetriever` without changing `MemoryRecord` or Runtime execution contracts.
+- V1 has no embeddings, automatic extraction, Workspace-wide memory, UI studio, Flow designer steps, distributed store, or multi-agent orchestration-specific injection.
diff --git a/docs/decisions/0063-memory-providers-are-management-bindings-and-aep-extends-stores.md b/docs/decisions/0063-memory-providers-are-management-bindings-and-aep-extends-stores.md
new file mode 100644
index 00000000..c1878281
--- /dev/null
+++ b/docs/decisions/0063-memory-providers-are-management-bindings-and-aep-extends-stores.md
@@ -0,0 +1,27 @@
+# ADR-0063: Memory providers are Management bindings and AEP extends stores
+
+## Status
+
+Accepted.
+
+## Context
+
+ADR-0062 established governed Memory records and Runtime-owned context assembly with one local SQLite store. Multiple storage technologies must be selectable without allowing a backend, AEP, or a retrieval strategy to own Agentstration's Memory model.
+
+## Decision
+
+`MemoryProviderResource` is a desired-state Management resource describing one configured store integration. V1 supports the builtin SQLite integration and an AEP integration identified by `extensionId` plus the extension's `providerId`. `MemoryProfileResource` references a provider and contains reusable recent-retrieval limits and optional default retention. An Agent optionally references a profile and retains only its own/shared scope selection.
+
+All record administration is explicitly provider-scoped. Runtime resolves Agent revision → profile → provider on the server. There is no implicit Workspace provider, routing index, `MemoryStore` resource, or `ContextGroup`. A shared scope is shared only by Agents that use the same Workspace, provider, and scope name.
+
+AEP exposes `aep.memory-provider` as a store capability: bounded CRUD, exact-scope listing, expiry, clear-scope and Workspace-scoped purge. Retrieval policy and context assembly remain inside Agentstration. AEP DTOs do not reference Memory, Runtime, MAF, Azure, or provider SDK types.
+
+Memory mutations are audited locally before and after provider invocation. Audit records contain identifiers, scope, provenance correlation, outcome and counts, never content, tags, prompts, credentials or Tool payloads.
+
+## Consequences
+
+- SQLite remains the offline default and AEP is optional.
+- An Azure implementation can be delivered as an extension without changing `MemoryRecord` or Runtime contracts.
+- Provider bindings cannot be edited to silently point existing records elsewhere.
+- Profiles are Pack-portable; providers are installation bindings; Memory records are never exported.
+- V1 AEP does not expose semantic or hybrid retrieval and no Azure SDK is included.
diff --git a/docs/decisions/index.md b/docs/decisions/index.md
index d1d77dd6..f9ec8602 100644
--- a/docs/decisions/index.md
+++ b/docs/decisions/index.md
@@ -91,3 +91,5 @@ Use **Proposed** when implementation or repository evidence does not establish a
59. [ADR-0059 — Tool arguments require explicit bounded retention](0059-tool-arguments-require-explicit-bounded-retention.md)
60. [ADR-0060 — Entry owns Workplace execution presentation](0060-entry-owns-workplace-execution-presentation.md)
61. [ADR-0061 — llama.cpp is an AEP provider and capabilities are resolved effectively](0061-llama-cpp-provider-and-effective-capabilities.md)
+62. [ADR-0062 — Memory is governed state and Runtime assembles execution context](0062-memory-is-governed-state-and-runtime-assembles-context.md)
+63. [ADR-0063 — Memory providers are Management bindings and AEP extends stores](0063-memory-providers-are-management-bindings-and-aep-extends-stores.md)
diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md
index ca000d17..2f9f3a95 100644
--- a/docs/getting-started/configuration.md
+++ b/docs/getting-started/configuration.md
@@ -8,7 +8,8 @@ The main verified settings are:
| --- | --- | --- |
| `AI:Provider` | `Managed` | Selects model resolution/execution mode. Use `Deterministic` explicitly for offline or test execution. |
| `LlamaCpp:Endpoint` | `http://localhost:8080` | Native llama.cpp server used by the autonomous llama.cpp extension and Aspire. |
-| `Data:Path` | `.agentstration/data.json` | Content and memory store used by the Console host. |
+| `Data:Path` | `.agentstration/data.json` | Legacy content store used by the Console host. |
+| `Data:MemoryPath` | `.agentstration/memory-plane.db` | Builtin governed Memory SQLite database. |
| `Data:ControlPlanePath` | `.agentstration/control-plane.db` | Management Plane SQLite database. |
| `Data:WorkPlanePath` | `.agentstration/work-plane.db` | Work Plane SQLite database. |
| `Data:FlowPath` | `.agentstration/flow-plane.db` | Flow SQLite database. |
@@ -17,5 +18,7 @@ The main verified settings are:
| `Agentstration:WorkApi:BaseAddress` | `http://localhost:5100/` | Console-to-Work-API connection on the authoritative server. |
| `Agentstration:ApiBaseUrl` | `http://localhost:5100/` | Workplace-to-server API connection. |
| `Agentstration:WorkplaceHubUrl` | `http://localhost:5100/hubs/workplace` | Workplace real-time endpoint. |
+| `MemorySqlite:Path` | `.agentstration/memory-sqlite-extension.db` in AppHost | Database owned by the autonomous SQLite AEP Memory extension. |
+| `Agentstration:Extensions:Agentstration.Extensions.Memory.Sqlite:Endpoint` | unset for direct Web startup | AEP endpoint used by an external SQLite Memory provider; Aspire supplies it automatically. |
Provider-specific options and persisted model resources are described in [Model providers](../concepts/model-providers.md) and [Model profiles](../concepts/model-profiles.md). Do not store secrets in committed settings files.
diff --git a/docs/memory-context.md b/docs/memory-context.md
new file mode 100644
index 00000000..f09fad8b
--- /dev/null
+++ b/docs/memory-context.md
@@ -0,0 +1,155 @@
+# Memory and execution context
+
+Agentstration Memory is explicit, workspace-isolated persisted data retained so it may influence a future Agent execution. It is not a transcript, a generic bag of state, or an automatic copy of everything an Agent observes.
+
+## Audit and taxonomy
+
+| Existing mechanism | Classification | Owner | Memory? |
+|---|---|---|---|
+| `ConversationMessage` and `Interaction` | Functional conversation history | Work Plane | No |
+| `InteractionContinuationContext` | Reconstructible projection of recent messages, results, artifact references, and continuation identifiers | Work Plane/Application | No |
+| `WorkItem`, `WorkTask`, results and artifacts | Durable functional work state | Work Plane | No; selected values may be supplied as execution context |
+| `FlowRun` and Runtime Run history/events | Execution record and correlation | Flow/Runtime Plane | No |
+| MAF checkpoint | Opaque technical resume state | Runtime adapter | No |
+| Agent definition and immutable revisions | Desired configuration | Management Plane | No; optional Memory read configuration is desired state |
+| `ItemAnalysis` summary/categories | Item-owned content-analysis result | Content vertical | No |
+| `MemoryRecord` | Deliberately retained fact/context with provenance and lifecycle | Memory capability | Yes |
+
+Therefore:
+
+**Conversation ≠ Context ≠ Memory ≠ Checkpoint.**
+
+- Conversation is the durable functional exchange shown by Workplace.
+- Context is data assembled for one execution and can be reconstructed.
+- Memory is governed persisted data that may be retrieved for a later execution.
+- Checkpoint is provider-adapter state used to resume the same technical execution.
+
+## Ownership and lifecycle
+
+A record always belongs to a server-resolved Workspace and exactly one scope:
+
+- `Agent/{stable-agent-uid}` for one logical Agent across revisions;
+- `Shared/{name}` for an explicitly named, workspace-local scope read by configured Agents.
+
+V1 deliberately has no broad Workspace scope, Interaction scope, Work scope, or `ContextGroup` resource. Interaction, WorkItem, FlowRun, and RuntimeRun identifiers are provenance, not ownership. This avoids accidental context pollution while a named shared scope already supports several Agents sharing selected facts.
+
+Every record answers:
+
+- owner: Workspace plus exact Agent/shared scope;
+- readers: principals with `memory/read`, further restricted by Agent configuration during execution;
+- writers: principals with `memory/write` using an explicit command;
+- lifetime: persistent until deletion, or bounded by `expiresAt`;
+- reason/source: required provider-neutral provenance and creating Principal.
+
+Individual delete, exact-scope clear, and bounded expiry purge are supported. Records are immutable in V1; correcting a fact means deleting it and explicitly writing a replacement.
+
+## Read and execution assembly
+
+```text
+Work Plane
+Interaction / Conversation
+ │
+ ▼
+Context Assembly ◄──── exact-scope, bounded Memory retrieval
+ │
+ ▼
+Runtime
+ │
+ ▼
+Agent
+ │
+ └──── explicit Memory write command
+```
+
+`AgentExecutionContextAssembler` is the single Runtime-owned assembly point. It preserves conversation messages, adds explicit functional/Work context separately, retrieves configured Memory newest-first, and emits provider-neutral messages. Retrieved records are labelled untrusted contextual data rather than instructions. Workplace, Flow, the MAF adapter, and model providers do not independently rebuild history.
+
+An Agent opts in with optional desired-state configuration:
+
+```yaml
+memory:
+ readOwnMemory: true
+ sharedScopes:
+ - customer-support
+ maximumRecords: 10
+```
+
+The configuration is optional. With no `memory` block the Agent performs no Memory read, receives no injected Memory block, and otherwise executes unchanged. The maximum is clamped to 20. V1 retrieval is exact scope plus recency; it has no query text, embedding, or LLM summarization.
+
+## Explicit writes and API
+
+V1 offers minimal administration and Runtime-correlation routes under `/api`:
+
+- `POST /memory/records` writes an explicit manual record;
+- `POST /runtime/runs/{runId}/memory-records` writes explicit caller-supplied content with RuntimeRun provenance;
+- `GET /memory/records` lists a bounded page, optionally for one exact scope;
+- `DELETE /memory/records/{id}` deletes one record;
+- `DELETE /memory/records` clears one exact scope.
+
+The client never supplies Tenant or Workspace ownership. The authenticated request context resolves both, and SQLite queries always include Workspace. Agent names supplied to the API are resolved server-side to stable Agent UIDs.
+
+Memory is stored in the independent local SQLite database configured by `Data:MemoryPath` (default `.agentstration/memory-plane.db`). Storage is separate from Management desired state, Work conversations, Flow runs, and Runtime checkpoints.
+
+No compatibility import from the former `memoryEntries` JSON property or `memory_entries` PostgreSQL table is performed. The generic legacy shape is replaced by the item-owned `ItemAnalysis` model. Prototype data must be reset; it is deliberately not promoted into governed Memory because it lacks the required ownership, reason, creator, retention, and provenance.
+
+## Sensitive data and governance
+
+No automatic capture path exists. Agent responses, conversation history, system prompts, authentication claims, secret values, credentials, tokens, Tool arguments/results, and governance traces are not copied to Memory. A Flow, Agent, or caller that derives a safe fact from a Tool result must submit new explicit content and a reason; it cannot reference non-persisted Tool arguments as an implicit source payload.
+
+Memory content remains untrusted input at execution time. Callers are responsible for data classification before an explicit write. Logs and Runtime context-assembly events contain record identifiers/counts, not Memory content.
+
+## Packs and future retrieval
+
+Accumulated records are runtime/user data and are never Pack payloads. The optional Agent read configuration is portable desired state and may later participate in Pack validation/binding without exporting personal history.
+
+`MemoryRecord`, `IMemoryRecordStore`, `IMemoryRetriever`, and context assembly are separate contracts. A semantic, tagged, explicit-reference, or hybrid retriever can replace the deterministic V1 retriever without changing record ownership or leaking a provider type into the domain. ADR-0063 adds external store providers through AEP while deliberately keeping retrieval inside Agentstration.
+
+## Providers and profiles
+
+The external boundary is concrete without changing record semantics:
+
+```text
+Agent revision
+ → MemoryProfile (recent retrieval, bound, retention default)
+ → MemoryProvider (configured store instance)
+ ├── builtin SQLite
+ └── AEP extension / providerId
+```
+
+`MemoryProvider` belongs to the Management Plane. `MemoryProfile` is portable desired-state configuration. Records remain Workspace-owned runtime/user data and are addressed through an explicit provider. AEP implements only the store contract; `IMemoryRetriever` and `AgentExecutionContextAssembler` remain Agentstration responsibilities.
+
+The AEP V1 capability supports exact-scope CRUD and expiry. It has no semantic retrieval, embeddings or provider-owned context assembly. `Agentstration.Extensions.Memory.Sqlite` is the executable reference implementation: it is an autonomous ASP.NET Core AEP extension with its own SQLite schema and no dependency on Agentstration's Memory domain, Runtime, Management, Web, or MAF assemblies. Aspire starts it, supplies its endpoint to the authoritative server, and seeds the optional `memory-sqlite-aep` provider plus `aep-memory-default` profile. Direct Web startup keeps the builtin `local-memory` provider as the offline default.
+
+The extension can also be run independently:
+
+```powershell
+$env:MemorySqlite__Path = "C:\data\agentstration-memory.db"
+dotnet run --project src/Agentstration.Extensions.Memory.Sqlite --no-launch-profile
+```
+
+Register its HTTP endpoint under `Agentstration:Extensions:Agentstration.Extensions.Memory.Sqlite:Endpoint`, then declare an AEP `MemoryProvider` whose `extensionId` is `Agentstration.Extensions.Memory.Sqlite` and `providerId` is `sqlite`. The path is installation configuration, never Pack-portable profile data. Azure remains a future provider implementation, not a dependency of this increment.
+
+Mutation audit is local even for external stores. It records provider, scope, operation, outcome, principal and Run/source correlation but never Memory content, tags, prompts, secrets or Tool arguments/results.
+
+## Provider conformance
+
+Every `IMemoryRecordStore` implementation must pass `MemoryRecordStoreConformanceSuite` from `Agentstration.Memory.Testing`. The runner has no dependency on MSTest, SQLite, AEP, Runtime, Infrastructure, or UI, so an external provider can invoke it from its preferred test framework:
+
+```csharp
+var suite = new MemoryRecordStoreConformanceSuite(CreateIsolatedStoreAsync);
+var report = await suite.RunAsync(cancellationToken);
+report.EnsureConformant();
+```
+
+The factory returns a fresh `MemoryRecordStoreLease` per scenario. This prevents scenario coupling and gives the provider a deterministic cleanup hook. The common scenarios verify:
+
+- exact record round-trip and Workspace isolation;
+- newest-first ordering, scope filtering, pagination, and bounded counts;
+- expiry filtering and Workspace-scoped bounded purge;
+- duplicate-write failure, exact-scope clear, and delete semantics;
+- cancellation propagation.
+
+Reports expose stable scenario/failure codes and exception type names only. Provider exception messages are deliberately discarded because they may contain Memory content or backend diagnostics. Builtin SQLite, the in-process AEP adapter, and the real out-of-process SQLite extension execute this same offline suite in `Agentstration.Memory.Conformance.Tests`. A separate restart scenario writes through HTTP, terminates the extension, starts a new process against the same database, and verifies both durability and cross-Workspace isolation. `Agentstration.Runtime.Tests` additionally proves the complete deterministic path: explicit AEP write after one Run, profile/provider resolution in a new Run, bounded context assembly, MAF execution influenced by the remembered fact, cross-Workspace exclusion, and unchanged Agent desired state/revisions.
+
+## V1 limitations
+
+There is no vector database, embeddings, RAG/document ingestion, automatic extraction, compaction, archival, policy engine, Workplace transcript projection, dedicated Flow steps, Workspace-wide scope, or built-in distributed/cloud store. The Console surface is limited to administrative inspection, provider testing and explicit deletion; it is not a user-facing “what the Agent remembers” experience. Multi-agent MAF orchestration-specific context injection is deferred; Runtime Run and the current simple Work/Flow execution path use the common assembler.
diff --git a/docs/reference/current-capabilities.md b/docs/reference/current-capabilities.md
index a0da1065..2efe5f8a 100644
--- a/docs/reference/current-capabilities.md
+++ b/docs/reference/current-capabilities.md
@@ -151,7 +151,7 @@ For the Aspire dashboard and orchestration experience:
dotnet run --project src/Agentstration.AppHost
```
-The AppHost exposes the authoritative server, Workplace, and autonomous extensions as separate resources and wires them through service discovery. It connects the Ollama extension to `Ollama:Endpoint` (default `http://localhost:11434`) and the llama.cpp extension to `LlamaCpp:Endpoint` (default `http://localhost:8080`). It provisions neither inference server nor model and requires no Docker for either path. Aspire preserves the server's normal `Managed` mode; deterministic execution remains an explicit offline/test override.
+The AppHost exposes the authoritative server, Workplace, and autonomous extensions as separate resources and wires them through service discovery. It connects the Ollama extension to `Ollama:Endpoint` (default `http://localhost:11434`) and the llama.cpp extension to `LlamaCpp:Endpoint` (default `http://localhost:8080`). It also starts the autonomous SQLite AEP Memory extension, assigns its local database path, and seeds an optional provider/profile binding without replacing the builtin direct-launch default. It provisions neither inference server nor model and requires no Docker for these paths. Aspire preserves the server's normal `Managed` mode; deterministic execution remains an explicit offline/test override.
Or with containers:
@@ -302,11 +302,12 @@ Start-Sleep -Seconds 1
Invoke-RestMethod "http://localhost:5100/api/workspaces/$($workspace.id.value)/items/$($accepted.itemId.value)"
```
-Search memory:
+Write and list an explicit shared Memory record (the server resolves the current Workspace):
```powershell
-$search = @{ query = "agent"; limit = 20 } | ConvertTo-Json
-Invoke-RestMethod -Method Post -ContentType application/json -Body $search "http://localhost:5100/api/workspaces/$($workspace.id.value)/memory/search"
+$memory = @{ scope = @{ kind = "shared"; name = "demo" }; content = "Prefer concise summaries."; reason = "Explicit user preference"; tags = @("preference") } | ConvertTo-Json -Depth 4
+Invoke-RestMethod -Method Post -ContentType application/json -Body $memory "http://localhost:5100/api/memory/records"
+Invoke-RestMethod "http://localhost:5100/api/memory/records?scopeKind=shared&scopeName=demo&top=20"
```
Create and run a deterministic monitoring mission:
@@ -366,7 +367,7 @@ The official C# MCP SDK exposes Streamable HTTP at `http://localhost:5100/mcp`.
}
```
-Tools: `list_workspaces`, `list_inboxes`, `ingest_text`, `ingest_url`, `search_memory`, `create_mission`, `get_mission`, `list_mission_runs`, and `run_mission_now`.
+Tools: `list_workspaces`, `list_inboxes`, `ingest_text`, `ingest_url`, `create_mission`, `get_mission`, `list_mission_runs`, and `run_mission_now`.
## Runtime and MAF observability
diff --git a/docs/reference/resources/agents.md b/docs/reference/resources/agents.md
index 209d86ad..70473691 100644
--- a/docs/reference/resources/agents.md
+++ b/docs/reference/resources/agents.md
@@ -22,7 +22,10 @@ definition:
- name: sql-readonly
behaviors: []
middleware: []
- contextProviders: []
+ memory:
+ readOwnMemory: true
+ sharedScopes: []
+ maximumRecords: 10
settings: {}
```
diff --git a/src/Agentstration.AppHost/Agentstration.AppHost.csproj b/src/Agentstration.AppHost/Agentstration.AppHost.csproj
index e6a596b4..70d19f96 100644
--- a/src/Agentstration.AppHost/Agentstration.AppHost.csproj
+++ b/src/Agentstration.AppHost/Agentstration.AppHost.csproj
@@ -9,5 +9,6 @@
+
diff --git a/src/Agentstration.AppHost/Program.cs b/src/Agentstration.AppHost/Program.cs
index 90bdcda6..7755e565 100644
--- a/src/Agentstration.AppHost/Program.cs
+++ b/src/Agentstration.AppHost/Program.cs
@@ -19,6 +19,10 @@
.WithEnvironment("LlamaCpp__Endpoint", parsedLlamaCppEndpoint.AbsoluteUri)
.WithHttpHealthCheck("/health");
var utilitiesExtension = builder.AddProject("utilities-extension").WithHttpHealthCheck("/health");
+var memorySqlitePath = Path.GetFullPath(builder.Configuration["MemorySqlite:Path"] ?? Path.Combine(".agentstration", "memory-sqlite-extension.db"));
+var memoryExtension = builder.AddProject("memory-sqlite-extension")
+ .WithEnvironment("MemorySqlite__Path", memorySqlitePath)
+ .WithHttpHealthCheck("/health");
var console = builder.AddProject("agentstration-console")
.WithEnvironment("ConnectionStrings__ollama-extension", ollamaExtension.GetEndpoint("http"))
@@ -26,10 +30,12 @@
.WithEnvironment("Agentstration__Extensions__Agentstration.Extensions.Ollama__Endpoint", ollamaExtension.GetEndpoint("http"))
.WithEnvironment("Agentstration__Extensions__Agentstration.Extensions.LlamaCpp__Endpoint", llamaCppExtension.GetEndpoint("http"))
.WithEnvironment("Agentstration__Extensions__Agentstration.Extensions.Utilities__Endpoint", utilitiesExtension.GetEndpoint("http"))
+ .WithEnvironment("Agentstration__Extensions__Agentstration.Extensions.Memory.Sqlite__Endpoint", memoryExtension.GetEndpoint("http"))
.WithHttpHealthCheck("/health")
.WaitFor(ollamaExtension);
console.WaitFor(llamaCppExtension);
console.WaitFor(utilitiesExtension);
+console.WaitFor(memoryExtension);
console
.WithEnvironment("Agentstration__ManagementApi__BaseAddress", console.GetEndpoint("http"))
.WithEnvironment("Agentstration__ManagementApi__ForwardSessionCookie", "true")
diff --git a/src/Agentstration.AppHost/appsettings.json b/src/Agentstration.AppHost/appsettings.json
index e05e566a..33998825 100644
--- a/src/Agentstration.AppHost/appsettings.json
+++ b/src/Agentstration.AppHost/appsettings.json
@@ -4,5 +4,8 @@
},
"LlamaCpp": {
"Endpoint": "http://localhost:8080"
+ },
+ "MemorySqlite": {
+ "Path": ".agentstration/memory-sqlite-extension.db"
}
}
diff --git a/src/Agentstration.Application/Abstractions.cs b/src/Agentstration.Application/Abstractions.cs
index 005098de..635a94c4 100644
--- a/src/Agentstration.Application/Abstractions.cs
+++ b/src/Agentstration.Application/Abstractions.cs
@@ -17,9 +17,8 @@ public interface IPlatformStore
Task SetItemStatusAsync(WorkspaceId workspaceId, ItemId itemId, ItemStatus status, string? error, CancellationToken cancellationToken);
Task AddNormalizedContentAsync(NormalizedContent content, CancellationToken cancellationToken);
Task GetNormalizedContentAsync(WorkspaceId workspaceId, ItemId itemId, CancellationToken cancellationToken);
- Task AddMemoryEntryAsync(MemoryEntry entry, CancellationToken cancellationToken);
- Task> SearchMemoryAsync(WorkspaceId workspaceId, string query, int limit, CancellationToken cancellationToken);
- Task> GetItemMemoryAsync(WorkspaceId workspaceId, ItemId itemId, CancellationToken cancellationToken);
+ Task AddItemAnalysisAsync(ItemAnalysis analysis, CancellationToken cancellationToken);
+ Task> GetItemAnalysesAsync(WorkspaceId workspaceId, ItemId itemId, CancellationToken cancellationToken);
Task AddMissionAsync(Mission mission, CancellationToken cancellationToken);
Task> ListMissionsAsync(WorkspaceId workspaceId, CancellationToken cancellationToken);
Task GetMissionAsync(WorkspaceId workspaceId, MissionId missionId, CancellationToken cancellationToken);
@@ -32,10 +31,8 @@ public interface IPlatformStore
Task AddAuditEntryAsync(AuditEntry entry, CancellationToken cancellationToken);
}
-public interface IMemoryStore { Task AddAsync(MemoryEntry entry, CancellationToken cancellationToken); }
-public interface IMemorySearch { Task> SearchAsync(WorkspaceId workspaceId, string query, int limit, CancellationToken cancellationToken); }
+public interface IItemAnalysisStore { Task AddAsync(ItemAnalysis analysis, CancellationToken cancellationToken); }
public interface IBlobStore { Task PutAsync(WorkspaceId workspaceId, string name, Stream content, CancellationToken cancellationToken); }
-public interface IEmbeddingStore { Task UpsertAsync(WorkspaceId workspaceId, Guid id, ReadOnlyMemory embedding, CancellationToken cancellationToken); }
public interface IEventBus
{
diff --git a/src/Agentstration.Application/Analysis/ItemAnalysisService.cs b/src/Agentstration.Application/Analysis/ItemAnalysisService.cs
new file mode 100644
index 00000000..ae986f4f
--- /dev/null
+++ b/src/Agentstration.Application/Analysis/ItemAnalysisService.cs
@@ -0,0 +1,8 @@
+using Agentstration.Domain;
+
+namespace Agentstration.Application.Analysis;
+
+public sealed class ItemAnalysisService(IPlatformStore store) : IItemAnalysisStore
+{
+ public Task AddAsync(ItemAnalysis analysis, CancellationToken cancellationToken) => store.AddItemAnalysisAsync(analysis, cancellationToken);
+}
diff --git a/src/Agentstration.Application/Ingestion/IngestionService.cs b/src/Agentstration.Application/Ingestion/IngestionService.cs
index f505a150..72edc5e6 100644
--- a/src/Agentstration.Application/Ingestion/IngestionService.cs
+++ b/src/Agentstration.Application/Ingestion/IngestionService.cs
@@ -82,8 +82,8 @@ public async Task> GetAsync(WorkspaceId workspaceId, ItemId
}
var normalized = await store.GetNormalizedContentAsync(workspaceId, itemId, cancellationToken);
- var memory = await store.GetItemMemoryAsync(workspaceId, itemId, cancellationToken);
- return Result.Success(new ItemDetails(item, raw, normalized, memory));
+ var analyses = await store.GetItemAnalysesAsync(workspaceId, itemId, cancellationToken);
+ return Result.Success(new ItemDetails(item, raw, normalized, analyses));
}
}
diff --git a/src/Agentstration.Application/Memory/MemoryService.cs b/src/Agentstration.Application/Memory/MemoryService.cs
deleted file mode 100644
index 3794e386..00000000
--- a/src/Agentstration.Application/Memory/MemoryService.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using Agentstration.Domain;
-
-namespace Agentstration.Application.Memory;
-
-public sealed class MemoryService(IPlatformStore store) : IMemoryStore, IMemorySearch
-{
- public Task AddAsync(MemoryEntry entry, CancellationToken cancellationToken) => store.AddMemoryEntryAsync(entry, cancellationToken);
-
- public Task> SearchAsync(WorkspaceId workspaceId, string query, int limit, CancellationToken cancellationToken)
- {
- limit = Math.Clamp(limit, 1, 100);
- return store.SearchMemoryAsync(workspaceId, query.Trim(), limit, cancellationToken);
- }
-}
diff --git a/src/Agentstration.Application/Missions/MissionService.cs b/src/Agentstration.Application/Missions/MissionService.cs
index 910acfcd..e1ac0478 100644
--- a/src/Agentstration.Application/Missions/MissionService.cs
+++ b/src/Agentstration.Application/Missions/MissionService.cs
@@ -65,7 +65,6 @@ public async Task> RunAsync(WorkspaceId workspaceId, MissionI
var changed = previous?.Observation != observation;
run = run with { Status = MissionRunStatus.Completed, Observation = observation, Changed = changed, CompletedAt = timeProvider.GetUtcNow() };
await store.UpdateMissionRunAsync(run, cancellationToken);
- await memoryStoreObservationAsync(run, cancellationToken);
mission = mission with { NextRunAt = timeProvider.GetUtcNow().Add(mission.Frequency) };
await store.UpdateMissionAsync(mission, cancellationToken);
@@ -87,7 +86,4 @@ public async Task> RunAsync(WorkspaceId workspaceId, MissionI
return Result.Failure("mission.run_failed", exception.Message);
}
}
-
- private Task memoryStoreObservationAsync(MissionRun run, CancellationToken cancellationToken) =>
- store.AddMemoryEntryAsync(new MemoryEntry(Guid.NewGuid(), run.WorkspaceId, null, run.MissionId, "observation", $"Observed value: {run.Observation}", Array.Empty(), timeProvider.GetUtcNow()), cancellationToken);
}
diff --git a/src/Agentstration.Application/Workflows/ContentProcessingWorkflow.cs b/src/Agentstration.Application/Workflows/ContentProcessingWorkflow.cs
index b8ebbce1..a7cc831d 100644
--- a/src/Agentstration.Application/Workflows/ContentProcessingWorkflow.cs
+++ b/src/Agentstration.Application/Workflows/ContentProcessingWorkflow.cs
@@ -9,7 +9,7 @@ public sealed partial class ContentProcessingWorkflow(
IPlatformStore store,
IIntentRouter router,
IAgentRuntime agentRuntime,
- IMemoryStore memoryStore,
+ IItemAnalysisStore analyses,
IEventBus eventBus,
TimeProvider timeProvider)
{
@@ -36,7 +36,7 @@ public async Task ExecuteAsync(WorkspaceId workspaceId, ItemId itemId, Cancellat
if (!decision.StoreOnly)
{
var result = await agentRuntime.RunAsync(new AgentExecutionRequest(workspaceId, itemId, normalizedText), cancellationToken);
- await memoryStore.AddAsync(new MemoryEntry(Guid.NewGuid(), workspaceId, itemId, null, "summary", result.Summary, result.Categories, timeProvider.GetUtcNow()), cancellationToken);
+ await analyses.AddAsync(new ItemAnalysis(Guid.NewGuid(), workspaceId, itemId, result.Summary, result.Categories, timeProvider.GetUtcNow()), cancellationToken);
}
await store.SetItemStatusAsync(workspaceId, itemId, ItemStatus.Processed, null, cancellationToken);
diff --git a/src/Agentstration.Contracts/ApiContracts.cs b/src/Agentstration.Contracts/ApiContracts.cs
index 67f3c2ac..48791736 100644
--- a/src/Agentstration.Contracts/ApiContracts.cs
+++ b/src/Agentstration.Contracts/ApiContracts.cs
@@ -7,7 +7,6 @@ public sealed record CreateInboxRequest(string Name, string? Slug, string? Descr
public sealed record InboxCreatedResponse(Inbox Inbox, string ApiKey);
public sealed record IngestItemRequest(string? Text, string? Url, string? ExternalId);
public sealed record IngestItemResponse(ItemId ItemId, string Status, bool Duplicate);
-public sealed record ItemDetails(Item Item, RawContent Raw, NormalizedContent? Normalized, IReadOnlyList Memory);
-public sealed record SearchMemoryRequest(string Query, int Limit = 20);
+public sealed record ItemDetails(Item Item, RawContent Raw, NormalizedContent? Normalized, IReadOnlyList Analyses);
public sealed record CreateMissionRequest(string Name, string Objective, string SourceUrl, int FrequencyMinutes, decimal? Threshold);
public sealed record MissionDetails(Mission Mission, IReadOnlyList Runs, IReadOnlyList Notifications);
diff --git a/src/Agentstration.Domain/Entities.cs b/src/Agentstration.Domain/Entities.cs
index bb8e4b0a..4549e652 100644
--- a/src/Agentstration.Domain/Entities.cs
+++ b/src/Agentstration.Domain/Entities.cs
@@ -40,13 +40,11 @@ public sealed record NormalizedContent(
string Value,
DateTimeOffset CreatedAt);
-public sealed record MemoryEntry(
+public sealed record ItemAnalysis(
Guid Id,
WorkspaceId WorkspaceId,
- ItemId? ItemId,
- MissionId? MissionId,
- string Kind,
- string Content,
+ ItemId ItemId,
+ string Summary,
IReadOnlyList Categories,
DateTimeOffset CreatedAt);
diff --git a/src/Agentstration.Extensions.Memory.Sqlite/Agentstration.Extensions.Memory.Sqlite.csproj b/src/Agentstration.Extensions.Memory.Sqlite/Agentstration.Extensions.Memory.Sqlite.csproj
new file mode 100644
index 00000000..235fe272
--- /dev/null
+++ b/src/Agentstration.Extensions.Memory.Sqlite/Agentstration.Extensions.Memory.Sqlite.csproj
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Agentstration.Extensions.Memory.Sqlite/Program.cs b/src/Agentstration.Extensions.Memory.Sqlite/Program.cs
new file mode 100644
index 00000000..687bbc87
--- /dev/null
+++ b/src/Agentstration.Extensions.Memory.Sqlite/Program.cs
@@ -0,0 +1,34 @@
+using Agentstration.Aep.AspNetCore;
+using Agentstration.Extensions.Memory.Sqlite;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+
+var builder = WebApplication.CreateBuilder(args);
+var configuredPath = builder.Configuration["MemorySqlite:Path"];
+var databasePath = Path.GetFullPath(string.IsNullOrWhiteSpace(configuredPath)
+ ? Path.Combine(".agentstration", "extensions", "memory-sqlite", "memory.db")
+ : configuredPath);
+Directory.CreateDirectory(Path.GetDirectoryName(databasePath)!);
+var connectionString = new SqliteConnectionStringBuilder
+{
+ DataSource = databasePath,
+ Mode = SqliteOpenMode.ReadWriteCreate,
+ Cache = SqliteCacheMode.Shared,
+ Pooling = true
+}.ToString();
+
+builder.Services.AddDbContextFactory(options => options.UseSqlite(connectionString));
+builder.Services.AddSingleton();
+builder.Services.AddSingleton(services => services.GetRequiredService());
+builder.Services.AddAgentstrationAep(options => options.Extension = new(
+ "Agentstration.Extensions.Memory.Sqlite",
+ "SQLite Memory",
+ "1.0.0",
+ "Durable local SQLite AEP Memory store provider."));
+
+var app = builder.Build();
+await app.Services.GetRequiredService().InitializeAsync(app.Lifetime.ApplicationStopping);
+app.MapAgentstrationAep();
+await app.RunAsync();
+
+public partial class Program;
diff --git a/src/Agentstration.Extensions.Memory.Sqlite/Properties/launchSettings.json b/src/Agentstration.Extensions.Memory.Sqlite/Properties/launchSettings.json
new file mode 100644
index 00000000..e30cf45f
--- /dev/null
+++ b/src/Agentstration.Extensions.Memory.Sqlite/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5285",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/src/Agentstration.Extensions.Memory.Sqlite/SqliteAepMemoryProvider.cs b/src/Agentstration.Extensions.Memory.Sqlite/SqliteAepMemoryProvider.cs
new file mode 100644
index 00000000..ad48ec10
--- /dev/null
+++ b/src/Agentstration.Extensions.Memory.Sqlite/SqliteAepMemoryProvider.cs
@@ -0,0 +1,204 @@
+using System.Text.Json;
+using Agentstration.Aep.Abstractions;
+using Agentstration.Aep.AspNetCore;
+using Microsoft.EntityFrameworkCore;
+
+namespace Agentstration.Extensions.Memory.Sqlite;
+
+public sealed class SqliteAepMemoryDbContext(DbContextOptions options) : DbContext(options)
+{
+ internal DbSet Records => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ var record = modelBuilder.Entity();
+ record.ToTable("MemoryRecords");
+ record.HasKey(value => new { value.WorkspaceId, value.Id });
+ record.Property(value => value.ScopeKind).HasMaxLength(32);
+ record.Property(value => value.ScopeKey).HasMaxLength(256);
+ record.Property(value => value.SourceKind).HasMaxLength(32);
+ record.Property(value => value.SourceId).HasMaxLength(256);
+ record.Property(value => value.Reason).HasMaxLength(512);
+ record.HasIndex(value => new { value.WorkspaceId, value.ScopeKind, value.ScopeKey, value.CreatedAt });
+ record.HasIndex(value => new { value.WorkspaceId, value.ExpiresAt });
+ }
+}
+
+internal sealed class SqliteAepMemoryRecordDocument
+{
+ public Guid WorkspaceId { get; set; }
+ public Guid Id { get; set; }
+ public required string ScopeKind { get; set; }
+ public required string ScopeKey { get; set; }
+ public required string Content { get; set; }
+ public required string TagsJson { get; set; }
+ public required string SourceKind { get; set; }
+ public string? SourceId { get; set; }
+ public required string Reason { get; set; }
+ public Guid CreatedByPrincipalId { get; set; }
+ public long CreatedAt { get; set; }
+ public long? ExpiresAt { get; set; }
+}
+
+public sealed class SqliteAepMemoryProvider(IDbContextFactory contexts) : IAepMemoryProvider
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
+ private const int MaximumPageSize = 100;
+
+ public AepMemoryProviderDescriptor Descriptor { get; } = new(
+ "sqlite",
+ "SQLite durable Memory",
+ new(ExactScope: true, Expiry: true, Delete: true, ClearScope: true, PurgeExpired: true),
+ new Dictionary
+ {
+ ["storage"] = JsonSerializer.SerializeToElement("sqlite"),
+ ["durability"] = JsonSerializer.SerializeToElement("local")
+ });
+
+ public async Task InitializeAsync(CancellationToken cancellationToken)
+ {
+ await using var context = await contexts.CreateDbContextAsync(cancellationToken);
+ await context.Database.EnsureCreatedAsync(cancellationToken);
+ }
+
+ public async Task GetHealthAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await using var context = await contexts.CreateDbContextAsync(cancellationToken);
+ return await context.Database.CanConnectAsync(cancellationToken)
+ ? new("available")
+ : new("unavailable", "SQLite database is not reachable.");
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception)
+ {
+ return new("unavailable", "SQLite database health check failed.");
+ }
+ }
+
+ public async Task WriteAsync(AepMemoryRecord record, CancellationToken cancellationToken)
+ {
+ ValidateRecord(record);
+ await using var context = await contexts.CreateDbContextAsync(cancellationToken);
+ context.Records.Add(ToDocument(record));
+ await context.SaveChangesAsync(cancellationToken);
+ }
+
+ public async Task GetAsync(AepMemoryRecordRequest request, CancellationToken cancellationToken)
+ {
+ ValidateWorkspace(request.WorkspaceId);
+ if (request.RecordId == Guid.Empty) throw new ArgumentException("A record id is required.", nameof(request));
+ await using var context = await contexts.CreateDbContextAsync(cancellationToken);
+ var value = await context.Records.AsNoTracking().SingleOrDefaultAsync(
+ item => item.WorkspaceId == request.WorkspaceId && item.Id == request.RecordId,
+ cancellationToken);
+ return value is null ? null : FromDocument(value);
+ }
+
+ public async Task> ListAsync(AepMemoryListRequest request, CancellationToken cancellationToken)
+ {
+ ValidateWorkspace(request.WorkspaceId);
+ if (request.Skip < 0) throw new ArgumentOutOfRangeException(nameof(request), "Skip cannot be negative.");
+ if (request.Take is < 1 or > MaximumPageSize) throw new ArgumentOutOfRangeException(nameof(request), $"Take must be between 1 and {MaximumPageSize}.");
+ if (request.Scope is not null) ValidateScope(request.Scope);
+ await using var context = await contexts.CreateDbContextAsync(cancellationToken);
+ var now = request.Now.UtcTicks;
+ var query = context.Records.AsNoTracking().Where(value => value.WorkspaceId == request.WorkspaceId && (value.ExpiresAt == null || value.ExpiresAt > now));
+ if (request.Scope is not null)
+ {
+ var kind = request.Scope.Kind;
+ var key = request.Scope.Key;
+ query = query.Where(value => value.ScopeKind == kind && value.ScopeKey == key);
+ }
+ var values = await query.OrderByDescending(value => value.CreatedAt).ThenBy(value => value.Id)
+ .Skip(request.Skip).Take(request.Take).ToArrayAsync(cancellationToken);
+ return values.Select(FromDocument).ToArray();
+ }
+
+ public async Task DeleteAsync(AepMemoryRecordRequest request, CancellationToken cancellationToken)
+ {
+ ValidateWorkspace(request.WorkspaceId);
+ if (request.RecordId == Guid.Empty) throw new ArgumentException("A record id is required.", nameof(request));
+ await using var context = await contexts.CreateDbContextAsync(cancellationToken);
+ return await context.Records.Where(value => value.WorkspaceId == request.WorkspaceId && value.Id == request.RecordId)
+ .ExecuteDeleteAsync(cancellationToken) == 1;
+ }
+
+ public async Task ClearScopeAsync(AepMemoryScopeRequest request, CancellationToken cancellationToken)
+ {
+ ValidateWorkspace(request.WorkspaceId);
+ ValidateScope(request.Scope);
+ await using var context = await contexts.CreateDbContextAsync(cancellationToken);
+ return await context.Records.Where(value => value.WorkspaceId == request.WorkspaceId && value.ScopeKind == request.Scope.Kind && value.ScopeKey == request.Scope.Key)
+ .ExecuteDeleteAsync(cancellationToken);
+ }
+
+ public async Task PurgeExpiredAsync(AepMemoryPurgeRequest request, CancellationToken cancellationToken)
+ {
+ ValidateWorkspace(request.WorkspaceId);
+ if (request.Take is < 1 or > MaximumPageSize) throw new ArgumentOutOfRangeException(nameof(request), $"Take must be between 1 and {MaximumPageSize}.");
+ await using var context = await contexts.CreateDbContextAsync(cancellationToken);
+ var now = request.Now.UtcTicks;
+ var ids = await context.Records.Where(value => value.WorkspaceId == request.WorkspaceId && value.ExpiresAt != null && value.ExpiresAt <= now)
+ .OrderBy(value => value.ExpiresAt).ThenBy(value => value.Id).Take(request.Take).Select(value => value.Id).ToArrayAsync(cancellationToken);
+ var deleted = 0;
+ foreach (var id in ids)
+ deleted += await context.Records.Where(value => value.WorkspaceId == request.WorkspaceId && value.Id == id).ExecuteDeleteAsync(cancellationToken);
+ return deleted;
+ }
+
+ private static void ValidateRecord(AepMemoryRecord record)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ ValidateWorkspace(record.WorkspaceId);
+ if (record.Id == Guid.Empty) throw new ArgumentException("A record id is required.", nameof(record));
+ ValidateScope(record.Scope);
+ if (string.IsNullOrWhiteSpace(record.Content) || record.Content.Length > 4_096) throw new ArgumentException("Content must contain at most 4096 characters.", nameof(record));
+ if (record.Tags.Count > 16 || record.Tags.Any(value => string.IsNullOrWhiteSpace(value) || value.Length > 64)) throw new ArgumentException("Tags are invalid.", nameof(record));
+ if (string.IsNullOrWhiteSpace(record.Provenance.Reason) || record.Provenance.Reason.Length > 512) throw new ArgumentException("Provenance reason is invalid.", nameof(record));
+ if (record.Provenance.CreatedByPrincipalId == Guid.Empty) throw new ArgumentException("A creating principal is required.", nameof(record));
+ if (record.Provenance.SourceId?.Length > 256) throw new ArgumentException("Source id is too long.", nameof(record));
+ }
+
+ private static void ValidateWorkspace(Guid workspaceId)
+ {
+ if (workspaceId == Guid.Empty) throw new ArgumentException("A Workspace id is required.", nameof(workspaceId));
+ }
+
+ private static void ValidateScope(AepMemoryScope scope)
+ {
+ ArgumentNullException.ThrowIfNull(scope);
+ if (scope.Kind is not ("Agent" or "Shared") || string.IsNullOrWhiteSpace(scope.Key) || scope.Key.Length > 256)
+ throw new ArgumentException("The Memory scope is invalid.", nameof(scope));
+ }
+
+ private static SqliteAepMemoryRecordDocument ToDocument(AepMemoryRecord value) => new()
+ {
+ WorkspaceId = value.WorkspaceId,
+ Id = value.Id,
+ ScopeKind = value.Scope.Kind,
+ ScopeKey = value.Scope.Key,
+ Content = value.Content,
+ TagsJson = JsonSerializer.Serialize(value.Tags, JsonOptions),
+ SourceKind = value.Provenance.SourceKind,
+ SourceId = value.Provenance.SourceId,
+ Reason = value.Provenance.Reason,
+ CreatedByPrincipalId = value.Provenance.CreatedByPrincipalId,
+ CreatedAt = value.CreatedAt.UtcTicks,
+ ExpiresAt = value.ExpiresAt?.UtcTicks
+ };
+
+ private static AepMemoryRecord FromDocument(SqliteAepMemoryRecordDocument value) => new(
+ value.Id,
+ value.WorkspaceId,
+ new(value.ScopeKind, value.ScopeKey),
+ value.Content,
+ JsonSerializer.Deserialize(value.TagsJson, JsonOptions) ?? [],
+ new(value.SourceKind, value.SourceId, value.Reason, value.CreatedByPrincipalId),
+ new DateTimeOffset(value.CreatedAt, TimeSpan.Zero),
+ value.ExpiresAt is null ? null : new DateTimeOffset(value.ExpiresAt.Value, TimeSpan.Zero));
+}
diff --git a/src/Agentstration.Infrastructure/AgentExecutionCoordinator.cs b/src/Agentstration.Infrastructure/AgentExecutionCoordinator.cs
index b585bdc5..9768b21d 100644
--- a/src/Agentstration.Infrastructure/AgentExecutionCoordinator.cs
+++ b/src/Agentstration.Infrastructure/AgentExecutionCoordinator.cs
@@ -1,15 +1,19 @@
using Agentstration.Management.Abstractions;
+using Agentstration.Management.Core;
using Agentstration.Runtime.Abstractions;
+using Agentstration.Runtime.Core;
namespace Agentstration.Infrastructure;
-public sealed record SelectedAgentRoute(AgentRouteResult Route, string DeploymentId);
+public sealed record SelectedAgentRoute(AgentRouteResult Route, string DeploymentId, ExecutableAgentDefinition Definition);
public sealed class AgentExecutionCoordinator(
IControlPlaneStore store,
IAgentResourceQueries agentQueries,
IAgentRouter router,
- IRuntimeRegistry runtimes)
+ IRuntimeRegistry runtimes,
+ IAgentExecutionContextAssembler? contextAssembler = null,
+ ICurrentRequestContext? requestContext = null)
{
public async Task<(AgentRouteResult Route, AgentExecutionResult Execution)> RouteAndExecuteAsync(
string input,
@@ -57,13 +61,25 @@ public async Task SelectAgentAsync(
: candidates.Any(candidate => candidate.AgentId == requestedAgentName)
? new AgentRouteResult(requestedAgentName, 1, "The caller explicitly requested this agent.")
: throw new InvalidOperationException($"Requested agent '{requestedAgentName}' is not ready or does not exist.");
- var deployment = newest.Single(item => item.Revision.Definition.AgentKey == route.AgentId).Deployment;
- return new SelectedAgentRoute(route, deployment.Uid.ToString("N"));
+ var selected = newest.Single(item => item.Revision.Definition.AgentKey == route.AgentId);
+ return new SelectedAgentRoute(route, selected.Deployment.Uid.ToString("N"), RuntimeAgentDefinitionMapper.ToExecutable(selected.Revision.Definition));
}
- public Task ExecuteSelectedAsync(
+ public async Task ExecuteSelectedAsync(
SelectedAgentRoute selected,
string input,
- CancellationToken cancellationToken) =>
- runtimes.ExecuteAsync(selected.DeploymentId, new AgentExecutionRequest(input), cancellationToken);
+ CancellationToken cancellationToken)
+ {
+ var request = new AgentExecutionRequest(input);
+ if (selected.Definition.Memory is not null)
+ {
+ if (contextAssembler is null || requestContext?.IsInitialized != true)
+ throw new InvalidOperationException("Memory-enabled Agent execution requires an initialized execution scope and context assembler.");
+ var current = requestContext.Current;
+ request = (await contextAssembler.AssembleAsync(new AgentExecutionContextRequest(
+ new RuntimeRunScope(current.TenantId, new Agentstration.Resources.WorkspaceId(current.WorkspaceId), current.PrincipalId),
+ selected.Definition, [new RuntimeRunMessage(RuntimeMessageRole.User, input)], null, Guid.NewGuid().ToString("N")), cancellationToken)).Request;
+ }
+ return await runtimes.ExecuteAsync(selected.DeploymentId, request, cancellationToken);
+ }
}
diff --git a/src/Agentstration.Infrastructure/Agents/DeterministicAgentRuntime.cs b/src/Agentstration.Infrastructure/Agents/DeterministicAgentRuntime.cs
index 04f890c9..adc12259 100644
--- a/src/Agentstration.Infrastructure/Agents/DeterministicAgentRuntime.cs
+++ b/src/Agentstration.Infrastructure/Agents/DeterministicAgentRuntime.cs
@@ -18,15 +18,23 @@ public Task GetResponseAsync(IEnumerable messages, Ch
{
return Task.FromResult(Route(content));
}
- var words = Words().Matches(content).Select(match => match.Value).ToArray();
+ var rememberedFacts = materialized
+ .Where(message => message.Text.StartsWith("Remembered facts follow.", StringComparison.Ordinal))
+ .SelectMany(message => message.Text.Split('\n').Skip(1))
+ .Where(line => line.StartsWith("- ", StringComparison.Ordinal))
+ .Select(line => line[2..].Trim())
+ .Where(line => line.Length > 0)
+ .ToArray();
+ var effectiveContent = rememberedFacts.Length == 0 ? content : $"{content} {string.Join(' ', rememberedFacts)}";
+ var words = Words().Matches(effectiveContent).Select(match => match.Value).ToArray();
var summary = string.Join(' ', words.Take(40));
if (words.Length > 40) summary += "…";
if (string.IsNullOrWhiteSpace(summary)) summary = "No textual content.";
var categories = new List();
- AddIfContains(content, categories, "artificial intelligence", "ai", "agent", "llm");
- AddIfContains(content, categories, "finance", "price", "invoice", "budget");
- AddIfContains(content, categories, "project", "project", "roadmap", "milestone");
+ AddIfContains(effectiveContent, categories, "artificial intelligence", "ai", "agent", "llm");
+ AddIfContains(effectiveContent, categories, "finance", "price", "invoice", "budget");
+ AddIfContains(effectiveContent, categories, "project", "project", "roadmap", "milestone");
if (categories.Count == 0) categories.Add("general");
var json = JsonSerializer.Serialize(new AgentExecutionResult(summary, categories));
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, json)));
diff --git a/src/Agentstration.Infrastructure/Agentstration.Infrastructure.csproj b/src/Agentstration.Infrastructure/Agentstration.Infrastructure.csproj
index 63393094..ec139859 100644
--- a/src/Agentstration.Infrastructure/Agentstration.Infrastructure.csproj
+++ b/src/Agentstration.Infrastructure/Agentstration.Infrastructure.csproj
@@ -1,5 +1,6 @@
+
@@ -7,6 +8,8 @@
+
+
diff --git a/src/Agentstration.Infrastructure/DependencyInjection.cs b/src/Agentstration.Infrastructure/DependencyInjection.cs
index 1171d778..1359447b 100644
--- a/src/Agentstration.Infrastructure/DependencyInjection.cs
+++ b/src/Agentstration.Infrastructure/DependencyInjection.cs
@@ -1,6 +1,6 @@
using Agentstration.Application;
using Agentstration.Application.Ingestion;
-using Agentstration.Application.Memory;
+using Agentstration.Application.Analysis;
using Agentstration.Application.Missions;
using Agentstration.Application.Routing;
using Agentstration.Application.Work;
@@ -12,6 +12,7 @@
using Agentstration.Infrastructure.Artifacts;
using Agentstration.Infrastructure.Events;
using Agentstration.Infrastructure.Flows;
+using Agentstration.Infrastructure.Memory;
using Agentstration.Infrastructure.Ingestion;
using Agentstration.Infrastructure.Missions;
using Agentstration.Infrastructure.Packs;
@@ -22,6 +23,8 @@
using Agentstration.Management.Abstractions;
using Agentstration.Management.Core;
using Agentstration.Management.Storage.Sqlite;
+using Agentstration.Memory.Storage.Abstractions;
+using Agentstration.Memory.Storage.Sqlite;
using Agentstration.ModelProviders;
using Agentstration.Runtime.Abstractions;
using Agentstration.Runtime.AgentFramework;
@@ -50,7 +53,8 @@ public static IServiceCollection AddAgentstration(
string? controlPlaneConnectionString = null,
string? workPlaneConnectionString = null,
string? flowConnectionString = null,
- string? runtimeConnectionString = null)
+ string? runtimeConnectionString = null,
+ string? memoryConnectionString = null)
{
services.AddSingleton(TimeProvider.System);
services.TryAddSingleton();
@@ -66,9 +70,8 @@ public static IServiceCollection AddAgentstration(
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
- services.AddSingleton();
- services.AddSingleton(provider => provider.GetRequiredService());
- services.AddSingleton(provider => provider.GetRequiredService());
+ services.AddSingleton();
+ services.AddSingleton(provider => provider.GetRequiredService());
aiOptions ??= new AiProviderOptions("Deterministic", new Uri("http://localhost/"), "deterministic", null);
services.AddSingleton(aiOptions);
var useManagedProfileResolver = string.Equals(aiOptions.Provider, "Managed", StringComparison.OrdinalIgnoreCase);
@@ -144,6 +147,7 @@ public static IServiceCollection AddAgentstration(
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
@@ -157,6 +161,14 @@ public static IServiceCollection AddAgentstration(
runtimeConnectionString ??= $"Data Source={Path.Combine(Path.GetDirectoryName(dataPath) ?? ".", "runtime-plane.db")}";
services.AddSqliteRuntimeRuns(runtimeConnectionString);
services.AddSingleton();
+ memoryConnectionString ??= $"Data Source={Path.Combine(Path.GetDirectoryName(dataPath) ?? ".", "memory-plane.db")}";
+ services.AddSqliteMemoryStorage(memoryConnectionString);
+ services.AddHttpClient("agentstration-aep-memory", client => client.Timeout = TimeSpan.FromSeconds(30));
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton(provider => provider.GetRequiredService());
+ services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.TryAddSingleton(new ToolExecutionCaptureOptions());
services.AddSingleton();
diff --git a/src/Agentstration.Infrastructure/Memory/ManagedMemoryRecordStoreResolver.cs b/src/Agentstration.Infrastructure/Memory/ManagedMemoryRecordStoreResolver.cs
new file mode 100644
index 00000000..9feecdca
--- /dev/null
+++ b/src/Agentstration.Infrastructure/Memory/ManagedMemoryRecordStoreResolver.cs
@@ -0,0 +1,73 @@
+using Agentstration.Aep.Abstractions;
+using Agentstration.Aep.Client;
+using Agentstration.Management.Abstractions;
+using Agentstration.Memory;
+using Agentstration.Memory.Storage.Abstractions;
+using Agentstration.Resources;
+using Agentstration.Tools.Mcp;
+
+namespace Agentstration.Infrastructure.Memory;
+
+public sealed class ManagedMemoryRecordStoreResolver(
+ IControlPlaneStore controlPlane,
+ IMemoryRecordStore builtin,
+ IAepExtensionEndpointResolver extensionEndpoints,
+ IHttpClientFactory httpClients) : IMemoryRecordStoreResolver
+{
+ public async ValueTask ResolveAsync(WorkspaceId workspaceId, MemoryProviderReference provider, CancellationToken cancellationToken)
+ {
+ // The reserved fallback initializes the local store before a request-scoped
+ // Workspace exists. Governed runtime calls use their explicit profile binding.
+ if (provider == MemoryProviderReference.Local) return builtin;
+ var @namespace = ResourceNamespace.Parse(provider.Namespace);
+ var resource = await controlPlane.GetAsync(new(ResourceKinds.MemoryProvider, provider.Name, @namespace), cancellationToken);
+ if (resource is null)
+ {
+ throw new InvalidOperationException($"Memory provider '{@namespace}/{provider.Name}' was not found.");
+ }
+ if (resource.Value.Definition.IntegrationKind == MemoryProviderIntegrationKind.Builtin) return builtin;
+ var configuration = resource.Value.Definition.Aep
+ ?? throw new InvalidOperationException($"Memory provider '{resource.Value.Address}' has no AEP binding.");
+ var endpoint = extensionEndpoints.Resolve(configuration.ExtensionId);
+ var http = httpClients.CreateClient("agentstration-aep-memory");
+ http.BaseAddress = endpoint;
+ var client = new AepClient(http);
+ var manifest = await client.DiscoverAsync(cancellationToken);
+ if (!string.Equals(manifest.Extension.Id, configuration.ExtensionId, StringComparison.Ordinal))
+ throw new InvalidOperationException($"Expected AEP extension '{configuration.ExtensionId}' but discovered '{manifest.Extension.Id}'.");
+ if (!(manifest.Contributions.MemoryProviders ?? []).Any(value => string.Equals(value.Id, configuration.ProviderId, StringComparison.Ordinal)))
+ throw new InvalidOperationException($"AEP extension '{configuration.ExtensionId}' does not provide Memory provider '{configuration.ProviderId}'.");
+ return new AepMemoryRecordStore(client.CreateMemoryProvider(configuration.ProviderId));
+ }
+}
+
+public sealed class AepMemoryRecordStore(AepMemoryProviderClient client) : IMemoryRecordStore
+{
+ public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ public Task AddAsync(MemoryRecord record, CancellationToken cancellationToken) => client.WriteAsync(ToAep(record), cancellationToken);
+
+ public async Task GetAsync(WorkspaceId workspaceId, MemoryRecordId id, CancellationToken cancellationToken) =>
+ FromAep(await client.GetAsync(new(workspaceId.Value, id.Value), cancellationToken));
+
+ public async Task> ListAsync(WorkspaceId workspaceId, MemoryScope? scope, DateTimeOffset now, int skip, int take, CancellationToken cancellationToken) =>
+ (await client.ListAsync(new(workspaceId.Value, scope is null ? null : ToAep(scope), now, skip, take), cancellationToken)).Select(value => FromAep(value)!).ToArray();
+
+ public Task DeleteAsync(WorkspaceId workspaceId, MemoryRecordId id, CancellationToken cancellationToken) =>
+ client.DeleteAsync(new(workspaceId.Value, id.Value), cancellationToken);
+
+ public Task ClearScopeAsync(WorkspaceId workspaceId, MemoryScope scope, CancellationToken cancellationToken) =>
+ client.ClearScopeAsync(new(workspaceId.Value, ToAep(scope)), cancellationToken);
+
+ public Task PurgeExpiredAsync(WorkspaceId workspaceId, DateTimeOffset now, int take, CancellationToken cancellationToken) =>
+ client.PurgeExpiredAsync(new(workspaceId.Value, now, take), cancellationToken);
+
+ private static AepMemoryScope ToAep(MemoryScope value) => new(value.Kind.ToString(), value.Key);
+ private static AepMemoryRecord ToAep(MemoryRecord value) => new(
+ value.Id.Value, value.WorkspaceId.Value, ToAep(value.Scope), value.Content, value.Tags,
+ new(value.Provenance.SourceKind.ToString(), value.Provenance.SourceId, value.Provenance.Reason, value.Provenance.CreatedByPrincipalId),
+ value.CreatedAt, value.ExpiresAt);
+ private static MemoryRecord? FromAep(AepMemoryRecord? value) => value is null ? null : new(
+ new(value.Id), new(value.WorkspaceId), new(Enum.Parse(value.Scope.Kind), value.Scope.Key), value.Content, value.Tags,
+ new(Enum.Parse(value.Provenance.SourceKind), value.Provenance.SourceId, value.Provenance.Reason, value.Provenance.CreatedByPrincipalId),
+ value.CreatedAt, value.ExpiresAt);
+}
diff --git a/src/Agentstration.Infrastructure/Packs/PackResourceHandlers.cs b/src/Agentstration.Infrastructure/Packs/PackResourceHandlers.cs
index 493dbbc9..67d99544 100644
--- a/src/Agentstration.Infrastructure/Packs/PackResourceHandlers.cs
+++ b/src/Agentstration.Infrastructure/Packs/PackResourceHandlers.cs
@@ -85,6 +85,23 @@ public async Task InstallAsync(PackResourceDocument resourc
private static ManagedPackResource Managed(PackResourceDocument resource, ResourceNamespace @namespace, string token) => new() { Namespace = @namespace, Kind = resource.Kind, Name = resource.Name, Path = resource.Path, VersionToken = token };
}
+public sealed class MemoryProfilePackResourceHandler(MemoryProfileManagementService service) : IPackResourceHandler
+{
+ public string Kind => ResourceKinds.MemoryProfile;
+ public int InstallOrder => 35;
+ public Task ValidateAsync(PackResourceDocument resource, IReadOnlyList allResources, CancellationToken cancellationToken) { _ = Parse(resource); return Task.CompletedTask; }
+ public async Task ExistsAsync(ResourceNamespace @namespace, string name, CancellationToken cancellationToken) => await service.GetAsync(@namespace, name, cancellationToken) is not null;
+ public async Task InstallAsync(PackResourceDocument resource, PackIdentity pack, ResourceNamespace @namespace, string packVersion, CancellationToken cancellationToken)
+ {
+ var value = Parse(resource);
+ var stored = await service.CreateAsync(value with { Metadata = PackProvenance.Add(value.Metadata, pack, @namespace, packVersion) }, cancellationToken);
+ return new() { Namespace = @namespace, Kind = resource.Kind, Name = resource.Name, Path = resource.Path, VersionToken = stored.ETag };
+ }
+ public async Task GetVersionTokenAsync(ResourceNamespace @namespace, string name, CancellationToken cancellationToken) => (await service.GetAsync(@namespace, name, cancellationToken))?.ETag;
+ public Task DeleteAsync(ManagedPackResource resource, CancellationToken cancellationToken) => service.DeleteAsync(resource.Namespace, resource.Name, resource.VersionToken, cancellationToken);
+ private static MemoryProfileResource Parse(PackResourceDocument resource) => ResourceManifestSerializer.FromJson(resource.Manifest.GetRawText());
+}
+
public sealed class AgentPackResourceHandler(AgentManagementService service) : IPackResourceHandler
{
public string Kind => ResourceKinds.Agent;
diff --git a/src/Agentstration.Infrastructure/Packs/WorkspacePackResourceCatalog.cs b/src/Agentstration.Infrastructure/Packs/WorkspacePackResourceCatalog.cs
index b6227ca4..de4ba893 100644
--- a/src/Agentstration.Infrastructure/Packs/WorkspacePackResourceCatalog.cs
+++ b/src/Agentstration.Infrastructure/Packs/WorkspacePackResourceCatalog.cs
@@ -33,6 +33,10 @@ public async Task> ListAsync(Cancellat
.Select(value => ModelProfileItem(value.Value)));
resources.AddRange((await store.ListAsync(ResourceNamespace.Default, ResourceKinds.ModelProvider, 0, 1000, cancellationToken))
.Select(value => ModelProviderItem(value.Value)));
+ resources.AddRange((await store.ListAsync(ResourceNamespace.Default, ResourceKinds.MemoryProfile, 0, 1000, cancellationToken))
+ .Select(value => MemoryProfileItem(value.Value)));
+ resources.AddRange((await store.ListAsync(ResourceNamespace.Default, ResourceKinds.MemoryProvider, 0, 1000, cancellationToken))
+ .Select(value => BindingItem(value.Value, value.Value.Definition.DisplayName, "Memory Providers are environment bindings; Memory records are never exported.")));
resources.AddRange((await store.ListAsync(ResourceNamespace.Default, ResourceKinds.RuntimeProfile, 0, 1000, cancellationToken))
.Select(value => RuntimeProfileItem(value.Value)));
resources.AddRange((await store.ListAsync(ResourceNamespace.Default, ResourceKinds.Secret, 0, 1000, cancellationToken))
@@ -58,6 +62,8 @@ public async Task> ListAsync(Cancellat
ResourceKinds.Entry => await GetEntryAsync(resource, cancellationToken),
ResourceKinds.ModelProfile => await GetModelProfileAsync(resource, cancellationToken),
ResourceKinds.ModelProvider => await GetModelProviderAsync(resource, cancellationToken),
+ ResourceKinds.MemoryProfile => await GetMemoryProfileAsync(resource, cancellationToken),
+ ResourceKinds.MemoryProvider => await GetBindingAsync(resource, PackBindingTargetKind.MemoryProvider, cancellationToken),
ResourceKinds.RuntimeProfile => await GetRuntimeProfileAsync(resource, cancellationToken),
ResourceKinds.Secret => await GetBindingAsync(resource, PackBindingTargetKind.Secret, cancellationToken),
_ => (await ListAsync(cancellationToken)).Where(value => value.Resource.Address == resource.Address).Select(value => new PackCompositionResourceSnapshot(value, [])).SingleOrDefault()
@@ -74,6 +80,7 @@ public async Task ExportAsync(
ResourceKinds.Entry => await ExportEntryAsync(resource, cancellationToken),
ResourceKinds.ModelProfile => await ExportModelProfileAsync(resource, bindings, cancellationToken),
ResourceKinds.ModelProvider => await ExportModelProviderAsync(resource, bindings, cancellationToken),
+ ResourceKinds.MemoryProfile => await ExportMemoryProfileAsync(resource, bindings, cancellationToken),
ResourceKinds.RuntimeProfile => await ExportRuntimeProfileAsync(resource, cancellationToken),
_ => throw new InvalidOperationException($"Resource kind '{resource.Kind}' is not exportable by the Pack Composer.")
};
@@ -87,6 +94,8 @@ public async Task ExportAsync(
{
BindingDependency(agent.Definition.ModelProfile, agent.Namespace, ResourceKinds.ModelProfile, PackBindingTargetKind.ModelProfile, "modelProfile")
};
+ if (agent.Definition.Memory is { } memory)
+ dependencies.Add(IncludeDependency(memory.Profile.Name, memory.Profile.Namespace ?? agent.Namespace, ResourceKinds.MemoryProfile, "memoryProfile"));
dependencies.AddRange(agent.Definition.Tools.Select(tool => UnsupportedDependency(tool, agent.Namespace, ResourceKinds.Tool, "tool")));
return new(AgentItem(agent) with { DependencyCount = dependencies.Count }, dependencies);
}
@@ -135,6 +144,18 @@ public async Task ExportAsync(
return new(ModelProviderItem(provider) with { DependencyCount = dependencies.Length }, dependencies);
}
+ private async Task GetMemoryProfileAsync(PackCompositionResourceKey key, CancellationToken token)
+ {
+ var stored = await store.GetAsync(ResourceKey.Create(ResourceKinds.MemoryProfile, key.Name, key.NamespaceValue), token);
+ if (stored is null) return null;
+ var profile = stored.Value;
+ var dependencies = new[]
+ {
+ BindingDependency(profile.Definition.Provider, profile.Namespace, ResourceKinds.MemoryProvider, PackBindingTargetKind.MemoryProvider, "provider")
+ };
+ return new(MemoryProfileItem(profile) with { DependencyCount = 1 }, dependencies);
+ }
+
private async Task GetRuntimeProfileAsync(PackCompositionResourceKey key, CancellationToken token)
{
var stored = await store.GetAsync(ResourceKey.Create(ResourceKinds.RuntimeProfile, key.Name, key.NamespaceValue), token);
@@ -151,6 +172,7 @@ public async Task ExportAsync(
var displayName = stored.Value switch
{
ModelProfileResource profile => profile.Definition.DisplayName,
+ MemoryProviderResource provider => provider.Definition.DisplayName,
SecretResource secret => secret.Definition.DisplayName,
_ => stored.Value.Name
};
@@ -268,6 +290,21 @@ private async Task ExportModelProviderAsync(
return ToElement(node);
}
+ private async Task ExportMemoryProfileAsync(PackCompositionResourceKey key, IReadOnlyDictionary bindings, CancellationToken token)
+ {
+ var profile = (await store.GetAsync(ResourceKey.Create(ResourceKinds.MemoryProfile, key.Name, key.NamespaceValue), token))?.Value
+ ?? throw new KeyNotFoundException($"Memory Profile '{key.Name}' was not found.");
+ var clean = profile with
+ {
+ Uid = Guid.Empty, TenantId = Guid.Empty, WorkspaceId = Guid.Empty, Generation = 1, ETag = null,
+ Metadata = CleanMetadata(profile.Metadata), Status = new ResourceStatus { ProvisioningState = ProvisioningState.Accepted }
+ };
+ var node = JsonSerializer.SerializeToNode(clean, JsonOptions)!.AsObject();
+ var target = profile.Definition.Provider.Resolve(profile.Namespace, ResourceKinds.MemoryProvider);
+ node["definition"]!.AsObject()["provider"] = BindingNode(bindings, target);
+ return ToElement(node);
+ }
+
private async Task ExportRuntimeProfileAsync(PackCompositionResourceKey key, CancellationToken token)
{
var runtime = (await store.GetAsync(ResourceKey.Create(ResourceKinds.RuntimeProfile, key.Name, key.NamespaceValue), token))?.Value
@@ -351,6 +388,7 @@ private async Task AddUnsupportedAsync(ICollection new() { Resource = new(ResourceKinds.Entry, value.Name, value.Id.Namespace), DisplayName = value.DisplayName, Description = value.Description, Version = value.Revision.ToString(System.Globalization.CultureInfo.InvariantCulture), Status = value.PublishedBinding is null ? "Draft" : "Published" };
private static PackCompositionCatalogItem ModelProfileItem(ModelProfileResource value) => new() { Resource = new(ResourceKinds.ModelProfile, value.Name, value.Namespace), DisplayName = value.Definition.DisplayName, Description = value.Definition.Description, Version = value.Generation.ToString(System.Globalization.CultureInfo.InvariantCulture), Status = value.Status.ProvisioningState.ToString() };
private static PackCompositionCatalogItem ModelProviderItem(ModelProviderResource value) => new() { Resource = new(ResourceKinds.ModelProvider, value.Name, value.Namespace), DisplayName = value.Definition.DisplayName, Description = $"{value.Definition.ProviderType} · {value.Definition.Endpoint}", Version = value.Generation.ToString(System.Globalization.CultureInfo.InvariantCulture), Status = value.Status.ProvisioningState.ToString() };
+ private static PackCompositionCatalogItem MemoryProfileItem(MemoryProfileResource value) => new() { Resource = new(ResourceKinds.MemoryProfile, value.Name, value.Namespace), DisplayName = value.Definition.DisplayName, Description = value.Definition.Description, Version = value.Generation.ToString(System.Globalization.CultureInfo.InvariantCulture), Status = value.Status.ProvisioningState.ToString() };
private static PackCompositionCatalogItem RuntimeProfileItem(RuntimeProfileResource value) => new() { Resource = new(ResourceKinds.RuntimeProfile, value.Name, value.Namespace), DisplayName = value.Definition.DisplayName, Description = $"Runtime type: {value.Definition.RuntimeType}", Version = value.Generation.ToString(System.Globalization.CultureInfo.InvariantCulture), Status = value.Status.ProvisioningState.ToString() };
private static PackCompositionCatalogItem BindingItem(Resource value, string displayName, string reason) => new() { Resource = new(value.Kind, value.Name, value.Namespace), DisplayName = displayName, Status = value.Status.ProvisioningState.ToString(), Availability = PackCompositionAvailability.BindingOnly, AvailabilityReason = reason };
private static PackCompositionDependency IncludeDependency(string name, ResourceNamespace @namespace, string kind, string relationship) => new() { Target = new(kind, name, @namespace), Relationship = relationship };
@@ -367,7 +405,7 @@ private static JsonNode ReferenceNode(IReadOnlyDictionary WithoutProvenance(IReadOnlyDictionary values) => values.Where(pair => !pair.Key.StartsWith("agentstration.io/pack.", StringComparison.Ordinal)).ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
private static string DisplayName(Resource value) => value switch { ModelProviderResource provider => provider.Definition.DisplayName, RuntimeProfileResource runtime => runtime.Definition.DisplayName, VaultResource vault => vault.Definition.DisplayName, ToolProviderResource provider => provider.Definition.DisplayName, ToolResource tool => tool.Definition.DisplayName, _ => value.Name };
private static bool Dynamic(string value) => value.StartsWith("${", StringComparison.Ordinal);
- private static string BindingLabel(PackBindingTargetKind kind) => kind switch { PackBindingTargetKind.Secret => "Secret", PackBindingTargetKind.ModelProvider => "Model Provider", _ => "Model Profile" };
- private static int KindOrder(string kind) => kind switch { ResourceKinds.Entry => 10, ResourceKinds.Flow => 20, ResourceKinds.Agent => 30, ResourceKinds.ModelProfile => 40, ResourceKinds.ModelProvider => 50, ResourceKinds.RuntimeProfile => 60, ResourceKinds.Secret => 70, _ => 100 };
+ private static string BindingLabel(PackBindingTargetKind kind) => kind switch { PackBindingTargetKind.Secret => "Secret", PackBindingTargetKind.ModelProvider => "Model Provider", PackBindingTargetKind.MemoryProvider => "Memory Provider", PackBindingTargetKind.MemoryProfile => "Memory Profile", _ => "Model Profile" };
+ private static int KindOrder(string kind) => kind switch { ResourceKinds.Entry => 10, ResourceKinds.Flow => 20, ResourceKinds.Agent => 30, ResourceKinds.ModelProfile => 40, ResourceKinds.MemoryProfile => 45, ResourceKinds.ModelProvider => 50, ResourceKinds.MemoryProvider => 55, ResourceKinds.RuntimeProfile => 60, ResourceKinds.Secret => 70, _ => 100 };
private static JsonSerializerOptions CreateJsonOptions() { var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true }; options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); return options; }
}
diff --git a/src/Agentstration.Infrastructure/Persistence/InMemoryPlatformStore.cs b/src/Agentstration.Infrastructure/Persistence/InMemoryPlatformStore.cs
index 000d2df2..cf801ccd 100644
--- a/src/Agentstration.Infrastructure/Persistence/InMemoryPlatformStore.cs
+++ b/src/Agentstration.Infrastructure/Persistence/InMemoryPlatformStore.cs
@@ -18,21 +18,16 @@ public class InMemoryPlatformStore : IPlatformStore
public Task- GetItemAsync(WorkspaceId workspaceId, ItemId id, CancellationToken cancellationToken) => ReadAsync(state => state.Items.FirstOrDefault(x => x.WorkspaceId == workspaceId && x.Id == id));
public Task GetRawContentAsync(WorkspaceId workspaceId, ItemId id, CancellationToken cancellationToken) => ReadAsync(state => state.RawContents.FirstOrDefault(x => x.WorkspaceId == workspaceId && x.ItemId == id));
public Task GetNormalizedContentAsync(WorkspaceId workspaceId, ItemId id, CancellationToken cancellationToken) => ReadAsync(state => state.NormalizedContents.FirstOrDefault(x => x.WorkspaceId == workspaceId && x.ItemId == id));
- public Task> GetItemMemoryAsync(WorkspaceId workspaceId, ItemId id, CancellationToken cancellationToken) => ReadAsync>(state => state.MemoryEntries.Where(x => x.WorkspaceId == workspaceId && x.ItemId == id).OrderByDescending(x => x.CreatedAt).ToArray());
+ public Task> GetItemAnalysesAsync(WorkspaceId workspaceId, ItemId id, CancellationToken cancellationToken) => ReadAsync>(state => state.ItemAnalyses.Where(x => x.WorkspaceId == workspaceId && x.ItemId == id).OrderByDescending(x => x.CreatedAt).ToArray());
public Task> ListMissionsAsync(WorkspaceId id, CancellationToken cancellationToken) => ReadAsync>(state => state.Missions.Where(x => x.WorkspaceId == id).OrderBy(x => x.Name).ToArray());
public Task GetMissionAsync(WorkspaceId workspaceId, MissionId id, CancellationToken cancellationToken) => ReadAsync(state => state.Missions.FirstOrDefault(x => x.WorkspaceId == workspaceId && x.Id == id));
public Task> ListMissionRunsAsync(WorkspaceId workspaceId, MissionId missionId, CancellationToken cancellationToken) => ReadAsync>(state => state.MissionRuns.Where(x => x.WorkspaceId == workspaceId && x.MissionId == missionId).OrderByDescending(x => x.StartedAt).ToArray());
public Task> ListNotificationsAsync(WorkspaceId workspaceId, MissionId missionId, CancellationToken cancellationToken) => ReadAsync>(state => state.Notifications.Where(x => x.WorkspaceId == workspaceId && x.MissionId == missionId).OrderByDescending(x => x.CreatedAt).ToArray());
- public Task> SearchMemoryAsync(WorkspaceId workspaceId, string query, int limit, CancellationToken cancellationToken) =>
- ReadAsync>(state => state.MemoryEntries
- .Where(x => x.WorkspaceId == workspaceId && (string.IsNullOrEmpty(query) || x.Content.Contains(query, StringComparison.OrdinalIgnoreCase) || x.Categories.Any(c => c.Contains(query, StringComparison.OrdinalIgnoreCase))))
- .OrderByDescending(x => x.CreatedAt).Take(limit).ToArray());
-
public Task AddWorkspaceAsync(Workspace value, CancellationToken token) => MutateAsync(state => state.Workspaces.Add(value), token);
public Task AddInboxAsync(Inbox value, CancellationToken token) => MutateAsync(state => state.Inboxes.Add(value), token);
public Task AddNormalizedContentAsync(NormalizedContent value, CancellationToken token) => MutateAsync(state => { state.NormalizedContents.RemoveAll(x => x.WorkspaceId == value.WorkspaceId && x.ItemId == value.ItemId); state.NormalizedContents.Add(value); }, token);
- public Task AddMemoryEntryAsync(MemoryEntry value, CancellationToken token) => MutateAsync(state => state.MemoryEntries.Add(value), token);
+ public Task AddItemAnalysisAsync(ItemAnalysis value, CancellationToken token) => MutateAsync(state => state.ItemAnalyses.Add(value), token);
public Task AddMissionAsync(Mission value, CancellationToken token) => MutateAsync(state => state.Missions.Add(value), token);
public Task AddMissionRunAsync(MissionRun value, CancellationToken token) => MutateAsync(state => state.MissionRuns.Add(value), token);
public Task AddNotificationAsync(Notification value, CancellationToken token) => MutateAsync(state => state.Notifications.Add(value), token);
@@ -75,7 +70,7 @@ public sealed class PlatformState
public List
- Items { get; init; } = [];
public List RawContents { get; init; } = [];
public List NormalizedContents { get; init; } = [];
- public List MemoryEntries { get; init; } = [];
+ public List ItemAnalyses { get; init; } = [];
public List Missions { get; init; } = [];
public List MissionRuns { get; init; } = [];
public List Notifications { get; init; } = [];
diff --git a/src/Agentstration.Infrastructure/Persistence/Postgres/Migrations/202607310001_InitialCreate.cs b/src/Agentstration.Infrastructure/Persistence/Postgres/Migrations/202607310001_InitialCreate.cs
index 58ce3ac1..ac57892e 100644
--- a/src/Agentstration.Infrastructure/Persistence/Postgres/Migrations/202607310001_InitialCreate.cs
+++ b/src/Agentstration.Infrastructure/Persistence/Postgres/Migrations/202607310001_InitialCreate.cs
@@ -36,17 +36,15 @@ protected override void Up(MigrationBuilder migrationBuilder)
NormalizedContent = table.Column(type: "text", nullable: true),
CreatedAt = table.Column(type: "timestamp with time zone", nullable: false)
}, constraints: table => table.PrimaryKey("PK_items", x => x.Id));
- migrationBuilder.CreateTable(name: "memory_entries", schema: "agent_platform", columns: table => new
+ migrationBuilder.CreateTable(name: "item_analyses", schema: "agent_platform", columns: table => new
{
Id = table.Column(type: "uuid", nullable: false),
WorkspaceId = table.Column(type: "uuid", nullable: false),
- ItemId = table.Column(type: "uuid", nullable: true),
- MissionId = table.Column(type: "uuid", nullable: true),
- Kind = table.Column(type: "text", nullable: false),
- Content = table.Column(type: "text", nullable: false),
+ ItemId = table.Column(type: "uuid", nullable: false),
+ Summary = table.Column(type: "text", nullable: false),
CategoriesJson = table.Column(type: "jsonb", nullable: false),
CreatedAt = table.Column(type: "timestamp with time zone", nullable: false)
- }, constraints: table => table.PrimaryKey("PK_memory_entries", x => x.Id));
+ }, constraints: table => table.PrimaryKey("PK_item_analyses", x => x.Id));
migrationBuilder.CreateTable(name: "missions", schema: "agent_platform", columns: table => new
{
Id = table.Column(type: "uuid", nullable: false),
@@ -75,7 +73,7 @@ protected override void Up(MigrationBuilder migrationBuilder)
migrationBuilder.CreateIndex(name: "IX_inboxes_WorkspaceId_Slug", schema: "agent_platform", table: "inboxes", columns: new[] { "WorkspaceId", "Slug" }, unique: true);
migrationBuilder.CreateIndex(name: "IX_items_WorkspaceId_InboxId_ContentHash", schema: "agent_platform", table: "items", columns: new[] { "WorkspaceId", "InboxId", "ContentHash" }, unique: true);
migrationBuilder.CreateIndex(name: "IX_items_WorkspaceId_Status_CreatedAt", schema: "agent_platform", table: "items", columns: new[] { "WorkspaceId", "Status", "CreatedAt" });
- migrationBuilder.CreateIndex(name: "IX_memory_entries_WorkspaceId_ItemId_CreatedAt", schema: "agent_platform", table: "memory_entries", columns: new[] { "WorkspaceId", "ItemId", "CreatedAt" });
+ migrationBuilder.CreateIndex(name: "IX_item_analyses_WorkspaceId_ItemId_CreatedAt", schema: "agent_platform", table: "item_analyses", columns: new[] { "WorkspaceId", "ItemId", "CreatedAt" });
migrationBuilder.CreateIndex(name: "IX_missions_WorkspaceId_Status_NextRunAt", schema: "agent_platform", table: "missions", columns: new[] { "WorkspaceId", "Status", "NextRunAt" });
migrationBuilder.CreateIndex(name: "IX_mission_runs_WorkspaceId_MissionId_StartedAt", schema: "agent_platform", table: "mission_runs", columns: new[] { "WorkspaceId", "MissionId", "StartedAt" });
}
@@ -84,7 +82,7 @@ protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "inboxes", schema: "agent_platform");
migrationBuilder.DropTable(name: "items", schema: "agent_platform");
- migrationBuilder.DropTable(name: "memory_entries", schema: "agent_platform");
+ migrationBuilder.DropTable(name: "item_analyses", schema: "agent_platform");
migrationBuilder.DropTable(name: "mission_runs", schema: "agent_platform");
migrationBuilder.DropTable(name: "missions", schema: "agent_platform");
migrationBuilder.DropTable(name: "workspaces", schema: "agent_platform");
diff --git a/src/Agentstration.Infrastructure/Persistence/Postgres/PlatformDbContext.cs b/src/Agentstration.Infrastructure/Persistence/Postgres/PlatformDbContext.cs
index 7cf315cd..5e786712 100644
--- a/src/Agentstration.Infrastructure/Persistence/Postgres/PlatformDbContext.cs
+++ b/src/Agentstration.Infrastructure/Persistence/Postgres/PlatformDbContext.cs
@@ -7,7 +7,7 @@ public sealed class PlatformDbContext(DbContextOptions option
public DbSet Workspaces => Set();
public DbSet Inboxes => Set();
public DbSet Items => Set();
- public DbSet