diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 5dc3c5a..9a38593 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -11,7 +11,7 @@ body:
id: version
attributes:
label: Runtime version
- placeholder: 0.3.0-alpha.1
+ placeholder: 0.3.0-alpha.2
validations:
required: true
- type: dropdown
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b875dbc..efbf609 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,7 +10,7 @@ permissions:
contents: read
env:
- CI_PACKAGE_VERSION: 0.3.0-alpha.1
+ CI_PACKAGE_VERSION: 0.3.0-alpha.2
jobs:
dotnet:
@@ -74,7 +74,7 @@ jobs:
shell: pwsh
run: |
$archive = Join-Path $env:RUNNER_TEMP 'godot.zip'
- Invoke-WebRequest -Uri 'https://github.com/godotengine/godot/releases/download/4.7.1-stable/Godot_v4.7.1-stable_mono_win64.zip' -OutFile $archive
+ Invoke-WebRequest -Uri 'https://github.com/godotengine/godot/releases/download/4.7.1-stable/Godot_v4.7.1-stable_mono_win64.zip' -OutFile $archive -MaximumRetryCount 4 -RetryIntervalSec 5
$actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA512).Hash.ToLowerInvariant()
$expected = 'aa04876c7932c2e6807233e6908da4045e904781a83dc3c793c34ba71c9f66292eb4da82aa55a6b8694fccf856f8c244c7709cfba20405066445daf9c2749b71'
if ($actual -ne $expected) { throw 'Godot archive checksum mismatch.' }
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cfc55ae..8d5ae08 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -6,7 +6,7 @@ on:
version:
description: Semantic version for the artifacts
required: true
- default: 0.3.0-alpha.1
+ default: 0.3.0-alpha.2
publish:
description: Publish NuGet packages and the GitHub release
required: true
@@ -65,7 +65,7 @@ jobs:
run: |
$archive = Join-Path $env:RUNNER_TEMP 'godot.zip'
$root = Join-Path $env:RUNNER_TEMP 'godot'
- Invoke-WebRequest -Uri 'https://github.com/godotengine/godot/releases/download/4.7.1-stable/Godot_v4.7.1-stable_mono_win64.zip' -OutFile $archive
+ Invoke-WebRequest -Uri 'https://github.com/godotengine/godot/releases/download/4.7.1-stable/Godot_v4.7.1-stable_mono_win64.zip' -OutFile $archive -MaximumRetryCount 4 -RetryIntervalSec 5
$actual = (Get-FileHash -LiteralPath $archive -Algorithm SHA512).Hash.ToLowerInvariant()
$expected = 'aa04876c7932c2e6807233e6908da4045e904781a83dc3c793c34ba71c9f66292eb4da82aa55a6b8694fccf856f8c244c7709cfba20405066445daf9c2749b71'
if ($actual -ne $expected) { throw 'Godot archive checksum mismatch.' }
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7842b71..ef53d25 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## 0.3.0-alpha.2
+
+- Add the optional `OpenGameAgent.Memory` package with a model-agnostic embedding provider contract, authoritative-save verification, rebuildable local vector indexes, hybrid lexical/vector recall, structured diagnostics, and game-time-aware reranking.
+- Add deterministic authoritative memory snapshots for in-memory and local-file stores so derived indexes can be rebuilt explicitly after embedding model or preprocessing changes.
+- Document local source references and game-provided local embedding integration, including BGE-M3-compatible query/document adapters and save boundaries.
+- Make generated memory, delegation, structured-interaction, large-result artifact, external-knowledge artifact, and MCP artifact IDs stable across fresh runtime attempts, and keep engine project lock files aligned with the release version.
+
## 0.3.0-alpha.1
- Introduce a compact stateful streaming Agent kernel with typed content, validated tools, steering, follow-up, hooks, cancellation, transcript integrity checks, bounded concurrency, and explicit failure results.
diff --git a/CITATION.cff b/CITATION.cff
index 59b3ba1..4c24428 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -8,7 +8,7 @@ authors:
repository-code: "https://github.com/EricSun0218/OpenGameAgent"
url: "https://github.com/EricSun0218/OpenGameAgent"
license: "Apache-2.0"
-version: "0.3.0-alpha.1"
+version: "0.3.0-alpha.2"
keywords:
- game AI
- AI agents
diff --git a/Directory.Build.props b/Directory.Build.props
index 391345a..93b66c5 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -8,7 +8,7 @@
$(MSBuildThisFileDirectory)=/_/
embedded
0.3.0
- alpha.1
+ alpha.2
Eric Sun
Eric Sun
Copyright © 2026 Eric Sun
diff --git a/OpenGameAgent.sln b/OpenGameAgent.sln
index 08a85d4..b8771af 100644
--- a/OpenGameAgent.sln
+++ b/OpenGameAgent.sln
@@ -98,6 +98,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Plugins", "sr
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Plugins.Tests", "tests\OpenGameAgent.Plugins.Tests\OpenGameAgent.Plugins.Tests.csproj", "{5697E98C-2249-4D4C-894B-CB0A8732238E}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Memory", "src\OpenGameAgent.Memory\OpenGameAgent.Memory.csproj", "{00AE7836-01FA-4151-A38A-8263D9164A75}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Memory.Tests", "tests\OpenGameAgent.Memory.Tests\OpenGameAgent.Memory.Tests.csproj", "{5E8B096B-DD5F-4463-B841-7675F560B52D}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -288,6 +292,14 @@ Global
{5697E98C-2249-4D4C-894B-CB0A8732238E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5697E98C-2249-4D4C-894B-CB0A8732238E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5697E98C-2249-4D4C-894B-CB0A8732238E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {00AE7836-01FA-4151-A38A-8263D9164A75}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {00AE7836-01FA-4151-A38A-8263D9164A75}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {00AE7836-01FA-4151-A38A-8263D9164A75}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {00AE7836-01FA-4151-A38A-8263D9164A75}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5E8B096B-DD5F-4463-B841-7675F560B52D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5E8B096B-DD5F-4463-B841-7675F560B52D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5E8B096B-DD5F-4463-B841-7675F560B52D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5E8B096B-DD5F-4463-B841-7675F560B52D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{01759D73-7B80-47A2-9D7D-154CC64C6851} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8}
@@ -320,5 +332,7 @@ Global
{9CFA2749-BE81-45DE-A07B-CC005F87C5BD} = {86AE6217-BFEE-4349-945A-70ECEC211437}
{01A9B761-5567-4C17-B6ED-574B4089D413} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8}
{5697E98C-2249-4D4C-894B-CB0A8732238E} = {86AE6217-BFEE-4349-945A-70ECEC211437}
+ {00AE7836-01FA-4151-A38A-8263D9164A75} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8}
+ {5E8B096B-DD5F-4463-B841-7675F560B52D} = {86AE6217-BFEE-4349-945A-70ECEC211437}
EndGlobalSection
EndGlobal
diff --git a/README.md b/README.md
index 9728970..4bc30b1 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ OpenGameAgent brings the small, composable agent-kernel model to game developmen
Inputs are bounded JSON. They may represent dialogue, combat observations, simulation ticks, UI events, plans, sensor state, or any other game-owned data; natural language is optional. No model is bundled. Cloud and local API endpoints are both supported.
-> Current version: `0.3.0-alpha.1`. Public APIs can change before `1.0`.
+> Current version: `0.3.0-alpha.2`. Public APIs can change before `1.0`.
The kernel boundary is intentionally small and designed to stabilize early. New game-specific capabilities should normally arrive as extensions, tools, policies, workflows, or game-owned services instead of expanding the model/tool loop.
@@ -21,7 +21,8 @@ The kernel boundary is intentionally small and designed to stabilize early. New
Install the complete game runtime from NuGet:
```bash
-dotnet add package OpenGameAgent --version 0.3.0-alpha.1
+dotnet add package OpenGameAgent --version 0.3.0-alpha.2
+dotnet add package OpenGameAgent.Memory --version 0.3.0-alpha.2 # optional semantic memory
```
The kernel, persistence, providers, and engine-compatible client are also published as separate `OpenGameAgent.*` packages. Godot, Unity, and portable server archives are available on the [Releases](https://github.com/EricSun0218/OpenGameAgent/releases) page. See [Getting started](docs/getting-started.md) and [Engine integration](docs/engine-integration.md) before connecting a game.
@@ -38,6 +39,7 @@ OpenGameAgent keeps the reusable agent machinery independent from the game while
- per-actor serialization with bounded cross-actor concurrency;
- journaled action intents and authoritative game receipts;
- game-time memory filtering, expiry, and optional custom ranking;
+- optional local/remote embeddings, rebuildable vector indexes, and lexical/vector hybrid recall;
- skills selected by input type and available tools;
- recurring game-time triggers and persistent actor mailboxes;
- a typed extension API for tools, skills, routes, workflows, hooks, events, and services;
@@ -89,6 +91,7 @@ Read [Architecture](docs/architecture.md) for the ownership and failure boundari
| Providers | Native Anthropic, Amazon Bedrock, Google Gemini/Vertex, Mistral, OpenAI Responses/Azure, OpenAI-compatible, remote gateway, and message-gateway transports; retry/fallback decorators |
| Generated media | Provider-neutral image/audio/video registry, generic async HTTP jobs, and a dedicated OpenRouter image adapter with progressive previews |
| Persistence | Crash-tolerant local snapshots plus optional append-only session history, cross-process coordination, action journals, workflow checkpoints, memories, mailboxes, artifacts, delegations, skills, and prompt templates |
+| Semantic memory | Optional model-agnostic embeddings, authoritative-save verification, rebuildable local vector index, hybrid lexical/vector recall, structured diagnostics, and game-time reranking |
| Placement | Shared `netstandard2.1` runtime in Godot, Unity, or another C# host; optional .NET 8 HTTP/SSE service and engine client |
| Engines | Godot 4.7 .NET and Unity 6 packages, both exercised in real Windows editors |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 60f467a..bb49eab 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -12,7 +12,7 @@ OpenGameAgent 把小型、可组合的 Agent 内核带进游戏开发。它的
输入是有大小限制的 JSON,可以表示对话、战斗观察、模拟 Tick、UI 事件、计划、传感状态或任意游戏数据,不要求是自然语言。项目不捆绑模型,同时支持云端和本地 API。
-> 当前版本:`0.3.0-alpha.1`。在 `1.0` 前公开 API 仍可能调整。
+> 当前版本:`0.3.0-alpha.2`。在 `1.0` 前公开 API 仍可能调整。
内核边界刻意保持小而稳定。后续游戏特有能力通常应通过扩展、工具、策略、工作流或游戏自有服务加入,而不是继续膨胀模型/工具循环。
@@ -21,7 +21,8 @@ OpenGameAgent 把小型、可组合的 Agent 内核带进游戏开发。它的
从 NuGet 安装完整的游戏 Runtime:
```bash
-dotnet add package OpenGameAgent --version 0.3.0-alpha.1
+dotnet add package OpenGameAgent --version 0.3.0-alpha.2
+dotnet add package OpenGameAgent.Memory --version 0.3.0-alpha.2 # 可选语义记忆
```
内核、持久化、模型提供方和引擎兼容客户端也分别提供 `OpenGameAgent.*` 包。Godot、Unity 与可移植服务端压缩包可以从 [Releases](https://github.com/EricSun0218/OpenGameAgent/releases) 页面下载。接入游戏前请阅读[快速开始](docs/getting-started.md)和[引擎接入](docs/engine-integration.md)。
@@ -38,6 +39,7 @@ OpenGameAgent 不替游戏规定玩法,而是提供可复用的游戏坐标与
- 同一角色串行、不同角色有界并行;
- 先记日志的动作意图与游戏权威回执;
- 按游戏时间过滤、过期并可自定义排序的记忆;
+- 可选本地/远程嵌入、可重建向量索引与词法/向量混合召回;
- 根据输入类型和可用工具选择的 Skills;
- 游戏时间触发器与持久邮箱;
- 可扩展工具、Skills、路由、Workflow、Hooks、事件与服务的类型化接口;
@@ -87,6 +89,7 @@ GameAgentRuntime
| 提供方 | Anthropic、Amazon Bedrock、Google Gemini/Vertex、Mistral、OpenAI Responses/Azure、OpenAI-compatible、远程网关和消息网关;重试与回退包装器 |
| 生成式媒体 | 图片/语音/视频中立注册表、通用异步 HTTP 任务,以及带渐进预览的专用图片适配器 |
| 持久化 | 崩溃安全本地快照、可选追加式会话历史、跨进程协调、动作日志、Workflow 检查点、记忆、邮箱、产物、委派、Skills 与提示词模板 |
+| 语义记忆 | 可选模型无关嵌入、权威存档核验、可重建本地向量索引、词法/向量混合召回、结构化诊断与游戏时间重排 |
| 运行位置 | `netstandard2.1` 共享运行时可放在 Godot、Unity 或其他 C# 宿主;可选 .NET 8 HTTP/SSE 服务端与引擎客户端 |
| 引擎 | Godot 4.7 .NET 与 Unity 6 包,均已在 Windows 真实编辑器中通过测试 |
diff --git a/docs/agent-plugins.md b/docs/agent-plugins.md
index c1f2324..5eece22 100644
--- a/docs/agent-plugins.md
+++ b/docs/agent-plugins.md
@@ -32,7 +32,7 @@ Legacy HTTP+SSE is optional in Agent Plugins 1.0.0 and is not implemented. Its e
Install the optional adapter package alongside the core runtime:
```powershell
-dotnet add package OpenGameAgent.Plugins --version 0.3.0-alpha.1
+dotnet add package OpenGameAgent.Plugins --version 0.3.0-alpha.2
```
```csharp
diff --git a/docs/architecture.md b/docs/architecture.md
index 9a12640..c1cdff0 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -41,6 +41,7 @@ It does not own a universal world model. Context remains opaque JSON supplied by
### Optional packages
- `OpenGameAgent.Extensions` adds policy, searchable tools, structured player interaction, goals, memory, artifacts, external knowledge, delegation, tracing, and durable workflow graphs.
+- `OpenGameAgent.Memory` adds an optional, model-agnostic embedding contract, rebuildable vector index, lexical/vector hybrid recall, structured diagnostics, and game-time reranking. It never replaces the authoritative memory save.
- `OpenGameAgent.Models` adds provider/model catalogs, capability-aware selection, reasoning levels, cost metadata, dynamic refresh, and replaceable authentication.
- `OpenGameAgent.Models.BuiltIn` turns the bundled directory into an executable multi-provider model runtime; `OpenGameAgent.Models.Auth.BuiltIn` adds explicitly configured browser and device authorization flows.
- `OpenGameAgent.ProviderTransport` centralizes bounded response observations, header guards, and retry metadata without adding HTTP concepts to the kernel.
@@ -68,7 +69,7 @@ model tool call
-> receipt returned to model
```
-The default operation identity is derived from the stable game input ID, model turn, and tool-call source index. It therefore remains stable even when a provider changes its generated tool-call ID during a retry. A game can replace this with a semantic identity through `GameActionOperationIdFactory`. Replaying an already closed operation returns the stored receipt.
+The default versioned operation identity is derived from the session, actor, stable game input ID, action, timeline/tick, optional save generation, model turn, and tool-call source index. It therefore remains stable when the same logical call is replayed, but cannot collide across actors, sessions, actions, or save generations. A game can replace this with a semantic identity through `GameActionOperationIdFactory`. Replaying an already closed operation returns the stored receipt, while changed arguments or authority preconditions at the same identity fail closed.
The journal distinguishes `Prepared`, `Dispatched`, and a final receipt. If a process can fail after dispatch but before the receipt is recorded, `RecoverAsync` asks the game to reconcile the operation. The framework reports `Uncertain` when the game cannot prove the outcome; it never converts cancellation or a timeout into permission to repeat a write.
@@ -97,7 +98,7 @@ Large worlds should not invoke every NPC on every frame. Let deterministic game
`IGameContextProvider` supplies current authoritative context slices. Memory is intentionally separate: `IGameMemoryStore` stores and filters records, while game code decides which retrieved memories become a context slice. This avoids silently inserting stale or private memory.
-The included memory stores support scopes, kinds, tags, importance, owner, game-time cutoffs, and expiry. `RankedGameMemoryStore` can apply a game-selected vector, reranking, or domain-specific ranker without requiring an embedding model in the framework.
+The included memory stores support scopes, kinds, tags, importance, owner, game-time cutoffs, and expiry. `RankedGameMemoryStore` applies a game-selected ranker. The optional `OpenGameAgent.Memory` package adds model-agnostic vector indexing and hybrid recall while keeping the original store authoritative; a game supplies its local or remote embedding implementation and explicitly rebuilds after changing its model identity.
Skills are bounded instruction packages selected by input type and required tools. Skills do not install or execute code. Directory-backed skills accept either a zero-configuration `SKILL.md` with scalar `name` and `description` front matter, or `skill.json` plus a separate Markdown instruction file for game-specific filtering. Manifests are rescanned for each selection and only selected instruction files are loaded, allowing safe edits without rebuilding the runtime.
diff --git a/docs/features.md b/docs/features.md
index 77cecef..838eeae 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -51,8 +51,8 @@ This page maps product needs to the smallest reusable OpenGameAgent primitive.
| Run many NPCs concurrently | `GameRuntimeLimits.MaxConcurrentActors`, `MultiActorScheduler` |
| Correct or cancel an active NPC run | `GameAgentRuntime.TrySteer`, `GameAgentRuntime.TryAbort` |
| Persist transcripts and deduplicate inputs | `IGameSessionStore` |
-| Keep an append-only branch/lane audit history | `IGameSessionHistoryRepository`, `GameSessionHistory` |
-| Fork, search, page, or project a session history | `GameSessionHistory`, `GameHistoryContextProjection` |
+| Build a standalone append-only branch/lane audit history | `IGameSessionHistoryRepository`, `GameSessionHistory` |
+| Fork, search, page, or project that explicit history | `GameSessionHistory`, `GameHistoryContextProjection` |
| Compact a long transcript | `IGameTranscriptCompactor` |
## World actions and simulation
@@ -64,6 +64,8 @@ This page maps product needs to the smallest reusable OpenGameAgent primitive.
| Execute on engine main thread | implement `IGameActionHandler` by queueing into the engine, then await the receipt |
| Store long-term NPC facts/events | `IGameMemoryStore`, `GameMemory` |
| Apply custom semantic ranking | `IGameMemoryRanker`, `RankedGameMemoryStore` |
+| Add local or remote vector embeddings and hybrid recall | `IMemoryEmbeddingProvider`, `VectorMemoryStore` |
+| Inspect or explicitly rebuild vectors after a model change | `RuntimeMemoryLifecycle`, `VectorMemoryStatus` |
| Add reusable behavior instructions | `IGameSkillSource`, `GameSkill` |
| Load portable or game-filtered skills | `DirectoryGameSkillSource` (`SKILL.md` or `skill.json`) |
| Load reusable prompt templates with bounded arguments | `FileGamePromptTemplateLoader`, `GamePromptTemplate` |
@@ -120,7 +122,7 @@ OpenGameAgent does not prescribe:
- pathfinding, animation, physics, combat, inventory, quests, or construction code;
- who can observe which data;
- NPC activation and level-of-detail policy;
-- vector database or embedding model;
+- embedding model runtime or service (the optional memory package provides the contract and derived index);
- model vendor, prompt catalog, or monetization;
- visual UI, world editor, or downloadable world-package format.
diff --git a/docs/game-integration-patterns.md b/docs/game-integration-patterns.md
index 6880968..5a77757 100644
--- a/docs/game-integration-patterns.md
+++ b/docs/game-integration-patterns.md
@@ -30,7 +30,7 @@ game tick / month advance
`MultiActorScheduler` gives per-actor ordering and global concurrency. `GameTimeScheduler` emits bounded recurring occurrences. `IGameMailbox` carries durable work to actors that are not currently resident. The game supplies activation, distance, importance, and budget policy.
-Use `GoalLoopExtension` when an actor owns semantic goals that can wait for a tick or event and continue later. Use `AgentDelegationExtension` when one actor needs bounded background research or planning without sharing its mutable transcript. Delegates still receive explicitly scoped context and tools; delegation is not permission escalation.
+Use `GoalLoopExtension` when an actor owns semantic goals that can wait for a tick or event and continue later. Use `AgentDelegationExtension` when one actor needs bounded background research or planning without sharing its mutable transcript. Delegates still receive explicitly scoped context and tools; delegation is not permission escalation. Delegation status can be persisted, but the included local executor runs child work in the current process and does not automatically resume an in-flight child after a process restart. Use a host-owned durable workflow or executor when child execution itself must survive restarts.
## Monthly or turn-based evolution
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 053d181..d5733b0 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -104,6 +104,8 @@ Context is treated as data, not as a hidden state mutation channel.
Create tools per input so they can carry the stable input identity and actor scope. Prefer `GameActionTool.Create` for state changes.
+Pass an explicit, game-owned `inputId` to `GameInput` when an input can be retried after a disconnect or process restart. The optional constructor fallback creates a fresh unique ID; it cannot identify the same logical input in a later process.
+
```csharp
var dispatcher = new DurableGameActionDispatcher(actionJournal, gameActionHandler);
@@ -144,7 +146,7 @@ options.RoutePolicy = new AutomaticGameRoutePolicy(new Dictionary
+
+
+
+
+```
+
+`OpenGameAgent.Memory` already references the shared runtime. The explicit
+runtime reference is still recommended because game code normally constructs
+`GameAgentRuntime`, `GameInput`, and tools directly. Add provider or engine
+projects only when the game actually uses them. Do not reference binaries from
+the pre-rewrite `GameAgent.*` architecture; current namespaces are
+`OpenGameAgent.*`.
+
+## Supply a local embedding model
+
+Implement `IMemoryEmbeddingProvider` around a game-owned in-process runtime or
+localhost sidecar. BGE-M3 is one valid choice, but the framework never assumes
+its transport or preprocessing. Query and document entry points are separate
+because some embedding models use different task prefixes or modes.
+
+```csharp
+using OpenGameAgent.Memory;
+
+public sealed class LocalBgeM3Embeddings : IMemoryEmbeddingProvider
+{
+ private readonly ILocalEmbeddingClient _client;
+
+ public LocalBgeM3Embeddings(ILocalEmbeddingClient client) => _client = client;
+
+ // Change Version whenever weights, quantization, dimensions, pooling, or
+ // preprocessing changes. Existing vectors will then require a rebuild.
+ public MemoryEmbeddingIdentity Identity { get; } =
+ new("local", "bge-m3", "weights-v1-preprocess-v1", 1024);
+
+ public ValueTask> EmbedQueryAsync(
+ string text,
+ CancellationToken cancellationToken) =>
+ _client.EmbedAsync(text, isQuery: true, cancellationToken);
+
+ public ValueTask>> EmbedDocumentsAsync(
+ IReadOnlyList texts,
+ CancellationToken cancellationToken) =>
+ _client.EmbedBatchAsync(texts, isQuery: false, cancellationToken);
+
+ public ValueTask DisposeAsync() => _client.DisposeAsync();
+}
+```
+
+Compose the authoritative save store, derived vector directory, lexical/vector
+fusion, game-time reranker, and diagnostics:
+
+```csharp
+var authoritative = new FileGameMemoryStore(saveMemoryDirectory);
+var memory = new VectorMemoryStore(
+ authoritative,
+ new FileVectorMemoryIndex(derivedVectorDirectory),
+ new LocalBgeM3Embeddings(localClient),
+ reranker: new GameAwareMemoryReranker(),
+ diagnostics: diagnosticSink);
+
+var status = await memory.GetStatusAsync(sessionId, cancellationToken);
+if (status.RequiresRebuild)
+{
+ status = await memory.RebuildAsync(sessionId, cancellationToken);
+}
+
+var results = await memory.SearchAsync(
+ new GameMemoryQuery(
+ sessionId,
+ limit: 8,
+ ownerId: npcId,
+ text: playerQuery,
+ atOrBefore: currentGameMoment),
+ cancellationToken);
+```
+
+Use this `memory` instance anywhere an `IGameMemoryStore` is accepted, including
+`GameMemoryExtension`. `RuntimeMemoryLifecycle` is a small optional owner for
+inspection, explicit rebuild, and provider disposal.
+
+## Save and failure boundaries
+
+- `FileGameMemoryStore` is authoritative save data. It is written before any
+ embedding call.
+- `remember_game_memory` is an idempotent state-changing tool, not part of the
+ conversation-store transaction. Its generated ID is a versioned digest of
+ session, actor, input, game moment, turn, and tool position, so retrying the
+ same logical call reuses the same memory and conflicting content fails
+ closed. A host that needs memory and world state in one atomic transaction
+ should expose its own durable game action and append memory in that
+ authoritative transaction.
+- `FileVectorMemoryIndex` is derived data. Keep it outside the authoritative
+ save directory. It contains memory text and metadata along with vectors, but
+ never credentials.
+- Search verifies every vector candidate against the authoritative snapshot.
+ Orphaned or mismatched derived records fail closed and cannot enter context.
+- Embedding failure emits a structured `MemoryVectorDiagnostic` and normally
+ falls back to lexical recall. Set `FailWhenEmbeddingUnavailable` only when
+ semantic recall is mandatory.
+- Changing `MemoryEmbeddingIdentity` makes existing vectors stale. The runtime
+ excludes them and reports `RebuildRequired`; it never silently mixes models.
+- Rebuild is explicit, bounded, resumable by rerunning, and removes derived
+ records that no longer exist in the authoritative save. Run an explicit
+ rebuild while writes for that session are quiescent. A concurrent append is
+ never lost from the authoritative store, but it can leave the derived index
+ in `RebuildRequired` state and require one more rebuild.
+- The framework normalizes vectors and applies reciprocal-rank fusion before an
+ optional game-aware reranker. Game time, not wall-clock time, controls the
+ included reranker's recency signal.
+
+## Verification
+
+```powershell
+dotnet test tests/OpenGameAgent.Memory.Tests/OpenGameAgent.Memory.Tests.csproj -c Release
+dotnet test OpenGameAgent.sln -c Release
+```
diff --git a/docs/nuget-package-readme.md b/docs/nuget-package-readme.md
index 55dccf2..6ab2e2e 100644
--- a/docs/nuget-package-readme.md
+++ b/docs/nuget-package-readme.md
@@ -7,6 +7,7 @@ Open-source C# agent runtime for AI-native games, autonomous NPCs, and interacti
- Durable game actions and workflows
- Typed extension API plus official policy, catalog, interaction, goal, memory, artifact, delegation, tracing, and workflow-graph extensions
- Skills, scheduling, mailboxes, large-result spill, and multi-actor concurrency
+- Optional model-agnostic vector memory and hybrid lexical/semantic recall
- Optional Agent Plugins 1.0.0 package loading for portable skills and MCP servers
- Capability-aware model catalogs, replaceable authentication, and developer-hosted short-lived credentials
- Lazy external tool discovery; cloud or local text/image/audio/video APIs with no bundled model
diff --git a/engines/godot/addons/open_game_agent/packages.lock.json b/engines/godot/addons/open_game_agent/packages.lock.json
index f584a80..e6ba2f6 100644
--- a/engines/godot/addons/open_game_agent/packages.lock.json
+++ b/engines/godot/addons/open_game_agent/packages.lock.json
@@ -10,14 +10,14 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.client": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/engines/godot/addons/open_game_agent/plugin.cfg b/engines/godot/addons/open_game_agent/plugin.cfg
index d677d2f..d480d95 100644
--- a/engines/godot/addons/open_game_agent/plugin.cfg
+++ b/engines/godot/addons/open_game_agent/plugin.cfg
@@ -3,5 +3,5 @@
name="OpenGameAgent"
description="Game-native agent runtime node for Godot."
author="Eric Sun"
-version="0.3.0-alpha.1"
+version="0.3.0-alpha.2"
script=""
diff --git a/engines/godot/build-package.ps1 b/engines/godot/build-package.ps1
index 0169be7..a671029 100644
--- a/engines/godot/build-package.ps1
+++ b/engines/godot/build-package.ps1
@@ -2,7 +2,7 @@
param(
[Parameter(Mandatory = $true)]
[string] $GodotSharpDir,
- [string] $Version = '0.3.0-alpha.1',
+ [string] $Version = '0.3.0-alpha.2',
[string] $OutputDirectory
)
diff --git a/engines/godot/test-engine.ps1 b/engines/godot/test-engine.ps1
index 3f25ecd..41ff510 100644
--- a/engines/godot/test-engine.ps1
+++ b/engines/godot/test-engine.ps1
@@ -3,7 +3,8 @@ param(
[Parameter(Mandatory = $true)]
[string] $Godot,
[Parameter(Mandatory = $true)]
- [string] $GodotSharpDir
+ [string] $GodotSharpDir,
+ [switch] $KeepProject
)
Set-StrictMode -Version Latest
@@ -141,13 +142,47 @@ script = ExtResource("1")
& dotnet build (Join-Path $testRoot 'OpenGameAgent.Godot.Smoke.csproj') -c Debug
if ($LASTEXITCODE -ne 0) { throw 'Godot smoke project build failed.' }
- & $Godot --headless --path $testRoot --editor --quit
- if ($LASTEXITCODE -ne 0) { throw 'Godot editor import failed.' }
- & $Godot --headless --path $testRoot
- if ($LASTEXITCODE -ne 0) { throw 'Godot runtime smoke test failed.' }
+ $editorOut = Join-Path $testRoot 'godot-editor.stdout.log'
+ $editorErr = Join-Path $testRoot 'godot-editor.stderr.log'
+ $editor = Start-Process `
+ -FilePath $Godot `
+ -ArgumentList @('--headless', '--path', $testRoot, '--editor', '--quit') `
+ -PassThru `
+ -Wait `
+ -NoNewWindow `
+ -RedirectStandardOutput $editorOut `
+ -RedirectStandardError $editorErr
+ if ($editor.ExitCode -ne 0) {
+ $details = ((Get-Content -LiteralPath $editorOut -Raw -ErrorAction SilentlyContinue) +
+ (Get-Content -LiteralPath $editorErr -Raw -ErrorAction SilentlyContinue)).Trim()
+ throw "Godot editor import failed with exit code $($editor.ExitCode). $details"
+ }
+
+ $runtimeOut = Join-Path $testRoot 'godot-runtime.stdout.log'
+ $runtimeErr = Join-Path $testRoot 'godot-runtime.stderr.log'
+ $runtime = Start-Process `
+ -FilePath $Godot `
+ -ArgumentList @('--headless', '--path', $testRoot) `
+ -PassThru `
+ -Wait `
+ -NoNewWindow `
+ -RedirectStandardOutput $runtimeOut `
+ -RedirectStandardError $runtimeErr
+ $runtimeLog = ((Get-Content -LiteralPath $runtimeOut -Raw -ErrorAction SilentlyContinue) +
+ (Get-Content -LiteralPath $runtimeErr -Raw -ErrorAction SilentlyContinue)).Trim()
+ if ($runtime.ExitCode -ne 0) {
+ throw "Godot runtime smoke test failed with exit code $($runtime.ExitCode). $runtimeLog"
+ }
+
+ if ($runtimeLog -notmatch 'OPENGAMEAGENT_GODOT_SMOKE_OK') {
+ throw "Godot runtime did not execute the OpenGameAgent smoke scene. $runtimeLog"
+ }
}
finally {
- if (Test-Path -LiteralPath $testRoot) {
+ if ($KeepProject) {
+ Write-Verbose "Godot smoke project retained at $testRoot"
+ }
+ elseif (Test-Path -LiteralPath $testRoot) {
Remove-Item -LiteralPath $testRoot -Recurse -Force
}
}
diff --git a/engines/godot/test-package.ps1 b/engines/godot/test-package.ps1
index bc2c20e..8c84bb9 100644
--- a/engines/godot/test-package.ps1
+++ b/engines/godot/test-package.ps1
@@ -2,7 +2,7 @@
param(
[Parameter(Mandatory = $true)]
[string] $GodotSharpDir,
- [string] $Version = '0.3.0-alpha.1'
+ [string] $Version = '0.3.0-alpha.2'
)
Set-StrictMode -Version Latest
diff --git a/engines/unity/Packages/com.opengameagent.runtime/CHANGELOG.md b/engines/unity/Packages/com.opengameagent.runtime/CHANGELOG.md
index 91d1874..c1173e4 100644
--- a/engines/unity/Packages/com.opengameagent.runtime/CHANGELOG.md
+++ b/engines/unity/Packages/com.opengameagent.runtime/CHANGELOG.md
@@ -1,5 +1,9 @@
# Changelog
+## 0.3.0-alpha.2
+
+- Add the optional source/NuGet vector-memory package and authoritative memory snapshot contract.
+
## 0.3.0-alpha.1
- Initial focused package with local and remote runtime modes.
diff --git a/engines/unity/Packages/com.opengameagent.runtime/package.json b/engines/unity/Packages/com.opengameagent.runtime/package.json
index f42a057..1765863 100644
--- a/engines/unity/Packages/com.opengameagent.runtime/package.json
+++ b/engines/unity/Packages/com.opengameagent.runtime/package.json
@@ -1,6 +1,6 @@
{
"name": "com.opengameagent.runtime",
- "version": "0.3.0-alpha.1",
+ "version": "0.3.0-alpha.2",
"displayName": "OpenGameAgent",
"description": "In-process and server-hosted agent runtime for AI-native Unity games.",
"unity": "6000.0",
diff --git a/engines/unity/build-package.ps1 b/engines/unity/build-package.ps1
index 48a3c48..1d43510 100644
--- a/engines/unity/build-package.ps1
+++ b/engines/unity/build-package.ps1
@@ -2,7 +2,7 @@
param(
[Parameter(Mandatory = $true)]
[string] $UnityManagedDir,
- [string] $Version = '0.3.0-alpha.1',
+ [string] $Version = '0.3.0-alpha.2',
[string] $OutputDirectory
)
diff --git a/engines/unity/packages.lock.json b/engines/unity/packages.lock.json
index d751690..0d67273 100644
--- a/engines/unity/packages.lock.json
+++ b/engines/unity/packages.lock.json
@@ -66,14 +66,14 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.client": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/engines/unity/test-package.ps1 b/engines/unity/test-package.ps1
index 95f44d4..ec94287 100644
--- a/engines/unity/test-package.ps1
+++ b/engines/unity/test-package.ps1
@@ -2,7 +2,7 @@
param(
[Parameter(Mandatory = $true)]
[string] $UnityManagedDir,
- [string] $Version = '0.3.0-alpha.1'
+ [string] $Version = '0.3.0-alpha.2'
)
Set-StrictMode -Version Latest
diff --git a/examples/OpenGameAgent.Example/packages.lock.json b/examples/OpenGameAgent.Example/packages.lock.json
index a497331..a0981f7 100644
--- a/examples/OpenGameAgent.Example/packages.lock.json
+++ b/examples/OpenGameAgent.Example/packages.lock.json
@@ -10,7 +10,7 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -23,8 +23,8 @@
"opengameagent.providers.openaicompatible": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/src/OpenGameAgent.Client/packages.lock.json b/src/OpenGameAgent.Client/packages.lock.json
index 8da3c96..1a59cbd 100644
--- a/src/OpenGameAgent.Client/packages.lock.json
+++ b/src/OpenGameAgent.Client/packages.lock.json
@@ -67,7 +67,7 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/src/OpenGameAgent.Connectors.Mcp/McpToolConnectorExtension.cs b/src/OpenGameAgent.Connectors.Mcp/McpToolConnectorExtension.cs
index 5ee0732..fdb0f35 100644
--- a/src/OpenGameAgent.Connectors.Mcp/McpToolConnectorExtension.cs
+++ b/src/OpenGameAgent.Connectors.Mcp/McpToolConnectorExtension.cs
@@ -864,17 +864,19 @@ private static string CreateArtifactId(
GameAgentExtensionRunContext context)
{
using var hash = SHA256.Create();
- var identity = string.Join(
- "\n",
- context.Input.SessionId,
- context.Input.ActorId,
- context.Input.InputId,
- execution.RunId,
- execution.Turn.ToString(System.Globalization.CultureInfo.InvariantCulture),
- execution.ToolCallIndex.ToString(System.Globalization.CultureInfo.InvariantCulture),
- server.Id,
- remoteTool.Name);
- var bytes = hash.ComputeHash(Encoding.UTF8.GetBytes(identity));
+ using var identity = new MemoryStream();
+ Write("OpenGameAgent.McpArtifactId.v1");
+ Write(context.Input.SessionId);
+ Write(context.Input.ActorId);
+ Write(context.Input.InputId);
+ Write(context.Input.Moment.TimelineId);
+ Write(context.Input.Moment.Tick.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ Write(execution.Turn.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ Write(execution.ToolCallIndex.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ Write(server.Id);
+ Write(remoteTool.Name);
+ identity.Position = 0;
+ var bytes = hash.ComputeHash(identity);
var encoded = new StringBuilder(bytes.Length * 2 + 4);
encoded.Append("mcp-");
foreach (var value in bytes)
@@ -883,6 +885,18 @@ private static string CreateArtifactId(
}
return encoded.ToString();
+
+ void Write(string value)
+ {
+ var bytes = Encoding.UTF8.GetBytes(value);
+ Span length = stackalloc byte[4];
+ length[0] = (byte)(bytes.Length >> 24);
+ length[1] = (byte)(bytes.Length >> 16);
+ length[2] = (byte)(bytes.Length >> 8);
+ length[3] = (byte)bytes.Length;
+ identity.Write(length);
+ identity.Write(bytes, 0, bytes.Length);
+ }
}
private static JsonElement EmptyObject()
diff --git a/src/OpenGameAgent.Connectors.Mcp/packages.lock.json b/src/OpenGameAgent.Connectors.Mcp/packages.lock.json
index 356a34d..5487085 100644
--- a/src/OpenGameAgent.Connectors.Mcp/packages.lock.json
+++ b/src/OpenGameAgent.Connectors.Mcp/packages.lock.json
@@ -146,15 +146,15 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.extensions": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )"
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )"
}
},
"opengameagent.kernel": {
@@ -166,7 +166,7 @@
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
}
}
diff --git a/src/OpenGameAgent.Extensions/AgentDelegationExtension.cs b/src/OpenGameAgent.Extensions/AgentDelegationExtension.cs
index d9b77e8..6fadd96 100644
--- a/src/OpenGameAgent.Extensions/AgentDelegationExtension.cs
+++ b/src/OpenGameAgent.Extensions/AgentDelegationExtension.cs
@@ -774,7 +774,11 @@ private AgentTool CreateDelegateTool(GameAgentExtensionApi api, GameAgentExtensi
var id = arguments.TryGetProperty("delegationId", out var configuredId)
? configuredId.GetString() ?? string.Empty
- : string.Join(":", context.Input.InputId, execution.RunId, execution.Turn, execution.ToolCallIndex);
+ : GameExtensionOperationIds.Create(
+ "oga-delegation-v1:",
+ "delegate_agent",
+ context.Input,
+ execution);
var taskJson = arguments.GetProperty("task").GetRawText();
var background = arguments.TryGetProperty("background", out var backgroundElement) && backgroundElement.GetBoolean();
var inheritContext = arguments.TryGetProperty("inheritContext", out var inheritElement) && inheritElement.GetBoolean();
diff --git a/src/OpenGameAgent.Extensions/ArtifactExtension.cs b/src/OpenGameAgent.Extensions/ArtifactExtension.cs
index 22ddcf6..a3fbe9e 100644
--- a/src/OpenGameAgent.Extensions/ArtifactExtension.cs
+++ b/src/OpenGameAgent.Extensions/ArtifactExtension.cs
@@ -213,16 +213,17 @@ public void Configure(GameAgentExtensionApi api)
context => new AgentHooks
{
AfterToolCallAsync = (toolContext, cancellationToken) =>
- SpillToolResultAsync(context, toolContext.ToolCall, toolContext.Result, cancellationToken),
+ SpillToolResultAsync(context, toolContext, cancellationToken),
});
}
private async ValueTask SpillToolResultAsync(
GameAgentExtensionRunContext context,
- ToolCallContent call,
- ToolResult result,
+ AfterToolCallContext toolContext,
CancellationToken cancellationToken)
{
+ var call = toolContext.ToolCall;
+ var result = toolContext.Result;
if (string.Equals(call.Name, "read_agent_artifact", StringComparison.Ordinal))
{
return result;
@@ -239,16 +240,23 @@ public void Configure(GameAgentExtensionApi api)
return result;
}
+ var toolCallIndex = FindToolCallIndex(toolContext.AssistantMessage, call);
var payload = JsonSerializer.Serialize(new
{
toolName = call.Name,
- toolCallId = call.Id,
+ toolCallId = string.Join(
+ ":",
+ context.Input.InputId,
+ toolContext.Turn.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ toolCallIndex.ToString(System.Globalization.CultureInfo.InvariantCulture)),
+ turn = toolContext.Turn,
+ toolCallIndex,
result.IsError,
result.OutcomeUncertain,
result.DetailsJson,
content = result.Content.Select(SerializeContent),
});
- var artifactId = CreateArtifactId(context, call, payload);
+ var artifactId = CreateArtifactId(context, toolContext.Turn, toolCallIndex, call.Name, payload);
try
{
await _store.PutAsync(
@@ -344,17 +352,25 @@ private static string CreatePreview(IReadOnlyList content, int max
private static string CreateArtifactId(
GameAgentExtensionRunContext context,
- ToolCallContent call,
+ int turn,
+ int toolCallIndex,
+ string toolName,
string payload)
{
using var hash = SHA256.Create();
- var bytes = hash.ComputeHash(Encoding.UTF8.GetBytes(string.Join(
- "\n",
- context.Input.SessionId,
- context.Input.ActorId,
- context.Input.InputId,
- call.Id,
- payload)));
+ using var identity = new MemoryStream();
+ Write("OpenGameAgent.ToolResultArtifactId.v1");
+ Write(context.Input.SessionId);
+ Write(context.Input.ActorId);
+ Write(context.Input.InputId);
+ Write(context.Input.Moment.TimelineId);
+ Write(context.Input.Moment.Tick.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ Write(turn.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ Write(toolCallIndex.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ Write(toolName);
+ Write(payload);
+ identity.Position = 0;
+ var bytes = hash.ComputeHash(identity);
var encoded = new StringBuilder(bytes.Length * 2);
foreach (var value in bytes)
{
@@ -362,6 +378,41 @@ private static string CreateArtifactId(
}
return "tool-result-" + encoded;
+
+ void Write(string value)
+ {
+ var bytes = Encoding.UTF8.GetBytes(value);
+ Span length = stackalloc byte[4];
+ length[0] = (byte)(bytes.Length >> 24);
+ length[1] = (byte)(bytes.Length >> 16);
+ length[2] = (byte)(bytes.Length >> 8);
+ length[3] = (byte)bytes.Length;
+ identity.Write(length);
+ identity.Write(bytes, 0, bytes.Length);
+ }
+ }
+
+ private static int FindToolCallIndex(AgentMessage assistantMessage, ToolCallContent call)
+ {
+ var index = 0;
+ foreach (var content in assistantMessage.Content)
+ {
+ if (content is not ToolCallContent candidate)
+ {
+ continue;
+ }
+
+ if (ReferenceEquals(candidate, call)
+ || (string.Equals(candidate.Id, call.Id, StringComparison.Ordinal)
+ && string.Equals(candidate.Name, call.Name, StringComparison.Ordinal)))
+ {
+ return index;
+ }
+
+ index++;
+ }
+
+ throw new InvalidOperationException("The completed tool call was not present in its assistant message.");
}
private AgentTool CreateReadTool(GameAgentExtensionRunContext context) =>
diff --git a/src/OpenGameAgent.Extensions/ExternalKnowledgeExtension.cs b/src/OpenGameAgent.Extensions/ExternalKnowledgeExtension.cs
index 47df2ee..0956599 100644
--- a/src/OpenGameAgent.Extensions/ExternalKnowledgeExtension.cs
+++ b/src/OpenGameAgent.Extensions/ExternalKnowledgeExtension.cs
@@ -238,13 +238,11 @@ private AgentTool CreateTool(GameAgentExtensionRunContext context) =>
"The knowledge result exceeded the inline limit and no artifact store is configured.");
}
- var artifactId = string.Join(
- ":",
- context.Input.InputId,
- execution.RunId,
- execution.Turn,
- execution.ToolCallIndex,
- sourceId);
+ var artifactId = GameExtensionOperationIds.Create(
+ "oga-knowledge-v1:",
+ "query_external_knowledge:" + sourceId,
+ context.Input,
+ execution);
await _artifactStore.PutAsync(
new GameAgentArtifact(
artifactId,
diff --git a/src/OpenGameAgent.Extensions/GameMemoryExtension.cs b/src/OpenGameAgent.Extensions/GameMemoryExtension.cs
index 7aaf3b9..1f12e37 100644
--- a/src/OpenGameAgent.Extensions/GameMemoryExtension.cs
+++ b/src/OpenGameAgent.Extensions/GameMemoryExtension.cs
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -76,7 +78,11 @@ private AgentTool CreateRememberTool(GameAgentExtensionApi api, GameAgentExtensi
{
var id = arguments.TryGetProperty("memoryId", out var configuredId)
? configuredId.GetString() ?? string.Empty
- : string.Join(":", context.Input.InputId, execution.RunId, execution.Turn, execution.ToolCallIndex);
+ : GameExtensionOperationIds.Create(
+ "oga-memory-v1:",
+ "remember_game_memory",
+ context.Input,
+ execution);
var expiresAt = arguments.TryGetProperty("expiresAtTick", out var expiry)
? new GameMoment(context.Input.Moment.TimelineId, expiry.GetInt64())
: (GameMoment?)null;
@@ -315,3 +321,52 @@ private static IReadOnlyCollection ReadKinds(JsonElement argumen
private static ToolResult JsonResult(object value) =>
new(new AgentContent[] { new JsonContent(JsonSerializer.Serialize(value)) });
}
+
+internal static class GameExtensionOperationIds
+{
+ public static string Create(
+ string prefix,
+ string operation,
+ GameInput input,
+ ToolExecutionContext execution)
+ {
+ if (string.IsNullOrWhiteSpace(prefix) || prefix.Length > 128)
+ {
+ throw new ArgumentException("An extension operation ID prefix is required.", nameof(prefix));
+ }
+
+ if (string.IsNullOrWhiteSpace(operation) || operation.Length > 1_024)
+ {
+ throw new ArgumentException("An extension operation name is required.", nameof(operation));
+ }
+
+ _ = input ?? throw new ArgumentNullException(nameof(input));
+ _ = execution ?? throw new ArgumentNullException(nameof(execution));
+ using var hash = SHA256.Create();
+ using var stream = new MemoryStream();
+ Write("OpenGameAgent.ExtensionOperationId.v1");
+ Write(input.SessionId);
+ Write(input.ActorId);
+ Write(input.InputId);
+ Write(input.Moment.TimelineId);
+ Write(input.Moment.Tick.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ Write(execution.Turn.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ Write(execution.ToolCallIndex.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ Write(operation);
+ stream.Position = 0;
+ return prefix + string.Concat(
+ hash.ComputeHash(stream).Select(value => value.ToString("x2", System.Globalization.CultureInfo.InvariantCulture)));
+
+ void Write(string value)
+ {
+ var bytes = Encoding.UTF8.GetBytes(value);
+ Span length = stackalloc byte[4];
+ length[0] = (byte)(bytes.Length >> 24);
+ length[1] = (byte)(bytes.Length >> 16);
+ length[2] = (byte)(bytes.Length >> 8);
+ length[3] = (byte)bytes.Length;
+ stream.Write(length);
+ stream.Write(bytes, 0, bytes.Length);
+ }
+ }
+}
diff --git a/src/OpenGameAgent.Extensions/StructuredInteractionExtension.cs b/src/OpenGameAgent.Extensions/StructuredInteractionExtension.cs
index 0e1aa82..bc07bd7 100644
--- a/src/OpenGameAgent.Extensions/StructuredInteractionExtension.cs
+++ b/src/OpenGameAgent.Extensions/StructuredInteractionExtension.cs
@@ -409,7 +409,11 @@ private static GameInteractionRequest ParseRequest(
}
return new GameInteractionRequest(
- string.Join(":", input.InputId, execution.RunId, execution.Turn, execution.ToolCallIndex),
+ GameExtensionOperationIds.Create(
+ "oga-interaction-v1:",
+ "ask_player",
+ input,
+ execution),
input,
questions);
}
diff --git a/src/OpenGameAgent.Extensions/packages.lock.json b/src/OpenGameAgent.Extensions/packages.lock.json
index 99754ad..10391a2 100644
--- a/src/OpenGameAgent.Extensions/packages.lock.json
+++ b/src/OpenGameAgent.Extensions/packages.lock.json
@@ -66,7 +66,7 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -79,7 +79,7 @@
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
}
}
diff --git a/src/OpenGameAgent.Media/packages.lock.json b/src/OpenGameAgent.Media/packages.lock.json
index 99754ad..10391a2 100644
--- a/src/OpenGameAgent.Media/packages.lock.json
+++ b/src/OpenGameAgent.Media/packages.lock.json
@@ -66,7 +66,7 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -79,7 +79,7 @@
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
}
}
diff --git a/src/OpenGameAgent.Memory/GameAwareMemoryReranker.cs b/src/OpenGameAgent.Memory/GameAwareMemoryReranker.cs
new file mode 100644
index 0000000..07dc080
--- /dev/null
+++ b/src/OpenGameAgent.Memory/GameAwareMemoryReranker.cs
@@ -0,0 +1,177 @@
+using System.Collections.ObjectModel;
+
+namespace OpenGameAgent.Memory;
+
+public sealed class GameAwareMemoryRerankerOptions
+{
+ public GameAwareMemoryRerankerOptions(
+ int sourceOrderWeight = 1_000_000,
+ int importanceWeight = 100_000,
+ int gameTimeRecencyWeight = 50_000,
+ int diversityPenalty = 10_000,
+ int maximumGreedySelections = 512)
+ {
+ ValidateWeight(sourceOrderWeight, nameof(sourceOrderWeight));
+ ValidateWeight(importanceWeight, nameof(importanceWeight));
+ ValidateWeight(gameTimeRecencyWeight, nameof(gameTimeRecencyWeight));
+ ValidateWeight(diversityPenalty, nameof(diversityPenalty));
+ if (maximumGreedySelections < 1 || maximumGreedySelections > 4_096)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maximumGreedySelections));
+ }
+
+ SourceOrderWeight = sourceOrderWeight;
+ ImportanceWeight = importanceWeight;
+ GameTimeRecencyWeight = gameTimeRecencyWeight;
+ DiversityPenalty = diversityPenalty;
+ MaximumGreedySelections = maximumGreedySelections;
+ }
+
+ public int SourceOrderWeight { get; }
+
+ public int ImportanceWeight { get; }
+
+ public int GameTimeRecencyWeight { get; }
+
+ public int DiversityPenalty { get; }
+
+ public int MaximumGreedySelections { get; }
+
+ private static void ValidateWeight(int value, string parameterName)
+ {
+ if (value < 0 || value > 10_000_000)
+ {
+ throw new ArgumentOutOfRangeException(parameterName);
+ }
+ }
+}
+
+///
+/// Reorders already-authorized candidates using source relevance, explicit
+/// importance, game-time recency, and bounded diversity. Wall-clock recency is
+/// intentionally absent because game worlds own their time semantics.
+///
+public sealed class GameAwareMemoryReranker : IGameMemoryRanker
+{
+ private readonly GameAwareMemoryRerankerOptions _options;
+
+ public GameAwareMemoryReranker(GameAwareMemoryRerankerOptions? options = null)
+ {
+ _options = options ?? new GameAwareMemoryRerankerOptions();
+ }
+
+ public ValueTask> RankAsync(
+ GameMemoryQuery query,
+ IReadOnlyList candidates,
+ CancellationToken cancellationToken)
+ {
+ if (query is null)
+ {
+ throw new ArgumentNullException(nameof(query));
+ }
+
+ if (candidates is null)
+ {
+ throw new ArgumentNullException(nameof(candidates));
+ }
+
+ var remaining = candidates
+ .Select((memory, index) => new Candidate(memory, index, BaseScore(query, memory, index, candidates.Count)))
+ .ToList();
+ var selected = new List(remaining.Count);
+ var greedyCount = Math.Min(remaining.Count, _options.MaximumGreedySelections);
+ while (remaining.Count > 0 && selected.Count < greedyCount)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Candidate? winner = null;
+ long winnerScore = long.MinValue;
+ foreach (var candidate in remaining)
+ {
+ var score = candidate.BaseScore
+ - ((long)candidate.MaximumDiversityOverlap * _options.DiversityPenalty);
+ if (winner is null
+ || score > winnerScore
+ || (score == winnerScore && Compare(candidate, winner) < 0))
+ {
+ winner = candidate;
+ winnerScore = score;
+ }
+ }
+
+ remaining.Remove(winner!);
+ selected.Add(winner!.Memory);
+ foreach (var candidate in remaining)
+ {
+ candidate.MaximumDiversityOverlap = Math.Max(
+ candidate.MaximumDiversityOverlap,
+ DiversityOverlap(candidate.Memory, winner.Memory));
+ }
+ }
+
+ selected.AddRange(remaining.OrderBy(candidate => candidate.InputIndex).Select(candidate => candidate.Memory));
+ return new ValueTask>(new ReadOnlyCollection(selected));
+ }
+
+ private long BaseScore(GameMemoryQuery query, GameMemory memory, int inputIndex, int candidateCount)
+ {
+ var sourceOrder = candidateCount - inputIndex;
+ var score = (long)sourceOrder * _options.SourceOrderWeight;
+ score += (long)Math.Round(memory.Importance * _options.ImportanceWeight, MidpointRounding.AwayFromZero);
+ if (_options.GameTimeRecencyWeight > 0
+ && query.AtOrBefore is { } moment
+ && string.Equals(moment.TimelineId, memory.Moment.TimelineId, StringComparison.Ordinal)
+ && moment.Tick >= memory.Moment.Tick)
+ {
+ var distance = moment.Tick - memory.Moment.Tick;
+ score += (long)_options.GameTimeRecencyWeight * 1_000 / (1 + Math.Min(distance, 1_000_000));
+ }
+
+ return score;
+ }
+
+ private static int DiversityOverlap(GameMemory left, GameMemory right)
+ {
+ var overlap = left.Tags.Intersect(right.Tags, StringComparer.Ordinal).Count();
+ if (string.Equals(left.Scope, right.Scope, StringComparison.Ordinal))
+ {
+ overlap++;
+ }
+
+ if (left.Kind == right.Kind)
+ {
+ overlap++;
+ }
+
+ return overlap;
+ }
+
+ private static int Compare(Candidate left, Candidate right)
+ {
+ var byInput = left.InputIndex.CompareTo(right.InputIndex);
+ if (byInput != 0)
+ {
+ return byInput;
+ }
+
+ var byOwner = string.CompareOrdinal(left.Memory.OwnerId, right.Memory.OwnerId);
+ return byOwner != 0 ? byOwner : string.CompareOrdinal(left.Memory.MemoryId, right.Memory.MemoryId);
+ }
+
+ private sealed class Candidate
+ {
+ public Candidate(GameMemory memory, int inputIndex, long baseScore)
+ {
+ Memory = memory ?? throw new InvalidOperationException("A memory candidate cannot be null.");
+ InputIndex = inputIndex;
+ BaseScore = baseScore;
+ }
+
+ public GameMemory Memory { get; }
+
+ public int InputIndex { get; }
+
+ public long BaseScore { get; }
+
+ public int MaximumDiversityOverlap { get; set; }
+ }
+}
diff --git a/src/OpenGameAgent.Memory/MemoryVectorContracts.cs b/src/OpenGameAgent.Memory/MemoryVectorContracts.cs
new file mode 100644
index 0000000..c8fce9c
--- /dev/null
+++ b/src/OpenGameAgent.Memory/MemoryVectorContracts.cs
@@ -0,0 +1,398 @@
+using System.Collections.ObjectModel;
+using System.Text;
+using System.Text.Json;
+
+namespace OpenGameAgent.Memory;
+
+public sealed class MemoryEmbeddingIdentity : IEquatable
+{
+ public MemoryEmbeddingIdentity(
+ string providerId,
+ string modelId,
+ string version,
+ int dimensions)
+ {
+ ProviderId = MemoryVectorGuard.Id(providerId, nameof(providerId), 256);
+ ModelId = MemoryVectorGuard.Id(modelId, nameof(modelId), 512);
+ Version = MemoryVectorGuard.Id(version, nameof(version), 256);
+ if (dimensions < 1 || dimensions > 65_536)
+ {
+ throw new ArgumentOutOfRangeException(nameof(dimensions));
+ }
+
+ Dimensions = dimensions;
+ }
+
+ public string ProviderId { get; }
+
+ public string ModelId { get; }
+
+ public string Version { get; }
+
+ public int Dimensions { get; }
+
+ public bool Equals(MemoryEmbeddingIdentity? other) =>
+ other is not null
+ && string.Equals(ProviderId, other.ProviderId, StringComparison.Ordinal)
+ && string.Equals(ModelId, other.ModelId, StringComparison.Ordinal)
+ && string.Equals(Version, other.Version, StringComparison.Ordinal)
+ && Dimensions == other.Dimensions;
+
+ public override bool Equals(object? obj) => Equals(obj as MemoryEmbeddingIdentity);
+
+ public override int GetHashCode()
+ {
+ unchecked
+ {
+ var hash = StringComparer.Ordinal.GetHashCode(ProviderId);
+ hash = (hash * 397) ^ StringComparer.Ordinal.GetHashCode(ModelId);
+ hash = (hash * 397) ^ StringComparer.Ordinal.GetHashCode(Version);
+ return (hash * 397) ^ Dimensions;
+ }
+ }
+
+ public override string ToString() => $"{ProviderId}/{ModelId}@{Version}:{Dimensions}";
+}
+
+///
+/// Supplies vectors without imposing a model runtime. A game may implement
+/// this interface with an in-process model, a local sidecar, or a remote API.
+/// Query and document methods are separate so asymmetric embedding models can
+/// select the correct task or input type.
+///
+public interface IMemoryEmbeddingProvider : IAsyncDisposable
+{
+ MemoryEmbeddingIdentity Identity { get; }
+
+ ValueTask> EmbedQueryAsync(
+ string text,
+ CancellationToken cancellationToken);
+
+ ValueTask>> EmbedDocumentsAsync(
+ IReadOnlyList texts,
+ CancellationToken cancellationToken);
+}
+
+public interface IMemoryEmbeddingTextProjector
+{
+ string ProjectDocument(GameMemory memory);
+
+ string ProjectQuery(GameMemoryQuery query);
+}
+
+public sealed class DefaultMemoryEmbeddingTextProjector : IMemoryEmbeddingTextProjector
+{
+ private readonly int _maximumCharacters;
+
+ public DefaultMemoryEmbeddingTextProjector(int maximumCharacters = 100_000)
+ {
+ if (maximumCharacters < 1 || maximumCharacters > 1_000_000)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maximumCharacters));
+ }
+
+ _maximumCharacters = maximumCharacters;
+ }
+
+ public string ProjectDocument(GameMemory memory)
+ {
+ if (memory is null)
+ {
+ throw new ArgumentNullException(nameof(memory));
+ }
+
+ var builder = new StringBuilder();
+ if (!string.IsNullOrWhiteSpace(memory.SearchableText))
+ {
+ builder.Append(memory.SearchableText);
+ }
+
+ if (builder.Length > 0)
+ {
+ builder.Append('\n');
+ }
+
+ builder.Append(memory.PayloadJson);
+ if (memory.Tags.Count > 0)
+ {
+ builder.Append('\n').Append(string.Join(" ", memory.Tags));
+ }
+
+ return Bound(builder.ToString());
+ }
+
+ public string ProjectQuery(GameMemoryQuery query)
+ {
+ if (query is null)
+ {
+ throw new ArgumentNullException(nameof(query));
+ }
+
+ return Bound(query.Text ?? string.Empty);
+ }
+
+ private string Bound(string value) => value.Length <= _maximumCharacters
+ ? value
+ : value.Substring(0, _maximumCharacters);
+}
+
+public sealed class VectorMemoryIndexEntry
+{
+ public VectorMemoryIndexEntry(
+ GameMemory memory,
+ MemoryEmbeddingIdentity? identity,
+ IReadOnlyList? vector,
+ string? diagnosticCode = null)
+ {
+ Memory = memory ?? throw new ArgumentNullException(nameof(memory));
+ Identity = identity;
+ if (vector is not null)
+ {
+ if (identity is null || vector.Count != identity.Dimensions)
+ {
+ throw new ArgumentException("A vector must match its embedding identity.", nameof(vector));
+ }
+
+ var copied = vector.ToArray();
+ MemoryVectorGuard.ValidateVector(copied, identity.Dimensions, nameof(vector));
+ Vector = Array.AsReadOnly(copied);
+ }
+
+ DiagnosticCode = diagnosticCode is null
+ ? null
+ : MemoryVectorGuard.Id(diagnosticCode, nameof(diagnosticCode), 256);
+ if (Vector is null && DiagnosticCode is null)
+ {
+ DiagnosticCode = "embedding_pending";
+ }
+ }
+
+ public GameMemory Memory { get; }
+
+ public MemoryEmbeddingIdentity? Identity { get; }
+
+ public IReadOnlyList? Vector { get; }
+
+ public string? DiagnosticCode { get; }
+}
+
+public interface IVectorMemoryIndex
+{
+ ValueTask UpsertAsync(VectorMemoryIndexEntry entry, CancellationToken cancellationToken);
+
+ ValueTask DeleteAsync(
+ string sessionId,
+ string ownerId,
+ string memoryId,
+ CancellationToken cancellationToken);
+
+ ValueTask> ListAsync(
+ string sessionId,
+ int maximumEntries,
+ CancellationToken cancellationToken);
+}
+
+public enum MemoryVectorDiagnosticSeverity
+{
+ Information,
+ Warning,
+ Error,
+}
+
+public sealed class MemoryVectorDiagnostic
+{
+ public MemoryVectorDiagnostic(
+ string code,
+ MemoryVectorDiagnosticSeverity severity,
+ string message,
+ string? sessionId = null,
+ string? ownerId = null,
+ string? memoryId = null,
+ string? detailsJson = null)
+ {
+ Code = MemoryVectorGuard.Id(code, nameof(code), 256);
+ if (!Enum.IsDefined(typeof(MemoryVectorDiagnosticSeverity), severity))
+ {
+ throw new ArgumentOutOfRangeException(nameof(severity));
+ }
+
+ Severity = severity;
+ Message = MemoryVectorGuard.Text(message, nameof(message), 4_096);
+ SessionId = MemoryVectorGuard.OptionalId(sessionId, nameof(sessionId), 1_024);
+ OwnerId = MemoryVectorGuard.OptionalId(ownerId, nameof(ownerId), 1_024);
+ MemoryId = MemoryVectorGuard.OptionalId(memoryId, nameof(memoryId), 1_024);
+ DetailsJson = detailsJson is null
+ ? null
+ : MemoryVectorGuard.Json(detailsJson, nameof(detailsJson), 65_536);
+ }
+
+ public string Code { get; }
+
+ public MemoryVectorDiagnosticSeverity Severity { get; }
+
+ public string Message { get; }
+
+ public string? SessionId { get; }
+
+ public string? OwnerId { get; }
+
+ public string? MemoryId { get; }
+
+ public string? DetailsJson { get; }
+}
+
+public interface IMemoryVectorDiagnosticSink
+{
+ ValueTask ReportAsync(MemoryVectorDiagnostic diagnostic, CancellationToken cancellationToken);
+}
+
+public sealed class NullMemoryVectorDiagnosticSink : IMemoryVectorDiagnosticSink
+{
+ public static NullMemoryVectorDiagnosticSink Instance { get; } = new();
+
+ private NullMemoryVectorDiagnosticSink()
+ {
+ }
+
+ public ValueTask ReportAsync(MemoryVectorDiagnostic diagnostic, CancellationToken cancellationToken)
+ {
+ _ = diagnostic ?? throw new ArgumentNullException(nameof(diagnostic));
+ cancellationToken.ThrowIfCancellationRequested();
+ return default;
+ }
+}
+
+public enum VectorMemoryState
+{
+ Empty,
+ Ready,
+ Degraded,
+ RebuildRequired,
+}
+
+public sealed class VectorMemoryStatus
+{
+ public VectorMemoryStatus(
+ VectorMemoryState state,
+ MemoryEmbeddingIdentity activeIdentity,
+ int totalEntries,
+ int readyEntries,
+ int pendingEntries,
+ int staleEntries,
+ int orphanEntries)
+ {
+ if (!Enum.IsDefined(typeof(VectorMemoryState), state))
+ {
+ throw new ArgumentOutOfRangeException(nameof(state));
+ }
+
+ if (totalEntries < 0 || readyEntries < 0 || pendingEntries < 0 || staleEntries < 0 || orphanEntries < 0
+ || readyEntries + pendingEntries + staleEntries != totalEntries)
+ {
+ throw new ArgumentOutOfRangeException(nameof(totalEntries));
+ }
+
+ State = state;
+ ActiveIdentity = activeIdentity ?? throw new ArgumentNullException(nameof(activeIdentity));
+ TotalEntries = totalEntries;
+ ReadyEntries = readyEntries;
+ PendingEntries = pendingEntries;
+ StaleEntries = staleEntries;
+ OrphanEntries = orphanEntries;
+ }
+
+ public VectorMemoryState State { get; }
+
+ public MemoryEmbeddingIdentity ActiveIdentity { get; }
+
+ public int TotalEntries { get; }
+
+ public int ReadyEntries { get; }
+
+ public int PendingEntries { get; }
+
+ public int StaleEntries { get; }
+
+ public int OrphanEntries { get; }
+
+ public bool RequiresRebuild => PendingEntries > 0 || StaleEntries > 0 || OrphanEntries > 0;
+}
+
+internal static class MemoryVectorGuard
+{
+ public static string Id(string value, string parameterName, int maximumCharacters)
+ {
+ if (string.IsNullOrWhiteSpace(value) || value.Length > maximumCharacters || HasControl(value))
+ {
+ throw new ArgumentException("A bounded non-control identifier is required.", parameterName);
+ }
+
+ return value;
+ }
+
+ public static string? OptionalId(string? value, string parameterName, int maximumCharacters) =>
+ value is null ? null : Id(value, parameterName, maximumCharacters);
+
+ public static string Text(string value, string parameterName, int maximumCharacters)
+ {
+ if (string.IsNullOrWhiteSpace(value) || value.Length > maximumCharacters || value.IndexOf('\0') >= 0)
+ {
+ throw new ArgumentException("A bounded text value is required.", parameterName);
+ }
+
+ return value;
+ }
+
+ public static string Json(string value, string parameterName, int maximumCharacters)
+ {
+ value = Text(value, parameterName, maximumCharacters);
+ try
+ {
+ using var document = JsonDocument.Parse(value, new JsonDocumentOptions { MaxDepth = 128 });
+ return document.RootElement.GetRawText();
+ }
+ catch (JsonException exception)
+ {
+ throw new ArgumentException("A valid bounded JSON value is required.", parameterName, exception);
+ }
+ }
+
+ public static void ValidateVector(IReadOnlyList vector, int dimensions, string parameterName)
+ {
+ if (vector.Count != dimensions)
+ {
+ throw new ArgumentException("Embedding dimensions do not match the provider identity.", parameterName);
+ }
+
+ var norm = 0d;
+ foreach (var value in vector)
+ {
+ if (float.IsNaN(value) || float.IsInfinity(value))
+ {
+ throw new ArgumentException("Embedding vectors must contain finite values.", parameterName);
+ }
+
+ norm += value * value;
+ }
+
+ if (norm <= 0 || double.IsInfinity(norm) || double.IsNaN(norm))
+ {
+ throw new ArgumentException("Embedding vectors must have a finite non-zero norm.", parameterName);
+ }
+ }
+
+ public static float[] Normalize(ReadOnlyMemory source, MemoryEmbeddingIdentity identity, string parameterName)
+ {
+ var values = source.ToArray();
+ ValidateVector(values, identity.Dimensions, parameterName);
+ var norm = Math.Sqrt(values.Sum(value => (double)value * value));
+ for (var index = 0; index < values.Length; index++)
+ {
+ values[index] = (float)(values[index] / norm);
+ }
+
+ return values;
+ }
+
+ private static bool HasControl(string value) => value.Any(character => char.IsControl(character));
+}
diff --git a/src/OpenGameAgent.Memory/MemoryVectorIndexes.cs b/src/OpenGameAgent.Memory/MemoryVectorIndexes.cs
new file mode 100644
index 0000000..b9dc3c8
--- /dev/null
+++ b/src/OpenGameAgent.Memory/MemoryVectorIndexes.cs
@@ -0,0 +1,542 @@
+using System.Collections.Concurrent;
+using System.Collections.ObjectModel;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace OpenGameAgent.Memory;
+
+public sealed class InMemoryVectorMemoryIndex : IVectorMemoryIndex
+{
+ private readonly object _gate = new();
+ private readonly Dictionary<(string SessionId, string OwnerId, string MemoryId), VectorMemoryIndexEntry> _entries = new();
+ private readonly int _capacity;
+
+ public InMemoryVectorMemoryIndex(int capacity = 100_000)
+ {
+ if (capacity < 1 || capacity > 1_000_000)
+ {
+ throw new ArgumentOutOfRangeException(nameof(capacity));
+ }
+
+ _capacity = capacity;
+ }
+
+ public ValueTask UpsertAsync(VectorMemoryIndexEntry entry, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (entry is null)
+ {
+ throw new ArgumentNullException(nameof(entry));
+ }
+
+ var key = (entry.Memory.SessionId, entry.Memory.OwnerId, entry.Memory.MemoryId);
+ lock (_gate)
+ {
+ if (_entries.TryGetValue(key, out var existing))
+ {
+ MemoryVectorIndexCodec.EnsureSameMemory(existing.Memory, entry.Memory);
+ _entries[key] = entry;
+ return default;
+ }
+
+ if (_entries.Count >= _capacity)
+ {
+ throw new InvalidOperationException("The vector memory index reached its configured capacity.");
+ }
+
+ _entries.Add(key, entry);
+ }
+
+ return default;
+ }
+
+ public ValueTask> ListAsync(
+ string sessionId,
+ int maximumEntries,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ sessionId = MemoryVectorGuard.Id(sessionId, nameof(sessionId), 1_024);
+ ValidateMaximumEntries(maximumEntries);
+ VectorMemoryIndexEntry[] snapshot;
+ lock (_gate)
+ {
+ snapshot = _entries.Values
+ .Where(entry => string.Equals(entry.Memory.SessionId, sessionId, StringComparison.Ordinal))
+ .OrderBy(entry => entry.Memory.OwnerId, StringComparer.Ordinal)
+ .ThenBy(entry => entry.Memory.MemoryId, StringComparer.Ordinal)
+ .Take(maximumEntries + 1)
+ .ToArray();
+ }
+
+ if (snapshot.Length > maximumEntries)
+ {
+ throw new InvalidOperationException("The vector memory index exceeded the requested snapshot bound.");
+ }
+
+ return new ValueTask>(Array.AsReadOnly(snapshot));
+ }
+
+ public ValueTask DeleteAsync(
+ string sessionId,
+ string ownerId,
+ string memoryId,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var key = (
+ MemoryVectorGuard.Id(sessionId, nameof(sessionId), 1_024),
+ MemoryVectorGuard.Id(ownerId, nameof(ownerId), 1_024),
+ MemoryVectorGuard.Id(memoryId, nameof(memoryId), 1_024));
+ lock (_gate)
+ {
+ _entries.Remove(key);
+ }
+
+ return default;
+ }
+
+ private static void ValidateMaximumEntries(int maximumEntries)
+ {
+ if (maximumEntries < 1 || maximumEntries > 1_000_000)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maximumEntries));
+ }
+ }
+}
+
+///
+/// Stores one derived vector record per memory. The files contain no provider
+/// credentials and remain separated from the game's authoritative save data.
+/// A missing or stale file is recoverable through an explicit rebuild.
+///
+public sealed class FileVectorMemoryIndex : IVectorMemoryIndex
+{
+ private const string Suffix = ".vector-memory.json";
+ private readonly string _directory;
+ private readonly int _capacity;
+ private readonly long _maximumFileBytes;
+ private readonly ConcurrentDictionary _gates = new(StringComparer.Ordinal);
+ private readonly SemaphoreSlim _capacityGate = new(1, 1);
+
+ public FileVectorMemoryIndex(
+ string directory,
+ int capacity = 100_000,
+ long maximumFileBytes = 4_000_000)
+ {
+ if (string.IsNullOrWhiteSpace(directory))
+ {
+ throw new ArgumentException("A vector index directory is required.", nameof(directory));
+ }
+
+ if (capacity < 1 || capacity > 1_000_000)
+ {
+ throw new ArgumentOutOfRangeException(nameof(capacity));
+ }
+
+ if (maximumFileBytes < 1_024 || maximumFileBytes > 1_000_000_000)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maximumFileBytes));
+ }
+
+ _directory = Path.GetFullPath(directory);
+ Directory.CreateDirectory(_directory);
+ _capacity = capacity;
+ _maximumFileBytes = maximumFileBytes;
+ }
+
+ public async ValueTask UpsertAsync(VectorMemoryIndexEntry entry, CancellationToken cancellationToken)
+ {
+ if (entry is null)
+ {
+ throw new ArgumentNullException(nameof(entry));
+ }
+
+ var key = StorageKey(entry.Memory.SessionId, entry.Memory.OwnerId, entry.Memory.MemoryId);
+ var path = Path.Combine(_directory, key + Suffix);
+ var gate = _gates.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
+ await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await _capacityGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await using var lease = await AcquireLeaseAsync(cancellationToken).ConfigureAwait(false);
+ if (File.Exists(path))
+ {
+ var existing = await ReadAsync(path, cancellationToken).ConfigureAwait(false);
+ MemoryVectorIndexCodec.EnsureSameMemory(existing.Memory, entry.Memory);
+ }
+ else if (Directory.EnumerateFiles(_directory, "*" + Suffix, SearchOption.TopDirectoryOnly)
+ .Take(_capacity)
+ .Count() >= _capacity)
+ {
+ throw new InvalidOperationException("The vector memory index reached its configured capacity.");
+ }
+
+ await WriteAtomicAsync(path, entry, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _capacityGate.Release();
+ }
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ public async ValueTask> ListAsync(
+ string sessionId,
+ int maximumEntries,
+ CancellationToken cancellationToken)
+ {
+ sessionId = MemoryVectorGuard.Id(sessionId, nameof(sessionId), 1_024);
+ if (maximumEntries < 1 || maximumEntries > 1_000_000)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maximumEntries));
+ }
+
+ var entries = new List();
+ var scanned = 0;
+ foreach (var path in Directory.EnumerateFiles(_directory, "*" + Suffix, SearchOption.TopDirectoryOnly)
+ .OrderBy(value => value, StringComparer.Ordinal))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (++scanned > _capacity)
+ {
+ throw new InvalidOperationException("The vector memory index exceeded its configured capacity.");
+ }
+
+ var entry = await ReadAsync(path, cancellationToken).ConfigureAwait(false);
+ var expectedPath = Path.Combine(
+ _directory,
+ StorageKey(entry.Memory.SessionId, entry.Memory.OwnerId, entry.Memory.MemoryId) + Suffix);
+ if (!string.Equals(Path.GetFullPath(path), Path.GetFullPath(expectedPath), StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidDataException("A vector memory index file has an invalid identity path.");
+ }
+
+ if (!string.Equals(entry.Memory.SessionId, sessionId, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (entries.Count >= maximumEntries)
+ {
+ throw new InvalidOperationException("The vector memory index exceeded the requested snapshot bound.");
+ }
+
+ entries.Add(entry);
+ }
+
+ return new ReadOnlyCollection(entries);
+ }
+
+ public async ValueTask DeleteAsync(
+ string sessionId,
+ string ownerId,
+ string memoryId,
+ CancellationToken cancellationToken)
+ {
+ sessionId = MemoryVectorGuard.Id(sessionId, nameof(sessionId), 1_024);
+ ownerId = MemoryVectorGuard.Id(ownerId, nameof(ownerId), 1_024);
+ memoryId = MemoryVectorGuard.Id(memoryId, nameof(memoryId), 1_024);
+ var key = StorageKey(sessionId, ownerId, memoryId);
+ var path = Path.Combine(_directory, key + Suffix);
+ var gate = _gates.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
+ await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await using var lease = await AcquireLeaseAsync(cancellationToken).ConfigureAwait(false);
+ if (!File.Exists(path))
+ {
+ return;
+ }
+
+ var existing = await ReadAsync(path, cancellationToken).ConfigureAwait(false);
+ if (!string.Equals(existing.Memory.SessionId, sessionId, StringComparison.Ordinal)
+ || !string.Equals(existing.Memory.OwnerId, ownerId, StringComparison.Ordinal)
+ || !string.Equals(existing.Memory.MemoryId, memoryId, StringComparison.Ordinal))
+ {
+ throw new InvalidDataException("A vector memory index file has an invalid identity path.");
+ }
+
+ File.Delete(path);
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ private async ValueTask AcquireLeaseAsync(CancellationToken cancellationToken)
+ {
+ var lockPath = Path.Combine(_directory, ".vector-memory.lock");
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ return new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, 1, useAsync: true);
+ }
+ catch (IOException)
+ {
+ await Task.Delay(25, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private async ValueTask ReadAsync(string path, CancellationToken cancellationToken)
+ {
+ var info = new FileInfo(path);
+ if (!info.Exists || info.Length < 2 || info.Length > _maximumFileBytes)
+ {
+ throw new InvalidDataException("A vector memory index file has an invalid size.");
+ }
+
+ await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, useAsync: true);
+ var document = await JsonSerializer.DeserializeAsync(
+ stream,
+ MemoryVectorIndexCodec.JsonOptions,
+ cancellationToken)
+ .ConfigureAwait(false);
+ return MemoryVectorIndexCodec.Decode(document);
+ }
+
+ private async ValueTask WriteAtomicAsync(
+ string path,
+ VectorMemoryIndexEntry entry,
+ CancellationToken cancellationToken)
+ {
+ var temp = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ await using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, useAsync: true))
+ {
+ await JsonSerializer.SerializeAsync(
+ stream,
+ MemoryVectorIndexCodec.Encode(entry),
+ MemoryVectorIndexCodec.JsonOptions,
+ cancellationToken)
+ .ConfigureAwait(false);
+ await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
+ stream.Flush(flushToDisk: true);
+ if (stream.Length > _maximumFileBytes)
+ {
+ throw new InvalidOperationException("A vector memory index entry exceeded its configured file bound.");
+ }
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ if (File.Exists(path))
+ {
+ File.Replace(temp, path, destinationBackupFileName: null);
+ }
+ else
+ {
+ File.Move(temp, path);
+ }
+ }
+ finally
+ {
+ if (File.Exists(temp))
+ {
+ File.Delete(temp);
+ }
+ }
+ }
+
+ private static string StorageKey(string sessionId, string ownerId, string memoryId)
+ {
+ using var algorithm = SHA256.Create();
+ var bytes = Encoding.UTF8.GetBytes(sessionId + "\0" + ownerId + "\0" + memoryId);
+ return string.Concat(algorithm.ComputeHash(bytes).Select(value => value.ToString("x2")));
+ }
+}
+
+internal static class MemoryVectorIndexCodec
+{
+ public static JsonSerializerOptions JsonOptions { get; } = new()
+ {
+ PropertyNameCaseInsensitive = false,
+ MaxDepth = 128,
+ };
+
+ public static MemoryVectorIndexDocument Encode(VectorMemoryIndexEntry entry) => new()
+ {
+ FormatVersion = 1,
+ Memory = MemoryDocument.Encode(entry.Memory),
+ Identity = entry.Identity is null
+ ? null
+ : new IdentityDocument
+ {
+ ProviderId = entry.Identity.ProviderId,
+ ModelId = entry.Identity.ModelId,
+ Version = entry.Identity.Version,
+ Dimensions = entry.Identity.Dimensions,
+ },
+ Vector = entry.Vector?.ToArray(),
+ DiagnosticCode = entry.DiagnosticCode,
+ };
+
+ public static VectorMemoryIndexEntry Decode(MemoryVectorIndexDocument? document)
+ {
+ if (document is null || document.FormatVersion != 1 || document.Memory is null)
+ {
+ throw new InvalidDataException("A vector memory index document is malformed.");
+ }
+
+ var identity = document.Identity is null
+ ? null
+ : new MemoryEmbeddingIdentity(
+ document.Identity.ProviderId ?? string.Empty,
+ document.Identity.ModelId ?? string.Empty,
+ document.Identity.Version ?? string.Empty,
+ document.Identity.Dimensions);
+ try
+ {
+ return new VectorMemoryIndexEntry(
+ document.Memory.Decode(),
+ identity,
+ document.Vector,
+ document.DiagnosticCode);
+ }
+ catch (ArgumentException exception)
+ {
+ throw new InvalidDataException("A vector memory index document contains invalid data.", exception);
+ }
+ }
+
+ public static void EnsureSameMemory(GameMemory left, GameMemory right)
+ {
+ if (!string.Equals(left.SessionId, right.SessionId, StringComparison.Ordinal)
+ || !string.Equals(left.OwnerId, right.OwnerId, StringComparison.Ordinal)
+ || !string.Equals(left.MemoryId, right.MemoryId, StringComparison.Ordinal)
+ || !string.Equals(left.Scope, right.Scope, StringComparison.Ordinal)
+ || left.Kind != right.Kind
+ || !string.Equals(left.PayloadJson, right.PayloadJson, StringComparison.Ordinal)
+ || left.Moment != right.Moment
+ || !left.Importance.Equals(right.Importance)
+ || !string.Equals(left.SearchableText, right.SearchableText, StringComparison.Ordinal)
+ || !left.Tags.SequenceEqual(right.Tags)
+ || !string.Equals(left.SourceInputId, right.SourceInputId, StringComparison.Ordinal)
+ || left.ExpiresAt != right.ExpiresAt
+ || !left.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)
+ .SequenceEqual(right.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal)))
+ {
+ throw new InvalidOperationException("A memory identity cannot be reused for different content.");
+ }
+ }
+
+ internal sealed class MemoryVectorIndexDocument
+ {
+ public int FormatVersion { get; set; }
+
+ public MemoryDocument? Memory { get; set; }
+
+ public IdentityDocument? Identity { get; set; }
+
+ public float[]? Vector { get; set; }
+
+ public string? DiagnosticCode { get; set; }
+ }
+
+ internal sealed class IdentityDocument
+ {
+ public string? ProviderId { get; set; }
+
+ public string? ModelId { get; set; }
+
+ public string? Version { get; set; }
+
+ public int Dimensions { get; set; }
+ }
+
+ internal sealed class MemoryDocument
+ {
+ public string? MemoryId { get; set; }
+
+ public string? SessionId { get; set; }
+
+ public string? OwnerId { get; set; }
+
+ public string? Scope { get; set; }
+
+ public int Kind { get; set; }
+
+ public string? PayloadJson { get; set; }
+
+ public string? TimelineId { get; set; }
+
+ public long Tick { get; set; }
+
+ public string? CalendarJson { get; set; }
+
+ public double Importance { get; set; }
+
+ public string? SearchableText { get; set; }
+
+ public string[]? Tags { get; set; }
+
+ public string? SourceInputId { get; set; }
+
+ public string? ExpiresTimelineId { get; set; }
+
+ public long? ExpiresTick { get; set; }
+
+ public string? ExpiresCalendarJson { get; set; }
+
+ public Dictionary? Metadata { get; set; }
+
+ public static MemoryDocument Encode(GameMemory memory) => new()
+ {
+ MemoryId = memory.MemoryId,
+ SessionId = memory.SessionId,
+ OwnerId = memory.OwnerId,
+ Scope = memory.Scope,
+ Kind = (int)memory.Kind,
+ PayloadJson = memory.PayloadJson,
+ TimelineId = memory.Moment.TimelineId,
+ Tick = memory.Moment.Tick,
+ CalendarJson = memory.Moment.CalendarJson,
+ Importance = memory.Importance,
+ SearchableText = memory.SearchableText,
+ Tags = memory.Tags.ToArray(),
+ SourceInputId = memory.SourceInputId,
+ ExpiresTimelineId = memory.ExpiresAt?.TimelineId,
+ ExpiresTick = memory.ExpiresAt?.Tick,
+ ExpiresCalendarJson = memory.ExpiresAt?.CalendarJson,
+ Metadata = new Dictionary(memory.Metadata, StringComparer.Ordinal),
+ };
+
+ public GameMemory Decode()
+ {
+ if (!Enum.IsDefined(typeof(GameMemoryKind), Kind))
+ {
+ throw new InvalidDataException("A vector memory kind is invalid.");
+ }
+
+ var moment = new GameMoment(TimelineId ?? string.Empty, Tick, CalendarJson);
+ var expires = ExpiresTick.HasValue
+ ? new GameMoment(ExpiresTimelineId ?? string.Empty, ExpiresTick.Value, ExpiresCalendarJson)
+ : (GameMoment?)null;
+ return new GameMemory(
+ MemoryId ?? string.Empty,
+ SessionId ?? string.Empty,
+ OwnerId ?? string.Empty,
+ Scope ?? string.Empty,
+ (GameMemoryKind)Kind,
+ PayloadJson ?? string.Empty,
+ moment,
+ Importance,
+ SearchableText,
+ Tags,
+ SourceInputId,
+ expires,
+ Metadata);
+ }
+ }
+}
diff --git a/src/OpenGameAgent.Memory/OpenGameAgent.Memory.csproj b/src/OpenGameAgent.Memory/OpenGameAgent.Memory.csproj
new file mode 100644
index 0000000..4c6a008
--- /dev/null
+++ b/src/OpenGameAgent.Memory/OpenGameAgent.Memory.csproj
@@ -0,0 +1,10 @@
+
+
+ netstandard2.1
+ OpenGameAgent.Memory
+ Optional vector memory, hybrid recall, rebuild lifecycle, and game-aware reranking for OpenGameAgent.
+
+
+
+
+
diff --git a/src/OpenGameAgent.Memory/RuntimeMemoryLifecycle.cs b/src/OpenGameAgent.Memory/RuntimeMemoryLifecycle.cs
new file mode 100644
index 0000000..1bbd81d
--- /dev/null
+++ b/src/OpenGameAgent.Memory/RuntimeMemoryLifecycle.cs
@@ -0,0 +1,51 @@
+namespace OpenGameAgent.Memory;
+
+///
+/// Small host-facing lifecycle for status checks, explicit rebuilds, and
+/// deterministic provider cleanup. It does not own game state or decide when a
+/// save should advance.
+///
+public sealed class RuntimeMemoryLifecycle : IAsyncDisposable
+{
+ private readonly VectorMemoryStore _store;
+ private int _disposed;
+
+ public RuntimeMemoryLifecycle(VectorMemoryStore store)
+ {
+ _store = store ?? throw new ArgumentNullException(nameof(store));
+ }
+
+ public VectorMemoryStore Store => _store;
+
+ public ValueTask InspectAsync(
+ string sessionId,
+ CancellationToken cancellationToken = default)
+ {
+ ThrowIfDisposed();
+ return _store.GetStatusAsync(sessionId, cancellationToken);
+ }
+
+ public ValueTask RebuildAsync(
+ string sessionId,
+ CancellationToken cancellationToken = default)
+ {
+ ThrowIfDisposed();
+ return _store.RebuildAsync(sessionId, cancellationToken);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) == 0)
+ {
+ await _store.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
+ private void ThrowIfDisposed()
+ {
+ if (Volatile.Read(ref _disposed) != 0)
+ {
+ throw new ObjectDisposedException(nameof(RuntimeMemoryLifecycle));
+ }
+ }
+}
diff --git a/src/OpenGameAgent.Memory/VectorMemoryStore.cs b/src/OpenGameAgent.Memory/VectorMemoryStore.cs
new file mode 100644
index 0000000..96d6252
--- /dev/null
+++ b/src/OpenGameAgent.Memory/VectorMemoryStore.cs
@@ -0,0 +1,846 @@
+using System.Collections.ObjectModel;
+using System.Text.Json;
+
+namespace OpenGameAgent.Memory;
+
+public sealed class VectorMemoryStoreOptions
+{
+ public VectorMemoryStoreOptions(
+ int maximumIndexEntries = 100_000,
+ long maximumStoredVectorValues = 200_000_000,
+ long maximumVectorComparisonsPerSearch = 20_000_000,
+ int candidateMultiplier = 4,
+ int maximumCandidates = 512,
+ int rebuildBatchSize = 32,
+ int maximumConcurrentEmbeddingCalls = 4,
+ TimeSpan? embeddingTimeout = null,
+ TimeSpan? diagnosticTimeout = null,
+ bool failWhenEmbeddingUnavailable = false,
+ bool disposeEmbeddingProvider = true)
+ {
+ if (maximumIndexEntries < 1 || maximumIndexEntries > 1_000_000)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maximumIndexEntries));
+ }
+
+ if (maximumVectorComparisonsPerSearch < 1 || maximumVectorComparisonsPerSearch > 10_000_000_000)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maximumVectorComparisonsPerSearch));
+ }
+
+ if (maximumStoredVectorValues < 1 || maximumStoredVectorValues > 10_000_000_000)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maximumStoredVectorValues));
+ }
+
+ if (candidateMultiplier < 1 || candidateMultiplier > 100)
+ {
+ throw new ArgumentOutOfRangeException(nameof(candidateMultiplier));
+ }
+
+ if (maximumCandidates < 1 || maximumCandidates > 100_000)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maximumCandidates));
+ }
+
+ if (rebuildBatchSize < 1 || rebuildBatchSize > 1_024)
+ {
+ throw new ArgumentOutOfRangeException(nameof(rebuildBatchSize));
+ }
+
+ if (maximumConcurrentEmbeddingCalls < 1 || maximumConcurrentEmbeddingCalls > 256)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maximumConcurrentEmbeddingCalls));
+ }
+
+ var effectiveEmbeddingTimeout = embeddingTimeout ?? TimeSpan.FromSeconds(30);
+ var effectiveDiagnosticTimeout = diagnosticTimeout ?? TimeSpan.FromMilliseconds(500);
+ ValidateTimeout(effectiveEmbeddingTimeout, nameof(embeddingTimeout), TimeSpan.FromMinutes(10));
+ ValidateTimeout(effectiveDiagnosticTimeout, nameof(diagnosticTimeout), TimeSpan.FromSeconds(30));
+
+ MaximumIndexEntries = maximumIndexEntries;
+ MaximumStoredVectorValues = maximumStoredVectorValues;
+ MaximumVectorComparisonsPerSearch = maximumVectorComparisonsPerSearch;
+ CandidateMultiplier = candidateMultiplier;
+ MaximumCandidates = maximumCandidates;
+ RebuildBatchSize = rebuildBatchSize;
+ MaximumConcurrentEmbeddingCalls = maximumConcurrentEmbeddingCalls;
+ EmbeddingTimeout = effectiveEmbeddingTimeout;
+ DiagnosticTimeout = effectiveDiagnosticTimeout;
+ FailWhenEmbeddingUnavailable = failWhenEmbeddingUnavailable;
+ DisposeEmbeddingProvider = disposeEmbeddingProvider;
+ }
+
+ public int MaximumIndexEntries { get; }
+
+ public long MaximumStoredVectorValues { get; }
+
+ public long MaximumVectorComparisonsPerSearch { get; }
+
+ public int CandidateMultiplier { get; }
+
+ public int MaximumCandidates { get; }
+
+ public int RebuildBatchSize { get; }
+
+ public int MaximumConcurrentEmbeddingCalls { get; }
+
+ public TimeSpan EmbeddingTimeout { get; }
+
+ public TimeSpan DiagnosticTimeout { get; }
+
+ public bool FailWhenEmbeddingUnavailable { get; }
+
+ public bool DisposeEmbeddingProvider { get; }
+
+ private static void ValidateTimeout(TimeSpan value, string parameterName, TimeSpan maximum)
+ {
+ if (value < TimeSpan.FromMilliseconds(1) || value > maximum)
+ {
+ throw new ArgumentOutOfRangeException(parameterName);
+ }
+ }
+}
+
+///
+/// Adds a derived vector index and hybrid retrieval to any authoritative
+/// memory store. The authoritative store is always written first. Embedding
+/// failures therefore degrade recall to lexical search without losing memory.
+///
+public sealed class VectorMemoryStore : IGameMemoryStore, IGameMemorySnapshotSource, IAsyncDisposable
+{
+ private readonly IGameMemoryStore _authoritativeStore;
+ private readonly IGameMemorySnapshotSource _snapshotSource;
+ private readonly IVectorMemoryIndex _index;
+ private readonly IMemoryEmbeddingProvider _embeddingProvider;
+ private readonly IMemoryEmbeddingTextProjector _projector;
+ private readonly IGameMemoryRanker? _reranker;
+ private readonly IMemoryVectorDiagnosticSink _diagnostics;
+ private readonly VectorMemoryStoreOptions _options;
+ private readonly SemaphoreSlim _embeddingSlots;
+ private readonly SemaphoreSlim _diagnosticSlot = new(1, 1);
+ private readonly MemoryEmbeddingIdentity _activeIdentity;
+ private int _disposed;
+
+ public VectorMemoryStore(
+ IGameMemoryStore authoritativeStore,
+ IVectorMemoryIndex index,
+ IMemoryEmbeddingProvider embeddingProvider,
+ IMemoryEmbeddingTextProjector? projector = null,
+ IGameMemoryRanker? reranker = null,
+ IMemoryVectorDiagnosticSink? diagnostics = null,
+ VectorMemoryStoreOptions? options = null,
+ IGameMemorySnapshotSource? snapshotSource = null)
+ {
+ _authoritativeStore = authoritativeStore ?? throw new ArgumentNullException(nameof(authoritativeStore));
+ _snapshotSource = snapshotSource ?? authoritativeStore as IGameMemorySnapshotSource
+ ?? throw new ArgumentException(
+ "The authoritative store must expose deterministic snapshots for vector rebuilds.",
+ nameof(authoritativeStore));
+ _index = index ?? throw new ArgumentNullException(nameof(index));
+ _embeddingProvider = embeddingProvider ?? throw new ArgumentNullException(nameof(embeddingProvider));
+ _activeIdentity = embeddingProvider.Identity
+ ?? throw new ArgumentException("The embedding provider requires an identity.", nameof(embeddingProvider));
+ _projector = projector ?? new DefaultMemoryEmbeddingTextProjector();
+ _reranker = reranker;
+ _diagnostics = diagnostics ?? NullMemoryVectorDiagnosticSink.Instance;
+ _options = options ?? new VectorMemoryStoreOptions();
+ if (checked((long)_options.MaximumIndexEntries * _activeIdentity.Dimensions)
+ > _options.MaximumStoredVectorValues)
+ {
+ throw new ArgumentException(
+ "The configured vector index size exceeds its stored-value bound.",
+ nameof(options));
+ }
+
+ _embeddingSlots = new SemaphoreSlim(
+ _options.MaximumConcurrentEmbeddingCalls,
+ _options.MaximumConcurrentEmbeddingCalls);
+ }
+
+ public MemoryEmbeddingIdentity ActiveIdentity => _activeIdentity;
+
+ public async ValueTask AppendAsync(GameMemory memory, CancellationToken cancellationToken)
+ {
+ ThrowIfDisposed();
+ if (memory is null)
+ {
+ throw new ArgumentNullException(nameof(memory));
+ }
+
+ await _authoritativeStore.AppendAsync(memory, cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await _index.UpsertAsync(
+ new VectorMemoryIndexEntry(memory, null, null, "embedding_pending"),
+ cancellationToken)
+ .ConfigureAwait(false);
+ var vector = await EmbedDocumentAsync(memory, cancellationToken).ConfigureAwait(false);
+ await _index.UpsertAsync(
+ new VectorMemoryIndexEntry(memory, ActiveIdentity, vector),
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ await ReportAsync(
+ "memory_embedding_append_failed",
+ MemoryVectorDiagnosticSeverity.Warning,
+ "The memory was saved, but its derived vector is unavailable. Lexical recall remains available.",
+ memory,
+ exception,
+ cancellationToken)
+ .ConfigureAwait(false);
+ if (_options.FailWhenEmbeddingUnavailable)
+ {
+ throw;
+ }
+ }
+ }
+
+ public async ValueTask> SearchAsync(
+ GameMemoryQuery query,
+ CancellationToken cancellationToken)
+ {
+ ThrowIfDisposed();
+ if (query is null)
+ {
+ throw new ArgumentNullException(nameof(query));
+ }
+
+ if (query.Limit == 0)
+ {
+ return Array.Empty();
+ }
+
+ var candidateLimit = Math.Min(
+ _options.MaximumCandidates,
+ Math.Max(query.Limit, checked(query.Limit * _options.CandidateMultiplier)));
+ var expandedQuery = CopyQuery(query, candidateLimit);
+ var lexical = await _authoritativeStore.SearchAsync(expandedQuery, cancellationToken).ConfigureAwait(false)
+ ?? throw new InvalidOperationException("The authoritative memory store returned null.");
+ ValidateCandidates(expandedQuery, lexical);
+
+ IReadOnlyList vector = Array.Empty();
+ if (!string.IsNullOrWhiteSpace(query.Text))
+ {
+ try
+ {
+ vector = await SearchVectorAsync(query, candidateLimit, cancellationToken).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ await ReportAsync(
+ "memory_vector_search_failed",
+ MemoryVectorDiagnosticSeverity.Warning,
+ "Vector recall is unavailable for this query. Lexical recall was used.",
+ memory: null,
+ exception,
+ cancellationToken,
+ query.SessionId)
+ .ConfigureAwait(false);
+ if (_options.FailWhenEmbeddingUnavailable)
+ {
+ throw;
+ }
+ }
+ }
+
+ var fused = Fuse(lexical, vector, candidateLimit);
+ if (_reranker is not null && fused.Count > 0)
+ {
+ var reranked = await _reranker.RankAsync(query, fused, cancellationToken).ConfigureAwait(false)
+ ?? throw new InvalidOperationException("The memory reranker returned null.");
+ fused = ValidateReranked(fused, reranked);
+ }
+
+ return Array.AsReadOnly(fused.Take(query.Limit).ToArray());
+ }
+
+ public async IAsyncEnumerable EnumerateAsync(
+ string sessionId,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ ThrowIfDisposed();
+ sessionId = MemoryVectorGuard.Id(sessionId, nameof(sessionId), 1_024);
+ await foreach (var memory in _snapshotSource.EnumerateAsync(sessionId, cancellationToken).ConfigureAwait(false))
+ {
+ yield return memory;
+ }
+ }
+
+ public async ValueTask GetStatusAsync(
+ string sessionId,
+ CancellationToken cancellationToken = default)
+ {
+ ThrowIfDisposed();
+ sessionId = MemoryVectorGuard.Id(sessionId, nameof(sessionId), 1_024);
+ var entries = await _index.ListAsync(
+ sessionId,
+ _options.MaximumIndexEntries,
+ cancellationToken)
+ .ConfigureAwait(false);
+ var authoritative = await LoadAuthoritativeSnapshotAsync(sessionId, cancellationToken).ConfigureAwait(false);
+ return BuildStatus(entries, authoritative);
+ }
+
+ ///
+ /// Explicitly regenerates derived vectors using the active provider
+ /// identity. Existing stale vectors remain excluded until each replacement
+ /// is durably written, so cancellation or a crash leaves a recoverable
+ /// partial rebuild.
+ ///
+ public async ValueTask RebuildAsync(
+ string sessionId,
+ CancellationToken cancellationToken = default)
+ {
+ ThrowIfDisposed();
+ sessionId = MemoryVectorGuard.Id(sessionId, nameof(sessionId), 1_024);
+ var authoritative = await LoadAuthoritativeSnapshotAsync(sessionId, cancellationToken).ConfigureAwait(false);
+ var memories = authoritative.Values.ToList();
+ var completed = true;
+
+ for (var offset = 0; offset < memories.Count; offset += _options.RebuildBatchSize)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var batch = memories.Skip(offset).Take(_options.RebuildBatchSize).ToArray();
+ try
+ {
+ var vectors = await EmbedDocumentsAsync(batch, cancellationToken).ConfigureAwait(false);
+ for (var index = 0; index < batch.Length; index++)
+ {
+ await _index.UpsertAsync(
+ new VectorMemoryIndexEntry(batch[index], ActiveIdentity, vectors[index]),
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ await ReportAsync(
+ "memory_embedding_rebuild_failed",
+ MemoryVectorDiagnosticSeverity.Error,
+ "The vector rebuild stopped after a batch failed. Retry the explicit rebuild after fixing the embedding provider or derived index.",
+ batch[0],
+ exception,
+ cancellationToken)
+ .ConfigureAwait(false);
+ foreach (var memory in batch)
+ {
+ try
+ {
+ await _index.UpsertAsync(
+ new VectorMemoryIndexEntry(memory, null, null, "embedding_rebuild_failed"),
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (Exception indexException)
+ {
+ await ReportAsync(
+ "memory_vector_index_write_failed",
+ MemoryVectorDiagnosticSeverity.Error,
+ "The derived vector index could not persist a failed rebuild marker.",
+ memory,
+ indexException,
+ cancellationToken)
+ .ConfigureAwait(false);
+ throw new InvalidOperationException(
+ "The derived vector index failed while recording rebuild state.",
+ indexException);
+ }
+ }
+ completed = false;
+ break;
+ }
+ }
+
+ if (completed)
+ {
+ var current = await _index.ListAsync(sessionId, _options.MaximumIndexEntries, cancellationToken)
+ .ConfigureAwait(false);
+ foreach (var entry in current)
+ {
+ if (!authoritative.ContainsKey((entry.Memory.OwnerId, entry.Memory.MemoryId)))
+ {
+ await _index.DeleteAsync(
+ entry.Memory.SessionId,
+ entry.Memory.OwnerId,
+ entry.Memory.MemoryId,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ }
+
+ var status = await GetStatusAsync(sessionId, cancellationToken).ConfigureAwait(false);
+ if (!status.RequiresRebuild)
+ {
+ await ReportAsync(
+ "memory_embedding_rebuild_completed",
+ MemoryVectorDiagnosticSeverity.Information,
+ "The vector memory index was rebuilt with the active embedding identity.",
+ memory: null,
+ exception: null,
+ cancellationToken,
+ sessionId)
+ .ConfigureAwait(false);
+ }
+
+ return status;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) != 0)
+ {
+ return;
+ }
+
+ if (_options.DisposeEmbeddingProvider)
+ {
+ await _embeddingProvider.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
+ private async ValueTask> EmbedDocumentAsync(
+ GameMemory memory,
+ CancellationToken cancellationToken)
+ {
+ var vectors = await EmbedDocumentsAsync(new[] { memory }, cancellationToken).ConfigureAwait(false);
+ return vectors[0];
+ }
+
+ private async ValueTask>> EmbedDocumentsAsync(
+ IReadOnlyList memories,
+ CancellationToken cancellationToken)
+ {
+ var texts = memories.Select(_projector.ProjectDocument).ToArray();
+ var raw = await InvokeEmbeddingAsync(
+ token => _embeddingProvider.EmbedDocumentsAsync(texts, token),
+ cancellationToken)
+ .ConfigureAwait(false);
+ if (raw is null || raw.Count != memories.Count)
+ {
+ throw new InvalidOperationException("The embedding provider returned an invalid batch size.");
+ }
+
+ return new ReadOnlyCollection>(
+ raw.Select(vector => (IReadOnlyList)Array.AsReadOnly(
+ MemoryVectorGuard.Normalize(vector, ActiveIdentity, nameof(raw))))
+ .ToArray());
+ }
+
+ private async ValueTask> SearchVectorAsync(
+ GameMemoryQuery query,
+ int candidateLimit,
+ CancellationToken cancellationToken)
+ {
+ var text = _projector.ProjectQuery(query);
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return Array.Empty();
+ }
+
+ var queryVector = await InvokeEmbeddingAsync(
+ token => _embeddingProvider.EmbedQueryAsync(text, token),
+ cancellationToken)
+ .ConfigureAwait(false);
+ var normalized = MemoryVectorGuard.Normalize(queryVector, ActiveIdentity, nameof(queryVector));
+ var entries = await _index.ListAsync(query.SessionId, _options.MaximumIndexEntries, cancellationToken)
+ .ConfigureAwait(false);
+ var authoritative = await LoadAuthoritativeSnapshotAsync(query.SessionId, cancellationToken).ConfigureAwait(false);
+ if (checked((long)entries.Count * ActiveIdentity.Dimensions) > _options.MaximumVectorComparisonsPerSearch)
+ {
+ throw new InvalidOperationException("Vector recall exceeded the configured comparison bound.");
+ }
+
+ var ranked = new List<(GameMemory Memory, double Score)>();
+ foreach (var entry in entries)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!authoritative.TryGetValue((entry.Memory.OwnerId, entry.Memory.MemoryId), out var authoritativeMemory))
+ {
+ throw new InvalidDataException("The derived vector index contains a memory absent from the authoritative store.");
+ }
+
+ MemoryVectorIndexCodec.EnsureSameMemory(authoritativeMemory, entry.Memory);
+ if (entry.Vector is null
+ || entry.Identity is null
+ || !entry.Identity.Equals(ActiveIdentity)
+ || !MatchesQuery(authoritativeMemory, query))
+ {
+ continue;
+ }
+
+ double score = 0;
+ for (var index = 0; index < normalized.Length; index++)
+ {
+ score += normalized[index] * entry.Vector[index];
+ }
+
+ ranked.Add((authoritativeMemory, Math.Max(-1, Math.Min(1, score))));
+ }
+
+ return Array.AsReadOnly(ranked
+ .OrderByDescending(value => value.Score)
+ .ThenByDescending(value => value.Memory.Importance)
+ .ThenByDescending(value => value.Memory.Moment.Tick)
+ .ThenBy(value => value.Memory.OwnerId, StringComparer.Ordinal)
+ .ThenBy(value => value.Memory.MemoryId, StringComparer.Ordinal)
+ .Take(candidateLimit)
+ .Select(value => value.Memory)
+ .ToArray());
+ }
+
+ private async ValueTask InvokeEmbeddingAsync(
+ Func> operation,
+ CancellationToken cancellationToken)
+ {
+ using var timeout = new CancellationTokenSource(_options.EmbeddingTimeout);
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
+ try
+ {
+ await _embeddingSlots.WaitAsync(linked.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
+ {
+ throw new TimeoutException("The memory embedding queue exceeded its configured deadline.");
+ }
+
+ var releaseSlot = true;
+ try
+ {
+ Task task;
+ try
+ {
+ task = operation(linked.Token).AsTask();
+ }
+ catch
+ {
+ throw;
+ }
+
+ var delay = Task.Delay(Timeout.InfiniteTimeSpan, linked.Token);
+ var completed = await Task.WhenAny(task, delay).ConfigureAwait(false);
+ if (completed == task)
+ {
+ return await task.ConfigureAwait(false);
+ }
+
+ // A provider is allowed to ignore cancellation. Keep its concurrency
+ // lease until it actually settles so repeated timeouts cannot create
+ // an unbounded number of detached embedding calls.
+ releaseSlot = false;
+ _ = task.ContinueWith(
+ continuation =>
+ {
+ _ = continuation.Exception;
+ _embeddingSlots.Release();
+ },
+ CancellationToken.None,
+ TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default);
+ if (cancellationToken.IsCancellationRequested)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+
+ throw new TimeoutException("The memory embedding provider exceeded its configured deadline.");
+ }
+ finally
+ {
+ if (releaseSlot)
+ {
+ _embeddingSlots.Release();
+ }
+ }
+ }
+
+ private VectorMemoryStatus BuildStatus(
+ IReadOnlyList entries,
+ IReadOnlyDictionary<(string OwnerId, string MemoryId), GameMemory> authoritative)
+ {
+ var byId = entries.ToDictionary(entry => (entry.Memory.OwnerId, entry.Memory.MemoryId));
+ var ready = 0;
+ var stale = 0;
+ var pending = 0;
+ foreach (var pair in authoritative)
+ {
+ if (!byId.TryGetValue(pair.Key, out var entry))
+ {
+ pending++;
+ continue;
+ }
+
+ MemoryVectorIndexCodec.EnsureSameMemory(pair.Value, entry.Memory);
+ if (entry.Vector is null)
+ {
+ pending++;
+ }
+ else if (entry.Identity?.Equals(ActiveIdentity) == true)
+ {
+ ready++;
+ }
+ else
+ {
+ stale++;
+ }
+ }
+
+ var orphans = byId.Keys.Count(key => !authoritative.ContainsKey(key));
+ var state = authoritative.Count == 0 && orphans == 0
+ ? VectorMemoryState.Empty
+ : stale > 0
+ ? VectorMemoryState.RebuildRequired
+ : pending > 0 || orphans > 0
+ ? VectorMemoryState.Degraded
+ : VectorMemoryState.Ready;
+ return new VectorMemoryStatus(state, ActiveIdentity, authoritative.Count, ready, pending, stale, orphans);
+ }
+
+ private async ValueTask> LoadAuthoritativeSnapshotAsync(
+ string sessionId,
+ CancellationToken cancellationToken)
+ {
+ var memories = new Dictionary<(string OwnerId, string MemoryId), GameMemory>();
+ await foreach (var memory in _snapshotSource.EnumerateAsync(sessionId, cancellationToken).ConfigureAwait(false))
+ {
+ if (!string.Equals(memory.SessionId, sessionId, StringComparison.Ordinal)
+ || !memories.TryAdd((memory.OwnerId, memory.MemoryId), memory))
+ {
+ throw new InvalidOperationException("The authoritative memory snapshot returned an invalid identity.");
+ }
+
+ if (memories.Count > _options.MaximumIndexEntries)
+ {
+ throw new InvalidOperationException("The authoritative memory snapshot exceeded the configured bound.");
+ }
+ }
+
+ return new ReadOnlyDictionary<(string OwnerId, string MemoryId), GameMemory>(memories);
+ }
+
+ private async ValueTask ReportAsync(
+ string code,
+ MemoryVectorDiagnosticSeverity severity,
+ string message,
+ GameMemory? memory,
+ Exception? exception,
+ CancellationToken cancellationToken,
+ string? sessionId = null)
+ {
+ var details = exception is null
+ ? null
+ : JsonSerializer.Serialize(new { exception = exception.GetType().Name });
+ var diagnostic = new MemoryVectorDiagnostic(
+ code,
+ severity,
+ message,
+ sessionId ?? memory?.SessionId,
+ memory?.OwnerId,
+ memory?.MemoryId,
+ details);
+ try
+ {
+ using var timeout = new CancellationTokenSource(_options.DiagnosticTimeout);
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
+ try
+ {
+ await _diagnosticSlot.WaitAsync(linked.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ var releaseSlot = true;
+ try
+ {
+ var task = _diagnostics.ReportAsync(diagnostic, linked.Token).AsTask();
+ var delay = Task.Delay(Timeout.InfiniteTimeSpan, linked.Token);
+ var completed = await Task.WhenAny(task, delay).ConfigureAwait(false);
+ if (completed == task)
+ {
+ await task.ConfigureAwait(false);
+ return;
+ }
+
+ releaseSlot = false;
+ _ = task.ContinueWith(
+ continuation =>
+ {
+ _ = continuation.Exception;
+ _diagnosticSlot.Release();
+ },
+ CancellationToken.None,
+ TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default);
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ finally
+ {
+ if (releaseSlot)
+ {
+ _diagnosticSlot.Release();
+ }
+ }
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ }
+ catch when (!cancellationToken.IsCancellationRequested)
+ {
+ }
+ }
+
+ private static GameMemoryQuery CopyQuery(GameMemoryQuery query, int limit) => new(
+ query.SessionId,
+ limit,
+ query.OwnerId,
+ query.Scopes,
+ query.Kinds,
+ query.Tags,
+ query.Text,
+ query.AtOrBefore,
+ query.MinimumImportance);
+
+ private static IReadOnlyList Fuse(
+ IReadOnlyList lexical,
+ IReadOnlyList vector,
+ int maximumCandidates)
+ {
+ const double rankConstant = 60;
+ var candidates = new Dictionary<(string OwnerId, string MemoryId), FusedCandidate>();
+ Add(lexical);
+ Add(vector);
+ return Array.AsReadOnly(candidates.Values
+ .OrderByDescending(candidate => candidate.Score)
+ .ThenByDescending(candidate => candidate.Memory.Importance)
+ .ThenByDescending(candidate => candidate.Memory.Moment.Tick)
+ .ThenBy(candidate => candidate.Memory.OwnerId, StringComparer.Ordinal)
+ .ThenBy(candidate => candidate.Memory.MemoryId, StringComparer.Ordinal)
+ .Take(maximumCandidates)
+ .Select(candidate => candidate.Memory)
+ .ToArray());
+
+ void Add(IReadOnlyList source)
+ {
+ for (var rank = 0; rank < source.Count; rank++)
+ {
+ var memory = source[rank] ?? throw new InvalidOperationException("A memory provider returned null.");
+ var key = (memory.OwnerId, memory.MemoryId);
+ if (!candidates.TryGetValue(key, out var candidate))
+ {
+ candidate = new FusedCandidate(memory);
+ candidates.Add(key, candidate);
+ }
+ else
+ {
+ MemoryVectorIndexCodec.EnsureSameMemory(candidate.Memory, memory);
+ }
+
+ candidate.Score += 1d / (rankConstant + rank + 1);
+ }
+ }
+ }
+
+ private static IReadOnlyList ValidateReranked(
+ IReadOnlyList source,
+ IReadOnlyList ranked)
+ {
+ if (ranked.Count > source.Count)
+ {
+ throw new InvalidOperationException("The memory reranker returned too many candidates.");
+ }
+
+ var canonical = source.ToDictionary(memory => (memory.OwnerId, memory.MemoryId));
+ var seen = new HashSet<(string OwnerId, string MemoryId)>();
+ var output = new List(ranked.Count);
+ foreach (var memory in ranked)
+ {
+ if (memory is null
+ || !canonical.TryGetValue((memory.OwnerId, memory.MemoryId), out var original)
+ || !seen.Add((memory.OwnerId, memory.MemoryId)))
+ {
+ throw new InvalidOperationException("The memory reranker returned an unknown, duplicate, or null memory.");
+ }
+
+ MemoryVectorIndexCodec.EnsureSameMemory(original, memory);
+ output.Add(original);
+ }
+
+ return new ReadOnlyCollection(output);
+ }
+
+ private static void ValidateCandidates(GameMemoryQuery query, IReadOnlyList candidates)
+ {
+ if (candidates.Count > query.Limit)
+ {
+ throw new InvalidOperationException("The authoritative memory store exceeded the candidate limit.");
+ }
+
+ var ids = new HashSet<(string OwnerId, string MemoryId)>();
+ foreach (var memory in candidates)
+ {
+ if (memory is null || !ids.Add((memory.OwnerId, memory.MemoryId)) || !MatchesQuery(memory, query))
+ {
+ throw new InvalidOperationException("The authoritative memory store returned an invalid candidate.");
+ }
+ }
+ }
+
+ private static bool MatchesQuery(GameMemory memory, GameMemoryQuery query)
+ {
+ if (!string.Equals(memory.SessionId, query.SessionId, StringComparison.Ordinal)
+ || (query.OwnerId is not null && !string.Equals(memory.OwnerId, query.OwnerId, StringComparison.Ordinal))
+ || (query.Scopes.Count > 0 && !query.Scopes.Contains(memory.Scope, StringComparer.Ordinal))
+ || (query.Kinds.Count > 0 && !query.Kinds.Contains(memory.Kind))
+ || query.Tags.Any(tag => !memory.Tags.Contains(tag, StringComparer.Ordinal))
+ || memory.Importance < query.MinimumImportance)
+ {
+ return false;
+ }
+
+ if (query.AtOrBefore is not { } moment)
+ {
+ return true;
+ }
+
+ return string.Equals(memory.Moment.TimelineId, moment.TimelineId, StringComparison.Ordinal)
+ && memory.Moment.Tick <= moment.Tick
+ && (memory.ExpiresAt is null || moment.Tick < memory.ExpiresAt.Value.Tick);
+ }
+
+ private void ThrowIfDisposed()
+ {
+ if (Volatile.Read(ref _disposed) != 0)
+ {
+ throw new ObjectDisposedException(nameof(VectorMemoryStore));
+ }
+ }
+
+ private sealed class FusedCandidate
+ {
+ public FusedCandidate(GameMemory memory)
+ {
+ Memory = memory;
+ }
+
+ public GameMemory Memory { get; }
+
+ public double Score { get; set; }
+
+ }
+}
diff --git a/src/OpenGameAgent.Memory/packages.lock.json b/src/OpenGameAgent.Memory/packages.lock.json
new file mode 100644
index 0000000..a0174ed
--- /dev/null
+++ b/src/OpenGameAgent.Memory/packages.lock.json
@@ -0,0 +1,81 @@
+{
+ "version": 1,
+ "dependencies": {
+ ".NETStandard,Version=v2.1": {
+ "Microsoft.Bcl.AsyncInterfaces": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw=="
+ },
+ "System.Buffers": {
+ "type": "Transitive",
+ "resolved": "4.5.1",
+ "contentHash": "Rw7ijyl1qqRS0YQD/WycNst8hUUMgrMH4FCn1nNm27M4VxchZ1js3fVjQaANHO5f3sN4isvP4a+Met9Y4YomAg=="
+ },
+ "System.Memory": {
+ "type": "Transitive",
+ "resolved": "4.5.5",
+ "contentHash": "XIWiDvKPXaTveaB7HVganDlOCRoj03l+jrwNvcge/t8vhGYKvqV+dMv6G4SAX2NoNmN0wZfVPTAlFwZcZvVOUw==",
+ "dependencies": {
+ "System.Buffers": "4.5.1",
+ "System.Numerics.Vectors": "4.4.0",
+ "System.Runtime.CompilerServices.Unsafe": "4.5.3"
+ }
+ },
+ "System.Numerics.Vectors": {
+ "type": "Transitive",
+ "resolved": "4.4.0",
+ "contentHash": "UiLzLW+Lw6HLed1Hcg+8jSRttrbuXv7DANVj0DkL9g6EnnzbL75EB7EWsw5uRbhxd/4YdG8li5XizGWepmG3PQ=="
+ },
+ "System.Runtime.CompilerServices.Unsafe": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg=="
+ },
+ "System.Text.Encodings.Web": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==",
+ "dependencies": {
+ "System.Buffers": "4.5.1",
+ "System.Memory": "4.5.5",
+ "System.Runtime.CompilerServices.Unsafe": "6.0.0"
+ }
+ },
+ "System.Text.Json": {
+ "type": "Transitive",
+ "resolved": "8.0.6",
+ "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ==",
+ "dependencies": {
+ "Microsoft.Bcl.AsyncInterfaces": "8.0.0",
+ "System.Buffers": "4.5.1",
+ "System.Memory": "4.5.5",
+ "System.Runtime.CompilerServices.Unsafe": "6.0.0",
+ "System.Text.Encodings.Web": "8.0.0",
+ "System.Threading.Tasks.Extensions": "4.5.4"
+ }
+ },
+ "System.Threading.Tasks.Extensions": {
+ "type": "Transitive",
+ "resolved": "4.5.4",
+ "contentHash": "zteT+G8xuGu6mS+mzDzYXbzS7rd3K6Fjb9RiZlYlJPam2/hU7JCBZBVEcywNuR+oZ1ncTvc/cq0faRr3P01OVg==",
+ "dependencies": {
+ "System.Runtime.CompilerServices.Unsafe": "4.5.3"
+ }
+ },
+ "opengameagent": {
+ "type": "Project",
+ "dependencies": {
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "System.Text.Json": "[8.0.6, )"
+ }
+ },
+ "opengameagent.kernel": {
+ "type": "Project",
+ "dependencies": {
+ "System.Text.Json": "[8.0.6, )"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/OpenGameAgent.Models.Auth.BuiltIn/packages.lock.json b/src/OpenGameAgent.Models.Auth.BuiltIn/packages.lock.json
index c4c96c2..d4f2537 100644
--- a/src/OpenGameAgent.Models.Auth.BuiltIn/packages.lock.json
+++ b/src/OpenGameAgent.Models.Auth.BuiltIn/packages.lock.json
@@ -135,27 +135,27 @@
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
},
"opengameagent.models.builtin": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.Anthropic": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.Bedrock": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.Google": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.Mistral": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.OpenAI": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.OpenAICompatible": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.Anthropic": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.Bedrock": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.Google": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.Mistral": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.OpenAI": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.OpenAICompatible": "[0.3.0-alpha.2, )"
}
},
"opengameagent.providers.anthropic": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -163,8 +163,8 @@
"type": "Project",
"dependencies": {
"AWSSDK.BedrockRuntime": "[4.0.101, )",
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -172,32 +172,32 @@
"type": "Project",
"dependencies": {
"Google.Apis.Auth": "[1.75.0, )",
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.mistral": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.openai": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.openaicompatible": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/src/OpenGameAgent.Models.BuiltIn/packages.lock.json b/src/OpenGameAgent.Models.BuiltIn/packages.lock.json
index c44a3f1..0d19e3c 100644
--- a/src/OpenGameAgent.Models.BuiltIn/packages.lock.json
+++ b/src/OpenGameAgent.Models.BuiltIn/packages.lock.json
@@ -135,14 +135,14 @@
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
},
"opengameagent.providers.anthropic": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -150,8 +150,8 @@
"type": "Project",
"dependencies": {
"AWSSDK.BedrockRuntime": "[4.0.101, )",
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -159,32 +159,32 @@
"type": "Project",
"dependencies": {
"Google.Apis.Auth": "[1.75.0, )",
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.mistral": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.openai": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.openaicompatible": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/src/OpenGameAgent.Persistence/FileGameMemoryStore.cs b/src/OpenGameAgent.Persistence/FileGameMemoryStore.cs
index c01d080..17a56dc 100644
--- a/src/OpenGameAgent.Persistence/FileGameMemoryStore.cs
+++ b/src/OpenGameAgent.Persistence/FileGameMemoryStore.cs
@@ -7,7 +7,7 @@
namespace OpenGameAgent.Persistence;
-public sealed class FileGameMemoryStore : IGameMemoryStore
+public sealed class FileGameMemoryStore : IGameMemoryStore, IGameMemorySnapshotSource
{
private const string Suffix = ".memory.json";
private readonly FileStore _files;
@@ -114,6 +114,42 @@ public async ValueTask> SearchAsync(
return await inMemory.SearchAsync(query, cancellationToken).ConfigureAwait(false);
}
+ public async IAsyncEnumerable EnumerateAsync(
+ string sessionId,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ sessionId = GameJson.RequireId(sessionId, nameof(sessionId));
+ var scanned = 0;
+ foreach (var path in Directory.EnumerateFiles(_files.DirectoryPath, "*" + Suffix, SearchOption.TopDirectoryOnly)
+ .OrderBy(path => path, StringComparer.Ordinal))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (++scanned > _maximumEntries)
+ {
+ throw new GameRuntimeLimitException(nameof(_maximumEntries), "The file memory snapshot exceeded its configured capacity.");
+ }
+
+ var document = await _files.ReadAsync(path, cancellationToken).ConfigureAwait(false);
+ if (document is null)
+ {
+ continue;
+ }
+
+ var memory = Decode(document);
+ _files.EnsurePathFor(
+ path,
+ StorageKey(memory.SessionId, memory.OwnerId, memory.MemoryId),
+ Suffix,
+ "memory");
+ if (!string.Equals(memory.SessionId, sessionId, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ yield return memory;
+ }
+ }
+
private static MemoryDocument Encode(GameMemory memory) => new()
{
FormatVersion = 1,
diff --git a/src/OpenGameAgent.Persistence/packages.lock.json b/src/OpenGameAgent.Persistence/packages.lock.json
index 8249b3b..08fe12b 100644
--- a/src/OpenGameAgent.Persistence/packages.lock.json
+++ b/src/OpenGameAgent.Persistence/packages.lock.json
@@ -67,15 +67,15 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.extensions": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )"
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )"
}
},
"opengameagent.kernel": {
@@ -87,7 +87,7 @@
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
}
}
diff --git a/src/OpenGameAgent.Plugins/packages.lock.json b/src/OpenGameAgent.Plugins/packages.lock.json
index fdf51c6..f1e6d27 100644
--- a/src/OpenGameAgent.Plugins/packages.lock.json
+++ b/src/OpenGameAgent.Plugins/packages.lock.json
@@ -146,7 +146,7 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -154,14 +154,14 @@
"type": "Project",
"dependencies": {
"ModelContextProtocol.Core": "[2.1.0, )",
- "OpenGameAgent.Extensions": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Extensions": "[0.3.0-alpha.2, )"
}
},
"opengameagent.extensions": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )"
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )"
}
},
"opengameagent.kernel": {
@@ -173,14 +173,14 @@
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
},
"opengameagent.persistence": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Extensions": "[0.3.0-alpha.1, )",
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Extensions": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
}
diff --git a/src/OpenGameAgent.Providers.MediaHttp/packages.lock.json b/src/OpenGameAgent.Providers.MediaHttp/packages.lock.json
index 8da3c96..1a59cbd 100644
--- a/src/OpenGameAgent.Providers.MediaHttp/packages.lock.json
+++ b/src/OpenGameAgent.Providers.MediaHttp/packages.lock.json
@@ -67,7 +67,7 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/src/OpenGameAgent.Providers.OpenRouter/packages.lock.json b/src/OpenGameAgent.Providers.OpenRouter/packages.lock.json
index 7ef1890..9cb3548 100644
--- a/src/OpenGameAgent.Providers.OpenRouter/packages.lock.json
+++ b/src/OpenGameAgent.Providers.OpenRouter/packages.lock.json
@@ -67,7 +67,7 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -80,14 +80,14 @@
"opengameagent.media": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )"
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )"
}
},
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
}
}
diff --git a/src/OpenGameAgent.Server/packages.lock.json b/src/OpenGameAgent.Server/packages.lock.json
index 14b2af1..4e24e0b 100644
--- a/src/OpenGameAgent.Server/packages.lock.json
+++ b/src/OpenGameAgent.Server/packages.lock.json
@@ -10,15 +10,15 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.extensions": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )"
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )"
}
},
"opengameagent.kernel": {
@@ -30,22 +30,22 @@
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
},
"opengameagent.persistence": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Extensions": "[0.3.0-alpha.1, )",
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Extensions": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.openaicompatible": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/src/OpenGameAgent/GameData.cs b/src/OpenGameAgent/GameData.cs
index 933984a..f015d63 100644
--- a/src/OpenGameAgent/GameData.cs
+++ b/src/OpenGameAgent/GameData.cs
@@ -89,6 +89,11 @@ private static string RequireId(string value, string parameterName)
public sealed class GameInput
{
+ ///
+ /// Creates one logical game input. Supply a stable
+ /// whenever the input may be retried across a process restart or participates
+ /// in durable actions. The generated fallback is unique only to this object.
+ ///
public GameInput(
string sessionId,
string actorId,
diff --git a/src/OpenGameAgent/Memory.cs b/src/OpenGameAgent/Memory.cs
index 45aa515..dd45e42 100644
--- a/src/OpenGameAgent/Memory.cs
+++ b/src/OpenGameAgent/Memory.cs
@@ -234,6 +234,18 @@ public interface IGameMemoryStore
ValueTask> SearchAsync(GameMemoryQuery query, CancellationToken cancellationToken);
}
+///
+/// Provides a deterministic, authoritative memory snapshot for rebuilding
+/// optional derived indexes. Implementations must not return memories from a
+/// different session and must preserve the store's normal visibility data.
+///
+public interface IGameMemorySnapshotSource
+{
+ IAsyncEnumerable EnumerateAsync(
+ string sessionId,
+ CancellationToken cancellationToken);
+}
+
public interface IGameMemoryRanker
{
ValueTask> RankAsync(
@@ -374,7 +386,7 @@ private static bool MatchesQuery(GameMemory memory, GameMemoryQuery query)
}
}
-public sealed class InMemoryGameMemoryStore : IGameMemoryStore
+public sealed class InMemoryGameMemoryStore : IGameMemoryStore, IGameMemorySnapshotSource
{
private readonly object _gate = new();
private readonly Dictionary<(string SessionId, string OwnerId, string MemoryId), GameMemory> _memories = new();
@@ -461,6 +473,29 @@ public ValueTask> SearchAsync(
return new ValueTask>(Array.AsReadOnly(candidates));
}
+ public async IAsyncEnumerable EnumerateAsync(
+ string sessionId,
+ [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ sessionId = GameJson.RequireId(sessionId, nameof(sessionId));
+ GameMemory[] snapshot;
+ lock (_gate)
+ {
+ snapshot = _memories.Values
+ .Where(memory => string.Equals(memory.SessionId, sessionId, StringComparison.Ordinal))
+ .OrderBy(memory => memory.OwnerId, StringComparer.Ordinal)
+ .ThenBy(memory => memory.MemoryId, StringComparer.Ordinal)
+ .ToArray();
+ }
+
+ foreach (var memory in snapshot)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ yield return memory;
+ await Task.Yield();
+ }
+ }
+
private static IReadOnlyList ScoreCandidates(
IReadOnlyList memories,
GameMemoryQuery query)
diff --git a/tests/OpenGameAgent.Connectors.Mcp.Tests/McpConnectorTests.cs b/tests/OpenGameAgent.Connectors.Mcp.Tests/McpConnectorTests.cs
index 8b8c3ef..994e8e9 100644
--- a/tests/OpenGameAgent.Connectors.Mcp.Tests/McpConnectorTests.cs
+++ b/tests/OpenGameAgent.Connectors.Mcp.Tests/McpConnectorTests.cs
@@ -300,6 +300,68 @@ public async Task LargeResultsUseBoundedArtifactIdsForLongGameIdentities()
await serverTask;
}
+ [Fact]
+ public async Task LargeResultArtifactIdentityIsStableAcrossFreshRunAttempts()
+ {
+ var artifacts = new InMemoryGameAgentArtifactStore();
+ var input = new GameInput(
+ "session",
+ "actor",
+ "request",
+ "{}",
+ new GameMoment("world", 7),
+ "stable-input");
+
+ var first = await ExecuteAsync();
+ var second = await ExecuteAsync();
+
+ Assert.Equal(first, second);
+
+ async Task ExecuteAsync()
+ {
+ var clientToServer = new Pipe();
+ var serverToClient = new Pipe();
+ await using var server = McpServer.Create(
+ new StreamServerTransport(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream()),
+ new McpServerOptions
+ {
+ ToolCollection =
+ [
+ McpServerTool.Create(() => new string('x', 2_048), new() { Name = "large" }),
+ ],
+ });
+ var serverTask = server.RunAsync(TestContext.Current.CancellationToken);
+ var provider = new ScriptedProvider(call => call == 1
+ ? new ModelResponse(
+ new AgentContent[] { new ToolCallContent("external", "test__large", "{}") },
+ ModelStopReason.ToolUse)
+ : new ModelResponse(new AgentContent[] { new TextContent("done") }, ModelStopReason.Stop));
+ var connection = new GameMcpServer(
+ "test",
+ async cancellationToken => await McpClient.CreateAsync(
+ new StreamClientTransport(clientToServer.Writer.AsStream(), serverToClient.Reader.AsStream()),
+ cancellationToken: cancellationToken));
+ await using var runtime = new GameAgentBuilder(provider, "model")
+ .UseExtension(new McpToolConnectorExtension(
+ new[] { connection },
+ maximumInlineResultCharacters: 1_024,
+ artifactStore: artifacts,
+ exposure: GameMcpToolExposure.Direct))
+ .Build();
+
+ var result = await runtime.RunAsync(input, TestContext.Current.CancellationToken);
+
+ Assert.True(result.Succeeded);
+ var toolMessage = provider.Requests.ElementAt(1).Messages.Last(message => message.Role == AgentRole.Tool);
+ var reference = Assert.IsType(Assert.Single(toolMessage.Content));
+ using var document = System.Text.Json.JsonDocument.Parse(reference.Json);
+ var artifactId = Assert.IsType(document.RootElement.GetProperty("artifactId").GetString());
+ await server.DisposeAsync();
+ await serverTask;
+ return artifactId;
+ }
+ }
+
[Fact]
public void StdioRejectsEmbeddedNullCharactersBeforeStartingAProcess()
{
diff --git a/tests/OpenGameAgent.Connectors.Mcp.Tests/packages.lock.json b/tests/OpenGameAgent.Connectors.Mcp.Tests/packages.lock.json
index bffb11e..2d9ec2b 100644
--- a/tests/OpenGameAgent.Connectors.Mcp.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Connectors.Mcp.Tests/packages.lock.json
@@ -214,7 +214,7 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -222,14 +222,14 @@
"type": "Project",
"dependencies": {
"ModelContextProtocol.Core": "[2.1.0, )",
- "OpenGameAgent.Extensions": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Extensions": "[0.3.0-alpha.2, )"
}
},
"opengameagent.extensions": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )"
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )"
}
},
"opengameagent.kernel": {
@@ -241,7 +241,7 @@
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
}
}
diff --git a/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs b/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs
index 5f3f1c6..16b05cd 100644
--- a/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs
+++ b/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs
@@ -867,6 +867,160 @@ public async Task MemoryToolsPreserveGameTimeAndFloatingPointPayloads()
Assert.Equal(5, memory.GetProperty("tick").GetInt64());
}
+ [Fact]
+ public async Task DefaultMemoryIdentityIsStableAcrossRunRetriesAndNamespacedByOwner()
+ {
+ var store = new InMemoryGameMemoryStore();
+
+ var first = await RunRememberAsync("actor");
+ var retry = await RunRememberAsync("actor");
+ var otherActor = await RunRememberAsync("other");
+
+ Assert.Equal(first, retry);
+ Assert.NotEqual(first, otherActor);
+ Assert.StartsWith("oga-memory-v1:", first, StringComparison.Ordinal);
+ Assert.Single(await store.SearchAsync(
+ new GameMemoryQuery("session", 10, ownerId: "actor"),
+ TestContext.Current.CancellationToken));
+ Assert.Single(await store.SearchAsync(
+ new GameMemoryQuery("session", 10, ownerId: "other"),
+ TestContext.Current.CancellationToken));
+
+ async Task RunRememberAsync(string actorId)
+ {
+ var provider = new ScriptedProvider(call => call == 1
+ ? ToolCall(
+ "remember",
+ "remember_game_memory",
+ "{\"scope\":\"facts\",\"kind\":\"fact\",\"payload\":{\"value\":1.25}}")
+ : TextResponse("remembered"));
+ await using var runtime = new GameAgentBuilder(provider, "model")
+ .UseExtension(new GameMemoryExtension(store))
+ .Build();
+ var result = await runtime.RunAsync(
+ new GameInput(
+ "session",
+ actorId,
+ "request",
+ "{}",
+ new GameMoment("world", 5),
+ "stable-input"),
+ TestContext.Current.CancellationToken);
+ Assert.True(result.Succeeded);
+ var toolResult = provider.Requests.ElementAt(1).Messages.Last(message => message.Role == AgentRole.Tool);
+ var json = Assert.IsType(Assert.Single(toolResult.Content)).Json;
+ using var document = System.Text.Json.JsonDocument.Parse(json);
+ return document.RootElement.GetProperty("memoryId").GetString()!;
+ }
+ }
+
+ [Fact]
+ public async Task GeneratedExtensionOperationIdsRemainStableAcrossFreshRunAttempts()
+ {
+ var broker = new RecordingBroker();
+ var firstInteraction = await RunInteractionAsync(broker);
+ var retriedInteraction = await RunInteractionAsync(broker);
+ Assert.Equal(firstInteraction, retriedInteraction);
+
+ var executor = new ImmediateDelegateExecutor(new GameAgentDelegateOutcome(
+ true,
+ new[] { Assistant("delegated") }));
+ var delegations = new InMemoryGameAgentDelegationStore();
+ await RunDelegationAsync(executor, delegations);
+ await RunDelegationAsync(executor, delegations);
+ var delegated = Assert.Single(executor.Requests);
+ Assert.StartsWith("oga-delegation-v1:", delegated.Id, StringComparison.Ordinal);
+
+ var artifacts = new InMemoryGameAgentArtifactStore();
+ var firstArtifact = await RunKnowledgeAsync(artifacts);
+ var retriedArtifact = await RunKnowledgeAsync(artifacts);
+ Assert.Equal(firstArtifact, retriedArtifact);
+
+ var toolArtifacts = new InMemoryGameAgentArtifactStore();
+ var firstToolArtifact = await RunToolArtifactAsync(toolArtifacts, "provider-call-a");
+ var retriedToolArtifact = await RunToolArtifactAsync(toolArtifacts, "provider-call-b");
+ Assert.Equal(firstToolArtifact, retriedToolArtifact);
+
+ async Task RunInteractionAsync(RecordingBroker targetBroker)
+ {
+ var provider = new ScriptedProvider(call => call == 1
+ ? ToolCall(
+ "ask",
+ "ask_player",
+ "{\"questions\":[{\"id\":\"approach\",\"prompt\":\"Choose\",\"options\":[{\"id\":\"safe\",\"label\":\"Safe\",\"description\":\"Validate\"},{\"id\":\"fast\",\"label\":\"Fast\",\"description\":\"Skip\"}]}]}")
+ : TextResponse("done"));
+ await using var runtime = new GameAgentBuilder(provider, "model")
+ .UseExtension(new StructuredInteractionExtension(targetBroker))
+ .Build();
+ Assert.True((await runtime.RunAsync(Input(), TestContext.Current.CancellationToken)).Succeeded);
+ return targetBroker.Requests.Last().RequestId;
+ }
+
+ async Task RunDelegationAsync(
+ ImmediateDelegateExecutor targetExecutor,
+ InMemoryGameAgentDelegationStore targetStore)
+ {
+ var provider = new ScriptedProvider(call => call == 1
+ ? ToolCall("delegate", "delegate_agent", "{\"task\":{\"kind\":\"inspect\"}}")
+ : TextResponse("done"));
+ await using var runtime = new GameAgentBuilder(provider, "model")
+ .UseExtension(new AgentDelegationExtension(targetExecutor, targetStore))
+ .Build();
+ Assert.True((await runtime.RunAsync(Input(), TestContext.Current.CancellationToken)).Succeeded);
+ }
+
+ async Task RunKnowledgeAsync(InMemoryGameAgentArtifactStore targetArtifacts)
+ {
+ var provider = new ScriptedProvider(call => call == 1
+ ? ToolCall(
+ "knowledge",
+ "query_external_knowledge",
+ "{\"source\":\"local\",\"query\":{\"topic\":\"world\"},\"limit\":1}")
+ : TextResponse("done"));
+ await using var runtime = new GameAgentBuilder(provider, "model")
+ .UseExtension(new ExternalKnowledgeExtension(
+ new[] { new LargeKnowledgeSource() },
+ maximumInlineResultCharacters: 1_024,
+ artifactStore: targetArtifacts))
+ .Build();
+ Assert.True((await runtime.RunAsync(Input(), TestContext.Current.CancellationToken)).Succeeded);
+ var tool = provider.Requests.ElementAt(1).Messages.Last(message => message.Role == AgentRole.Tool);
+ using var document = System.Text.Json.JsonDocument.Parse(
+ Assert.IsType(Assert.Single(tool.Content)).Json);
+ return document.RootElement.GetProperty("artifactId").GetString()!;
+ }
+
+ async Task RunToolArtifactAsync(
+ InMemoryGameAgentArtifactStore targetArtifacts,
+ string providerToolCallId)
+ {
+ var provider = new ScriptedProvider(call => call == 1
+ ? ToolCall(providerToolCallId, "large_result", "{}")
+ : TextResponse("done"));
+ await using var runtime = new GameAgentBuilder(provider, "model")
+ .UseExtension(
+ "game.large-results",
+ "1",
+ api => api.RegisterTool(new AgentTool(
+ new ToolDefinition(
+ "large_result",
+ "Return a large result.",
+ "{\"type\":\"object\",\"additionalProperties\":false}"),
+ (_, _, _) => new ValueTask(new ToolResult(
+ new AgentContent[] { new TextContent(new string('x', 2_048)) })))))
+ .UseExtension(new GameAgentArtifactExtension(
+ targetArtifacts,
+ spillToolResultsAboveCharacters: 1_024,
+ maximumInlinePreviewCharacters: 64))
+ .Build();
+ Assert.True((await runtime.RunAsync(Input(), TestContext.Current.CancellationToken)).Succeeded);
+ var tool = provider.Requests.ElementAt(1).Messages.Last(message => message.Role == AgentRole.Tool);
+ using var document = System.Text.Json.JsonDocument.Parse(
+ Assert.IsType(Assert.Single(tool.Content)).Json);
+ return document.RootElement.GetProperty("artifactId").GetString()!;
+ }
+ }
+
[Fact]
public async Task AutomaticMemoryRecallDefaultsToCurrentActorAndCurrentGameMoment()
{
diff --git a/tests/OpenGameAgent.Extensions.Tests/packages.lock.json b/tests/OpenGameAgent.Extensions.Tests/packages.lock.json
index 5e4c125..ecd7682 100644
--- a/tests/OpenGameAgent.Extensions.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Extensions.Tests/packages.lock.json
@@ -205,15 +205,15 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.extensions": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )"
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )"
}
},
"opengameagent.kernel": {
@@ -225,7 +225,7 @@
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
}
}
diff --git a/tests/OpenGameAgent.Media.Tests/packages.lock.json b/tests/OpenGameAgent.Media.Tests/packages.lock.json
index bf5656c..cfeb704 100644
--- a/tests/OpenGameAgent.Media.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Media.Tests/packages.lock.json
@@ -205,7 +205,7 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -218,14 +218,14 @@
"opengameagent.media": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )"
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )"
}
},
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
}
}
diff --git a/tests/OpenGameAgent.Memory.Tests/OpenGameAgent.Memory.Tests.csproj b/tests/OpenGameAgent.Memory.Tests/OpenGameAgent.Memory.Tests.csproj
new file mode 100644
index 0000000..7b677f1
--- /dev/null
+++ b/tests/OpenGameAgent.Memory.Tests/OpenGameAgent.Memory.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+ Exe
+ net8.0
+ false
+ true
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/OpenGameAgent.Memory.Tests/PublicApiCompatibilityTests.cs b/tests/OpenGameAgent.Memory.Tests/PublicApiCompatibilityTests.cs
new file mode 100644
index 0000000..0c41c10
--- /dev/null
+++ b/tests/OpenGameAgent.Memory.Tests/PublicApiCompatibilityTests.cs
@@ -0,0 +1,21 @@
+using OpenGameAgent.Testing;
+using Xunit;
+
+namespace OpenGameAgent.Memory.Tests;
+
+public sealed class PublicApiCompatibilityTests
+{
+ private const string ApprovedApiHash = "D2C8CFC19C641681EC3EB2629AFD0BD2199A9491D862FF7B61EC97A4DE08B2CD";
+
+ [Fact]
+ public void MemoryPublicApiMatchesTheApprovedStableSurface()
+ {
+ var assembly = typeof(VectorMemoryStore).Assembly;
+ var surface = PublicApiSurface.Describe(assembly);
+ var hash = PublicApiSurface.Hash(assembly);
+
+ Assert.True(
+ string.Equals(ApprovedApiHash, hash, StringComparison.Ordinal),
+ $"The memory public API changed. Review the complete surface below, then update the approved hash intentionally.\nHash: {hash}\n\n{surface}");
+ }
+}
diff --git a/tests/OpenGameAgent.Memory.Tests/VectorMemoryTests.cs b/tests/OpenGameAgent.Memory.Tests/VectorMemoryTests.cs
new file mode 100644
index 0000000..b8af8a0
--- /dev/null
+++ b/tests/OpenGameAgent.Memory.Tests/VectorMemoryTests.cs
@@ -0,0 +1,504 @@
+using System.Reflection;
+using System.Text.Json;
+using OpenGameAgent.Memory;
+using OpenGameAgent.Persistence;
+using Xunit;
+
+namespace OpenGameAgent.Memory.Tests;
+
+public sealed class VectorMemoryTests
+{
+ [Fact]
+ public async Task SemanticRecallFindsMemoryThatLexicalSearchMisses()
+ {
+ var provider = new TestEmbeddingProvider(version: "1");
+ await using var store = new VectorMemoryStore(
+ new InMemoryGameMemoryStore(),
+ new InMemoryVectorMemoryIndex(),
+ provider,
+ reranker: new GameAwareMemoryReranker());
+ await store.AppendAsync(Memory("feline", "A quiet feline watches the gate."), TestCancellation);
+
+ var results = await store.SearchAsync(Query("cat"), TestCancellation);
+
+ var result = Assert.Single(results);
+ Assert.Equal("feline", result.MemoryId);
+ Assert.Equal(1, provider.QueryCalls);
+ Assert.Equal(1, provider.DocumentCalls);
+ }
+
+ [Fact]
+ public async Task EmbeddingFailurePreservesAuthoritativeMemoryAndFallsBackToLexical()
+ {
+ var diagnostics = new RecordingDiagnosticSink();
+ var provider = new TestEmbeddingProvider(version: "1") { FailDocuments = true };
+ await using var store = new VectorMemoryStore(
+ new InMemoryGameMemoryStore(),
+ new InMemoryVectorMemoryIndex(),
+ provider,
+ diagnostics: diagnostics,
+ options: new VectorMemoryStoreOptions(embeddingTimeout: TimeSpan.FromSeconds(1)));
+
+ await store.AppendAsync(Memory("saved", "orchard ledger"), TestCancellation);
+ var results = await store.SearchAsync(Query("orchard"), TestCancellation);
+ var status = await store.GetStatusAsync("session", TestCancellation);
+
+ Assert.Equal("saved", Assert.Single(results).MemoryId);
+ Assert.Equal(VectorMemoryState.Degraded, status.State);
+ Assert.Equal(1, status.PendingEntries);
+ Assert.Contains(diagnostics.Items, item => item.Code == "memory_embedding_append_failed");
+ }
+
+ [Fact]
+ public async Task NonCooperativeEmbeddingIsBoundedAndReported()
+ {
+ var diagnostics = new RecordingDiagnosticSink();
+ var provider = new TestEmbeddingProvider(version: "1") { NeverCompleteDocuments = true };
+ await using var store = new VectorMemoryStore(
+ new InMemoryGameMemoryStore(),
+ new InMemoryVectorMemoryIndex(),
+ provider,
+ diagnostics: diagnostics,
+ options: new VectorMemoryStoreOptions(embeddingTimeout: TimeSpan.FromMilliseconds(25)));
+
+ await store.AppendAsync(Memory("bounded", "timeout record"), TestCancellation).AsTask()
+ .WaitAsync(TimeSpan.FromSeconds(2), TestCancellation);
+
+ Assert.Contains(diagnostics.Items, item => item.Code == "memory_embedding_append_failed");
+ provider.CompletePendingDocuments();
+ }
+
+ [Fact]
+ public async Task TimedOutNonCooperativeEmbeddingKeepsItsConcurrencyLeaseUntilSettlement()
+ {
+ var provider = new TestEmbeddingProvider(version: "1") { NeverCompleteDocuments = true };
+ await using var store = new VectorMemoryStore(
+ new InMemoryGameMemoryStore(),
+ new InMemoryVectorMemoryIndex(),
+ provider,
+ options: new VectorMemoryStoreOptions(
+ maximumConcurrentEmbeddingCalls: 1,
+ embeddingTimeout: TimeSpan.FromMilliseconds(25)));
+
+ await store.AppendAsync(Memory("first", "first record"), TestCancellation);
+ await store.AppendAsync(Memory("second", "second record"), TestCancellation).AsTask()
+ .WaitAsync(TimeSpan.FromSeconds(1), TestCancellation);
+ Assert.Equal(1, provider.DocumentCalls);
+
+ provider.CompletePendingDocuments();
+ }
+
+ [Fact]
+ public async Task ModelIdentityChangeRequiresExplicitRebuildAndSurvivesRestart()
+ {
+ var root = TempDirectory();
+ try
+ {
+ var authoritative = new FileGameMemoryStore(Path.Combine(root, "memory"));
+ var indexPath = Path.Combine(root, "vectors");
+ await using (var first = new VectorMemoryStore(
+ authoritative,
+ new FileVectorMemoryIndex(indexPath),
+ new TestEmbeddingProvider(version: "bge-m3-v1")))
+ {
+ await first.AppendAsync(Memory("legacy", "feline sentry"), TestCancellation);
+ Assert.Equal(VectorMemoryState.Ready, (await first.GetStatusAsync("session", TestCancellation)).State);
+ }
+
+ await using (var second = new VectorMemoryStore(
+ authoritative,
+ new FileVectorMemoryIndex(indexPath),
+ new TestEmbeddingProvider(version: "bge-m3-v2")))
+ {
+ var before = await second.GetStatusAsync("session", TestCancellation);
+ Assert.Equal(VectorMemoryState.RebuildRequired, before.State);
+ Assert.Equal(1, before.StaleEntries);
+ Assert.Empty(await second.SearchAsync(Query("cat"), TestCancellation));
+
+ var after = await second.RebuildAsync("session", TestCancellation);
+ Assert.Equal(VectorMemoryState.Ready, after.State);
+ Assert.Equal("bge-m3-v2", after.ActiveIdentity.Version);
+ }
+
+ await using var reopened = new VectorMemoryStore(
+ authoritative,
+ new FileVectorMemoryIndex(indexPath),
+ new TestEmbeddingProvider(version: "bge-m3-v2"));
+ var persisted = await reopened.GetStatusAsync("session", TestCancellation);
+ Assert.Equal(VectorMemoryState.Ready, persisted.State);
+ Assert.Equal("legacy", Assert.Single(await reopened.SearchAsync(Query("cat"), TestCancellation)).MemoryId);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task RebuildImportsMemoriesThatPredateTheVectorWrapper()
+ {
+ var authoritative = new InMemoryGameMemoryStore();
+ await authoritative.AppendAsync(Memory("existing", "feline archive"), TestCancellation);
+ await using var store = new VectorMemoryStore(
+ authoritative,
+ new InMemoryVectorMemoryIndex(),
+ new TestEmbeddingProvider(version: "1"));
+
+ var before = await store.GetStatusAsync("session", TestCancellation);
+ Assert.Equal(VectorMemoryState.Degraded, before.State);
+ Assert.True(before.RequiresRebuild);
+ var rebuilt = await store.RebuildAsync("session", TestCancellation);
+
+ Assert.Equal(VectorMemoryState.Ready, rebuilt.State);
+ Assert.Equal("existing", Assert.Single(await store.SearchAsync(Query("cat"), TestCancellation)).MemoryId);
+ }
+
+ [Fact]
+ public async Task ConcurrentAppendRemainsAuthoritativeAndMakesRebuildExplicitlyIncomplete()
+ {
+ var authoritative = new InMemoryGameMemoryStore();
+ await authoritative.AppendAsync(Memory("existing", "feline archive"), TestCancellation);
+ var provider = new OrderedEmbeddingProvider();
+ await using var store = new VectorMemoryStore(
+ authoritative,
+ new InMemoryVectorMemoryIndex(),
+ provider,
+ options: new VectorMemoryStoreOptions(maximumConcurrentEmbeddingCalls: 2));
+
+ var rebuild = store.RebuildAsync("session", TestCancellation).AsTask();
+ await provider.FirstStarted.Task.WaitAsync(TimeSpan.FromSeconds(2), TestCancellation);
+ var append = store.AppendAsync(Memory("concurrent", "new feline record"), TestCancellation).AsTask();
+ await provider.SecondStarted.Task.WaitAsync(TimeSpan.FromSeconds(2), TestCancellation);
+
+ provider.CompleteSecond();
+ await append;
+ provider.CompleteFirst();
+ var status = await rebuild;
+
+ Assert.True(status.RequiresRebuild);
+ Assert.Equal(1, status.PendingEntries);
+ Assert.Contains(
+ await authoritative.SearchAsync(Query("record"), TestCancellation),
+ memory => memory.MemoryId == "concurrent");
+ }
+
+ [Fact]
+ public async Task DerivedOrphanNeverEntersRecallAndExplicitRebuildRemovesIt()
+ {
+ var authoritative = new InMemoryGameMemoryStore();
+ var index = new InMemoryVectorMemoryIndex();
+ var provider = new TestEmbeddingProvider(version: "1");
+ await index.UpsertAsync(
+ new VectorMemoryIndexEntry(
+ Memory("orphan", "feline impostor"),
+ provider.Identity,
+ new float[] { 1, 0 }),
+ TestCancellation);
+ await using var store = new VectorMemoryStore(authoritative, index, provider);
+
+ var before = await store.GetStatusAsync("session", TestCancellation);
+ Assert.Equal(1, before.OrphanEntries);
+ Assert.Empty(await store.SearchAsync(Query("cat"), TestCancellation));
+
+ var after = await store.RebuildAsync("session", TestCancellation);
+ Assert.Equal(VectorMemoryState.Empty, after.State);
+ Assert.Equal(0, after.OrphanEntries);
+ }
+
+ [Fact]
+ public async Task VectorRecallPreservesSessionActorAndGameTimeFilters()
+ {
+ await using var store = new VectorMemoryStore(
+ new InMemoryGameMemoryStore(),
+ new InMemoryVectorMemoryIndex(),
+ new TestEmbeddingProvider(version: "1"));
+ await store.AppendAsync(Memory("visible", "feline", owner: "npc", tick: 2), TestCancellation);
+ await store.AppendAsync(Memory("future", "feline", owner: "npc", tick: 8), TestCancellation);
+ await store.AppendAsync(Memory("other-owner", "feline", owner: "other", tick: 2), TestCancellation);
+ await store.AppendAsync(Memory("other-session", "feline", owner: "npc", tick: 2, session: "other-session"), TestCancellation);
+
+ var results = await store.SearchAsync(
+ new GameMemoryQuery(
+ "session",
+ 10,
+ ownerId: "npc",
+ text: "cat",
+ atOrBefore: new GameMoment("world", 5)),
+ TestCancellation);
+
+ Assert.Equal("visible", Assert.Single(results).MemoryId);
+ }
+
+ [Fact]
+ public async Task GameAwareRerankerUsesGameTimeAndNeverWallClock()
+ {
+ var reranker = new GameAwareMemoryReranker(new GameAwareMemoryRerankerOptions(
+ sourceOrderWeight: 0,
+ importanceWeight: 0,
+ gameTimeRecencyWeight: 1_000_000,
+ diversityPenalty: 0));
+ var old = Memory("old", "same", tick: 1);
+ var recent = Memory("recent", "same", tick: 99);
+
+ var ranked = await reranker.RankAsync(
+ new GameMemoryQuery("session", 2, text: "same", atOrBefore: new GameMoment("world", 100)),
+ new[] { old, recent },
+ TestCancellation);
+
+ Assert.Equal(new[] { "recent", "old" }, ranked.Select(memory => memory.MemoryId));
+ }
+
+ [Fact]
+ public async Task FileIndexRejectsCorruptDerivedStateWithoutDamagingAuthoritativeSave()
+ {
+ var root = TempDirectory();
+ try
+ {
+ var authoritative = new FileGameMemoryStore(Path.Combine(root, "memory"));
+ var indexPath = Path.Combine(root, "vectors");
+ await using var store = new VectorMemoryStore(
+ authoritative,
+ new FileVectorMemoryIndex(indexPath),
+ new TestEmbeddingProvider(version: "1"));
+ await store.AppendAsync(Memory("safe", "orchard"), TestCancellation);
+ var file = Assert.Single(Directory.GetFiles(indexPath, "*.vector-memory.json"));
+ await File.WriteAllTextAsync(file, "{broken", TestCancellation);
+
+ await Assert.ThrowsAsync(async () =>
+ await new FileVectorMemoryIndex(indexPath).ListAsync("session", 100, TestCancellation));
+ Assert.Equal("safe", Assert.Single(await authoritative.SearchAsync(Query("orchard"), TestCancellation)).MemoryId);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public async Task RuntimeLifecycleOwnsExplicitInspectionRebuildAndCleanup()
+ {
+ var provider = new TestEmbeddingProvider(version: "1");
+ var authoritative = new InMemoryGameMemoryStore();
+ await authoritative.AppendAsync(Memory("existing", "feline"), TestCancellation);
+ var store = new VectorMemoryStore(authoritative, new InMemoryVectorMemoryIndex(), provider);
+ await using (var lifecycle = new RuntimeMemoryLifecycle(store))
+ {
+ var before = await lifecycle.InspectAsync("session", TestCancellation);
+ Assert.Equal(VectorMemoryState.Degraded, before.State);
+ Assert.True(before.RequiresRebuild);
+ Assert.Equal(VectorMemoryState.Ready, (await lifecycle.RebuildAsync("session", TestCancellation)).State);
+ }
+
+ Assert.True(provider.Disposed);
+ await Assert.ThrowsAsync(async () =>
+ await store.GetStatusAsync("session", TestCancellation));
+ }
+
+ [Fact]
+ public async Task SnapshotSourcesAreDeterministicAndSessionScoped()
+ {
+ var memory = new InMemoryGameMemoryStore();
+ await memory.AppendAsync(Memory("b", "two", owner: "z"), TestCancellation);
+ await memory.AppendAsync(Memory("a", "one", owner: "a"), TestCancellation);
+ await memory.AppendAsync(Memory("foreign", "three", session: "foreign"), TestCancellation);
+
+ var items = new List();
+ await foreach (var item in memory.EnumerateAsync("session", TestCancellation))
+ {
+ items.Add(item);
+ }
+
+ Assert.Equal(new[] { "a", "b" }, items.Select(item => item.MemoryId));
+ }
+
+ [Fact]
+ public void PublicApiContainsTheStableVectorMemoryEntryPoints()
+ {
+ var assembly = typeof(VectorMemoryStore).Assembly;
+ var exported = assembly.GetExportedTypes().Select(type => type.FullName).ToHashSet(StringComparer.Ordinal);
+
+ Assert.Contains("OpenGameAgent.Memory.IMemoryEmbeddingProvider", exported);
+ Assert.Contains("OpenGameAgent.Memory.VectorMemoryStore", exported);
+ Assert.Contains("OpenGameAgent.Memory.RuntimeMemoryLifecycle", exported);
+ Assert.Contains("OpenGameAgent.Memory.GameAwareMemoryReranker", exported);
+ }
+
+ [Fact]
+ public void StoredVectorValueBoundRejectsAnOversizedIndexAtCompositionTime()
+ {
+ var exception = Assert.Throws(() => new VectorMemoryStore(
+ new InMemoryGameMemoryStore(),
+ new InMemoryVectorMemoryIndex(),
+ new TestEmbeddingProvider(version: "1"),
+ options: new VectorMemoryStoreOptions(
+ maximumIndexEntries: 10,
+ maximumStoredVectorValues: 10)));
+
+ Assert.Equal("options", exception.ParamName);
+ }
+
+ private static GameMemory Memory(
+ string id,
+ string text,
+ string owner = "npc",
+ long tick = 1,
+ string session = "session") =>
+ new(
+ id,
+ session,
+ owner,
+ "personal",
+ GameMemoryKind.Fact,
+ "{\"text\":\"" + text + "\"}",
+ new GameMoment("world", tick),
+ searchableText: text);
+
+ private static GameMemoryQuery Query(string text) => new(
+ "session",
+ 10,
+ ownerId: "npc",
+ text: text,
+ atOrBefore: new GameMoment("world", 100));
+
+ private static CancellationToken TestCancellation => TestContext.Current.CancellationToken;
+
+ private static string TempDirectory()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "opengameagent-memory-tests", Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ private sealed class RecordingDiagnosticSink : IMemoryVectorDiagnosticSink
+ {
+ public List Items { get; } = new();
+
+ public ValueTask ReportAsync(MemoryVectorDiagnostic diagnostic, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Items.Add(diagnostic);
+ return default;
+ }
+ }
+
+ private sealed class TestEmbeddingProvider : IMemoryEmbeddingProvider
+ {
+ private readonly TaskCompletionSource>> _pending =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public TestEmbeddingProvider(string version)
+ {
+ Identity = new MemoryEmbeddingIdentity("local", "bge-m3", version, 2);
+ }
+
+ public MemoryEmbeddingIdentity Identity { get; }
+
+ public bool FailDocuments { get; set; }
+
+ public bool NeverCompleteDocuments { get; set; }
+
+ public int QueryCalls { get; private set; }
+
+ public int DocumentCalls { get; private set; }
+
+ public bool Disposed { get; private set; }
+
+ public ValueTask> EmbedQueryAsync(string text, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ QueryCalls++;
+ return new ValueTask>(Embed(text));
+ }
+
+ public ValueTask>> EmbedDocumentsAsync(
+ IReadOnlyList texts,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ DocumentCalls++;
+ if (FailDocuments)
+ {
+ throw new InvalidOperationException("simulated embedding outage");
+ }
+
+ if (NeverCompleteDocuments)
+ {
+ return new ValueTask>>(_pending.Task);
+ }
+
+ IReadOnlyList> results = texts.Select(Embed).ToArray();
+ return new ValueTask>>(results);
+ }
+
+ public void CompletePendingDocuments()
+ {
+ IReadOnlyList> value = new[] { Embed("fallback") };
+ _pending.TrySetResult(value);
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ Disposed = true;
+ CompletePendingDocuments();
+ return default;
+ }
+
+ private static ReadOnlyMemory Embed(string text) =>
+ text.Contains("cat", StringComparison.OrdinalIgnoreCase)
+ || text.Contains("feline", StringComparison.OrdinalIgnoreCase)
+ ? new float[] { 1, 0 }
+ : new float[] { 0, 1 };
+ }
+
+ private sealed class OrderedEmbeddingProvider : IMemoryEmbeddingProvider
+ {
+ private readonly TaskCompletionSource>> _first =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private readonly TaskCompletionSource>> _second =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private int _calls;
+
+ public MemoryEmbeddingIdentity Identity { get; } = new("local", "ordered", "1", 2);
+
+ public TaskCompletionSource FirstStarted { get; } =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public TaskCompletionSource SecondStarted { get; } =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public ValueTask> EmbedQueryAsync(string text, CancellationToken cancellationToken) =>
+ new(new ReadOnlyMemory(new float[] { 1, 0 }));
+
+ public ValueTask>> EmbedDocumentsAsync(
+ IReadOnlyList texts,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var call = Interlocked.Increment(ref _calls);
+ if (call == 1)
+ {
+ FirstStarted.TrySetResult(true);
+ return new(_first.Task);
+ }
+
+ if (call == 2)
+ {
+ SecondStarted.TrySetResult(true);
+ return new(_second.Task);
+ }
+
+ throw new InvalidOperationException("Unexpected embedding call.");
+ }
+
+ public void CompleteFirst() => _first.TrySetResult(new[] { new ReadOnlyMemory(new float[] { 1, 0 }) });
+
+ public void CompleteSecond() => _second.TrySetResult(new[] { new ReadOnlyMemory(new float[] { 1, 0 }) });
+
+ public ValueTask DisposeAsync()
+ {
+ CompleteFirst();
+ CompleteSecond();
+ return default;
+ }
+ }
+}
diff --git a/tests/OpenGameAgent.Memory.Tests/packages.lock.json b/tests/OpenGameAgent.Memory.Tests/packages.lock.json
new file mode 100644
index 0000000..62ce6c9
--- /dev/null
+++ b/tests/OpenGameAgent.Memory.Tests/packages.lock.json
@@ -0,0 +1,247 @@
+{
+ "version": 1,
+ "dependencies": {
+ "net8.0": {
+ "Microsoft.NET.Test.Sdk": {
+ "type": "Direct",
+ "requested": "[18.8.1, )",
+ "resolved": "18.8.1",
+ "contentHash": "dknJL3/9Y3t4XuCBqnc0PevPxgLsUMmVhjwup/b1HNovA8zWcj3XsfIf7c6p05363DWcqL7X/YhDL9B+Zymv1w==",
+ "dependencies": {
+ "Microsoft.CodeCoverage": "18.8.1",
+ "Microsoft.TestPlatform.TestHost": "18.8.1"
+ }
+ },
+ "System.Diagnostics.DiagnosticSource": {
+ "type": "Direct",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ=="
+ },
+ "System.Security.AccessControl": {
+ "type": "Direct",
+ "requested": "[6.0.1, )",
+ "resolved": "6.0.1",
+ "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw=="
+ },
+ "xunit.runner.visualstudio": {
+ "type": "Direct",
+ "requested": "[3.1.5, )",
+ "resolved": "3.1.5",
+ "contentHash": "tKi7dSTwP4m5m9eXPM2Ime4Kn7xNf4x4zT9sdLO/G4hZVnQCRiMTWoSZqI/pYTVeI27oPPqHBKYI/DjJ9GsYgA=="
+ },
+ "xunit.v3": {
+ "type": "Direct",
+ "requested": "[3.2.2, )",
+ "resolved": "3.2.2",
+ "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==",
+ "dependencies": {
+ "xunit.v3.mtp-v1": "[3.2.2]"
+ }
+ },
+ "Microsoft.ApplicationInsights": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==",
+ "dependencies": {
+ "System.Diagnostics.DiagnosticSource": "5.0.0"
+ }
+ },
+ "Microsoft.Bcl.AsyncInterfaces": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
+ },
+ "Microsoft.CodeCoverage": {
+ "type": "Transitive",
+ "resolved": "18.8.1",
+ "contentHash": "Eclse/ZZjr4lmWzZFNN9h/OluhKL+SK/QbUyKUewgX139aGeyMEO/DkMPwuFs2MixvanTnz6891rF8UHDg+W4Q=="
+ },
+ "Microsoft.Testing.Extensions.Telemetry": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==",
+ "dependencies": {
+ "Microsoft.ApplicationInsights": "2.23.0",
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.Testing.Extensions.TrxReport.Abstractions": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==",
+ "dependencies": {
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.Testing.Platform": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA=="
+ },
+ "Microsoft.Testing.Platform.MSBuild": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==",
+ "dependencies": {
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.TestPlatform.ObjectModel": {
+ "type": "Transitive",
+ "resolved": "18.8.1",
+ "contentHash": "qLbktNB1+b1XZLNJBTzaWVVJAd6PEzD7cgD406geMb6PcFZhp3EDNa1tctWx1+mtMU6MP/6ozVvFPC9vs2a9rw==",
+ "dependencies": {
+ "System.Reflection.Metadata": "8.0.0"
+ }
+ },
+ "Microsoft.TestPlatform.TestHost": {
+ "type": "Transitive",
+ "resolved": "18.8.1",
+ "contentHash": "FaQHPDTUOcE+SFTjssNPfrub2lT9Zyon4J2W/KLHt/efLJACb1TCeWXyOgh0D/4Q1e4n+S3E6mOKud+9nLZlEA==",
+ "dependencies": {
+ "Microsoft.TestPlatform.ObjectModel": "18.8.1"
+ }
+ },
+ "Microsoft.Win32.Registry": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==",
+ "dependencies": {
+ "System.Security.AccessControl": "5.0.0",
+ "System.Security.Principal.Windows": "5.0.0"
+ }
+ },
+ "System.Collections.Immutable": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg=="
+ },
+ "System.Reflection.Metadata": {
+ "type": "Transitive",
+ "resolved": "8.0.0",
+ "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==",
+ "dependencies": {
+ "System.Collections.Immutable": "8.0.0"
+ }
+ },
+ "System.Security.Principal.Windows": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA=="
+ },
+ "System.Text.Json": {
+ "type": "Transitive",
+ "resolved": "8.0.6",
+ "contentHash": "BvSpVBsVN9b+Y+wONbvJOHd1HjXQf33+XiC28ZMOwRsYb42mz3Q8YHnpTSwpwJLqYCMqM+0UUVC3V+pi25XfkQ=="
+ },
+ "xunit.analyzers": {
+ "type": "Transitive",
+ "resolved": "1.27.0",
+ "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g=="
+ },
+ "xunit.v3.assert": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA=="
+ },
+ "xunit.v3.common": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==",
+ "dependencies": {
+ "Microsoft.Bcl.AsyncInterfaces": "6.0.0"
+ }
+ },
+ "xunit.v3.core.mtp-v1": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==",
+ "dependencies": {
+ "Microsoft.Testing.Extensions.Telemetry": "1.9.1",
+ "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1",
+ "Microsoft.Testing.Platform": "1.9.1",
+ "Microsoft.Testing.Platform.MSBuild": "1.9.1",
+ "xunit.v3.extensibility.core": "[3.2.2]",
+ "xunit.v3.runner.inproc.console": "[3.2.2]"
+ }
+ },
+ "xunit.v3.extensibility.core": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==",
+ "dependencies": {
+ "xunit.v3.common": "[3.2.2]"
+ }
+ },
+ "xunit.v3.mtp-v1": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==",
+ "dependencies": {
+ "xunit.analyzers": "1.27.0",
+ "xunit.v3.assert": "[3.2.2]",
+ "xunit.v3.core.mtp-v1": "[3.2.2]"
+ }
+ },
+ "xunit.v3.runner.common": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==",
+ "dependencies": {
+ "Microsoft.Win32.Registry": "[5.0.0]",
+ "xunit.v3.common": "[3.2.2]"
+ }
+ },
+ "xunit.v3.runner.inproc.console": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==",
+ "dependencies": {
+ "xunit.v3.extensibility.core": "[3.2.2]",
+ "xunit.v3.runner.common": "[3.2.2]"
+ }
+ },
+ "opengameagent": {
+ "type": "Project",
+ "dependencies": {
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "System.Text.Json": "[8.0.6, )"
+ }
+ },
+ "opengameagent.extensions": {
+ "type": "Project",
+ "dependencies": {
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )"
+ }
+ },
+ "opengameagent.kernel": {
+ "type": "Project",
+ "dependencies": {
+ "System.Text.Json": "[8.0.6, )"
+ }
+ },
+ "opengameagent.memory": {
+ "type": "Project",
+ "dependencies": {
+ "OpenGameAgent": "[0.3.0-alpha.2, )"
+ }
+ },
+ "opengameagent.models": {
+ "type": "Project",
+ "dependencies": {
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
+ }
+ },
+ "opengameagent.persistence": {
+ "type": "Project",
+ "dependencies": {
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Extensions": "[0.3.0-alpha.2, )",
+ "System.Text.Json": "[8.0.6, )"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/OpenGameAgent.Models.Auth.BuiltIn.Tests/packages.lock.json b/tests/OpenGameAgent.Models.Auth.BuiltIn.Tests/packages.lock.json
index bfea949..709cba2 100644
--- a/tests/OpenGameAgent.Models.Auth.BuiltIn.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Models.Auth.BuiltIn.Tests/packages.lock.json
@@ -268,34 +268,34 @@
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
},
"opengameagent.models.auth.builtin": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Models.BuiltIn": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Models.BuiltIn": "[0.3.0-alpha.2, )"
}
},
"opengameagent.models.builtin": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.Anthropic": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.Bedrock": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.Google": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.Mistral": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.OpenAI": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.OpenAICompatible": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.Anthropic": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.Bedrock": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.Google": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.Mistral": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.OpenAI": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.OpenAICompatible": "[0.3.0-alpha.2, )"
}
},
"opengameagent.providers.anthropic": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -303,8 +303,8 @@
"type": "Project",
"dependencies": {
"AWSSDK.BedrockRuntime": "[4.0.101, )",
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -312,32 +312,32 @@
"type": "Project",
"dependencies": {
"Google.Apis.Auth": "[1.75.0, )",
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.mistral": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.openai": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.openaicompatible": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/tests/OpenGameAgent.Models.BuiltIn.Tests/packages.lock.json b/tests/OpenGameAgent.Models.BuiltIn.Tests/packages.lock.json
index fc73535..9c3a75c 100644
--- a/tests/OpenGameAgent.Models.BuiltIn.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Models.BuiltIn.Tests/packages.lock.json
@@ -268,27 +268,27 @@
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
},
"opengameagent.models.builtin": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.Anthropic": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.Bedrock": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.Google": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.Mistral": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.OpenAI": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.OpenAICompatible": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.Anthropic": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.Bedrock": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.Google": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.Mistral": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.OpenAI": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.OpenAICompatible": "[0.3.0-alpha.2, )"
}
},
"opengameagent.providers.anthropic": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -296,8 +296,8 @@
"type": "Project",
"dependencies": {
"AWSSDK.BedrockRuntime": "[4.0.101, )",
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -305,32 +305,32 @@
"type": "Project",
"dependencies": {
"Google.Apis.Auth": "[1.75.0, )",
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.mistral": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.openai": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.openaicompatible": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/tests/OpenGameAgent.Models.Tests/packages.lock.json b/tests/OpenGameAgent.Models.Tests/packages.lock.json
index df218c8..9d772d3 100644
--- a/tests/OpenGameAgent.Models.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Models.Tests/packages.lock.json
@@ -262,15 +262,15 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.extensions": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )"
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )"
}
},
"opengameagent.kernel": {
@@ -282,14 +282,14 @@
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
},
"opengameagent.providers.anthropic": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -297,8 +297,8 @@
"type": "Project",
"dependencies": {
"AWSSDK.BedrockRuntime": "[4.0.101, )",
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -306,32 +306,32 @@
"type": "Project",
"dependencies": {
"Google.Apis.Auth": "[1.75.0, )",
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.mistral": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.openai": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.openaicompatible": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/tests/OpenGameAgent.Persistence.Tests/packages.lock.json b/tests/OpenGameAgent.Persistence.Tests/packages.lock.json
index 34cf43d..cd8632c 100644
--- a/tests/OpenGameAgent.Persistence.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Persistence.Tests/packages.lock.json
@@ -205,15 +205,15 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.extensions": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )"
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )"
}
},
"opengameagent.kernel": {
@@ -225,14 +225,14 @@
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
},
"opengameagent.persistence": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Extensions": "[0.3.0-alpha.1, )",
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Extensions": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
}
diff --git a/tests/OpenGameAgent.Plugins.Tests/packages.lock.json b/tests/OpenGameAgent.Plugins.Tests/packages.lock.json
index 5efc829..1c77817 100644
--- a/tests/OpenGameAgent.Plugins.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Plugins.Tests/packages.lock.json
@@ -257,7 +257,7 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -265,14 +265,14 @@
"type": "Project",
"dependencies": {
"ModelContextProtocol.Core": "[2.1.0, )",
- "OpenGameAgent.Extensions": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Extensions": "[0.3.0-alpha.2, )"
}
},
"opengameagent.extensions": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )"
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )"
}
},
"opengameagent.kernel": {
@@ -284,22 +284,22 @@
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
},
"opengameagent.persistence": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Extensions": "[0.3.0-alpha.1, )",
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Extensions": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.plugins": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Connectors.Mcp": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Persistence": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Connectors.Mcp": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Persistence": "[0.3.0-alpha.2, )",
"System.Text.Json": "[10.0.10, )"
}
}
diff --git a/tests/OpenGameAgent.Providers.Anthropic.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.Anthropic.Tests/packages.lock.json
index 17c81c1..6b6a4cb 100644
--- a/tests/OpenGameAgent.Providers.Anthropic.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Providers.Anthropic.Tests/packages.lock.json
@@ -211,8 +211,8 @@
"opengameagent.providers.anthropic": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/tests/OpenGameAgent.Providers.Bedrock.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.Bedrock.Tests/packages.lock.json
index fad414d..4bb6cd1 100644
--- a/tests/OpenGameAgent.Providers.Bedrock.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Providers.Bedrock.Tests/packages.lock.json
@@ -225,8 +225,8 @@
"type": "Project",
"dependencies": {
"AWSSDK.BedrockRuntime": "[4.0.101, )",
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/tests/OpenGameAgent.Providers.Google.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.Google.Tests/packages.lock.json
index 4c93823..5c2e29b 100644
--- a/tests/OpenGameAgent.Providers.Google.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Providers.Google.Tests/packages.lock.json
@@ -256,8 +256,8 @@
"type": "Project",
"dependencies": {
"Google.Apis.Auth": "[1.75.0, )",
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/tests/OpenGameAgent.Providers.MediaHttp.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.MediaHttp.Tests/packages.lock.json
index 209b76c..66d2fc5 100644
--- a/tests/OpenGameAgent.Providers.MediaHttp.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Providers.MediaHttp.Tests/packages.lock.json
@@ -205,7 +205,7 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -218,7 +218,7 @@
"opengameagent.providers.mediahttp": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
}
diff --git a/tests/OpenGameAgent.Providers.MessageGateway.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.MessageGateway.Tests/packages.lock.json
index bac04c5..a4378fa 100644
--- a/tests/OpenGameAgent.Providers.MessageGateway.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Providers.MessageGateway.Tests/packages.lock.json
@@ -205,7 +205,7 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -218,8 +218,8 @@
"opengameagent.providers.messagegateway": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )"
}
},
"opengameagent.providertransport": {
diff --git a/tests/OpenGameAgent.Providers.Mistral.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.Mistral.Tests/packages.lock.json
index 0a68ba2..d4d5b4a 100644
--- a/tests/OpenGameAgent.Providers.Mistral.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Providers.Mistral.Tests/packages.lock.json
@@ -211,8 +211,8 @@
"opengameagent.providers.mistral": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/tests/OpenGameAgent.Providers.OpenAI.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.OpenAI.Tests/packages.lock.json
index 9941aa6..86c6861 100644
--- a/tests/OpenGameAgent.Providers.OpenAI.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Providers.OpenAI.Tests/packages.lock.json
@@ -211,8 +211,8 @@
"opengameagent.providers.openai": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/packages.lock.json
index 169c209..97e1ddc 100644
--- a/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/packages.lock.json
@@ -211,8 +211,8 @@
"opengameagent.providers.openaicompatible": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/tests/OpenGameAgent.Providers.OpenRouter.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.OpenRouter.Tests/packages.lock.json
index cd69df5..67b37fe 100644
--- a/tests/OpenGameAgent.Providers.OpenRouter.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Providers.OpenRouter.Tests/packages.lock.json
@@ -185,7 +185,7 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -198,20 +198,20 @@
"opengameagent.media": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )"
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )"
}
},
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
},
"opengameagent.providers.openrouter": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Media": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Media": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
}
diff --git a/tests/OpenGameAgent.Providers.Remote.Tests/packages.lock.json b/tests/OpenGameAgent.Providers.Remote.Tests/packages.lock.json
index 6233e5a..b5d39fb 100644
--- a/tests/OpenGameAgent.Providers.Remote.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Providers.Remote.Tests/packages.lock.json
@@ -211,7 +211,7 @@
"opengameagent.providers.remote": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
}
diff --git a/tests/OpenGameAgent.Server.Tests/packages.lock.json b/tests/OpenGameAgent.Server.Tests/packages.lock.json
index f1076ab..3962b74 100644
--- a/tests/OpenGameAgent.Server.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Server.Tests/packages.lock.json
@@ -219,22 +219,22 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.client": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.extensions": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Models": "[0.3.0-alpha.1, )"
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Models": "[0.3.0-alpha.2, )"
}
},
"opengameagent.kernel": {
@@ -246,22 +246,22 @@
"opengameagent.models": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )"
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )"
}
},
"opengameagent.persistence": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Extensions": "[0.3.0-alpha.1, )",
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Extensions": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
"opengameagent.providers.openaicompatible": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
- "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.ProviderTransport": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
@@ -271,9 +271,9 @@
"opengameagent.server": {
"type": "Project",
"dependencies": {
- "OpenGameAgent": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Persistence": "[0.3.0-alpha.1, )",
- "OpenGameAgent.Providers.OpenAICompatible": "[0.3.0-alpha.1, )"
+ "OpenGameAgent": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Persistence": "[0.3.0-alpha.2, )",
+ "OpenGameAgent.Providers.OpenAICompatible": "[0.3.0-alpha.2, )"
}
}
}
diff --git a/tests/OpenGameAgent.Tests/PublicApiCompatibilityTests.cs b/tests/OpenGameAgent.Tests/PublicApiCompatibilityTests.cs
index 7cda29b..91a3921 100644
--- a/tests/OpenGameAgent.Tests/PublicApiCompatibilityTests.cs
+++ b/tests/OpenGameAgent.Tests/PublicApiCompatibilityTests.cs
@@ -5,7 +5,7 @@ namespace OpenGameAgent.Tests;
public sealed class PublicApiCompatibilityTests
{
- private const string ApprovedApiHash = "4B4E24261A64AA1944A7A416B751BF0D47693C7034F8A226A7283EE684C6C2A8";
+ private const string ApprovedApiHash = "356405F4CFB66C1CEC6D5F5BE5AB9B428EF3E46C85986D9BF7BF04940247D95B";
[Fact]
public void RuntimePublicApiMatchesTheApprovedStableSurface()
diff --git a/tests/OpenGameAgent.Tests/packages.lock.json b/tests/OpenGameAgent.Tests/packages.lock.json
index d6cc02f..f55ba4f 100644
--- a/tests/OpenGameAgent.Tests/packages.lock.json
+++ b/tests/OpenGameAgent.Tests/packages.lock.json
@@ -205,7 +205,7 @@
"opengameagent": {
"type": "Project",
"dependencies": {
- "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )",
+ "OpenGameAgent.Kernel": "[0.3.0-alpha.2, )",
"System.Text.Json": "[8.0.6, )"
}
},
diff --git a/tools/Pack-NuGet.ps1 b/tools/Pack-NuGet.ps1
index e002b18..0fce6b9 100644
--- a/tools/Pack-NuGet.ps1
+++ b/tools/Pack-NuGet.ps1
@@ -13,6 +13,11 @@ $repositoryRoot = Split-Path -Parent $PSScriptRoot
$outputPath = [IO.Path]::GetFullPath((Join-Path $repositoryRoot $OutputDirectory))
New-Item -ItemType Directory -Path $outputPath -Force | Out-Null
+$repositoryCommit = (& git -C $repositoryRoot rev-parse HEAD 2>$null).Trim()
+if ($LASTEXITCODE -ne 0 -or $repositoryCommit -notmatch '^[0-9a-fA-F]{40,64}$') {
+ throw 'Packing requires a repository HEAD commit.'
+}
+
if (-not [string]::IsNullOrWhiteSpace($PackageVersion)) {
$null = Get-ReleaseVersionInfo -Version $PackageVersion
}
@@ -21,13 +26,27 @@ $packages = @(Get-ReleasePackageManifest -RepositoryRoot $repositoryRoot)
Assert-ReleasePackageManifestGraph -RepositoryRoot $repositoryRoot -Packages $packages
foreach ($package in $packages) {
+ $expectedPackagePath = $null
+ if (-not [string]::IsNullOrWhiteSpace($PackageVersion)) {
+ $expectedPackagePath = Join-Path $outputPath "$($package.id).$PackageVersion.nupkg"
+ foreach ($stalePackagePath in @(
+ $expectedPackagePath,
+ (Join-Path $outputPath "$($package.id).$PackageVersion.snupkg")
+ )) {
+ if (Test-Path -LiteralPath $stalePackagePath -PathType Leaf) {
+ Remove-Item -LiteralPath $stalePackagePath -Force
+ }
+ }
+ }
+
$arguments = @(
'pack',
$package.FullProjectPath,
'-c', $Configuration,
'--no-build',
'--no-restore',
- '-o', $outputPath
+ '-o', $outputPath,
+ "-p:RepositoryCommit=$repositoryCommit"
)
if (-not [string]::IsNullOrWhiteSpace($PackageVersion)) {
@@ -38,6 +57,10 @@ foreach ($package in $packages) {
if ($LASTEXITCODE -ne 0) {
throw "Packing failed for '$($package.project)'."
}
+ if ($null -ne $expectedPackagePath -and
+ -not (Test-Path -LiteralPath $expectedPackagePath -PathType Leaf)) {
+ throw "Packing did not produce '$expectedPackagePath'."
+ }
}
Write-Output $outputPath
diff --git a/tools/Test-ReleaseScripts.ps1 b/tools/Test-ReleaseScripts.ps1
index 0505f98..632888b 100644
--- a/tools/Test-ReleaseScripts.ps1
+++ b/tools/Test-ReleaseScripts.ps1
@@ -1,6 +1,6 @@
[CmdletBinding()]
param(
- [string] $Version = '0.3.0-alpha.1'
+ [string] $Version = '0.3.0-alpha.2'
)
$ErrorActionPreference = 'Stop'
@@ -64,6 +64,30 @@ foreach ($invalidVersion in @(
$packages = @(Get-ReleasePackageManifest -RepositoryRoot $repositoryRoot)
Assert-ReleasePackageManifestGraph -RepositoryRoot $repositoryRoot -Packages $packages
+$godotDownloadPattern = "Godot_v4\.7\.1-stable_mono_win64\.zip'.*-MaximumRetryCount\s+4\s+-RetryIntervalSec\s+5"
+foreach ($workflowPath in @('.github\workflows\ci.yml', '.github\workflows\release.yml')) {
+ $workflow = Get-Content -LiteralPath (Join-Path $repositoryRoot $workflowPath) -Raw
+ if ($workflow -notmatch $godotDownloadPattern) {
+ throw "Godot download in '$workflowPath' must use bounded transient retries before checksum verification."
+ }
+}
+$packScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'tools\Pack-NuGet.ps1') -Raw
+$removesExactVersionedPackage =
+ $packScript -match '\$\(\$package\.id\)\.\$PackageVersion\.nupkg' -and
+ $packScript -match '\$\(\$package\.id\)\.\$PackageVersion\.snupkg' -and
+ $packScript -match 'Remove-Item\s+-LiteralPath\s+\$stalePackagePath'
+$pinsRepositoryCommit = $packScript -match '-p:RepositoryCommit=\$repositoryCommit'
+$assertsExpectedPackage = $packScript -match "Packing did not produce"
+if (-not ($removesExactVersionedPackage -and $pinsRepositoryCommit -and $assertsExpectedPackage)) {
+ throw 'NuGet packing must replace exact same-version outputs, pin HEAD, and verify each result.'
+}
+$godotSmokeScript = Get-Content -LiteralPath (Join-Path $repositoryRoot 'engines\godot\test-engine.ps1') -Raw
+$startsGodotProcess = $godotSmokeScript -match 'Start-Process'
+$waitsForGodotProcess = $godotSmokeScript -match '(?m)^\s*-Wait\s*`?\s*$'
+$requiresGodotMarker = $godotSmokeScript -match 'OPENGAMEAGENT_GODOT_SMOKE_OK'
+if (-not ($startsGodotProcess -and $waitsForGodotProcess -and $requiresGodotMarker)) {
+ throw 'The Godot real-editor gate must wait for the editor process and require the runtime smoke marker.'
+}
$packageLayers = @(Get-ReleasePackageLayers -Packages $packages)
$layeredPackages = @($packageLayers | ForEach-Object { $_.Packages })
if ($packageLayers.Count -eq 0 -or $layeredPackages.Count -ne $packages.Count) {
diff --git a/tools/release-packages.json b/tools/release-packages.json
index 24e47f1..6bc05dd 100644
--- a/tools/release-packages.json
+++ b/tools/release-packages.json
@@ -13,6 +13,10 @@
"id": "OpenGameAgent",
"project": "src/OpenGameAgent/OpenGameAgent.csproj"
},
+ {
+ "id": "OpenGameAgent.Memory",
+ "project": "src/OpenGameAgent.Memory/OpenGameAgent.Memory.csproj"
+ },
{
"id": "OpenGameAgent.Models",
"project": "src/OpenGameAgent.Models/OpenGameAgent.Models.csproj"