diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1c480d..ab3f90c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,13 +117,19 @@ jobs: find release-assets -maxdepth 1 -type f ! -name RELEASE_NOTES.md -print | sort ) - if [[ "${#assets[@]}" -ne 10 ]]; then - echo "::error::Expected 10 downloadable assets, found ${#assets[@]}." + if [[ "${#assets[@]}" -ne 13 ]]; then + echo "::error::Expected 13 downloadable assets, found ${#assets[@]}." printf '%s\n' "${assets[@]}" exit 1 fi - gh release create "${tag}" + "${release_target[@]}" + --draft + --prerelease + --title "OpenGameAgent ${tag}" + --notes-file release-assets/RELEASE_NOTES.md + "${assets[@]}" + gh release create "${tag}" \ + "${release_target[@]}" \ + --draft \ + --prerelease \ + --title "OpenGameAgent ${tag}" \ + --notes-file release-assets/RELEASE_NOTES.md \ + "${assets[@]}" expected="$(printf '%s\n' "${assets[@]##*/}" | sort)" actual="$(gh release view "${tag}" --json assets --jq '.assets[].name' | sort)" @@ -166,12 +172,15 @@ jobs: run: | set -euo pipefail mapfile -t packages < <(find release-assets -maxdepth 1 -type f -name '*.nupkg' -print | sort) - if [[ "${#packages[@]}" -ne 6 ]]; then - echo "::error::Expected 6 NuGet packages, found ${#packages[@]}." + if [[ "${#packages[@]}" -ne 9 ]]; then + echo "::error::Expected 9 NuGet packages, found ${#packages[@]}." exit 1 fi for package in "${packages[@]}"; do - dotnet nuget push "${package}" + --api-key "${NUGET_API_KEY}" + --source https://api.nuget.org/v3/index.json + --skip-duplicate + dotnet nuget push "${package}" \ + --api-key "${NUGET_API_KEY}" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate done publish-github-release: @@ -192,4 +201,6 @@ jobs: set -euo pipefail tag="v${RELEASE_VERSION}" gh release edit "${tag}" --draft=false --prerelease - gh release view "${tag}" + --json isDraft,isPrerelease + --jq 'select(.isDraft == false and .isPrerelease == true)' >/dev/null + gh release view "${tag}" \ + --json isDraft,isPrerelease \ + --jq 'select(.isDraft == false and .isPrerelease == true)' >/dev/null diff --git a/CHANGELOG.md b/CHANGELOG.md index 629fed2..8860bc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,14 @@ ## 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. -- Add safe tool execution with schema validation, source-ordered results, progress, timeout handling, conflict-key serialization, and fail-closed uncertain-write semantics across a tool batch. +- Add safe tool execution with schema validation, source-ordered results, progress, model/tool deadlines, conflict-key serialization, and fail-closed uncertain-write semantics across a tool batch. - Add the game runtime with arbitrary structured inputs, floating-point preservation, named game timelines, automatic quick/full/workflow routing, optimistic sessions, duplicate protection, live steering/abort, and per-actor concurrency. -- Add durable action intents and receipts, prepared/dispatched/final recovery, resumable workflows, game-time memory and expiry, skills, recurring schedules, actor mailboxes, transcript compaction, and media-generation API contracts. -- Add crash-tolerant single-process file stores for sessions, action journals, workflow checkpoints, memories, mailboxes, and hot-reloaded directory skills, with identity and saved-state trust checks. -- Add strict streaming OpenAI-compatible and generic HTTP media providers, bounded request/response parsing, rotating credentials, polling controls, and retry/fallback provider composition. +- Add a typed extension API with immutable composition, namespaced session state, lifecycle events, channels, diagnostics, and official policy, searchable-tool, interaction, goal, memory, artifact, knowledge, delegation, tracing, and durable workflow-graph extensions. +- Add durable action intents and receipts, prepared/dispatched/final recovery, resumable sequential and dependency-graph workflows, game-time memory and expiry, recursive skills, recurring schedules, actor mailboxes, context-window admission, large-result artifact spill, and media-generation API contracts. +- Add crash-tolerant, cross-process-coordinated local file stores for sessions, action journals, workflow checkpoints, memories, mailboxes, artifacts, delegations, and hot-reloaded directory skills, with identity and saved-state trust checks. +- Add capability-aware provider/model catalogs, reasoning and cost metadata, dynamic refresh, replaceable authentication, and developer-hosted short-lived credentials. +- Add lazy external tool-server search/describe/call by default with explicit direct exposure for small trusted catalogs. +- Add strict streaming OpenAI-compatible and generic HTTP media providers, bounded request/response parsing, rotating credentials, polling controls, and retry/fallback composition that stops before replaying meaningful streamed output. - Add Godot 4.7 .NET and Unity 6 adapters with local and remote modes, bounded main-thread delivery with terminal reservation, package verification, and real local-runtime editor tests on Windows. -- Add an optional .NET 8 JSON/SSE server, engine-compatible client, authenticated steering and abort, strict wire contracts, and redirect/credential guidance. +- Add an optional .NET 8 JSON/SSE server, engine-compatible client, authenticated steering and abort, bounded JSON request bodies, strict wire contracts, and redirect/credential guidance. - Add bilingual documentation, a buildable living-world action example, pinned release automation, and cross-platform .NET validation. diff --git a/OpenGameAgent.sln b/OpenGameAgent.sln index 5e65fc7..5627ea5 100644 --- a/OpenGameAgent.sln +++ b/OpenGameAgent.sln @@ -1,4 +1,4 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 @@ -16,6 +16,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Client", "src EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Server", "src\OpenGameAgent.Server\OpenGameAgent.Server.csproj", "{126F4F68-C8D5-403B-899D-7EA25D446007}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Extensions", "src\OpenGameAgent.Extensions\OpenGameAgent.Extensions.csproj", "{6264B0A9-9050-4ED6-A7D8-F1DBBBCE6008}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Kernel.Tests", "tests\OpenGameAgent.Kernel.Tests\OpenGameAgent.Kernel.Tests.csproj", "{E0E06633-9C9A-4D70-BE88-491734701101}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Tests", "tests\OpenGameAgent.Tests\OpenGameAgent.Tests.csproj", "{D6FF2E41-75D9-463A-B9AC-26EA360F1102}" @@ -28,8 +30,22 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.Med EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Server.Tests", "tests\OpenGameAgent.Server.Tests\OpenGameAgent.Server.Tests.csproj", "{8DC373B7-B6D6-42E8-BF98-7B8DC5FD1106}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Extensions.Tests", "tests\OpenGameAgent.Extensions.Tests\OpenGameAgent.Extensions.Tests.csproj", "{D23EE0CD-40FD-47F3-9549-B0675A821107}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Example", "examples\OpenGameAgent.Example\OpenGameAgent.Example.csproj", "{48B319EF-B033-4D17-A025-8987FCABA107}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{EA3AF59A-9A1C-4197-B2A3-F93894D131B8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Connectors.Mcp", "src\OpenGameAgent.Connectors.Mcp\OpenGameAgent.Connectors.Mcp.csproj", "{01759D73-7B80-47A2-9D7D-154CC64C6851}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{86AE6217-BFEE-4349-945A-70ECEC211437}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Connectors.Mcp.Tests", "tests\OpenGameAgent.Connectors.Mcp.Tests\OpenGameAgent.Connectors.Mcp.Tests.csproj", "{98A0255B-E6C3-46C4-868C-A16FB559A79C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Models", "src\OpenGameAgent.Models\OpenGameAgent.Models.csproj", "{CC89911F-B920-4203-8CE4-03D434C3B01E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Models.Tests", "tests\OpenGameAgent.Models.Tests\OpenGameAgent.Models.Tests.csproj", "{839EA4C2-45A0-4E78-8FAE-E39155C96F4C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -64,6 +80,10 @@ Global {126F4F68-C8D5-403B-899D-7EA25D446007}.Debug|Any CPU.Build.0 = Debug|Any CPU {126F4F68-C8D5-403B-899D-7EA25D446007}.Release|Any CPU.ActiveCfg = Release|Any CPU {126F4F68-C8D5-403B-899D-7EA25D446007}.Release|Any CPU.Build.0 = Release|Any CPU + {6264B0A9-9050-4ED6-A7D8-F1DBBBCE6008}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6264B0A9-9050-4ED6-A7D8-F1DBBBCE6008}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6264B0A9-9050-4ED6-A7D8-F1DBBBCE6008}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6264B0A9-9050-4ED6-A7D8-F1DBBBCE6008}.Release|Any CPU.Build.0 = Release|Any CPU {E0E06633-9C9A-4D70-BE88-491734701101}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E0E06633-9C9A-4D70-BE88-491734701101}.Debug|Any CPU.Build.0 = Debug|Any CPU {E0E06633-9C9A-4D70-BE88-491734701101}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -88,9 +108,35 @@ Global {8DC373B7-B6D6-42E8-BF98-7B8DC5FD1106}.Debug|Any CPU.Build.0 = Debug|Any CPU {8DC373B7-B6D6-42E8-BF98-7B8DC5FD1106}.Release|Any CPU.ActiveCfg = Release|Any CPU {8DC373B7-B6D6-42E8-BF98-7B8DC5FD1106}.Release|Any CPU.Build.0 = Release|Any CPU + {D23EE0CD-40FD-47F3-9549-B0675A821107}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D23EE0CD-40FD-47F3-9549-B0675A821107}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D23EE0CD-40FD-47F3-9549-B0675A821107}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D23EE0CD-40FD-47F3-9549-B0675A821107}.Release|Any CPU.Build.0 = Release|Any CPU {48B319EF-B033-4D17-A025-8987FCABA107}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {48B319EF-B033-4D17-A025-8987FCABA107}.Debug|Any CPU.Build.0 = Debug|Any CPU {48B319EF-B033-4D17-A025-8987FCABA107}.Release|Any CPU.ActiveCfg = Release|Any CPU {48B319EF-B033-4D17-A025-8987FCABA107}.Release|Any CPU.Build.0 = Release|Any CPU + {01759D73-7B80-47A2-9D7D-154CC64C6851}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {01759D73-7B80-47A2-9D7D-154CC64C6851}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01759D73-7B80-47A2-9D7D-154CC64C6851}.Release|Any CPU.ActiveCfg = Release|Any CPU + {01759D73-7B80-47A2-9D7D-154CC64C6851}.Release|Any CPU.Build.0 = Release|Any CPU + {98A0255B-E6C3-46C4-868C-A16FB559A79C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {98A0255B-E6C3-46C4-868C-A16FB559A79C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {98A0255B-E6C3-46C4-868C-A16FB559A79C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {98A0255B-E6C3-46C4-868C-A16FB559A79C}.Release|Any CPU.Build.0 = Release|Any CPU + {CC89911F-B920-4203-8CE4-03D434C3B01E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CC89911F-B920-4203-8CE4-03D434C3B01E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CC89911F-B920-4203-8CE4-03D434C3B01E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CC89911F-B920-4203-8CE4-03D434C3B01E}.Release|Any CPU.Build.0 = Release|Any CPU + {839EA4C2-45A0-4E78-8FAE-E39155C96F4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {839EA4C2-45A0-4E78-8FAE-E39155C96F4C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {839EA4C2-45A0-4E78-8FAE-E39155C96F4C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {839EA4C2-45A0-4E78-8FAE-E39155C96F4C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {01759D73-7B80-47A2-9D7D-154CC64C6851} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} + {98A0255B-E6C3-46C4-868C-A16FB559A79C} = {86AE6217-BFEE-4349-945A-70ECEC211437} + {CC89911F-B920-4203-8CE4-03D434C3B01E} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} + {839EA4C2-45A0-4E78-8FAE-E39155C96F4C} = {86AE6217-BFEE-4349-945A-70ECEC211437} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 1d9217b..0556974 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,14 @@ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) [![Status](https://img.shields.io/badge/status-alpha-orange.svg)](CHANGELOG.md) -OpenGameAgent brings the small, composable agent-kernel model to game development. Its stateful core streams model output, executes validated tools, accepts steering while running, and continues the model/tool loop until work is complete. Use that kernel by itself, or add the game layer for game time, durable actions, sessions, skills, memory primitives, routing, workflows, and bounded multi-character concurrency. +OpenGameAgent brings the small, composable agent-kernel model to game development. Its stateful core streams model output, executes validated tools, accepts steering while running, and continues the model/tool loop until work is complete. Use that kernel by itself, add the game layer for game time and durable state, then opt into extension packages for memory, goals, artifacts, delegation, external tools, structured interaction, and workflow graphs. 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`. +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. + ## Install Install the complete game runtime from NuGet: @@ -38,6 +40,9 @@ OpenGameAgent keeps the reusable agent machinery independent from the game while - game-time memory filtering, expiry, and optional custom ranking; - 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; +- capability-aware model catalogs and developer-hosted short-lived credentials; +- lazy external-tool discovery and large-result artifact spill; - image, audio, and video generation through replaceable APIs. The runtime does **not** decide combat legality, inventory rules, economy changes, NPC permissions, or other business rules. The game exposes narrow tools, validates every requested mutation, performs it on the correct thread or server, and returns the authoritative receipt. @@ -50,7 +55,7 @@ Godot / Unity / .NET game server | GameInput (bounded JSON + GameMoment) v GameAgentRuntime - context | skills | route | session | actor lane + context | skills | route | session | actor lane | extensions | v small stateful Agent kernel <---- steering / follow-up @@ -74,13 +79,17 @@ Read [Architecture](docs/architecture.md) for the ownership and failure boundari | Agent kernel | Streaming typed messages, tool loop, progress events, steering, follow-up, hooks, cancellation, strict transcript validation, provider failures as results | | Tool execution | Bounded JSON Schema subset, guaranteed result for every accepted call, safe parallel reads, conflict-key serialization, policy blocking/termination, timeouts, uncertain write outcomes | | Game runtime | Arbitrary JSON input, game clocks/timelines, fast/full/workflow routing, optimistic sessions, duplicate-input protection, actor concurrency, active-run steering/abort | +| Extension API | Immutable builder; prompt/context/tool/skill/route/workflow/hook/provider/service registration; typed lifecycle events and channels; namespaced persistent state | +| Official extensions | Tool policy and search, structured player questions/recommended replies, goals, memory, artifacts, knowledge, delegation, tracing, and durable parallel workflow graphs | | World primitives | Durable actions, resumable workflows, memories, skills, signals, game-time schedules, actor mailboxes | +| Models and auth | Capability/context/reasoning/cost catalog, dynamic model refresh, static/environment/stored/local auth, developer-hosted short-lived credential gateway | +| External tools | Lazy on-demand search/describe/call by default; explicit direct exposure for small trusted catalogs | | Providers | Streaming OpenAI-compatible text/tool API; generic HTTP image/audio/video API; retry and fallback decorators | -| Persistence | Crash-tolerant local files for sessions, action journals, workflow checkpoints, memories, mailboxes, and hot-reloaded `SKILL.md` or game-manifest skills | +| Persistence | Crash-tolerant, cross-process-coordinated local files for sessions, action journals, workflow checkpoints, memories, mailboxes, artifacts, delegations, and recursive hot-reloaded skills | | 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 | -Run inputs, model content, tool catalogs, loops, queues, progress, and concurrency are bounded by explicit limits. Game-owned stores and rankers can replace the included in-memory or local-file implementations. +Run inputs, model content, tool catalogs, loops, queues, progress, and concurrency are bounded by explicit limits. Context admission runs before every model request, model and tool calls have deadlines, and large tool results can be retained as artifacts instead of repeatedly filling the prompt. Game-owned stores and rankers can replace the included in-memory or local-file implementations. ## Minimal kernel @@ -138,6 +147,8 @@ See the buildable [living-world example](examples/OpenGameAgent.Example/Program. - **In the game server:** best when the game already has an authoritative server. Run the same C# runtime beside game rules and persistence. - **Separate agent service:** useful for centrally paid inference, secrets, scaling, or independent updates. Engine adapters call `OpenGameAgent.Server` over JSON/SSE and can steer or abort an active actor through authenticated control endpoints. +For developer-funded client inference, use a developer-controlled gateway that issues short-lived scoped credentials. The permanent upstream provider key stays on developer infrastructure; the framework supplies the client credential flow, while the game owns login, quotas, revocation, and abuse controls. + Placement does not change ownership: only game code decides whether an action commits. ## Build and verify diff --git a/README.zh-CN.md b/README.zh-CN.md index 9d7ede4..2b56a2e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,12 +8,14 @@ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) [![Status](https://img.shields.io/badge/status-alpha-orange.svg)](CHANGELOG.md) -OpenGameAgent 把小型、可组合的 Agent 内核带进游戏开发。它的有状态核心会流式接收模型输出、执行经过校验的工具、在运行中接受 steering,并持续进行模型/工具循环直到任务结束。开发者既可以只使用这个内核,也可以叠加游戏层,获得游戏时间、可恢复动作、会话、Skills、记忆原语、路由、Workflow 和有界多角色并发。 +OpenGameAgent 把小型、可组合的 Agent 内核带进游戏开发。它的有状态核心会流式接收模型输出、执行经过校验的工具、在运行中接受 steering,并持续进行模型/工具循环直到任务结束。开发者既可以只使用这个内核,也可以叠加游戏层获得游戏时间与可靠状态,再按需加入记忆、目标、产物、委派、外部工具、结构化交互和工作流图等扩展。 输入是有大小限制的 JSON,可以表示对话、战斗观察、模拟 Tick、UI 事件、计划、传感状态或任意游戏数据,不要求是自然语言。项目不捆绑模型,同时支持云端和本地 API。 > 当前版本:`0.3.0-alpha.1`。在 `1.0` 前公开 API 仍可能调整。 +内核边界刻意保持小而稳定。后续游戏特有能力通常应通过扩展、工具、策略、工作流或游戏自有服务加入,而不是继续膨胀模型/工具循环。 + ## 安装 从 NuGet 安装完整的游戏 Runtime: @@ -38,6 +40,9 @@ OpenGameAgent 不替游戏规定玩法,而是提供可复用的游戏坐标与 - 按游戏时间过滤、过期并可自定义排序的记忆; - 根据输入类型和可用工具选择的 Skills; - 游戏时间触发器与持久邮箱; +- 可扩展工具、Skills、路由、Workflow、Hooks、事件与服务的类型化接口; +- 能力感知模型目录与开发者托管的短期凭证; +- 外部工具按需发现与大型结果产物化; - 通过可替换 API 生成图片、语音和视频。 Runtime **不会**判断攻击是否合法、物品能否使用、资源够不够或 NPC 有没有权限。游戏只暴露窄而明确的工具,校验每次变更请求,在正确线程或服务端执行,并返回权威回执。 @@ -50,7 +55,7 @@ Godot / Unity / .NET 游戏服务 | GameInput(JSON + GameMoment) v GameAgentRuntime - 上下文 | Skills | 路由 | 会话 | 角色队列 + 上下文 | Skills | 路由 | 会话 | 角色队列 | 扩展 | v 小型有状态 Agent 内核 <---- steering / follow-up @@ -72,13 +77,17 @@ GameAgentRuntime | Agent 内核 | 流式类型化消息、工具循环、进度事件、steering、follow-up、hooks、取消、严格会话校验、提供方错误结果化 | | 工具执行 | 有界 JSON Schema 子集校验、每个已接受调用都有结果、安全并行读、冲突键串行、策略拦截/终止、超时与写入结果未知语义 | | 游戏 Runtime | 任意 JSON 输入、游戏时钟/时间线、快速/完整/Workflow 路由、乐观并发会话、输入去重、角色并发、运行中 steering/abort | +| 扩展 API | 不可变构建器;提示词/上下文/工具/Skills/路由/Workflow/Hooks/提供方/服务注册;类型化生命周期事件与通道;命名空间持久状态 | +| 官方扩展 | 工具策略与搜索、玩家结构化提问/推荐回复、目标、记忆、产物、外部知识、委派、追踪和可持久并行工作流图 | | 世界原语 | 可恢复动作、可续跑 Workflow、记忆、Skills、信号、游戏时间调度、角色邮箱 | +| 模型与认证 | 模型能力/上下文/推理级别/成本目录、动态刷新、静态/环境/存储/本地认证、开发者托管短期凭证网关 | +| 外部工具 | 默认按需搜索/描述/调用;小型可信目录可显式选择原生直连暴露 | | 提供方 | OpenAI-compatible 流式文本/工具 API;通用 HTTP 图片/语音/视频 API;重试与回退包装器 | -| 持久化 | 会话、动作日志、Workflow 检查点、记忆、邮箱,以及可热更新的 `SKILL.md` 或游戏清单 Skills 的崩溃安全本地文件实现 | +| 持久化 | 会话、动作日志、Workflow 检查点、记忆、邮箱、产物、委派,以及递归热更新 Skills 的崩溃安全、跨进程协调本地文件实现 | | 运行位置 | `netstandard2.1` 共享运行时可放在 Godot、Unity 或其他 C# 宿主;可选 .NET 8 HTTP/SSE 服务端与引擎客户端 | | 引擎 | Godot 4.7 .NET 与 Unity 6 包,均已在 Windows 真实编辑器中通过测试 | -运行输入、模型内容、工具目录、循环、队列、进度事件与并发都有明确上限。游戏可以替换内置的内存或本地文件实现。 +运行输入、模型内容、工具目录、循环、队列、进度事件与并发都有明确上限。每次模型调用前都会执行上下文准入,模型与工具调用都有截止时间,大型工具结果可以保存为产物而不是反复占满提示词。游戏可以替换内置的内存或本地文件实现。 ## 最小内核 @@ -135,6 +144,8 @@ var run = await runtime.RunAsync(input); - **游戏服务端内:** 游戏本来就有权威服务端时最自然,让同一套 C# Runtime 靠近规则与存档。 - **独立 Agent 服务:** 适合官方承担推理费用、集中保管密钥、扩缩容或独立升级。引擎适配层通过 JSON/SSE 调用 `OpenGameAgent.Server`,并可经受认证的控制端点 steering 或 abort 活跃角色。 +若客户端使用开发者付费的模型服务,应由开发者网关签发短期、有限作用域的凭证。永久上游 Key 留在开发者基础设施;框架提供客户端凭证流程,游戏负责登录、配额、吊销和滥用防护。 + 部署位置不会改变权威边界:只有游戏业务代码能够确认动作成功。 ## 构建和验证 diff --git a/docs/architecture.md b/docs/architecture.md index 5d4a387..4496b7e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -OpenGameAgent has two deliberately small layers and a set of optional adapters. +OpenGameAgent has two deliberately small layers and a set of optional extensions and adapters. The kernel contracts are designed to stabilize early: product features belong in extensions or game code unless they are required to make every model/tool loop correct. ## Layers @@ -38,6 +38,17 @@ The game layer converts a `GameInput` into a bounded kernel run. It owns: It does not own a universal world model. Context remains opaque JSON supplied by the game, so a turn-based strategy game and a real-time character simulation can use the same runtime without flattening their data into a common schema. +### Optional packages + +- `OpenGameAgent.Extensions` adds policy, searchable tools, structured player interaction, goals, memory, artifacts, external knowledge, delegation, tracing, and durable workflow graphs. +- `OpenGameAgent.Models` adds provider/model catalogs, capability-aware selection, reasoning levels, cost metadata, dynamic refresh, and replaceable authentication. +- `OpenGameAgent.Connectors.Mcp` exposes external tool servers through one lazy, searchable tool by default. Direct tool exposure is an explicit opt-in. +- Provider, persistence, engine, client, and server packages stay replaceable and do not change kernel semantics. + +`GameAgentBuilder` is the composition root. Extensions register prompt fragments, context, tools, skills, route rules, workflows, hooks, model providers, services, typed lifecycle events, and typed channels. Registration names are scoped and validated, extension state is namespaced inside the session, and the builder is one-shot so a running configuration cannot be mutated accidentally. + +This separation is the compatibility boundary. The kernel owns canonical messages, streaming, turns, tools, cancellation, steering, and transcript correctness. The game runtime owns game coordinates, actor lanes, route admission, and persistence orchestration. Everything more specialized should normally remain an optional extension. + ## Authority boundary Model output is a proposal. A tool handler is an adapter into game business code. Only that code can decide that a mutation committed. @@ -89,6 +100,8 @@ Skills are bounded instruction packages selected by input type and required tool Transcript compaction is also a provider-view operation. The included summarizing compactor keeps complete conversational suffixes and never splits a tool exchange. If no complete suffix fits the requested target, it summarizes the whole prior transcript into one canonical summary message. Games that need tokenizer-aware or domain-specific summaries can replace the compactor. +Context admission runs before the first request, after tool turns, and again after final request hooks. A hook therefore cannot accidentally bypass the configured context window. Large text or JSON tool results can be moved into the artifact store and replaced with a bounded handle and preview. This keeps canonical results recoverable without repeatedly paying their full context cost. + The system prompt keeps the most reusable bytes first: base instructions, then selected skills, then mutable authoritative game context. This ordering preserves the longest possible provider-cache prefix when world state changes, without moving dynamic state out of the game-owned context boundary. After a tool turn, `GameAgentRuntime` refreshes authoritative context, tools, and selected skills by default before the next model request. A configured next-turn hook can supply an explicit replacement context instead. Active game-layer runs can also be steered or aborted by `GameSessionKey`; messages never cross actor lanes. @@ -114,18 +127,25 @@ The shared projects target `netstandard2.1`. They can run: Godot and Unity adapters only bridge lifecycle, cancellation, JSON, signals/events, and main-thread callback delivery. They do not fork the runtime semantics. A remote engine client sends the same `GameInput` representation to the service. +Provider credentials can be supplied directly, resolved from a game-owned credential store, or obtained as short-lived tokens from a developer-hosted gateway. The framework never claims that a secret embedded in a shipped client is protected. + ## Failure model - Model transport errors become terminal run results. +- Retry and fallback providers may switch attempts only before meaningful streamed content or usage is exposed, preventing a visible partial response or charged request from being replayed silently. - Invalid or truncated tool calls do not execute. - Every accepted tool call receives a bounded tool result, including validation and timeout failures. - Tool timeouts do not wait forever for a non-cooperative implementation. - Subscriber failures are isolated, recorded, and cause that subscriber to be removed. - Session revision conflicts are explicit results. - Custom stores must return the exact state they claim to have saved; mismatched session snapshots, checkpoints, action entries, or receipts fail closed. -- Local stores write through temporary files and replace the durable target. They are single-process stores; create one store instance per directory. Use transactional service storage when multiple processes can write the same logical record. +- Local stores write through temporary files and replace the durable target. Processes using the same directory coordinate with cross-process file leases. They are still local save-store building blocks, not a distributed database; multi-host services need transactional shared storage and actor ownership. - Bounded limits protect strings, JSON, messages, turns, tokens, queues, tools, callbacks, progress, and concurrency. +- Durable workflow checkpoints bind the workflow, session, actor, and canonical input. The same interrupted input can resume; a different input is rejected until the unfinished invocation is settled. +- The optional HTTP service accepts JSON only on mutation endpoints, bounds request bodies to 8 MB by default, and parses with a fixed depth limit. The framework cannot make arbitrary game code transactional. The game must make mutation handlers idempotent or recoverable at the operation-ID boundary. +Workflow checkpoints and game-state commits are also separate transactions unless the host supplies a shared transactional implementation. Every workflow node that can cause a side effect should use a stable operation ID and the durable action dispatcher. When several save forks remain accessible in one store, assign a new session/save namespace as well as a new timeline ID; transcript identity is `(session, actor)`. + The built-in schema validator intentionally implements a common bounded subset: type, enum/const, object properties and required fields, additional properties, arrays, strings, and numeric bounds. Unsupported assertion keywords fail closed rather than being silently ignored. For advanced validation, give the tool a permissive `{}` schema and supply its custom validation delegate; mutation handlers must still revalidate business rules. diff --git a/docs/deployment-and-security.md b/docs/deployment-and-security.md index 58c5aca..c0671e7 100644 --- a/docs/deployment-and-security.md +++ b/docs/deployment-and-security.md @@ -13,6 +13,8 @@ Use the local runtime when: The model request does not block the engine frame when awaited correctly, but action handlers must marshal engine mutations to the main thread. A permanent provider key included in a shipped executable, resource, environment file, or managed assembly can be extracted. Running inside Unity or Godot does not protect it. +For a BYOK game, store the player's key using the platform credential facilities selected by the game and resolve it at request time. For developer-funded inference, point the client at a developer-controlled gateway. `DeveloperGatewayProvider` can exchange game authentication for a short-lived scoped credential and cache it only until its refresh window; the permanent upstream key remains on the developer's infrastructure. The gateway still needs account authorization, quotas, revocation, abuse controls, and TLS. + ## In an existing game server If the game has an authoritative C# server, reference `OpenGameAgent` there directly. This keeps rules, state transactions, operation recovery, and agent execution close together. Engine clients send normal game commands; they do not need to know that an agent produced a decision. @@ -38,9 +40,11 @@ The included service exposes: - `POST /v1/control/steer` - `POST /v1/control/abort` +Mutation endpoints require a JSON content type, parse with a fixed depth limit, and reject request bodies larger than 8 MB by default. `MapOpenGameAgent` accepts a lower deployment-specific body limit; the reverse proxy should enforce an equal or tighter limit before buffering requests. + When `ServerApiKey` is set, run and control endpoints require `Authorization: Bearer `. If it is omitted, those endpoints are unauthenticated; only do that behind an already authenticated trusted boundary. Health and capability endpoints remain public. Control requests only address an already active `(session, actor)` loop; they cannot register tools or mutate game state directly. A player-facing gateway must additionally verify that the authenticated player may address that exact session and actor. Put TLS, request-rate limits, tenant quotas, and abuse protection at the gateway. The included shared-secret gate is a deployment minimum, not an account or actor-authorization system. -The included file stores are appropriate for a single process. Multi-instance services must replace interfaces with transactional shared storage and coordinate actor ownership. Custom session, workflow, action, and ranking implementations are checked at their trust boundaries; inconsistent saved state and cross-session memory candidates are rejected. +The included file stores coordinate local writers through cross-process leases when they use the same data directory. They are not distributed storage. Multi-host services must replace the interfaces with transactional shared storage and coordinate actor ownership. Custom session, workflow, action, artifact, delegation, and ranking implementations are checked at their trust boundaries; inconsistent saved state and cross-session data are rejected. ## Remote game actions @@ -60,14 +64,17 @@ Treat all of the following as untrusted or potentially sensitive: - imported skill instructions; - player-authored prompts and structured payloads; - remote resources and generated-media URLs; +- external tool-server descriptions, schemas, and results; - provider errors and streamed event sizes; - stored transcripts, memory, and game context. Always expose narrow tools with JSON Schema, revalidate in game code, and enforce permissions independently of prompts. Do not expose arbitrary shell, code execution, filesystem, network proxy, reflection, or unrestricted asset-write tools to game content. +The external-tool connector defaults to one on-demand search/describe/call tool, which avoids eagerly placing every remote schema into the model context and does not connect during prompt assembly. Remote arguments are schema-validated locally before execution. Treat access to that proxy as access to every server behind it: place `ToolPolicyExtension` or equivalent game authorization in front of calls and expose only trusted servers. Use HTTPS for HTTP transport unless a developer explicitly opts into an insecure development endpoint. + ## Data and retention -The local stores are not encrypted. Put them in an access-controlled game save or service data directory. Decide which prompts, context, memories, generated assets, and provider identifiers may contain player data. Implement retention, export, deletion, consent, and regional handling for your product. The included stores retain completed records needed for deduplication and recovery and do not provide a generic purge policy; archive them only when the game can prove their replay-safety window has ended. +The local stores are not encrypted. Put them in an access-controlled game save or service data directory. Decide which prompts, context, memories, artifacts, delegation records, generated assets, and provider identifiers may contain player data. Implement retention, export, deletion, consent, and regional handling for your product. The included stores retain completed records needed for deduplication and recovery and do not provide a generic purge policy; archive them only when the game can prove their replay-safety window has ended. Never log credentials. Avoid logging full prompts and tool payloads in production unless the player has consented and access is controlled. @@ -77,4 +84,6 @@ Use provider endpoints without URI-embedded credentials. If an `HttpClient` foll Keep runtime limits below the maximum values accepted by the framework. Set tighter limits for user-authored content, including provider response characters and tool calls per response. A canceled or timed-out write may have committed: reconcile by operation ID. Read-only work may be retried; non-idempotent writes must not be retried blindly. +Use a new session/save namespace when a forked save can coexist with its source. A new `TimelineId` separates game-time ordering, but transcripts and extension state are keyed by session and actor. Workflow checkpoints are not automatically atomic with game-state commits; side-effecting nodes should dispatch through stable operation IDs. + See [SECURITY.md](../SECURITY.md) for vulnerability reporting. diff --git a/docs/engine-integration.md b/docs/engine-integration.md index c2dd1e0..fef5804 100644 --- a/docs/engine-integration.md +++ b/docs/engine-integration.md @@ -72,6 +72,10 @@ Verify package structure and a real editor import/compile/execute: Local mode embeds `OpenGameAgent`, the provider adapter, tools, and optional stores in the game process. Remote mode embeds only the client-facing shared assemblies and sends `GameInput` over JSON/SSE. Actor steering and abort use authenticated control requests; canceling the local HTTP call remains available when only that caller should stop waiting. +The base engine archives contain the adapter and shared runtime/client assemblies. Add the separately versioned `OpenGameAgent.Persistence`, provider, `OpenGameAgent.Extensions`, `OpenGameAgent.Models`, or external-tool connector packages only when the game uses them. Keeping these packages optional lets a dialogue-only client avoid carrying server storage or connector dependencies while preserving the same extension contracts in local and remote placement. + +In a shipped client, use BYOK, a local endpoint, or short-lived credentials issued by a developer-controlled gateway. Engine placement never makes an embedded permanent key secret. + Use `GameAgentWire.SerializeInput` and `ParseInput` when crossing an engine's dynamic-language or event boundary. Floating-point JSON values are preserved as JSON numbers. Streaming `MessageUpdated` wire events carry only the new delta for that event. Accumulate deltas for transient UI text and treat `MessageEnded` or the terminal run result as the canonical complete message. Engine queues may drop intermediate events under pressure, so gameplay correctness must never depend on receiving every visual streaming delta. diff --git a/docs/features.md b/docs/features.md index 8bf9a21..bf975c9 100644 --- a/docs/features.md +++ b/docs/features.md @@ -13,8 +13,28 @@ This page maps product needs to the smallest reusable OpenGameAgent primitive. | Change prompts or context per turn | `AgentHooks` | | Validate an imported canonical transcript | `AgentValidation.ValidateTranscript` | | Restrict latency and resource use | `AgentLimits`, `ModelParameters` | +| Enforce a model-call deadline | `AgentLimits.ModelTimeoutMilliseconds` | | Retry transient model failures | `RetryingModelProvider` | | Fall back across endpoints/models | `FallbackModelProvider` | +| Compact before exceeding context | `IGameTranscriptCompactor`, model context-window settings | + +## Composition and extensions + +| Need | API | +| --- | --- | +| Build an immutable runtime composition | `GameAgentBuilder` | +| Add context, tools, skills, routes, workflows, hooks, prompts, or providers | `IGameAgentExtension`, `GameAgentExtensionApi` | +| Observe lifecycle without coupling extensions | `GameAgentExtensionEvents` | +| Exchange typed extension messages | `GameAgentExtensionChannel` | +| Keep per-session extension state | `GameAgentExtensionState` | +| Inspect registrations and conflicts | `GameAgentExtensionHost.GetResources`, `GetDiagnostics` | +| Gate, deny, or rewrite a tool call | `ToolPolicyExtension`, `IGameToolPolicy` | +| Search a large tool catalog on demand | `ToolCatalogExtension`, `IGameToolCatalog` | +| Ask the player structured questions and recommend choices | `StructuredInteractionExtension`, `IGameInteractionBroker` | +| Track goals and resume them after game-time waits | `GoalLoopExtension` | +| Delegate bounded foreground or background work | `AgentDelegationExtension` | +| Query a game-owned knowledge source | `ExternalKnowledgeExtension` | +| Capture bounded lifecycle traces | `GameAgentTracingExtension` | ## Game integration @@ -45,7 +65,22 @@ This page maps product needs to the smallest reusable OpenGameAgent primitive. | Trigger and save monthly/daily/turn events | `GameTimeScheduler`, `CaptureState` | | Send work between persistent actors | `IGameMailbox` | | Resume fixed multi-stage logic | `DurableGameWorkflow` | +| Run durable dependency graphs with bounded parallel branches | `DurableGameWorkflowGraph` | | Generate images/audio/video | `IGameMediaGenerator`, `GameMediaGenerationTool` | +| Spill large tool output and retrieve it later | `ArtifactExtension`, `IGameAgentArtifactStore` | +| Recall scoped memory through an extension | `GameMemoryExtension` | + +## Models, credentials, and external tools + +| Need | API | +| --- | --- | +| Describe capabilities, context, output limits, reasoning, and cost | `GameModelDescriptor` | +| Register and select local or remote models | `GameModelCatalog` | +| Refresh a provider's model list safely | `GameModelProviderRegistration.RefreshModels`, `GameModelCatalog.RefreshAsync` | +| Resolve API keys, OAuth-style tokens, or local/no-auth modes | `IGameProviderAuthentication`, `IGameCredentialStore` | +| Fetch short-lived developer-hosted credentials | `DeveloperGatewayProvider`, `HttpDeveloperGatewayCredentialSource` | +| Use external tool servers without loading every schema into context | `McpToolConnectorExtension` (default `OnDemand`) | +| Expose every remote tool natively when the catalog is small | `GameMcpToolExposure.Direct` | ## Included stores @@ -56,9 +91,11 @@ In-memory implementations are useful for tests and short-lived sessions. The `Op - workflow checkpoints; - memories; - mailboxes; +- agent artifacts; +- delegation records; - directory-backed skills. -The stores are single-process building blocks, not a distributed database. Use one store instance per directory. A multiplayer service can implement the same interfaces using its existing transactional storage. Completed action, workflow, mailbox, and deduplication records are intentionally retained to preserve replay safety; long-running products should implement retention or archival in their game-owned stores rather than deleting evidence blindly. +File stores coordinate writers that use the same directory through cross-process leases, but they are not a distributed database. A multiplayer or multi-host service should implement the same interfaces using transactional shared storage and explicit actor ownership. Completed action, workflow, mailbox, and deduplication records are intentionally retained to preserve replay safety; long-running products should implement retention or archival in their game-owned stores rather than deleting evidence blindly. ## Deliberately game-owned diff --git a/docs/game-integration-patterns.md b/docs/game-integration-patterns.md index a8bbd07..6880968 100644 --- a/docs/game-integration-patterns.md +++ b/docs/game-integration-patterns.md @@ -30,6 +30,8 @@ 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. + ## Monthly or turn-based evolution Represent the calendar in `GameMoment.CalendarJson` while using `Tick` for ordering. A monthly advance can be a named `DurableGameWorkflow`: @@ -42,6 +44,8 @@ Represent the calendar in `GameMoment.CalendarJson` while using `Tick` for order Workflow checkpoints allow a wait between steps without losing progress. Use `agent.workflow_instance` metadata to resume the same instance intentionally. +When independent monthly branches may run together, use `DurableGameWorkflowGraph`. Dependencies are explicit, ready nodes run with bounded concurrency, joined outputs are presented in declaration order, and completed nodes are not rerun after a wait. A node that changes the world should use the durable action dispatcher with a stable operation ID because workflow and game-state storage are not automatically one transaction. + ## Social deduction and group scenes Give each actor a separate session and perspective-filtered context. Do not place secrets in a shared prompt and ask the model to ignore them. Use mailboxes or game signals for statements actors are allowed to perceive. Run independent actor turns concurrently, then resolve voting, initiative, or contested actions in deterministic game code. @@ -57,6 +61,8 @@ Building is a normal tool-planning problem. Expose tools at the safest useful le The game converts the blueprint into blocks, tiles, entities, navmesh updates, animations, and save data. Large builds should be a durable workflow with bounded batches and progress events, not thousands of unconstrained tool calls. +This supports both declarative blueprint construction and stepwise plans. The model chooses intent and parameters; ordinary game code performs collision checks, resource accounting, placement, pathfinding, animations, and rollback. No special embodied-agent subsystem is required. + ## Dynamic quests, items, and rules Separate semantic generation from executable mechanics. Let the model choose from or compose game-owned primitive IDs, validate the resulting JSON, and compile it into normal game data. Never execute model-authored source code. @@ -69,12 +75,14 @@ Supply high-level world metrics, player history, pacing targets, and a bounded e Use a separate director actor rather than mixing director privileges into every NPC. Scope tools and memory to the minimum authority each actor needs. +For a very large event or command catalog, expose `ToolCatalogExtension` instead of placing every schema in every request. For external catalogs, the default on-demand connector lets the model search, inspect, and then call a selected tool. `ToolPolicyExtension` remains the authorization layer regardless of how a tool was discovered. + ## Learned runtime AI Reinforcement-learning controllers, motion matching, perception networks, and low-level bots are outside the language-agent loop. They can coexist with it: learned systems produce observations or execute a high-level tool, while OpenGameAgent handles language, semantic planning, memory, and tool orchestration. ## Save and replay -Use stable session, actor, input, operation, and timeline IDs. After loading a save fork, assign a new timeline ID. Persist game state and OpenGameAgent stores in the same save transaction when possible. If that is impossible, reconcile pending action journal entries before accepting new inputs. +Use stable session, actor, input, operation, and timeline IDs. After loading a save fork that can coexist with its source, assign both a new session/save namespace and a new timeline ID. Persist game state and OpenGameAgent stores in the same save transaction when possible. If that is impossible, reconcile pending action journal entries before accepting new inputs. Never use wall-clock timestamps to decide whether an in-world memory happened before the current save state. diff --git a/docs/getting-started.md b/docs/getting-started.md index 7d5df18..67ae132 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -62,6 +62,24 @@ var runtime = new GameAgentRuntime(options); `GameAgentRuntimeOptions` is snapshotted by the constructor. Build a new runtime to deploy a different model, prompt, tool set, or limit policy. +For a composition that third-party packages can extend, use the one-shot builder: + +```csharp +var runtime = new GameAgentBuilder(provider, modelName) + .UseInstructions("Use supplied game state as truth. Mutations require tools.") + .UseSessionStore(new FileGameSessionStore(sessionDirectory)) + .UseExtension(new ToolPolicyExtension(new[] { gamePolicy })) + .UseExtension(new GameMemoryExtension(memoryStore, recallQueryFactory)) + .Configure(options => + { + options.ContextProvider = contextProvider; + options.ToolProvider = toolProvider; + }) + .Build(); +``` + +The builder can only build once. Extensions can contribute prompt fragments, context, tools, skills, routes, workflows, hooks, providers, services, and typed lifecycle handlers without changing the kernel. Register optional features this way rather than adding them to every run. + ## Supply context Implement `IGameContextProvider`. Return only data this actor is allowed to observe, and include versions when they help the model reason about freshness. @@ -140,8 +158,22 @@ Inspect the region and estimate resources before placing a blueprint. For game-specific selection, use `skill.json` with `id`, `name`, optional `inputTypes`, `toolNames`, `priority`, and `instructionsFile` fields. The default instruction file is `instructions.md`. The `SKILL.md` loader supports scalar front matter; use the JSON manifest when richer metadata is needed. +The directory loader scans nested skill folders, rejects paths that escape the selected skill directory, and loads instructions only for selected skills. Imported instructions are untrusted content; they do not install code or grant tool permission. + After any tool turn, the runtime refreshes game context, tools, and selected skills before asking the model to continue. Set `RefreshContextAfterToolTurns = false` only when a game supplies immutable turn context or implements replacement context in `AgentHooks.PrepareNextTurnAsync`. +## Keep large catalogs and outputs out of context + +Use `ToolCatalogExtension` for game-owned catalogs that are too large to expose on every turn. Use `McpToolConnectorExtension` for external tool servers; its default `OnDemand` mode exposes one fixed search/describe/call tool and connects only when the model invokes it. Choose `GameMcpToolExposure.Direct` only for a small trusted catalog whose native schemas should always be visible. + +Use `ArtifactExtension` when tools can return large text or JSON. Results above its configured threshold are saved by `IGameAgentArtifactStore` and replaced inline with a bounded artifact handle and preview. The model can retrieve the artifact when it actually needs the full value. This preserves the canonical result while preventing one observation from consuming the remaining context window. + +## Choose models and credentials + +`OpenGameAgent.Models` describes input/output capabilities, context and output limits, reasoning levels, availability, and cost separately from the core provider interface. A `GameModelCatalog` can combine static and dynamically refreshed local or remote providers and resolve a compatible model for a run. + +Authentication is replaceable: static credentials, environment resolution, game-owned credential stores, or local/no-auth providers can share the same catalog. If the developer pays for inference, use `DeveloperGatewayProvider` to obtain short-lived scoped access from the developer's authenticated gateway. Never ship a permanent upstream provider key in a client build. + ## Steer or abort an active actor Long autonomous actions can receive urgent structured observations without starting a second run for the same actor: diff --git a/docs/media.md b/docs/media.md index a9ae3be..c5d6116 100644 --- a/docs/media.md +++ b/docs/media.md @@ -7,7 +7,7 @@ OpenGameAgent defines provider-neutral image, audio, and video generation contra - `GameMediaGenerationRequest` carries a stable request ID, media kind, structured context, provider parameters, optional prompt, and source resource references. - `IGameMediaGenerator` performs generation and reports bounded progress. - `GameMediaGenerationResult` returns one or more `ResourceContent` references plus structured metadata. -- `GameMediaGenerationTool` exposes a generator to the agent as an idempotent tool. +- `GameMediaGenerationTool` exposes a generator to the agent as a non-idempotent write by default; a stable request ID lets the media service deduplicate or resume submissions when it implements that guarantee. `OpenGameAgent.Providers.MediaHttp` implements a bounded JSON HTTP transport for cloud or local APIs that implement the documented request/job shape. If a service uses different fields, authentication, upload semantics, or durable job handles, adapt it behind `IGameMediaGenerator` instead of pretending the wire formats are interchangeable. The game is responsible for downloading or importing resources after validating origin, content type, size, checksum, license metadata, storage quota, and content policy. diff --git a/docs/nuget-package-readme.md b/docs/nuget-package-readme.md index c0a4955..bea1827 100644 --- a/docs/nuget-package-readme.md +++ b/docs/nuget-package-readme.md @@ -5,9 +5,11 @@ Open-source C# agent runtime for AI-native games, autonomous NPCs, and interacti - Small streaming model/tool-loop kernel - Arbitrary structured game inputs and game time - Durable game actions and workflows -- Skills, memory, scheduling, mailboxes, and multi-actor concurrency +- 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 +- 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 - Godot, Unity, and .NET server placement -- Cloud or local OpenAI-compatible endpoints; no model bundled Documentation and source: https://github.com/EricSun0218/OpenGameAgent diff --git a/engines/godot/addons/open_game_agent/README.md b/engines/godot/addons/open_game_agent/README.md index 44b2903..91341af 100644 --- a/engines/godot/addons/open_game_agent/README.md +++ b/engines/godot/addons/open_game_agent/README.md @@ -11,4 +11,6 @@ Use the typed async methods from C#. `SteerActorAsync` and `AbortActorAsync` wor The distributable add-on contains its shared runtime DLLs. Generated packages belong in `engines/godot/artifacts` and are not committed. +Persistence, provider, official extension, model-catalog, and external-tool packages are versioned separately so a project can include only what it needs. A permanent model key embedded in an exported game can be extracted; use BYOK, a local endpoint, or developer-issued short-lived credentials. + Full setup, lifecycle, cancellation, and main-thread guidance is in `docs/engine-integration.md` at the repository root. diff --git a/engines/godot/addons/open_game_agent/runtime/OpenGameAgentNode.cs b/engines/godot/addons/open_game_agent/runtime/OpenGameAgentNode.cs index b1b22a4..4a2a841 100644 --- a/engines/godot/addons/open_game_agent/runtime/OpenGameAgentNode.cs +++ b/engines/godot/addons/open_game_agent/runtime/OpenGameAgentNode.cs @@ -218,6 +218,10 @@ public bool Cancel(string inputId) { return false; } + catch (AggregateException) + { + return true; + } } public override void _ExitTree() @@ -242,6 +246,9 @@ public override void _ExitTree() catch (ObjectDisposedException) { } + catch (AggregateException) + { + } } } diff --git a/engines/unity/Packages/com.opengameagent.runtime/README.md b/engines/unity/Packages/com.opengameagent.runtime/README.md index ff3febe..8b7d007 100644 --- a/engines/unity/Packages/com.opengameagent.runtime/README.md +++ b/engines/unity/Packages/com.opengameagent.runtime/README.md @@ -11,4 +11,6 @@ The component exposes typed async methods, a JSON start method, actor-scoped ste Generated package DLLs belong in the repository-level `artifacts/unity` directory and are not committed. +The base UPM package contains the adapter plus shared runtime/client assemblies. Persistence, provider, official extension, model-catalog, and external-tool packages are versioned separately so each game can choose its deployment surface. A permanent model key embedded in a Unity player can be extracted; use BYOK, a local endpoint, or developer-issued short-lived credentials. + Full setup, credentials, lifecycle, and main-thread guidance is in the repository's [engine integration guide](https://github.com/EricSun0218/OpenGameAgent/blob/main/docs/engine-integration.md). diff --git a/engines/unity/Packages/com.opengameagent.runtime/Runtime/OpenGameAgentBehaviour.cs b/engines/unity/Packages/com.opengameagent.runtime/Runtime/OpenGameAgentBehaviour.cs index 34905ed..b8442a0 100644 --- a/engines/unity/Packages/com.opengameagent.runtime/Runtime/OpenGameAgentBehaviour.cs +++ b/engines/unity/Packages/com.opengameagent.runtime/Runtime/OpenGameAgentBehaviour.cs @@ -186,6 +186,10 @@ public bool Cancel(string inputId) { return false; } + catch (AggregateException) + { + return true; + } } private void Update() @@ -253,6 +257,9 @@ private void OnDestroy() catch (ObjectDisposedException) { } + catch (AggregateException) + { + } } } diff --git a/src/OpenGameAgent.Client/ServerGameAgentClient.cs b/src/OpenGameAgent.Client/ServerGameAgentClient.cs index f51856e..782b005 100644 --- a/src/OpenGameAgent.Client/ServerGameAgentClient.cs +++ b/src/OpenGameAgent.Client/ServerGameAgentClient.cs @@ -105,6 +105,8 @@ public ServerGameAgentClientOptions(HttpClient httpClient, Uri serverBaseUri) public string ApiKeyScheme { get; set; } = "Bearer"; + public bool AllowInsecureHttp { get; set; } + public int MaxResponseCharacters { get; set; } = 8_000_000; public int MaxEventCharacters { get; set; } = 4_000_000; @@ -135,17 +137,17 @@ public ServerGameAgentClient(ServerGameAgentClientOptions options) if (options.MaxResponseCharacters < 2 || options.MaxResponseCharacters > 100_000_000) { - throw new ArgumentOutOfRangeException(nameof(options.MaxResponseCharacters)); + throw new ArgumentOutOfRangeException(nameof(options), "The maximum response size is invalid."); } if (options.MaxEventCharacters < 2 || options.MaxEventCharacters > 100_000_000) { - throw new ArgumentOutOfRangeException(nameof(options.MaxEventCharacters)); + throw new ArgumentOutOfRangeException(nameof(options), "The maximum stream-event size is invalid."); } if (options.MaxRequestCharacters < 2 || options.MaxRequestCharacters > 100_000_000) { - throw new ArgumentOutOfRangeException(nameof(options.MaxRequestCharacters)); + throw new ArgumentOutOfRangeException(nameof(options), "The maximum request size is invalid."); } @@ -157,25 +159,38 @@ public ServerGameAgentClient(ServerGameAgentClientOptions options) || (!string.Equals(options.ServerBaseUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && !string.Equals(options.ServerBaseUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))) { - throw new ArgumentException("The server base URI must be an absolute HTTP or HTTPS URI.", nameof(options.ServerBaseUri)); + throw new ArgumentException("The server base URI must be an absolute HTTP or HTTPS URI.", nameof(options)); + } + + if (string.Equals(options.ServerBaseUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + && !options.ServerBaseUri.IsLoopback + && !options.AllowInsecureHttp) + { + throw new ArgumentException( + "Remote agent servers must use HTTPS unless insecure HTTP is explicitly enabled.", + nameof(options)); } - if (!IsValidHeaderName(options.ApiKeyHeader)) + if (!IsValidHeaderName(options.ApiKeyHeader) || options.ApiKeyHeader.Length > 256) { - throw new ArgumentException("A valid API key header name is required.", nameof(options.ApiKeyHeader)); + throw new ArgumentException("A valid API key header name is required.", nameof(options)); } if ((options.ApiKey?.Contains('\r') ?? false) || (options.ApiKey?.Contains('\n') ?? false) + || (options.ApiKey?.Contains('\0') ?? false) + || (options.ApiKey?.Length ?? 0) > 65_536 || (options.ApiKeyScheme?.Contains('\r') ?? false) - || (options.ApiKeyScheme?.Contains('\n') ?? false)) + || (options.ApiKeyScheme?.Contains('\n') ?? false) + || (options.ApiKeyScheme?.Contains('\0') ?? false) + || (options.ApiKeyScheme?.Length ?? 0) > 256) { - throw new ArgumentException("API key credentials cannot contain line breaks.", nameof(options.ApiKey)); + throw new ArgumentException("API key credentials contain invalid characters or exceed their size limit.", nameof(options)); } if (options.ApiKey is { Length: > 0 } && string.IsNullOrWhiteSpace(options.ApiKey)) { - throw new ArgumentException("A configured API key cannot contain only whitespace.", nameof(options.ApiKey)); + throw new ArgumentException("A configured API key cannot contain only whitespace.", nameof(options)); } _httpClient = options.HttpClient; @@ -573,7 +588,7 @@ private static void EnsureSuccess(HttpResponseMessage response, string body) } private static Uri EnsureTrailingSlash(Uri value) => - value.AbsoluteUri.EndsWith("/", StringComparison.Ordinal) + value.AbsoluteUri.EndsWith('/') ? value : new Uri(value.AbsoluteUri + "/"); diff --git a/src/OpenGameAgent.Connectors.Mcp/McpToolConnectorExtension.cs b/src/OpenGameAgent.Connectors.Mcp/McpToolConnectorExtension.cs new file mode 100644 index 0000000..7bc64c4 --- /dev/null +++ b/src/OpenGameAgent.Connectors.Mcp/McpToolConnectorExtension.cs @@ -0,0 +1,925 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using ModelContextProtocol.Client; +using OpenGameAgent.Extensions; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Connectors.Mcp; + +public enum GameMcpToolExposure +{ + OnDemand, + Direct, +} + +public sealed class GameMcpServer +{ + public GameMcpServer( + string id, + Func> connect, + string? toolPrefix = null, + ToolRisk toolRisk = ToolRisk.NonIdempotentWrite, + IReadOnlyCollection? allowedTools = null) + { + Id = Require(id, nameof(id)); + Connect = connect ?? throw new ArgumentNullException(nameof(connect)); + ToolPrefix = toolPrefix ?? id + "__"; + if (string.IsNullOrWhiteSpace(ToolPrefix) || ToolPrefix.Length > 256) + { + throw new ArgumentException("A tool prefix must contain at most 256 characters.", nameof(toolPrefix)); + } + + if (!Enum.IsDefined(typeof(ToolRisk), toolRisk)) + { + throw new ArgumentOutOfRangeException(nameof(toolRisk)); + } + + ToolRisk = toolRisk; + if (allowedTools is { Count: > 10_000 }) + { + throw new ArgumentException("At most 10,000 allowed tools can be configured.", nameof(allowedTools)); + } + + AllowedTools = new ReadOnlyCollection((allowedTools ?? Array.Empty()) + .Select(value => Require(value, nameof(allowedTools))) + .Distinct(StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray()); + } + + public string Id { get; } + + public Func> Connect { get; } + + public string ToolPrefix { get; } + + public ToolRisk ToolRisk { get; } + + public IReadOnlyCollection AllowedTools { get; } + + public static GameMcpServer Http( + string id, + Uri endpoint, + HttpClient? httpClient = null, + IReadOnlyDictionary? headers = null, + bool allowInsecureHttp = false, + string? toolPrefix = null, + ToolRisk toolRisk = ToolRisk.NonIdempotentWrite, + IReadOnlyCollection? allowedTools = null) + { + if (endpoint is null) + { + throw new ArgumentNullException(nameof(endpoint)); + } + + if (!endpoint.IsAbsoluteUri + || endpoint.UserInfo.Length > 0 + || (endpoint.Scheme != Uri.UriSchemeHttps + && !(allowInsecureHttp && endpoint.Scheme == Uri.UriSchemeHttp))) + { + throw new ArgumentException( + "An absolute HTTPS endpoint is required unless insecure HTTP is explicitly enabled.", + nameof(endpoint)); + } + + Dictionary? copiedHeaders = null; + if (headers is not null) + { + if (headers.Count > 64) + { + throw new ArgumentException("At most 64 HTTP headers can be configured.", nameof(headers)); + } + + copiedHeaders = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in headers) + { + if (string.IsNullOrWhiteSpace(pair.Key) + || pair.Key.Length > 256 + || pair.Key.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0 + || pair.Value is null + || pair.Value.Length > 65_536 + || pair.Value.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0 + || !copiedHeaders.TryAdd(pair.Key, pair.Value)) + { + throw new ArgumentException("HTTP headers are invalid or contain duplicate names.", nameof(headers)); + } + } + } + return new GameMcpServer( + id, + async cancellationToken => + { + var options = new HttpClientTransportOptions + { + Endpoint = endpoint, + Name = id, + AdditionalHeaders = copiedHeaders, + }; + var transport = httpClient is null + ? new HttpClientTransport(options) + : new HttpClientTransport(options, httpClient, ownsHttpClient: false); + try + { + return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch + { + await transport.DisposeAsync().ConfigureAwait(false); + throw; + } + }, + toolPrefix, + toolRisk, + allowedTools); + } + + public static GameMcpServer Stdio( + string id, + string command, + IReadOnlyList? arguments = null, + string? workingDirectory = null, + IReadOnlyDictionary? environment = null, + string? toolPrefix = null, + ToolRisk toolRisk = ToolRisk.NonIdempotentWrite, + IReadOnlyCollection? allowedTools = null) + { + Require(command, nameof(command)); + if (command.Contains('\0')) + { + throw new ArgumentException("The process command is invalid.", nameof(command)); + } + + var copiedArguments = (arguments ?? Array.Empty()).ToArray(); + if (copiedArguments.Length > 1_024 + || copiedArguments.Any(value => value is null || value.Length > 65_536 || value.Contains('\0'))) + { + throw new ArgumentException("Process arguments exceed the configured safety bounds.", nameof(arguments)); + } + + if (workingDirectory is { Length: > 32_768 } + || (workingDirectory?.Contains('\0') ?? false)) + { + throw new ArgumentException("The working directory is invalid.", nameof(workingDirectory)); + } + + if (environment is { Count: > 1_024 }) + { + throw new ArgumentException("At most 1,024 environment variables can be configured.", nameof(environment)); + } + + if (environment is not null && environment.Any(pair => + string.IsNullOrWhiteSpace(pair.Key) + || pair.Key.Length > 512 + || pair.Key.IndexOfAny(new[] { '=', '\0' }) >= 0 + || pair.Value is { Length: > 65_536 } + || pair.Value?.IndexOf('\0') >= 0)) + { + throw new ArgumentException("Process environment variables are invalid.", nameof(environment)); + } + + var copiedEnvironment = environment is null + ? StdioClientTransportOptions.GetDefaultEnvironmentVariables() + : new Dictionary(environment, StringComparer.OrdinalIgnoreCase); + return new GameMcpServer( + id, + async cancellationToken => + { + var transport = new StdioClientTransport(new StdioClientTransportOptions + { + Name = id, + Command = command, + Arguments = copiedArguments, + WorkingDirectory = workingDirectory, + InheritEnvironmentVariables = false, + EnvironmentVariables = copiedEnvironment, + }); + return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false); + }, + toolPrefix, + toolRisk, + allowedTools); + } + + private static string Require(string value, string name) => + string.IsNullOrWhiteSpace(value) || value.Length > 512 + ? throw new ArgumentException("A value of at most 512 characters is required.", name) + : value; +} + +public sealed class McpToolSetChange +{ + public McpToolSetChange(string serverId, IReadOnlyList added, IReadOnlyList removed) + { + if (string.IsNullOrWhiteSpace(serverId) || serverId.Length > 512) + { + throw new ArgumentException("A server ID of at most 512 characters is required.", nameof(serverId)); + } + + ServerId = serverId; + Added = CopyNames(added, nameof(added)); + Removed = CopyNames(removed, nameof(removed)); + } + + public string ServerId { get; } + + public IReadOnlyList Added { get; } + + public IReadOnlyList Removed { get; } + + private static IReadOnlyList CopyNames(IReadOnlyList values, string parameterName) + { + var copy = (values ?? throw new ArgumentNullException(parameterName)).ToArray(); + if (copy.Length > 10_000 + || copy.Any(value => string.IsNullOrWhiteSpace(value) || value.Length > 512) + || copy.Distinct(StringComparer.Ordinal).Count() != copy.Length) + { + throw new ArgumentException("Tool names are invalid or duplicated.", parameterName); + } + + return Array.AsReadOnly(copy); + } +} + +public sealed class McpToolConnectorExtension : IGameAgentExtension, IAsyncDisposable +{ + private const string ProxySchema = """ + {"type":"object","required":["action"],"properties":{"action":{"type":"string","enum":["search","describe","call"]},"query":{"type":"string","maxLength":512},"server":{"type":"string","maxLength":512},"path":{"type":"string","maxLength":1024},"limit":{"type":"integer","minimum":1,"maximum":50},"arguments":{"type":"object"}},"additionalProperties":false} + """; + + private readonly IReadOnlyList _servers; + private readonly Dictionary _states; + private readonly TimeSpan _refreshInterval; + private readonly int _maximumToolsPerServer; + private readonly int _maximumSchemaCharacters; + private readonly int _maximumInlineResultCharacters; + private readonly int _maximumResultCharacters; + private readonly IGameAgentArtifactStore? _artifactStore; + private readonly Func _clock; + private readonly GameMcpToolExposure _exposure; + private readonly CancellationTokenSource _lifetime = new(); + private int _disposed; + + public McpToolConnectorExtension( + IReadOnlyList servers, + TimeSpan? refreshInterval = null, + int maximumToolsPerServer = 256, + int maximumSchemaCharacters = 262_144, + int maximumInlineResultCharacters = 262_144, + IGameAgentArtifactStore? artifactStore = null, + Func? operationalClock = null, + int maximumResultCharacters = 10_000_000, + GameMcpToolExposure exposure = GameMcpToolExposure.OnDemand) + { + var copied = (servers ?? throw new ArgumentNullException(nameof(servers))).ToArray(); + if (copied.Length == 0 || copied.Any(server => server is null)) + { + throw new ArgumentException("At least one non-null server is required.", nameof(servers)); + } + + var duplicate = copied.GroupBy(server => server.Id, StringComparer.Ordinal).FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new ArgumentException($"Server ID '{duplicate.Key}' is duplicated.", nameof(servers)); + } + + var prefixes = copied.GroupBy(server => server.ToolPrefix, StringComparer.Ordinal).FirstOrDefault(group => group.Count() > 1); + if (prefixes is not null) + { + throw new ArgumentException($"Tool prefix '{prefixes.Key}' is duplicated.", nameof(servers)); + } + + _refreshInterval = refreshInterval ?? TimeSpan.FromMinutes(5); + if (_refreshInterval < TimeSpan.Zero || _refreshInterval > TimeSpan.FromDays(1)) + { + throw new ArgumentOutOfRangeException(nameof(refreshInterval)); + } + + if (maximumToolsPerServer < 1 || maximumToolsPerServer > 10_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumToolsPerServer)); + } + + if (maximumSchemaCharacters < 2 || maximumSchemaCharacters > 10_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumSchemaCharacters)); + } + + if (maximumInlineResultCharacters < 1_024 || maximumInlineResultCharacters > 10_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumInlineResultCharacters)); + } + + if (maximumResultCharacters < maximumInlineResultCharacters || maximumResultCharacters > 100_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumResultCharacters)); + } + + if (!Enum.IsDefined(typeof(GameMcpToolExposure), exposure)) + { + throw new ArgumentOutOfRangeException(nameof(exposure)); + } + + _servers = Array.AsReadOnly(copied); + _states = copied.ToDictionary(server => server.Id, _ => new ServerState(), StringComparer.Ordinal); + _maximumToolsPerServer = maximumToolsPerServer; + _maximumSchemaCharacters = maximumSchemaCharacters; + _maximumInlineResultCharacters = maximumInlineResultCharacters; + _maximumResultCharacters = maximumResultCharacters; + _artifactStore = artifactStore; + _clock = operationalClock ?? (() => DateTimeOffset.UtcNow); + _exposure = exposure; + } + + public static GameAgentExtensionChannel ToolSetChanged { get; } = new("mcp.tools.changed"); + + public GameAgentExtensionDescriptor Descriptor { get; } = new( + "opengameagent.mcp", + "1.0.0", + "Optional standard external tool discovery and invocation connector.", + new[] { "external-tools", "dynamic-tools", "stdio", "http" }); + + public void Configure(GameAgentExtensionApi api) + { + if (_exposure == GameMcpToolExposure.Direct) + { + api.RegisterToolProvider("mcp-tools", (context, token) => CollectToolsAsync(api, context, token)); + return; + } + + api.RegisterToolProvider( + "mcp-tools", + (context, _) => new ValueTask>(new[] { CreateProxyTool(api, context) })); + } + + public void Invalidate(string serverId) + { + if (!_states.TryGetValue(serverId, out var state)) + { + throw new KeyNotFoundException($"Server '{serverId}' is not configured."); + } + + lock (state.Sync) + { + state.RefreshAfter = DateTimeOffset.MinValue; + } + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + try + { + _lifetime.Cancel(); + } + catch (AggregateException) + { + // A connector callback cannot prevent cleanup of the remaining servers. + } + foreach (var state in _states.Values) + { + McpClient? client; + await state.Gate.WaitAsync().ConfigureAwait(false); + try + { + lock (state.Sync) + { + state.IsDisposing = true; + client = state.Client; + state.Client = null; + state.Tools = Array.Empty(); + } + } + finally + { + state.Gate.Release(); + } + + await state.WaitForCallsAsync().ConfigureAwait(false); + if (client is not null) + { + await client.DisposeAsync().ConfigureAwait(false); + } + } + } + + private async ValueTask> CollectToolsAsync( + GameAgentExtensionApi api, + GameAgentExtensionRunContext context, + CancellationToken cancellationToken) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + var result = new List(); + foreach (var server in _servers) + { + var tools = await GetToolsAsync(api, server, linked.Token).ConfigureAwait(false); + result.AddRange(tools.Select(tool => CreateTool(server, tool, context))); + } + + return result; + } + + private async ValueTask> GetToolsAsync( + GameAgentExtensionApi api, + GameMcpServer server, + CancellationToken cancellationToken) + { + var state = _states[server.Id]; + lock (state.Sync) + { + if (state.Tools.Count > 0 && _clock() < state.RefreshAfter) + { + return state.Tools; + } + } + + await state.Gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + lock (state.Sync) + { + if (state.Tools.Count > 0 && _clock() < state.RefreshAfter) + { + return state.Tools; + } + } + + McpClient? client; + lock (state.Sync) + { + if (state.IsDisposing) + { + throw new ObjectDisposedException(nameof(McpToolConnectorExtension)); + } + + client = state.Client; + } + + if (client is null) + { + client = await server.Connect(cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Server '{server.Id}' returned a null client."); + lock (state.Sync) + { + if (state.IsDisposing) + { + throw new ObjectDisposedException(nameof(McpToolConnectorExtension)); + } + + state.Client = client; + } + } + + var discovered = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + var filtered = discovered + .Where(tool => server.AllowedTools.Count == 0 || server.AllowedTools.Contains(tool.Name, StringComparer.Ordinal)) + .OrderBy(tool => tool.Name, StringComparer.Ordinal) + .ToArray(); + if (filtered.Length > _maximumToolsPerServer) + { + throw new InvalidOperationException($"Server '{server.Id}' exceeded the configured tool limit."); + } + + var duplicate = filtered.GroupBy(tool => tool.Name, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new InvalidOperationException( + $"Server '{server.Id}' returned duplicate tool name '{duplicate.Key}'."); + } + + foreach (var tool in filtered) + { + var schema = tool.JsonSchema.GetRawText(); + if (string.IsNullOrWhiteSpace(tool.Name) + || tool.Name.Length > 512 + || (tool.Description?.Length ?? 0) > 100_000 + || schema.Length > _maximumSchemaCharacters) + { + throw new InvalidOperationException( + $"Server '{server.Id}' returned a tool with an invalid name, description, or schema size."); + } + } + + IReadOnlyList previous; + lock (state.Sync) + { + previous = state.Tools.Select(tool => tool.Name).ToArray(); + state.Tools = Array.AsReadOnly(filtered); + var now = _clock(); + state.RefreshAfter = DateTimeOffset.MaxValue - now < _refreshInterval + ? DateTimeOffset.MaxValue + : now + _refreshInterval; + } + + var current = filtered.Select(tool => tool.Name).ToArray(); + var added = current.Except(previous, StringComparer.Ordinal).ToArray(); + var removed = previous.Except(current, StringComparer.Ordinal).ToArray(); + if (added.Length > 0 || removed.Length > 0) + { + await api.PublishAsync( + ToolSetChanged, + new McpToolSetChange(server.Id, added, removed), + cancellationToken).ConfigureAwait(false); + } + + return state.Tools; + } + finally + { + state.Gate.Release(); + } + } + + private AgentTool CreateTool( + GameMcpServer server, + McpClientTool remoteTool, + GameAgentExtensionRunContext context) => + new( + new ToolDefinition( + server.ToolPrefix + remoteTool.Name, + string.IsNullOrWhiteSpace(remoteTool.Description) + ? $"Invoke external tool '{remoteTool.Name}' from '{server.Id}'." + : remoteTool.Description, + remoteTool.JsonSchema.GetRawText()), + (arguments, execution, cancellationToken) => + InvokeAsync(server, remoteTool, arguments, execution, context, cancellationToken), + server.ToolRisk, + ToolExecutionMode.Sequential); + + private AgentTool CreateProxyTool( + GameAgentExtensionApi api, + GameAgentExtensionRunContext context) => + new( + new ToolDefinition( + "external_tools", + "Discover, inspect, or call a configured external tool. Search before calling an unfamiliar path.", + ProxySchema), + async (arguments, execution, cancellationToken) => + { + var action = arguments.GetProperty("action").GetString(); + switch (action) + { + case "search": + return await SearchAsync(api, arguments, cancellationToken).ConfigureAwait(false); + case "describe": + { + var resolved = await ResolveAsync( + api, + arguments.TryGetProperty("path", out var pathElement) ? pathElement.GetString() : null, + cancellationToken).ConfigureAwait(false); + return resolved.Error is not null + ? ToolResult.Error(resolved.Error) + : new ToolResult(new AgentContent[] + { + new JsonContent(JsonSerializer.Serialize(new + { + path = resolved.Path, + server = resolved.Server!.Id, + name = resolved.Tool!.Name, + resolved.Tool.Description, + inputSchema = resolved.Tool.JsonSchema, + risk = resolved.Server.ToolRisk.ToString(), + })), + }); + } + case "call": + { + var resolved = await ResolveAsync( + api, + arguments.TryGetProperty("path", out var pathElement) ? pathElement.GetString() : null, + cancellationToken).ConfigureAwait(false); + if (resolved.Error is not null) + { + return ToolResult.Error(resolved.Error); + } + + var callArguments = arguments.TryGetProperty("arguments", out var value) + ? value + : EmptyObject(); + var validationError = ValidateRemoteArguments(resolved.Tool!, callArguments); + if (validationError is not null) + { + return ToolResult.Error("Invalid external tool arguments: " + validationError); + } + + return await InvokeAsync( + resolved.Server!, + resolved.Tool!, + callArguments, + execution, + context, + cancellationToken).ConfigureAwait(false); + } + default: + return ToolResult.Error("Unsupported external tool action."); + } + }, + ToolRisk.NonIdempotentWrite, + ToolExecutionMode.Sequential); + + private async ValueTask SearchAsync( + GameAgentExtensionApi api, + JsonElement arguments, + CancellationToken cancellationToken) + { + var query = arguments.TryGetProperty("query", out var queryElement) + ? queryElement.GetString() ?? string.Empty + : string.Empty; + var serverFilter = arguments.TryGetProperty("server", out var serverElement) + ? serverElement.GetString() + : null; + var limit = arguments.TryGetProperty("limit", out var limitElement) ? limitElement.GetInt32() : 20; + if (serverFilter is not null && !_states.ContainsKey(serverFilter)) + { + return ToolResult.Error($"External tool server '{serverFilter}' is not configured."); + } + + var matches = new List(); + foreach (var server in _servers.Where(value => serverFilter is null + || string.Equals(value.Id, serverFilter, StringComparison.Ordinal))) + { + var tools = await GetToolsAsync(api, server, cancellationToken).ConfigureAwait(false); + foreach (var tool in tools) + { + if (query.Length > 0 + && tool.Name.IndexOf(query, StringComparison.OrdinalIgnoreCase) < 0 + && (tool.Description?.IndexOf(query, StringComparison.OrdinalIgnoreCase) ?? -1) < 0) + { + continue; + } + + matches.Add(new + { + path = server.ToolPrefix + tool.Name, + server = server.Id, + name = tool.Name, + description = tool.Description ?? string.Empty, + risk = server.ToolRisk.ToString(), + }); + if (matches.Count > limit) + { + break; + } + } + + if (matches.Count > limit) + { + break; + } + } + + var truncated = matches.Count > limit; + var returned = matches.Take(limit).ToArray(); + + return new ToolResult(new AgentContent[] + { + new JsonContent(JsonSerializer.Serialize(new + { + query, + matches = returned, + truncated, + })), + }); + } + + private async ValueTask ResolveAsync( + GameAgentExtensionApi api, + string? path, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(path)) + { + return ResolvedTool.Failed("An external tool path is required."); + } + + foreach (var server in _servers.OrderByDescending(value => value.ToolPrefix.Length)) + { + if (!path.StartsWith(server.ToolPrefix, StringComparison.Ordinal)) + { + continue; + } + + var name = path.Substring(server.ToolPrefix.Length); + var tools = await GetToolsAsync(api, server, cancellationToken).ConfigureAwait(false); + var tool = tools.FirstOrDefault(value => string.Equals(value.Name, name, StringComparison.Ordinal)); + if (tool is not null) + { + return ResolvedTool.Found(path, server, tool); + } + } + + return ResolvedTool.Failed($"External tool '{path}' does not exist."); + } + + private async ValueTask InvokeAsync( + GameMcpServer server, + McpClientTool remoteTool, + JsonElement arguments, + ToolExecutionContext execution, + GameAgentExtensionRunContext context, + CancellationToken cancellationToken) + { + var state = _states[server.Id]; + using var lease = state.LeaseClient(server.Id); + var values = JsonSerializer.Deserialize>(arguments.GetRawText()) + ?? new Dictionary(StringComparer.Ordinal); + var boxed = values.ToDictionary(pair => pair.Key, pair => (object?)pair.Value, StringComparer.Ordinal); + var result = await lease.Client.CallToolAsync( + remoteTool.Name, + boxed, + cancellationToken: cancellationToken).ConfigureAwait(false); + var json = JsonSerializer.Serialize(result); + if (json.Length > _maximumResultCharacters) + { + return ToolResult.Error("The external tool result exceeded the configured result limit."); + } + + if (json.Length <= _maximumInlineResultCharacters) + { + return new ToolResult( + new AgentContent[] { new JsonContent(json) }, + isError: result.IsError is true); + } + + if (_artifactStore is null) + { + return ToolResult.Error( + "The external tool result exceeded the inline limit and no artifact store is configured."); + } + + var artifactId = CreateArtifactId(server, remoteTool, execution, context); + await _artifactStore.PutAsync( + new GameAgentArtifact( + artifactId, + context.Input.SessionId, + context.Input.ActorId, + "application/json", + json, + context.Input.Moment), + cancellationToken).ConfigureAwait(false); + return new ToolResult( + new AgentContent[] + { + new JsonContent(JsonSerializer.Serialize(new + { + artifactId, + mediaType = "application/json", + totalCharacters = json.Length, + readTool = "read_agent_artifact", + })), + }, + isError: result.IsError is true); + } + + private static string? ValidateRemoteArguments(McpClientTool tool, JsonElement arguments) + { + var validator = new AgentTool( + new ToolDefinition("external_validation", "Validate external tool arguments.", tool.JsonSchema.GetRawText()), + (_, _, _) => new ValueTask(ToolResult.Error("Validation-only tool."))); + return validator.ValidateArguments(arguments.GetRawText()); + } + + private static string CreateArtifactId( + GameMcpServer server, + McpClientTool remoteTool, + ToolExecutionContext execution, + 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)); + var encoded = new StringBuilder(bytes.Length * 2 + 4); + encoded.Append("mcp-"); + foreach (var value in bytes) + { + encoded.Append(value.ToString("x2", System.Globalization.CultureInfo.InvariantCulture)); + } + + return encoded.ToString(); + } + + private static JsonElement EmptyObject() + { + using var document = JsonDocument.Parse("{}"); + return document.RootElement.Clone(); + } + + private sealed class ResolvedTool + { + private ResolvedTool(string? path, GameMcpServer? server, McpClientTool? tool, string? error) + { + Path = path; + Server = server; + Tool = tool; + Error = error; + } + + public string? Path { get; } + + public GameMcpServer? Server { get; } + + public McpClientTool? Tool { get; } + + public string? Error { get; } + + public static ResolvedTool Found(string path, GameMcpServer server, McpClientTool tool) => + new(path, server, tool, null); + + public static ResolvedTool Failed(string error) => new(null, null, null, error); + } + + private sealed class ServerState + { + public object Sync { get; } = new(); + + public SemaphoreSlim Gate { get; } = new(1, 1); + + public McpClient? Client { get; set; } + + public IReadOnlyList Tools { get; set; } = Array.Empty(); + + public DateTimeOffset RefreshAfter { get; set; } = DateTimeOffset.MinValue; + + public bool IsDisposing { get; set; } + + private int ActiveCalls { get; set; } + + private TaskCompletionSource? CallsDrained { get; set; } + + public ClientLease LeaseClient(string serverId) + { + lock (Sync) + { + if (IsDisposing || Client is null) + { + throw new ObjectDisposedException($"MCP server '{serverId}' is not available."); + } + + ActiveCalls = checked(ActiveCalls + 1); + return new ClientLease(this, Client); + } + } + + public Task WaitForCallsAsync() + { + lock (Sync) + { + if (ActiveCalls == 0) + { + return Task.CompletedTask; + } + + CallsDrained ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + return CallsDrained.Task; + } + } + + private void ReleaseClient() + { + lock (Sync) + { + ActiveCalls--; + if (ActiveCalls == 0) + { + CallsDrained?.TrySetResult(true); + } + } + } + + public sealed class ClientLease : IDisposable + { + private ServerState? _owner; + + public ClientLease(ServerState owner, McpClient client) + { + _owner = owner; + Client = client; + } + + public McpClient Client { get; } + + public void Dispose() => Interlocked.Exchange(ref _owner, null)?.ReleaseClient(); + } + } +} diff --git a/src/OpenGameAgent.Connectors.Mcp/OpenGameAgent.Connectors.Mcp.csproj b/src/OpenGameAgent.Connectors.Mcp/OpenGameAgent.Connectors.Mcp.csproj new file mode 100644 index 0000000..e74014e --- /dev/null +++ b/src/OpenGameAgent.Connectors.Mcp/OpenGameAgent.Connectors.Mcp.csproj @@ -0,0 +1,13 @@ + + + netstandard2.1 + Lazy searchable Model Context Protocol tool connector for OpenGameAgent, with optional direct exposure. + OpenGameAgent.Connectors.Mcp + + + + + + + + diff --git a/src/OpenGameAgent.Connectors.Mcp/packages.lock.json b/src/OpenGameAgent.Connectors.Mcp/packages.lock.json new file mode 100644 index 0000000..0d84afc --- /dev/null +++ b/src/OpenGameAgent.Connectors.Mcp/packages.lock.json @@ -0,0 +1,167 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": { + "ModelContextProtocol.Core": { + "type": "Direct", + "requested": "[2.1.0, )", + "resolved": "2.1.0", + "contentHash": "cU/urrhRxE4/iSyBIJI7QOaFqSP1FOEnwEHsct9n6t6/XluCAFD9iqnrPkBAsEYr+f/G4tVQ21U+6wN/6fQvOg==", + "dependencies": { + "Microsoft.Bcl.Memory": "10.0.10", + "Microsoft.Extensions.AI.Abstractions": "10.8.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "System.Collections.Immutable": "10.0.10", + "System.Diagnostics.DiagnosticSource": "10.0.10", + "System.IO.Pipelines": "10.0.10", + "System.Net.ServerSentEvents": "10.0.10", + "System.Text.Json": "10.0.10", + "System.Threading.Channels": "10.0.10" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "TFI6OKYE1XZz4SGuTSH70c6SBdPpFktXsoa1gCxTr3mKrhmXirnvaS0tKz+J3ZWICEAmMpEGn59nO4ICtUpQXA==" + }, + "Microsoft.Bcl.Memory": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "/PxGfVdy8P15x9TUyAyWfzR92DcRVRCMs4nMnVS6bldRaDTJJPG9ECJPlF0lbkeZdqwM5eSkOQ214YwvcimAWQ==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "Microsoft.Extensions.AI.Abstractions": { + "type": "Transitive", + "resolved": "10.8.3", + "contentHash": "K0B05oApxmviWalNHPMBBcRC7erKiDATz3ENNR/jqTR9JwIwLRefgDhj2jCRwL1aca99pXUe0qyQC73/xIuZig==", + "dependencies": { + "System.Text.Json": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "System.Buffers": "4.6.1", + "System.Diagnostics.DiagnosticSource": "10.0.10", + "System.Memory": "4.6.3" + } + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "Ih5zrydoDc1H5I0eNP6f4Lzw3cjsPMN4Nikd86kbyi66y0flkM9GfjVo62aUbcxeTbypzBCFAFQ+/6RF2jRORg==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "7WX0W96y3dpQdYG4sEGdh38g3/0lOD4/dKbn2rRVOVzKhzoZUn2gKNIKaFeKWs8RCbpFfmmEWsRhSy95hMpvqA==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==" + }, + "System.Net.ServerSentEvents": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "1m3dGOl5YI9VhOE+MPCSII+WXZcyYVr5D/UbBifOUxkrx2npczhWjdl0PYZ1tMGygVce1mIfUDhdM1LBiEQFNw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "10.0.10", + "System.Memory": "4.6.3", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "o16m2YpDN/pjHsnxf9pTGwkpcuvjW8v1/wGUwJtM1c3QZUKm7ZEO/eYRJg7iIx6GxS2Zv9lAMHpiQwHDdgqauA==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "bmsO6UdYtBdtn32zYXfsh7KlyTIzV/3V9hdT9RIb4pXKgYOsNxXR+VbWigNwBtNFVGYGm6Hwmqw5a+/IWFd36Q==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "10.0.10", + "System.Buffers": "4.6.1", + "System.IO.Pipelines": "10.0.10", + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encodings.Web": "10.0.10", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "System.Threading.Channels": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "O4jTqxreMrNt9vYzPF4jQkbVNjRBrQPye5N9IYMcBpKWbCiKnnCz+I72I4jBGuGbDaadH9Lz5vrelFrvViW9Ow==" + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==" + }, + "opengameagent": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.extensions": { + "type": "Project", + "dependencies": { + "OpenGameAgent": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + } + } + } +} \ No newline at end of file diff --git a/src/OpenGameAgent.Extensions/AgentDelegationExtension.cs b/src/OpenGameAgent.Extensions/AgentDelegationExtension.cs new file mode 100644 index 0000000..d9b77e8 --- /dev/null +++ b/src/OpenGameAgent.Extensions/AgentDelegationExtension.cs @@ -0,0 +1,1192 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Extensions; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum GameAgentDelegationStatus +{ + Pending, + Running, + Completed, + Failed, + Cancelled, +} + +public sealed class GameAgentDelegationRecord +{ + public GameAgentDelegationRecord( + string id, + string sessionId, + string actorId, + long revision, + GameAgentDelegationStatus status, + string taskJson, + int depth, + GameMoment createdAt, + string? resultJson = null, + string? error = null) + { + if (string.IsNullOrWhiteSpace(id) + || string.IsNullOrWhiteSpace(sessionId) + || string.IsNullOrWhiteSpace(actorId)) + { + throw new ArgumentException("Delegation IDs and owners are required."); + } + + if (revision < 0 || depth < 1) + { + throw new ArgumentOutOfRangeException(nameof(revision)); + } + + if (!Enum.IsDefined(typeof(GameAgentDelegationStatus), status)) + { + throw new ArgumentOutOfRangeException(nameof(status)); + } + + Id = id; + SessionId = sessionId; + ActorId = actorId; + Revision = revision; + Status = status; + TaskJson = RequireJson(taskJson, nameof(taskJson)); + Depth = depth; + if (string.IsNullOrWhiteSpace(createdAt.TimelineId)) + { + throw new ArgumentException("A valid creation moment is required.", nameof(createdAt)); + } + + CreatedAt = createdAt; + ResultJson = resultJson is null ? null : RequireJson(resultJson, nameof(resultJson)); + Error = error; + } + + public string Id { get; } + + public string SessionId { get; } + + public string ActorId { get; } + + public long Revision { get; } + + public GameAgentDelegationStatus Status { get; } + + public string TaskJson { get; } + + public int Depth { get; } + + public GameMoment CreatedAt { get; } + + public string? ResultJson { get; } + + public string? Error { get; } + + private static string RequireJson(string value, string name) + { + try + { + using var document = JsonDocument.Parse(value, new JsonDocumentOptions { MaxDepth = 128 }); + return value; + } + catch (JsonException exception) + { + throw new ArgumentException("The value must contain valid JSON.", name, exception); + } + } +} + +public sealed class GameAgentDelegationSaveResult +{ + public GameAgentDelegationSaveResult(bool saved, GameAgentDelegationRecord current) + { + Saved = saved; + Current = current ?? throw new ArgumentNullException(nameof(current)); + } + + public bool Saved { get; } + + public GameAgentDelegationRecord Current { get; } +} + +public interface IGameAgentDelegationStore +{ + ValueTask LoadAsync( + string sessionId, + string actorId, + string id, + CancellationToken cancellationToken); + + ValueTask SaveAsync( + GameAgentDelegationRecord record, + long expectedRevision, + CancellationToken cancellationToken); +} + +public sealed class InMemoryGameAgentDelegationStore : IGameAgentDelegationStore +{ + private readonly object _gate = new(); + private readonly Dictionary<(string SessionId, string ActorId, string Id), GameAgentDelegationRecord> _records = new(); + private readonly int _capacity; + + public InMemoryGameAgentDelegationStore(int capacity = 10_000) + { + if (capacity < 1) + { + throw new ArgumentOutOfRangeException(nameof(capacity)); + } + + _capacity = capacity; + } + + public ValueTask LoadAsync( + string sessionId, + string actorId, + string id, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var key = RequireKey(sessionId, actorId, id); + + lock (_gate) + { + return new ValueTask(_records.TryGetValue(key, out var value) ? value : null); + } + } + + public ValueTask SaveAsync( + GameAgentDelegationRecord record, + long expectedRevision, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (record is null) + { + throw new ArgumentNullException(nameof(record)); + } + + var key = (record.SessionId, record.ActorId, record.Id); + lock (_gate) + { + if (_records.TryGetValue(key, out var current)) + { + if (!string.Equals(current.SessionId, record.SessionId, StringComparison.Ordinal) + || !string.Equals(current.ActorId, record.ActorId, StringComparison.Ordinal) + || !string.Equals(current.TaskJson, record.TaskJson, StringComparison.Ordinal) + || current.Depth != record.Depth + || current.CreatedAt != record.CreatedAt) + { + throw new InvalidOperationException("A delegation record cannot change ownership or task identity."); + } + + if (current.Revision != expectedRevision) + { + return new ValueTask(new GameAgentDelegationSaveResult(false, current)); + } + + if (IsTerminal(current.Status)) + { + throw new InvalidOperationException("A terminal delegation record is immutable."); + } + } + else if (expectedRevision != 0) + { + return new ValueTask(new GameAgentDelegationSaveResult( + false, + new GameAgentDelegationRecord( + record.Id, + record.SessionId, + record.ActorId, + 0, + GameAgentDelegationStatus.Pending, + record.TaskJson, + record.Depth, + record.CreatedAt))); + } + else if (_records.Count >= _capacity) + { + throw new InvalidOperationException("The delegation store reached its capacity."); + } + + if (record.Revision != checked(expectedRevision + 1)) + { + throw new ArgumentException("A delegation revision must advance by exactly one.", nameof(record)); + } + + _records[key] = record; + return new ValueTask(new GameAgentDelegationSaveResult(true, record)); + } + } + + private static (string SessionId, string ActorId, string Id) RequireKey( + string sessionId, + string actorId, + string id) + { + if (string.IsNullOrWhiteSpace(sessionId) + || string.IsNullOrWhiteSpace(actorId) + || string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentException("Delegation IDs and owners are required."); + } + + return (sessionId, actorId, id); + } + + private static bool IsTerminal(GameAgentDelegationStatus status) => + status is GameAgentDelegationStatus.Completed + or GameAgentDelegationStatus.Failed + or GameAgentDelegationStatus.Cancelled; +} + +public sealed class GameAgentDelegateRequest +{ + public GameAgentDelegateRequest( + string id, + GameInput parentInput, + string taskJson, + int depth, + int maximumTurns, + bool inheritContext, + IReadOnlyList parentMessages) + { + Id = string.IsNullOrWhiteSpace(id) + ? throw new ArgumentException("A delegation ID is required.", nameof(id)) + : id; + ParentInput = parentInput ?? throw new ArgumentNullException(nameof(parentInput)); + TaskJson = RequireJson(taskJson); + if (depth < 1) + { + throw new ArgumentOutOfRangeException(nameof(depth)); + } + + if (maximumTurns < 1 || maximumTurns > 10_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumTurns)); + } + + Depth = depth; + MaximumTurns = maximumTurns; + InheritContext = inheritContext; + var messages = (parentMessages ?? throw new ArgumentNullException(nameof(parentMessages))).ToArray(); + if (messages.Any(message => message is null)) + { + throw new ArgumentException("Delegated parent context cannot contain null messages.", nameof(parentMessages)); + } + + ParentMessages = new ReadOnlyCollection(messages); + } + + public string Id { get; } + + public GameInput ParentInput { get; } + + public string TaskJson { get; } + + public int Depth { get; } + + public int MaximumTurns { get; } + + public bool InheritContext { get; } + + public IReadOnlyList ParentMessages { get; } + + private static string RequireJson(string value) + { + try + { + using var document = JsonDocument.Parse(value, new JsonDocumentOptions { MaxDepth = 128 }); + return value; + } + catch (JsonException exception) + { + throw new ArgumentException("The delegated task must contain valid JSON.", nameof(value), exception); + } + } +} + +public sealed class GameAgentDelegateOutcome +{ + public GameAgentDelegateOutcome( + bool succeeded, + IReadOnlyList messages, + string? error = null, + bool cancelled = false) + { + if (succeeded && cancelled) + { + throw new ArgumentException("A delegated operation cannot be both successful and cancelled."); + } + + if (succeeded && error is not null) + { + throw new ArgumentException("A successful delegated operation cannot contain an error.", nameof(error)); + } + + if (!succeeded && !cancelled && string.IsNullOrWhiteSpace(error)) + { + throw new ArgumentException("A failed delegated operation must contain an error.", nameof(error)); + } + + var copy = (messages ?? throw new ArgumentNullException(nameof(messages))).ToArray(); + if (copy.Any(message => message is null)) + { + throw new ArgumentException("A delegated outcome cannot contain null messages.", nameof(messages)); + } + + Succeeded = succeeded; + Messages = Array.AsReadOnly(copy); + Error = error; + Cancelled = cancelled; + } + + public bool Succeeded { get; } + + public IReadOnlyList Messages { get; } + + public string? Error { get; } + + public bool Cancelled { get; } +} + +public interface IGameAgentDelegateHandle : IDisposable +{ + Task Completion { get; } + + bool TrySteer(AgentMessage message); + + bool TryCancel(); +} + +public interface IGameAgentDelegateExecutor +{ + IGameAgentDelegateHandle Start(GameAgentDelegateRequest request, CancellationToken cancellationToken); +} + +public delegate ValueTask> GameDelegateToolProvider( + GameAgentDelegateRequest request, + CancellationToken cancellationToken); + +public sealed class LocalGameAgentDelegateExecutor : IGameAgentDelegateExecutor +{ + private readonly IModelProvider _provider; + private readonly string _model; + private readonly string _instructions; + private readonly GameDelegateToolProvider? _tools; + private readonly AgentLimits _limits; + + public LocalGameAgentDelegateExecutor( + IModelProvider provider, + string model, + string instructions = "Complete the delegated game-agent task and return a concise bounded result.", + GameDelegateToolProvider? tools = null, + AgentLimits? limits = null) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _model = string.IsNullOrWhiteSpace(model) ? throw new ArgumentException("A model is required.", nameof(model)) : model; + _instructions = instructions ?? throw new ArgumentNullException(nameof(instructions)); + _tools = tools; + var configuredLimits = limits ?? new AgentLimits { MaxTurns = 16, MaxMessages = 256, MaxTotalTokens = 256_000 }; + _limits = LocalHandle.CopyLimits(configuredLimits, configuredLimits.MaxTurns); + } + + public IGameAgentDelegateHandle Start(GameAgentDelegateRequest request, CancellationToken cancellationToken) => + new LocalHandle( + _provider, + _model, + _instructions, + _tools, + _limits, + request ?? throw new ArgumentNullException(nameof(request)), + cancellationToken); + + private sealed class LocalHandle : IGameAgentDelegateHandle + { + private readonly CancellationTokenSource _cancellation; + private readonly Agent _agent; + private int _disposed; + + public LocalHandle( + IModelProvider provider, + string model, + string instructions, + GameDelegateToolProvider? tools, + AgentLimits limits, + GameAgentDelegateRequest request, + CancellationToken cancellationToken) + { + _cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var options = new AgentOptions(provider, model) + { + SystemPrompt = instructions, + SessionId = request.Id, + Limits = CopyLimits(limits, request.MaximumTurns), + }; + if (request.InheritContext) + { + var available = Math.Max(0, options.Limits.MaxMessages - 1); + var start = Math.Max(0, request.ParentMessages.Count - available); + while (start < request.ParentMessages.Count + && request.ParentMessages[start].Role == AgentRole.Tool) + { + start++; + } + + for (var index = start; index < request.ParentMessages.Count; index++) + { + options.InitialMessages.Add(request.ParentMessages[index]); + } + } + + _agent = new Agent(options); + Completion = RunAsync(tools, request); + } + + public Task Completion { get; } + + public bool TrySteer(AgentMessage message) => _agent.TrySteer(message); + + public bool TryCancel() + { + if (_cancellation.IsCancellationRequested) + { + return false; + } + + _cancellation.Cancel(); + _agent.TryAbort(); + return true; + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + TryCancel(); + _cancellation.Dispose(); + } + + private async Task RunAsync( + GameDelegateToolProvider? tools, + GameAgentDelegateRequest request) + { + try + { + if (tools is not null) + { + var contributed = await tools(request, _cancellation.Token).ConfigureAwait(false) + ?? throw new InvalidOperationException("The delegate tool provider returned null."); + _agent.SetTools(contributed); + } + + var result = await _agent.RunAsync(AgentMessage.UserJson(request.TaskJson), _cancellation.Token).ConfigureAwait(false); + return new GameAgentDelegateOutcome(result.Succeeded, result.NewMessages, result.Error); + } + catch (OperationCanceledException) when (_cancellation.IsCancellationRequested) + { + return new GameAgentDelegateOutcome( + false, + Array.Empty(), + "The delegated agent was cancelled.", + cancelled: true); + } + catch (Exception exception) + { + return new GameAgentDelegateOutcome(false, Array.Empty(), exception.Message); + } + } + + internal static AgentLimits CopyLimits(AgentLimits source, int maximumTurns) => new() + { + MaxSystemPromptCharacters = source.MaxSystemPromptCharacters, + MaxModelNameCharacters = source.MaxModelNameCharacters, + MaxSessionIdCharacters = source.MaxSessionIdCharacters, + MaxTurns = Math.Min(source.MaxTurns, maximumTurns), + MaxTotalTokens = source.MaxTotalTokens, + MaxMessages = source.MaxMessages, + MaxContentPartsPerMessage = source.MaxContentPartsPerMessage, + MaxTextCharactersPerPart = source.MaxTextCharactersPerPart, + MaxJsonCharactersPerPart = source.MaxJsonCharactersPerPart, + MaxResourceUriCharacters = source.MaxResourceUriCharacters, + MaxToolCallsPerTurn = source.MaxToolCallsPerTurn, + MaxTools = source.MaxTools, + MaxToolNameCharacters = source.MaxToolNameCharacters, + MaxToolCallIdCharacters = source.MaxToolCallIdCharacters, + MaxToolDescriptionCharacters = source.MaxToolDescriptionCharacters, + MaxToolSchemaCharacters = source.MaxToolSchemaCharacters, + MaxMetadataEntriesPerMessage = source.MaxMetadataEntriesPerMessage, + MaxMetadataKeyCharacters = source.MaxMetadataKeyCharacters, + MaxMetadataValueCharacters = source.MaxMetadataValueCharacters, + MaxQueuedMessages = source.MaxQueuedMessages, + MaxConcurrentTools = source.MaxConcurrentTools, + ToolTimeoutMilliseconds = source.ToolTimeoutMilliseconds, + ModelTimeoutMilliseconds = source.ModelTimeoutMilliseconds, + MaxProgressEventsPerTool = source.MaxProgressEventsPerTool, + MaxSubscribers = source.MaxSubscribers, + }; + } +} + +public sealed class AgentDelegationExtension : IGameAgentExtension, IAsyncDisposable +{ + private const string DelegateSchema = """ + {"type":"object","required":["task"],"properties":{"delegationId":{"type":"string","minLength":1,"maxLength":256},"task":{},"background":{"type":"boolean"},"inheritContext":{"type":"boolean"},"maxTurns":{"type":"integer","minimum":1,"maximum":128}},"additionalProperties":false} + """; + private const string IdSchema = """ + {"type":"object","required":["delegationId"],"properties":{"delegationId":{"type":"string","minLength":1,"maxLength":256}},"additionalProperties":false} + """; + private const string SteerSchema = """ + {"type":"object","required":["delegationId","message"],"properties":{"delegationId":{"type":"string","minLength":1,"maxLength":256},"message":{}},"additionalProperties":false} + """; + + private readonly IGameAgentDelegateExecutor _executor; + private readonly IGameAgentDelegationStore _store; + private readonly int _maximumDepth; + private readonly int _maximumResultCharacters; + private readonly TimeSpan _settlementTimeout; + private readonly SemaphoreSlim _concurrency; + private readonly CancellationTokenSource _lifetime = new(); + private readonly ConcurrentDictionary<(string SessionId, string ActorId, string Id), IGameAgentDelegateHandle> _active = new(); + private readonly ConcurrentDictionary<(string SessionId, string ActorId, string Id), Task> _running = new(); + private int _disposed; + private int _resourcesDisposed; + + public AgentDelegationExtension( + IGameAgentDelegateExecutor executor, + IGameAgentDelegationStore? store = null, + int maximumConcurrent = 4, + int maximumDepth = 3, + int maximumResultCharacters = 262_144, + int settlementTimeoutMilliseconds = 10_000) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + _store = store ?? new InMemoryGameAgentDelegationStore(); + if (maximumConcurrent < 1 || maximumConcurrent > 128) + { + throw new ArgumentOutOfRangeException(nameof(maximumConcurrent)); + } + + if (maximumDepth < 1 || maximumDepth > 16) + { + throw new ArgumentOutOfRangeException(nameof(maximumDepth)); + } + + if (maximumResultCharacters < 1_024 || maximumResultCharacters > 10_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumResultCharacters)); + } + + if (settlementTimeoutMilliseconds < 100 || settlementTimeoutMilliseconds > 300_000) + { + throw new ArgumentOutOfRangeException(nameof(settlementTimeoutMilliseconds)); + } + + _concurrency = new SemaphoreSlim(maximumConcurrent, maximumConcurrent); + _maximumDepth = maximumDepth; + _maximumResultCharacters = maximumResultCharacters; + _settlementTimeout = TimeSpan.FromMilliseconds(settlementTimeoutMilliseconds); + } + + public static GameAgentExtensionChannel DelegationChanged { get; } = + new("delegation.changed"); + + public GameAgentExtensionDescriptor Descriptor { get; } = new( + "opengameagent.delegation", + "1.0.0", + "Bounded foreground and background delegated agents with isolated context and durable status records.", + new[] { "delegation", "multi-agent", "background-work", "steering" }); + + public void Configure(GameAgentExtensionApi api) + { + api.RegisterPromptFragment( + "delegation-guidance", + "Delegate only independent or context-heavy subtasks. Give each delegated agent a complete task payload, keep context inheritance opt-in, and retrieve background results by ID."); + api.RegisterToolProvider( + "delegation-tools", + (context, _) => new ValueTask>(new[] + { + CreateDelegateTool(api, context), + CreateGetTool(context), + CreateSteerTool(context), + CreateCancelTool(context), + })); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + var failures = new List(); + try + { + _lifetime.Cancel(); + } + catch (Exception exception) + { + failures.Add(exception); + } + + foreach (var handle in _active.Values) + { + try + { + handle.TryCancel(); + } + catch (Exception exception) + { + failures.Add(exception); + } + } + + var running = _running.Values.ToArray(); + var deferredResourceDisposal = false; + if (running.Length > 0) + { + var completion = Task.WhenAll(running); + using var timeoutCancellation = new CancellationTokenSource(); + var timeout = Task.Delay(_settlementTimeout, timeoutCancellation.Token); + var winner = await Task.WhenAny(completion, timeout).ConfigureAwait(false); + TryCancel(timeoutCancellation); + if (!ReferenceEquals(winner, completion)) + { + deferredResourceDisposal = true; + _ = DisposeResourcesWhenDrainedAsync(completion); + failures.Add(new TimeoutException( + "Delegated agent shutdown exceeded its configured settlement timeout.")); + } + + try + { + if (!deferredResourceDisposal) + { + await completion.ConfigureAwait(false); + } + } + catch + { + failures.AddRange(running + .Where(task => task.IsFaulted && task.Exception is not null) + .SelectMany(task => task.Exception!.Flatten().InnerExceptions)); + } + } + + foreach (var handle in deferredResourceDisposal + ? Array.Empty() + : _active.Values) + { + try + { + handle.Dispose(); + } + catch (Exception exception) + { + failures.Add(exception); + } + } + + if (!deferredResourceDisposal) + { + DisposeResources(); + } + if (failures.Count == 1) + { + throw failures[0]; + } + + if (failures.Count > 1) + { + throw new AggregateException("Delegated agent shutdown encountered one or more failures.", failures); + } + } + + private async Task DisposeResourcesWhenDrainedAsync(Task completion) + { + try + { + await completion.ConfigureAwait(false); + } + catch + { + // Shutdown already reported the failure; resource release must still run. + } + finally + { + DisposeResources(); + } + } + + private void DisposeResources() + { + if (Interlocked.Exchange(ref _resourcesDisposed, 1) != 0) + { + return; + } + + _lifetime.Dispose(); + _concurrency.Dispose(); + } + + private static void TryCancel(CancellationTokenSource cancellation) + { + try + { + cancellation.Cancel(); + } + catch (ObjectDisposedException) + { + } + catch (AggregateException) + { + // A timer cancellation callback cannot replace the shutdown outcome. + } + } + + private AgentTool CreateDelegateTool(GameAgentExtensionApi api, GameAgentExtensionRunContext context) => + new( + new ToolDefinition( + "delegate_agent", + "Run an isolated delegated agent in the foreground or background.", + DelegateSchema), + async (arguments, execution, cancellationToken) => + { + var parentDepth = context.Input.Metadata.TryGetValue("agent.delegate_depth", out var depthValue) + && int.TryParse(depthValue, out var parsedDepth) + ? parsedDepth + : 0; + var depth = checked(parentDepth + 1); + if (depth > _maximumDepth) + { + return ToolResult.Error($"Delegation depth {depth} exceeds the configured maximum {_maximumDepth}."); + } + + var id = arguments.TryGetProperty("delegationId", out var configuredId) + ? configuredId.GetString() ?? string.Empty + : string.Join(":", context.Input.InputId, execution.RunId, execution.Turn, execution.ToolCallIndex); + 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(); + var maximumTurns = arguments.TryGetProperty("maxTurns", out var maxTurnsElement) + ? maxTurnsElement.GetInt32() + : 16; + var key = Key(context, id); + var existing = await _store.LoadAsync( + key.SessionId, + key.ActorId, + key.Id, + cancellationToken).ConfigureAwait(false); + if (existing is not null) + { + EnsureOwner(existing, context); + if (!string.Equals(existing.TaskJson, taskJson, StringComparison.Ordinal) + || existing.Depth != depth) + { + return ToolResult.Error( + $"Delegation ID '{id}' is already reserved for a different task."); + } + + return JsonResult(existing); + } + + var pending = new GameAgentDelegationRecord( + id, + context.Input.SessionId, + context.Input.ActorId, + 1, + GameAgentDelegationStatus.Pending, + taskJson, + depth, + context.Input.Moment); + var saved = await _store.SaveAsync(pending, 0, cancellationToken).ConfigureAwait(false); + if (!saved.Saved) + { + if (!string.Equals(saved.Current.TaskJson, pending.TaskJson, StringComparison.Ordinal) + || saved.Current.Depth != pending.Depth + || saved.Current.CreatedAt != pending.CreatedAt) + { + return ToolResult.Error( + $"Delegation ID '{id}' is already reserved for a different task."); + } + + return JsonResult(saved.Current); + } + + await api.PublishAsync(DelegationChanged, pending, cancellationToken).ConfigureAwait(false); + var request = new GameAgentDelegateRequest( + id, + context.Input, + taskJson, + depth, + maximumTurns, + inheritContext, + context.Session.Messages); + if (background) + { + var task = RunAsync(api, pending, request, _lifetime.Token); + _running[key] = task; + _ = ObserveAsync(key, task); + return JsonResult(new { delegationId = id, status = GameAgentDelegationStatus.Pending, background = true }); + } + + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + var completed = await RunAsync(api, pending, request, linked.Token).ConfigureAwait(false); + return JsonResult(completed); + }, + ToolRisk.IdempotentWrite, + ToolExecutionMode.Sequential); + + private AgentTool CreateGetTool(GameAgentExtensionRunContext context) => + new( + new ToolDefinition("get_delegate_result", "Get delegated agent status or result by ID.", IdSchema), + async (arguments, _, cancellationToken) => + { + var id = arguments.GetProperty("delegationId").GetString() ?? string.Empty; + var key = Key(context, id); + var record = await _store.LoadAsync( + key.SessionId, + key.ActorId, + key.Id, + cancellationToken).ConfigureAwait(false); + if (record is null) + { + return ToolResult.Error($"Delegation '{id}' does not exist."); + } + + return JsonResult(record); + }, + ToolRisk.ReadOnly); + + private AgentTool CreateSteerTool(GameAgentExtensionRunContext context) => + new( + new ToolDefinition("steer_delegate", "Send a bounded JSON message to a running delegated agent.", SteerSchema), + async (arguments, _, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + var id = arguments.GetProperty("delegationId").GetString() ?? string.Empty; + var key = Key(context, id); + var record = await _store.LoadAsync( + key.SessionId, + key.ActorId, + key.Id, + cancellationToken).ConfigureAwait(false); + if (record is null) + { + return ToolResult.Error($"Delegation '{id}' does not exist."); + } + + if (!_active.TryGetValue(key, out var handle)) + { + return ToolResult.Error($"Delegation '{id}' is not currently running."); + } + + var accepted = handle.TrySteer(AgentMessage.UserJson(arguments.GetProperty("message").GetRawText())); + return JsonResult(new { delegationId = id, accepted }); + }, + ToolRisk.NonIdempotentWrite, + ToolExecutionMode.Sequential); + + private AgentTool CreateCancelTool(GameAgentExtensionRunContext context) => + new( + new ToolDefinition("cancel_delegate", "Cancel a running delegated agent by ID.", IdSchema), + async (arguments, _, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + var id = arguments.GetProperty("delegationId").GetString() ?? string.Empty; + var key = Key(context, id); + var record = await _store.LoadAsync( + key.SessionId, + key.ActorId, + key.Id, + cancellationToken).ConfigureAwait(false); + if (record is null) + { + return ToolResult.Error($"Delegation '{id}' does not exist."); + } + + var accepted = _active.TryGetValue(key, out var handle) && handle.TryCancel(); + return JsonResult(new { delegationId = id, accepted }); + }, + ToolRisk.IdempotentWrite, + ToolExecutionMode.Sequential); + + private async Task RunAsync( + GameAgentExtensionApi api, + GameAgentDelegationRecord pending, + GameAgentDelegateRequest request, + CancellationToken cancellationToken) + { + var current = pending; + var key = (pending.SessionId, pending.ActorId, pending.Id); + var concurrencyAcquired = false; + GameAgentDelegationStatus finalStatus; + string? resultJson; + string? error; + try + { + await _concurrency.WaitAsync(cancellationToken).ConfigureAwait(false); + concurrencyAcquired = true; + var running = WithStatus(pending, GameAgentDelegationStatus.Running, pending.Revision + 1); + var runningSave = await _store.SaveAsync(running, pending.Revision, cancellationToken).ConfigureAwait(false); + if (!runningSave.Saved) + { + return runningSave.Current; + } + + current = running; + await api.PublishAsync(DelegationChanged, running, cancellationToken).ConfigureAwait(false); + using var handle = _executor.Start(request, cancellationToken) + ?? throw new InvalidOperationException("The delegate executor returned null."); + if (!_active.TryAdd(key, handle)) + { + throw new InvalidOperationException($"Delegation '{request.Id}' is already active."); + } + + GameAgentDelegateOutcome outcome; + try + { + outcome = await handle.Completion.ConfigureAwait(false) + ?? throw new InvalidOperationException("The delegated agent returned null."); + } + finally + { + _active.TryRemove(key, out _); + } + + var status = outcome.Succeeded + ? GameAgentDelegationStatus.Completed + : outcome.Cancelled || cancellationToken.IsCancellationRequested + ? GameAgentDelegationStatus.Cancelled + : GameAgentDelegationStatus.Failed; + finalStatus = status; + resultJson = SerializeOutcome(outcome.Messages, _maximumResultCharacters); + error = Bound(outcome.Error, _maximumResultCharacters); + } + catch (OperationCanceledException) + { + finalStatus = GameAgentDelegationStatus.Cancelled; + resultJson = null; + error = "The delegation was cancelled before execution completed."; + } + catch (Exception exception) + { + finalStatus = GameAgentDelegationStatus.Failed; + resultJson = null; + error = Bound(exception.Message, _maximumResultCharacters); + } + finally + { + if (concurrencyAcquired) + { + _concurrency.Release(); + } + } + + return await FinishAsync(api, current, finalStatus, resultJson, error).ConfigureAwait(false); + } + + private async Task FinishAsync( + GameAgentExtensionApi api, + GameAgentDelegationRecord current, + GameAgentDelegationStatus status, + string? resultJson, + string? error) + { + if (IsTerminal(current.Status)) + { + return current; + } + + var final = new GameAgentDelegationRecord( + current.Id, + current.SessionId, + current.ActorId, + current.Revision + 1, + status, + current.TaskJson, + current.Depth, + current.CreatedAt, + resultJson, + error); + using var settlement = new CancellationTokenSource(_settlementTimeout); + GameAgentDelegationSaveResult save; + try + { + save = await _store.SaveAsync(final, current.Revision, settlement.Token).ConfigureAwait(false); + } + catch (OperationCanceledException exception) when (settlement.IsCancellationRequested) + { + throw new TimeoutException("Delegation status settlement exceeded its configured timeout.", exception); + } + + var saved = save.Saved ? final : save.Current; + try + { + await api.PublishAsync(DelegationChanged, saved, settlement.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (settlement.IsCancellationRequested) + { + // The durable terminal record is authoritative; a slow observer cannot undo it. + } + + return saved; + } + + private static bool IsTerminal(GameAgentDelegationStatus status) => + status == GameAgentDelegationStatus.Completed + || status == GameAgentDelegationStatus.Failed + || status == GameAgentDelegationStatus.Cancelled; + + private async Task ObserveAsync((string SessionId, string ActorId, string Id) key, Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch + { + // The task remains visible through _running until this observer reaches its finally block. + // Observing the exception prevents an abandoned background fault from surfacing globally. + } + finally + { + _running.TryRemove(key, out _); + } + } + + private static (string SessionId, string ActorId, string Id) Key( + GameAgentExtensionRunContext context, + string id) => + (context.Input.SessionId, context.Input.ActorId, id); + + private static GameAgentDelegationRecord WithStatus( + GameAgentDelegationRecord current, + GameAgentDelegationStatus status, + long revision) => + new( + current.Id, + current.SessionId, + current.ActorId, + revision, + status, + current.TaskJson, + current.Depth, + current.CreatedAt, + current.ResultJson, + current.Error); + + private static void EnsureOwner(GameAgentDelegationRecord record, GameAgentExtensionRunContext context) + { + if (!string.Equals(record.SessionId, context.Input.SessionId, StringComparison.Ordinal) + || !string.Equals(record.ActorId, context.Input.ActorId, StringComparison.Ordinal)) + { + throw new InvalidOperationException("A delegation record belongs to another actor session."); + } + } + + private static string SerializeOutcome(IReadOnlyList messages, int maximumCharacters) + { + var projected = new List(); + var remainingStringBudget = Math.Max(128, maximumCharacters / 8); + var truncated = false; + foreach (var message in messages) + { + if (remainingStringBudget <= 0 || projected.Count >= 512) + { + truncated = true; + break; + } + + var content = new List(); + foreach (var part in message.Content) + { + if (remainingStringBudget <= 0 || content.Count >= 256) + { + truncated = true; + break; + } + + content.Add(ContentValue(part, ref remainingStringBudget)); + } + + projected.Add(new { role = message.Role.ToString(), content }); + } + + string serialized; + do + { + serialized = JsonSerializer.Serialize(new { messages = projected, truncated }); + if (serialized.Length <= maximumCharacters || projected.Count == 0) + { + return serialized; + } + + projected.RemoveAt(projected.Count - 1); + truncated = true; + } + while (true); + } + + private static object ContentValue(AgentContent content, ref int remainingStringBudget) => content switch + { + TextContent text => new { type = "text", text = Take(text.Text, ref remainingStringBudget) }, + JsonContent json => new { type = "json", json = Take(json.Json, ref remainingStringBudget) }, + ReasoningContent => new { type = "reasoning", omitted = true }, + ResourceContent resource => new + { + type = "resource", + uri = Take(resource.Uri, ref remainingStringBudget), + mediaType = Take(resource.MediaType, ref remainingStringBudget), + }, + ToolCallContent call => new + { + type = "toolCall", + id = Take(call.Id, ref remainingStringBudget), + name = Take(call.Name, ref remainingStringBudget), + arguments = Take(call.ArgumentsJson, ref remainingStringBudget), + }, + _ => new { type = "unknown" }, + }; + + private static string Take(string value, ref int remaining) + { + var count = Math.Min(value.Length, remaining); + if (count > 0 && count < value.Length && char.IsHighSurrogate(value[count - 1])) + { + count--; + } + + remaining -= count; + return count == value.Length ? value : value.Substring(0, count); + } + + private static string? Bound(string? value, int maximumCharacters) + { + if (value is null || value.Length <= maximumCharacters) + { + return value; + } + + var length = maximumCharacters; + if (length > 0 && char.IsHighSurrogate(value[length - 1])) + { + length--; + } + + return value.Substring(0, length); + } + + private static ToolResult JsonResult(object value) => + new(new AgentContent[] { new JsonContent(JsonSerializer.Serialize(value)) }); +} diff --git a/src/OpenGameAgent.Extensions/ArtifactExtension.cs b/src/OpenGameAgent.Extensions/ArtifactExtension.cs new file mode 100644 index 0000000..bebd676 --- /dev/null +++ b/src/OpenGameAgent.Extensions/ArtifactExtension.cs @@ -0,0 +1,426 @@ +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; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Extensions; + +public sealed class GameAgentArtifact +{ + public GameAgentArtifact( + string artifactId, + string sessionId, + string actorId, + string mediaType, + string content, + GameMoment createdAt) + { + ArtifactId = Require(artifactId, 512, nameof(artifactId)); + SessionId = Require(sessionId, 1_024, nameof(sessionId)); + ActorId = Require(actorId, 1_024, nameof(actorId)); + MediaType = Require(mediaType, 512, nameof(mediaType)); + Content = content ?? throw new ArgumentNullException(nameof(content)); + if (string.IsNullOrWhiteSpace(createdAt.TimelineId)) + { + throw new ArgumentException("A valid creation moment is required.", nameof(createdAt)); + } + + CreatedAt = createdAt; + } + + public string ArtifactId { get; } + + public string SessionId { get; } + + public string ActorId { get; } + + public string MediaType { get; } + + public string Content { get; } + + public GameMoment CreatedAt { get; } + + private static string Require(string value, int maximumCharacters, string name) => + string.IsNullOrWhiteSpace(value) || value.Length > maximumCharacters + ? throw new ArgumentException($"A value containing at most {maximumCharacters} characters is required.", name) + : value; +} + +public interface IGameAgentArtifactStore +{ + ValueTask PutAsync(GameAgentArtifact artifact, CancellationToken cancellationToken); + + ValueTask GetAsync( + string sessionId, + string actorId, + string artifactId, + CancellationToken cancellationToken); +} + +public sealed class InMemoryGameAgentArtifactStore : IGameAgentArtifactStore +{ + private readonly object _gate = new(); + private readonly Dictionary<(string SessionId, string ActorId, string ArtifactId), GameAgentArtifact> _artifacts = new(); + private readonly int _maximumArtifacts; + private readonly int _maximumArtifactCharacters; + private readonly long _maximumTotalCharacters; + private long _totalCharacters; + + public InMemoryGameAgentArtifactStore( + int maximumArtifacts = 10_000, + int maximumArtifactCharacters = 10_000_000, + long maximumTotalCharacters = 100_000_000) + { + if (maximumArtifacts < 1) + { + throw new ArgumentOutOfRangeException(nameof(maximumArtifacts)); + } + + if (maximumArtifactCharacters < 1_024) + { + throw new ArgumentOutOfRangeException(nameof(maximumArtifactCharacters)); + } + + if (maximumTotalCharacters < maximumArtifactCharacters) + { + throw new ArgumentOutOfRangeException(nameof(maximumTotalCharacters)); + } + + _maximumArtifacts = maximumArtifacts; + _maximumArtifactCharacters = maximumArtifactCharacters; + _maximumTotalCharacters = maximumTotalCharacters; + } + + public ValueTask PutAsync(GameAgentArtifact artifact, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (artifact is null) + { + throw new ArgumentNullException(nameof(artifact)); + } + + if (artifact.Content.Length > _maximumArtifactCharacters) + { + throw new InvalidOperationException("The artifact exceeds the configured size limit."); + } + + var key = (artifact.SessionId, artifact.ActorId, artifact.ArtifactId); + lock (_gate) + { + if (_artifacts.TryGetValue(key, out var existing)) + { + if (!Equivalent(existing, artifact)) + { + throw new InvalidOperationException("An artifact ID cannot be reused for different content."); + } + + return default; + } + + if (_artifacts.Count >= _maximumArtifacts + || artifact.Content.Length > _maximumTotalCharacters - _totalCharacters) + { + throw new InvalidOperationException("The artifact store reached its configured capacity."); + } + + _artifacts.Add(key, artifact); + _totalCharacters += artifact.Content.Length; + } + + return default; + } + + public ValueTask GetAsync( + string sessionId, + string actorId, + string artifactId, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(sessionId) + || string.IsNullOrWhiteSpace(actorId) + || string.IsNullOrWhiteSpace(artifactId)) + { + throw new ArgumentException("Artifact IDs and owners are required."); + } + + lock (_gate) + { + return new ValueTask( + _artifacts.TryGetValue((sessionId, actorId, artifactId), out var artifact) ? artifact : null); + } + } + + private static bool Equivalent(GameAgentArtifact left, GameAgentArtifact right) => + string.Equals(left.ArtifactId, right.ArtifactId, StringComparison.Ordinal) + && string.Equals(left.SessionId, right.SessionId, StringComparison.Ordinal) + && string.Equals(left.ActorId, right.ActorId, StringComparison.Ordinal) + && string.Equals(left.MediaType, right.MediaType, StringComparison.Ordinal) + && string.Equals(left.Content, right.Content, StringComparison.Ordinal) + && left.CreatedAt == right.CreatedAt; +} + +public sealed class GameAgentArtifactExtension : IGameAgentExtension +{ + private const string ReadSchema = """ + {"type":"object","required":["artifactId"],"properties":{"artifactId":{"type":"string","minLength":1,"maxLength":512},"offset":{"type":"integer","minimum":0},"maximumCharacters":{"type":"integer","minimum":256,"maximum":65536}},"additionalProperties":false} + """; + + private readonly IGameAgentArtifactStore _store; + private readonly int _spillToolResultsAboveCharacters; + private readonly int _maximumInlinePreviewCharacters; + + public GameAgentArtifactExtension( + IGameAgentArtifactStore store, + int spillToolResultsAboveCharacters = 65_536, + int maximumInlinePreviewCharacters = 4_096) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + if (spillToolResultsAboveCharacters < 1_024 || spillToolResultsAboveCharacters > 10_000_000) + { + throw new ArgumentOutOfRangeException(nameof(spillToolResultsAboveCharacters)); + } + + if (maximumInlinePreviewCharacters < 0 + || maximumInlinePreviewCharacters > spillToolResultsAboveCharacters) + { + throw new ArgumentOutOfRangeException(nameof(maximumInlinePreviewCharacters)); + } + + _spillToolResultsAboveCharacters = spillToolResultsAboveCharacters; + _maximumInlinePreviewCharacters = maximumInlinePreviewCharacters; + } + + public GameAgentExtensionDescriptor Descriptor { get; } = new( + "opengameagent.artifacts", + "1.0.0", + "Out-of-context storage and bounded reads for large tool or knowledge results.", + new[] { "artifacts", "large-results", "context-control" }); + + public void Configure(GameAgentExtensionApi api) + { + api.RegisterService("artifact-store", _store); + api.RegisterToolProvider( + "artifact-tools", + (context, _) => new ValueTask>(new[] { CreateReadTool(context) })); + api.RegisterAgentHooks( + "large-tool-result-spill", + context => new AgentHooks + { + AfterToolCallAsync = (call, result, _, cancellationToken) => + SpillToolResultAsync(context, call, result, cancellationToken), + }); + } + + private async ValueTask SpillToolResultAsync( + GameAgentExtensionRunContext context, + ToolCallContent call, + ToolResult result, + CancellationToken cancellationToken) + { + if (string.Equals(call.Name, "read_agent_artifact", StringComparison.Ordinal)) + { + return result; + } + + var spillCharacters = result.Content.Sum(content => content switch + { + TextContent text => (long)text.Text.Length, + JsonContent json => json.Json.Length, + _ => 0, + }); + if (spillCharacters <= _spillToolResultsAboveCharacters) + { + return result; + } + + var payload = JsonSerializer.Serialize(new + { + toolName = call.Name, + toolCallId = call.Id, + result.IsError, + result.OutcomeUncertain, + result.DetailsJson, + content = result.Content.Select(SerializeContent), + }); + var artifactId = CreateArtifactId(context, call, payload); + try + { + await _store.PutAsync( + new GameAgentArtifact( + artifactId, + context.Input.SessionId, + context.Input.ActorId, + "application/vnd.opengameagent.tool-result+json", + payload, + context.Input.Moment), + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + // Artifact storage is a context optimization. Preserve the authoritative + // tool result if the optional store is unavailable. + return result; + } + + var preview = CreatePreview(result.Content, _maximumInlinePreviewCharacters); + var replacement = new List + { + new JsonContent(JsonSerializer.Serialize(new + { + artifactId, + toolName = call.Name, + totalCharacters = payload.Length, + preview, + truncated = true, + readTool = "read_agent_artifact", + })), + }; + replacement.AddRange(result.Content.OfType()); + return new ToolResult( + replacement, + result.IsError, + result.DetailsJson, + result.Terminate, + result.Usage, + result.OutcomeUncertain); + } + + private static object SerializeContent(AgentContent content) => content switch + { + TextContent text => new { type = "text", text = (object)text.Text }, + JsonContent json => new { type = "json", text = (object)ParseElement(json.Json) }, + ResourceContent resource => new + { + type = "resource", + text = (object)new { resource.Uri, resource.MediaType, resource.Name }, + }, + _ => throw new InvalidOperationException($"Unsupported tool-result content '{content.GetType().FullName}'."), + }; + + private static JsonElement ParseElement(string json) + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 128 }); + return document.RootElement.Clone(); + } + + private static string CreatePreview(IReadOnlyList content, int maximumCharacters) + { + if (maximumCharacters == 0) + { + return string.Empty; + } + + var preview = new StringBuilder(Math.Min(maximumCharacters, 4_096)); + foreach (var item in content) + { + var value = item switch + { + TextContent text => text.Text, + JsonContent json => json.Json, + _ => null, + }; + if (value is null || preview.Length >= maximumCharacters) + { + continue; + } + + var count = Math.Min(value.Length, maximumCharacters - preview.Length); + count = AvoidSplitSurrogate(value, 0, count); + preview.Append(value, 0, count); + } + + return preview.ToString(); + } + + private static string CreateArtifactId( + GameAgentExtensionRunContext context, + ToolCallContent call, + 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))); + var encoded = new StringBuilder(bytes.Length * 2); + foreach (var value in bytes) + { + encoded.Append(value.ToString("x2", System.Globalization.CultureInfo.InvariantCulture)); + } + + return "tool-result-" + encoded; + } + + private AgentTool CreateReadTool(GameAgentExtensionRunContext context) => + new( + new ToolDefinition( + "read_agent_artifact", + "Read one bounded text chunk from a large result artifact.", + ReadSchema), + async (arguments, _, cancellationToken) => + { + var id = arguments.GetProperty("artifactId").GetString() ?? string.Empty; + var artifact = await _store.GetAsync( + context.Input.SessionId, + context.Input.ActorId, + id, + cancellationToken).ConfigureAwait(false); + if (artifact is null) + { + return ToolResult.Error($"Artifact '{id}' does not exist."); + } + + var offset = arguments.TryGetProperty("offset", out var offsetElement) ? offsetElement.GetInt32() : 0; + if (offset > artifact.Content.Length) + { + return ToolResult.Error("The artifact offset is beyond the end of the content."); + } + + var maximum = arguments.TryGetProperty("maximumCharacters", out var maximumElement) + ? maximumElement.GetInt32() + : 16_384; + var count = Math.Min(maximum, artifact.Content.Length - offset); + count = AvoidSplitSurrogate(artifact.Content, offset, count); + var nextOffset = offset + count; + return new ToolResult(new AgentContent[] + { + new JsonContent(JsonSerializer.Serialize(new + { + artifactId = artifact.ArtifactId, + artifact.MediaType, + offset, + content = artifact.Content.Substring(offset, count), + nextOffset = nextOffset < artifact.Content.Length ? nextOffset : (int?)null, + complete = nextOffset >= artifact.Content.Length, + totalCharacters = artifact.Content.Length, + })), + }); + }, + ToolRisk.ReadOnly); + + private static int AvoidSplitSurrogate(string content, int offset, int count) + { + if (count > 0 + && offset + count < content.Length + && char.IsHighSurrogate(content[offset + count - 1]) + && char.IsLowSurrogate(content[offset + count])) + { + return count - 1; + } + + return count; + } +} diff --git a/src/OpenGameAgent.Extensions/DurableGameWorkflowGraph.cs b/src/OpenGameAgent.Extensions/DurableGameWorkflowGraph.cs new file mode 100644 index 0000000..bbcfae9 --- /dev/null +++ b/src/OpenGameAgent.Extensions/DurableGameWorkflowGraph.cs @@ -0,0 +1,715 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Extensions; + +public enum GameWorkflowNodeStatus +{ + Completed, + Wait, + Failed, +} + +public sealed class GameWorkflowNodeResult +{ + public GameWorkflowNodeResult( + GameWorkflowNodeStatus status, + string outputJson, + IReadOnlyList? messages = null, + string? error = null) + { + if (!Enum.IsDefined(typeof(GameWorkflowNodeStatus), status)) + { + throw new ArgumentOutOfRangeException(nameof(status)); + } + + if (status == GameWorkflowNodeStatus.Failed && string.IsNullOrWhiteSpace(error)) + { + throw new ArgumentException("A failed workflow node requires an error.", nameof(error)); + } + + if (status != GameWorkflowNodeStatus.Failed && error is not null) + { + throw new ArgumentException("Only a failed workflow node can carry an error.", nameof(error)); + } + + OutputJson = RequireJson(outputJson, nameof(outputJson)); + var copied = (messages ?? Array.Empty()).ToArray(); + if (copied.Any(message => message is null)) + { + throw new ArgumentException("Workflow node output cannot contain null messages.", nameof(messages)); + } + + Status = status; + Messages = Array.AsReadOnly(copied); + Error = error; + } + + public GameWorkflowNodeStatus Status { get; } + + public string OutputJson { get; } + + public IReadOnlyList Messages { get; } + + public string? Error { get; } + + public static GameWorkflowNodeResult Complete(string outputJson, params AgentMessage[] messages) => + new(GameWorkflowNodeStatus.Completed, outputJson, messages); + + public static GameWorkflowNodeResult Wait(string outputJson, params AgentMessage[] messages) => + new(GameWorkflowNodeStatus.Wait, outputJson, messages); + + public static GameWorkflowNodeResult Fail(string outputJson, string error, params AgentMessage[] messages) => + new(GameWorkflowNodeStatus.Failed, outputJson, messages, error); + + private static string RequireJson(string value, string parameterName) + { + if (value is null) + { + throw new ArgumentNullException(parameterName); + } + + try + { + return new JsonContent(value).Json; + } + catch (ArgumentException exception) + { + throw new ArgumentException("The value must contain valid JSON.", parameterName, exception); + } + } +} + +public sealed class GameWorkflowNodeContext +{ + internal GameWorkflowNodeContext( + GameWorkflowContext run, + string instanceId, + string nodeId, + string previousOutputJson, + IReadOnlyDictionary dependencyOutputs) + { + Run = run; + InstanceId = instanceId; + NodeId = nodeId; + PreviousOutputJson = previousOutputJson; + DependencyOutputs = dependencyOutputs; + } + + public GameWorkflowContext Run { get; } + + public string InstanceId { get; } + + public string NodeId { get; } + + public string PreviousOutputJson { get; } + + public IReadOnlyDictionary DependencyOutputs { get; } + + public string CreateOperationId(string suffix) + { + if (string.IsNullOrWhiteSpace(suffix)) + { + throw new ArgumentException("An operation suffix is required.", nameof(suffix)); + } + + return string.Join(":", new[] { InstanceId, NodeId, suffix }.Select(Uri.EscapeDataString)); + } +} + +public delegate ValueTask GameWorkflowNodeHandler( + GameWorkflowNodeContext context, + CancellationToken cancellationToken); + +public sealed class GameWorkflowNode +{ + public GameWorkflowNode( + string nodeId, + GameWorkflowNodeHandler handler, + IReadOnlyCollection? dependencies = null) + { + NodeId = RequireId(nodeId, nameof(nodeId)); + Handler = handler ?? throw new ArgumentNullException(nameof(handler)); + var copied = (dependencies ?? Array.Empty()) + .Select(value => RequireId(value, nameof(dependencies))) + .Distinct(StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + if (copied.Contains(NodeId, StringComparer.Ordinal)) + { + throw new ArgumentException("A workflow node cannot depend on itself.", nameof(dependencies)); + } + + Dependencies = Array.AsReadOnly(copied); + } + + public string NodeId { get; } + + public IReadOnlyList Dependencies { get; } + + public GameWorkflowNodeHandler Handler { get; } + + private static string RequireId(string value, string parameterName) => + string.IsNullOrWhiteSpace(value) || value.Length > 512 + ? throw new ArgumentException("A non-empty identifier of at most 512 characters is required.", parameterName) + : value; +} + +/// +/// A checkpointed acyclic workflow whose independent nodes run concurrently and whose +/// outputs are joined through explicit dependencies. Node handlers remain game-owned. +/// +public sealed class DurableGameWorkflowGraph : IGameWorkflow +{ + private static readonly JsonSerializerOptions StateSerializerOptions = new() { MaxDepth = 128 }; + private readonly IReadOnlyList _nodes; + private readonly IReadOnlyDictionary _nodesById; + private readonly IReadOnlyDictionary _nodeOrder; + private readonly IGameWorkflowCheckpointStore _checkpoints; + private readonly int _maximumConcurrentNodes; + private readonly int _maximumNodesPerRun; + private readonly int _maximumStateCharacters; + private readonly string _definition; + + public DurableGameWorkflowGraph( + string name, + IEnumerable nodes, + IGameWorkflowCheckpointStore checkpoints, + int maximumConcurrentNodes = 4, + int maximumNodesPerRun = 256, + int maximumStateCharacters = 1_000_000) + { + Name = string.IsNullOrWhiteSpace(name) || name.Length > 512 + ? throw new ArgumentException("A workflow name of at most 512 characters is required.", nameof(name)) + : name; + var copied = (nodes ?? throw new ArgumentNullException(nameof(nodes))).ToArray(); + if (copied.Length == 0 || copied.Length > 1_024 || copied.Any(node => node is null)) + { + throw new ArgumentException("A workflow graph requires between 1 and 1,024 nodes.", nameof(nodes)); + } + + var duplicate = copied.GroupBy(node => node.NodeId, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new ArgumentException($"Duplicate workflow node ID '{duplicate.Key}'.", nameof(nodes)); + } + + var ids = copied.Select(node => node.NodeId).ToHashSet(StringComparer.Ordinal); + var missing = copied.SelectMany(node => node.Dependencies) + .FirstOrDefault(dependency => !ids.Contains(dependency)); + if (missing is not null) + { + throw new ArgumentException($"Workflow dependency '{missing}' does not exist.", nameof(nodes)); + } + + EnsureAcyclic(copied); + if (maximumConcurrentNodes < 1 || maximumConcurrentNodes > 1_024) + { + throw new ArgumentOutOfRangeException(nameof(maximumConcurrentNodes)); + } + + if (maximumNodesPerRun < 1 || maximumNodesPerRun > 100_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumNodesPerRun)); + } + + if (maximumStateCharacters < 1_024 || maximumStateCharacters > 100_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumStateCharacters)); + } + + _nodes = Array.AsReadOnly(copied); + _nodesById = new ReadOnlyDictionary( + copied.ToDictionary(node => node.NodeId, StringComparer.Ordinal)); + _nodeOrder = new ReadOnlyDictionary(copied + .Select((node, index) => (node.NodeId, index)) + .ToDictionary(value => value.NodeId, value => value.index, StringComparer.Ordinal)); + _checkpoints = checkpoints ?? throw new ArgumentNullException(nameof(checkpoints)); + _maximumConcurrentNodes = Math.Min(maximumConcurrentNodes, copied.Length); + _maximumNodesPerRun = maximumNodesPerRun; + _maximumStateCharacters = maximumStateCharacters; + _definition = ComputeDefinition(copied); + } + + public string Name { get; } + + public async ValueTask RunAsync( + GameWorkflowContext context, + CancellationToken cancellationToken) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + var externalInstance = context.Input.Metadata.TryGetValue("agent.workflow_instance", out var configured) + ? configured + : context.Input.InputId; + var instanceId = string.Join(":", new[] + { + context.Input.SessionId, + context.Input.ActorId, + Name, + externalInstance, + }.Select(Uri.EscapeDataString)); + var checkpoint = await _checkpoints.LoadAsync(instanceId, cancellationToken).ConfigureAwait(false) + ?? new GameWorkflowCheckpoint(instanceId, Name, 0, 0, Serialize(CreateInitialState())); + ValidateCheckpoint(checkpoint, instanceId); + var state = Deserialize(checkpoint.StateJson); + ValidateState(state, checkpoint); + var invocation = checkpoint.Invocation; + if (invocation is { } previousInvocation + && !string.Equals(previousInvocation.InputId, context.Input.InputId, StringComparison.Ordinal) + && !context.Session.ProcessedInputIds.Contains(previousInvocation.InputId, StringComparer.Ordinal)) + { + throw new InvalidOperationException( + $"Workflow input '{previousInvocation.InputId}' has a durable result that must be replayed before another input can continue this instance."); + } + + if (invocation is not null + && string.Equals(invocation.InputId, context.Input.InputId, StringComparison.Ordinal) + && invocation.Complete) + { + var replay = invocation.Messages; + context.ValidateOutput(replay); + return new GameWorkflowResult(replay, invocation.Succeeded, invocation.Error); + } + + if (checkpoint.Completed) + { + return new GameWorkflowResult(Array.Empty(), checkpoint.Error is null, checkpoint.Error); + } + + if (invocation is null + || !string.Equals(invocation.InputId, context.Input.InputId, StringComparison.Ordinal)) + { + invocation = new GameWorkflowInvocationResult( + context.Input.InputId, + Array.Empty(), + complete: false); + } + + var messages = invocation.Messages.ToList(); + context.ValidateOutput(messages); + var waited = new HashSet(StringComparer.Ordinal); + var executed = 0; + while (executed < _maximumNodesPerRun) + { + var ready = _nodes + .Where(node => state.Nodes[node.NodeId].Status == NodeState.Pending) + .Where(node => !waited.Contains(node.NodeId)) + .Where(node => node.Dependencies.All(dependency => + state.Nodes[dependency].Status == NodeState.Completed)) + .Take(Math.Min(_maximumConcurrentNodes, _maximumNodesPerRun - executed)) + .ToArray(); + if (ready.Length == 0) + { + if (state.Nodes.Values.All(node => node.Status == NodeState.Completed)) + { + invocation = CompleteInvocation(context.Input.InputId, messages, succeeded: true, error: null); + checkpoint = await SaveAsync( + checkpoint, + state, + completed: true, + error: null, + invocation: invocation, + cancellationToken: cancellationToken).ConfigureAwait(false); + return new GameWorkflowResult(messages, true); + } + + if (waited.Count > 0) + { + invocation = CompleteInvocation(context.Input.InputId, messages, succeeded: true, error: null); + checkpoint = await SaveAsync( + checkpoint, + state, + completed: false, + error: null, + invocation: invocation, + cancellationToken: cancellationToken).ConfigureAwait(false); + return new GameWorkflowResult(messages, true); + } + + throw new InvalidOperationException("The workflow graph has unfinished nodes but no runnable node."); + } + + var tasks = ready.Select(node => ExecuteAsync(node, state, context, instanceId, cancellationToken)).ToArray(); + var outcomes = await Task.WhenAll(tasks).ConfigureAwait(false); + executed = checked(executed + outcomes.Length); + string? failure = null; + foreach (var outcome in outcomes.OrderBy(value => _nodeOrder[value.Node.NodeId])) + { + messages.AddRange(outcome.Result.Messages); + context.ValidateOutput(messages); + var nodeState = state.Nodes[outcome.Node.NodeId]; + nodeState.OutputJson = outcome.Result.OutputJson; + switch (outcome.Result.Status) + { + case GameWorkflowNodeStatus.Completed: + nodeState.Status = NodeState.Completed; + break; + case GameWorkflowNodeStatus.Wait: + waited.Add(outcome.Node.NodeId); + break; + case GameWorkflowNodeStatus.Failed: + nodeState.Status = NodeState.Failed; + nodeState.Error = BoundError(outcome.Result.Error!); + failure ??= BoundError($"Workflow node '{outcome.Node.NodeId}' failed: {nodeState.Error}"); + break; + default: + throw new InvalidOperationException("Unsupported workflow node status."); + } + } + + var graphCompleted = failure is null + && state.Nodes.Values.All(node => node.Status == NodeState.Completed); + invocation = failure is not null || graphCompleted + ? CompleteInvocation(context.Input.InputId, messages, failure is null, failure) + : new GameWorkflowInvocationResult( + context.Input.InputId, + messages, + complete: false); + + checkpoint = await SaveAsync( + checkpoint, + state, + completed: failure is not null || graphCompleted, + error: failure, + invocation: invocation, + cancellationToken: cancellationToken).ConfigureAwait(false); + if (failure is not null) + { + return new GameWorkflowResult(messages, false, failure); + } + + if (graphCompleted) + { + return new GameWorkflowResult(messages, true); + } + } + + const string limitError = "The workflow graph reached its per-run node limit."; + invocation = CompleteInvocation(context.Input.InputId, messages, succeeded: false, error: limitError); + _ = await SaveAsync( + checkpoint, + state, + completed: false, + error: null, + invocation: invocation, + cancellationToken: cancellationToken).ConfigureAwait(false); + return new GameWorkflowResult(messages, false, limitError); + } + + private async Task ExecuteAsync( + GameWorkflowNode node, + GraphState state, + GameWorkflowContext run, + string instanceId, + CancellationToken cancellationToken) + { + var dependencies = new ReadOnlyDictionary(node.Dependencies.ToDictionary( + dependency => dependency, + dependency => state.Nodes[dependency].OutputJson, + StringComparer.Ordinal)); + try + { + var result = await node.Handler( + new GameWorkflowNodeContext( + run, + instanceId, + node.NodeId, + state.Nodes[node.NodeId].OutputJson, + dependencies), + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Workflow node '{node.NodeId}' returned null."); + return new NodeOutcome(node, result); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + var error = string.IsNullOrWhiteSpace(exception.Message) + ? exception.GetType().Name + : exception.Message; + return new NodeOutcome( + node, + GameWorkflowNodeResult.Fail( + state.Nodes[node.NodeId].OutputJson, + BoundError(error))); + } + } + + private async ValueTask SaveAsync( + GameWorkflowCheckpoint current, + GraphState state, + bool completed, + string? error, + GameWorkflowInvocationResult invocation, + CancellationToken cancellationToken) + { + var stateJson = Serialize(state); + var completedNodes = state.Nodes.Values.Count(node => node.Status == NodeState.Completed); + var next = new GameWorkflowCheckpoint( + current.InstanceId, + Name, + checked(current.Revision + 1), + completedNodes, + stateJson, + completed, + error, + invocation); + var save = await _checkpoints.SaveAsync(next, current.Revision, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The workflow checkpoint store returned null."); + if (!save.Saved) + { + throw new InvalidOperationException("The workflow checkpoint changed concurrently."); + } + + if (!Equivalent(save.Current, next)) + { + throw new InvalidOperationException("The workflow checkpoint store returned a different saved checkpoint."); + } + + return save.Current; + } + + private void ValidateCheckpoint(GameWorkflowCheckpoint checkpoint, string instanceId) + { + if (!string.Equals(checkpoint.InstanceId, instanceId, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The workflow checkpoint store returned a different workflow instance."); + } + + if (!string.Equals(checkpoint.Workflow, Name, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The workflow checkpoint belongs to a different workflow."); + } + + if (checkpoint.NextStep < 0 || checkpoint.NextStep > _nodes.Count) + { + throw new InvalidOperationException("The workflow checkpoint contains an invalid completed-node count."); + } + } + + private void ValidateState(GraphState state, GameWorkflowCheckpoint checkpoint) + { + if (!string.Equals(state.Definition, _definition, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The workflow graph definition changed after this instance was checkpointed."); + } + + if (state.Nodes is null + || state.Nodes.Count != _nodes.Count + || state.Nodes.Keys.Any(id => !_nodesById.ContainsKey(id))) + { + throw new InvalidOperationException("The workflow graph checkpoint contains an invalid node set."); + } + + foreach (var pair in state.Nodes) + { + if (pair.Value is null + || !Enum.IsDefined(typeof(NodeState), pair.Value.Status) + || pair.Value.OutputJson is null + || pair.Value.Error is { Length: > 65_536 } + || pair.Value.Status == NodeState.Failed + || (pair.Value.Status != NodeState.Failed && pair.Value.Error is not null)) + { + throw new InvalidOperationException("The workflow graph checkpoint contains invalid node state."); + } + + _ = GameWorkflowNodeResult.Complete(pair.Value.OutputJson); + if (pair.Value.Status == NodeState.Completed + && _nodesById[pair.Key].Dependencies.Any(dependency => + state.Nodes[dependency].Status != NodeState.Completed)) + { + throw new InvalidOperationException( + "The workflow graph checkpoint completed a node before its dependencies."); + } + } + + var completed = state.Nodes.Values.Count(node => node.Status == NodeState.Completed); + if (completed != checkpoint.NextStep) + { + throw new InvalidOperationException("The workflow graph checkpoint completed-node count is inconsistent."); + } + + } + + private GraphState CreateInitialState() => new() + { + Definition = _definition, + Nodes = _nodes.ToDictionary( + node => node.NodeId, + _ => new NodeStateDocument(), + StringComparer.Ordinal), + }; + + private string Serialize(GraphState state) + { + var json = JsonSerializer.Serialize(state); + if (json.Length > _maximumStateCharacters) + { + throw new InvalidOperationException("The workflow graph state exceeded its configured size limit."); + } + + return json; + } + + private GraphState Deserialize(string json) + { + if (json.Length > _maximumStateCharacters) + { + throw new InvalidOperationException("The workflow graph checkpoint exceeded its configured size limit."); + } + + try + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 128 }); + EnsureUnambiguous(document.RootElement); + return JsonSerializer.Deserialize( + document.RootElement.GetRawText(), + StateSerializerOptions) + ?? throw new InvalidOperationException("The workflow graph checkpoint is empty."); + } + catch (JsonException exception) + { + throw new InvalidOperationException("The workflow graph checkpoint is invalid.", exception); + } + } + + private static void EnsureAcyclic(IReadOnlyList nodes) + { + var remaining = nodes.ToDictionary(node => node.NodeId, node => node.Dependencies.Count, StringComparer.Ordinal); + var dependents = nodes.SelectMany(node => node.Dependencies.Select(dependency => (dependency, node.NodeId))) + .GroupBy(value => value.dependency, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Select(value => value.NodeId).ToArray(), StringComparer.Ordinal); + var ready = new Queue(remaining.Where(pair => pair.Value == 0).Select(pair => pair.Key)); + var visited = 0; + while (ready.Count > 0) + { + var current = ready.Dequeue(); + visited++; + if (!dependents.TryGetValue(current, out var next)) + { + continue; + } + + foreach (var dependent in next) + { + remaining[dependent]--; + if (remaining[dependent] == 0) + { + ready.Enqueue(dependent); + } + } + } + + if (visited != nodes.Count) + { + throw new ArgumentException("The workflow graph cannot contain dependency cycles.", nameof(nodes)); + } + } + + private static string ComputeDefinition(IReadOnlyList nodes) + { + var canonical = JsonSerializer.Serialize(nodes.Select(node => new + { + node.NodeId, + node.Dependencies, + })); + using var hash = SHA256.Create(); + return string.Concat(hash.ComputeHash(Encoding.UTF8.GetBytes(canonical)) + .Select(value => value.ToString("x2", System.Globalization.CultureInfo.InvariantCulture))); + } + + private static string BoundError(string value) => + value.Length <= 65_536 ? value : value.Substring(0, 65_536); + + private static GameWorkflowInvocationResult CompleteInvocation( + string inputId, + IReadOnlyList messages, + bool succeeded, + string? error) => + new(inputId, messages, complete: true, succeeded, error); + + private static void EnsureUnambiguous(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in value.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new InvalidOperationException("The workflow graph checkpoint contains duplicate JSON properties."); + } + + EnsureUnambiguous(property.Value); + } + } + else if (value.ValueKind == JsonValueKind.Array) + { + foreach (var item in value.EnumerateArray()) + { + EnsureUnambiguous(item); + } + } + } + + private static bool Equivalent(GameWorkflowCheckpoint left, GameWorkflowCheckpoint right) => + string.Equals(left.InstanceId, right.InstanceId, StringComparison.Ordinal) + && string.Equals(left.Workflow, right.Workflow, StringComparison.Ordinal) + && left.Revision == right.Revision + && left.NextStep == right.NextStep + && string.Equals(left.StateJson, right.StateJson, StringComparison.Ordinal) + && left.Completed == right.Completed + && string.Equals(left.Error, right.Error, StringComparison.Ordinal) + && GameAgentValueComparer.WorkflowInvocationEquals(left.Invocation, right.Invocation); + + private enum NodeState + { + Pending, + Completed, + Failed, + } + + private sealed class GraphState + { + public string Definition { get; set; } = string.Empty; + + public Dictionary Nodes { get; set; } = new(StringComparer.Ordinal); + } + + private sealed class NodeStateDocument + { + public NodeState Status { get; set; } + + public string OutputJson { get; set; } = "{}"; + + public string? Error { get; set; } + } + + private sealed class NodeOutcome + { + public NodeOutcome(GameWorkflowNode node, GameWorkflowNodeResult result) + { + Node = node; + Result = result; + } + + public GameWorkflowNode Node { get; } + + public GameWorkflowNodeResult Result { get; } + } +} diff --git a/src/OpenGameAgent.Extensions/ExternalKnowledgeExtension.cs b/src/OpenGameAgent.Extensions/ExternalKnowledgeExtension.cs new file mode 100644 index 0000000..47df2ee --- /dev/null +++ b/src/OpenGameAgent.Extensions/ExternalKnowledgeExtension.cs @@ -0,0 +1,518 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Extensions; + +public sealed class GameExternalKnowledgeRequest +{ + public GameExternalKnowledgeRequest(GameInput input, string queryJson, int limit) + { + Input = input ?? throw new ArgumentNullException(nameof(input)); + QueryJson = RequireJson(queryJson); + if (limit < 1 || limit > 64) + { + throw new ArgumentOutOfRangeException(nameof(limit)); + } + + Limit = limit; + } + + public GameInput Input { get; } + + public string QueryJson { get; } + + public int Limit { get; } + + private static string RequireJson(string value) + { + using var document = JsonDocument.Parse(value, new JsonDocumentOptions { MaxDepth = 128 }); + return value; + } +} + +public sealed class GameExternalKnowledgeItem +{ + public GameExternalKnowledgeItem( + string id, + string title, + string payloadJson, + string? summary = null, + string? uri = null, + IReadOnlyDictionary? metadata = null) + { + Id = Require(id, nameof(id), 512); + Title = Require(title, nameof(title), 4_096); + if (payloadJson is null || payloadJson.Length > 10_000_000) + { + throw new ArgumentException("A knowledge payload cannot exceed 10000000 characters.", nameof(payloadJson)); + } + + using (var document = JsonDocument.Parse(payloadJson, new JsonDocumentOptions { MaxDepth = 128 })) + { + PayloadJson = payloadJson; + } + + if (summary?.Length > 65_536) + { + throw new ArgumentException("A knowledge summary cannot exceed 65536 characters.", nameof(summary)); + } + + Summary = summary; + if (uri is not null && (!System.Uri.TryCreate(uri, UriKind.Absolute, out _) || uri.Length > 16_384)) + { + throw new ArgumentException("A knowledge URI must be an absolute bounded URI.", nameof(uri)); + } + + Uri = uri; + var copied = new Dictionary(metadata ?? new Dictionary(), StringComparer.Ordinal); + if (copied.Any(pair => string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)) + { + throw new ArgumentException("Knowledge metadata keys and values must be non-null.", nameof(metadata)); + } + + if (copied.Count > 128 + || copied.Any(pair => pair.Key.Length > 256 || pair.Value.Length > 16_384)) + { + throw new ArgumentException("Knowledge metadata exceeds its configured field limits.", nameof(metadata)); + } + + Metadata = new ReadOnlyDictionary(copied); + } + + public string Id { get; } + + public string Title { get; } + + public string PayloadJson { get; } + + public string? Summary { get; } + + public string? Uri { get; } + + public IReadOnlyDictionary Metadata { get; } + + private static string Require(string value, string name, int maximum) => + string.IsNullOrWhiteSpace(value) || value.Length > maximum + ? throw new ArgumentException($"A value with at most {maximum} characters is required.", name) + : value; +} + +public interface IGameExternalKnowledgeSource +{ + string Id { get; } + + ValueTask> QueryAsync( + GameExternalKnowledgeRequest request, + CancellationToken cancellationToken); +} + +public sealed class ExternalKnowledgeExtension : IGameAgentExtension +{ + private readonly IReadOnlyDictionary _sources; + private readonly int _maximumInlineResultCharacters; + private readonly int _maximumResultCharacters; + private readonly IGameAgentArtifactStore? _artifactStore; + private readonly string _schema; + + public ExternalKnowledgeExtension( + IReadOnlyList sources, + int maximumInlineResultCharacters = 262_144, + IGameAgentArtifactStore? artifactStore = null, + int maximumResultCharacters = 10_000_000) + { + var copied = (sources ?? throw new ArgumentNullException(nameof(sources))).ToArray(); + if (copied.Length == 0 || copied.Any(source => source is null || string.IsNullOrWhiteSpace(source.Id))) + { + throw new ArgumentException("At least one source with a valid ID is required.", nameof(sources)); + } + + var duplicate = copied.GroupBy(source => source.Id, StringComparer.Ordinal).FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new ArgumentException($"Knowledge source '{duplicate.Key}' is duplicated.", nameof(sources)); + } + + if (maximumInlineResultCharacters < 1_024 || maximumInlineResultCharacters > 10_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumInlineResultCharacters)); + } + + if (maximumResultCharacters < maximumInlineResultCharacters || maximumResultCharacters > 100_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumResultCharacters)); + } + + _sources = new ReadOnlyDictionary( + copied.ToDictionary(source => source.Id, StringComparer.Ordinal)); + _maximumInlineResultCharacters = maximumInlineResultCharacters; + _maximumResultCharacters = maximumResultCharacters; + _artifactStore = artifactStore; + _schema = JsonSerializer.Serialize(new + { + type = "object", + required = new[] { "source", "query" }, + properties = new + { + source = new { type = "string", @enum = copied.Select(source => source.Id).OrderBy(value => value, StringComparer.Ordinal) }, + query = new { }, + limit = new { type = "integer", minimum = 1, maximum = 64 }, + }, + additionalProperties = false, + }); + } + + public GameAgentExtensionDescriptor Descriptor { get; } = new( + "opengameagent.external-knowledge", + "1.0.0", + "Bounded queries to developer-configured local or remote knowledge sources.", + new[] { "knowledge", "local-data", "remote-data", "large-results" }); + + public void Configure(GameAgentExtensionApi api) => + api.RegisterToolProvider( + "external-knowledge-tools", + (context, _) => new ValueTask>(new[] { CreateTool(context) })); + + private AgentTool CreateTool(GameAgentExtensionRunContext context) => + new( + new ToolDefinition( + "query_external_knowledge", + "Query one developer-configured local or remote knowledge source. URLs are never chosen by the model.", + _schema), + async (arguments, execution, cancellationToken) => + { + var sourceId = arguments.GetProperty("source").GetString() ?? string.Empty; + if (!_sources.TryGetValue(sourceId, out var source)) + { + return ToolResult.Error($"Knowledge source '{sourceId}' is not configured."); + } + + var request = new GameExternalKnowledgeRequest( + context.Input, + arguments.GetProperty("query").GetRawText(), + arguments.TryGetProperty("limit", out var limit) ? limit.GetInt32() : 8); + var items = await source.QueryAsync(request, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Knowledge source '{sourceId}' returned null."); + if (items.Count > request.Limit || items.Any(item => item is null)) + { + throw new InvalidOperationException($"Knowledge source '{sourceId}' returned an invalid result set."); + } + + var duplicate = items.GroupBy(item => item.Id, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new InvalidOperationException( + $"Knowledge source '{sourceId}' returned duplicate item ID '{duplicate.Key}'."); + } + + var rawCharacters = items.Sum(EstimateCharacters); + if (rawCharacters > _maximumResultCharacters) + { + throw new InvalidOperationException( + $"Knowledge source '{sourceId}' exceeded the configured result limit."); + } + + var json = Serialize(sourceId, items); + if (json.Length > _maximumResultCharacters) + { + throw new InvalidOperationException( + $"Knowledge source '{sourceId}' exceeded the configured serialized result limit."); + } + if (json.Length <= _maximumInlineResultCharacters) + { + return new ToolResult(new AgentContent[] { new JsonContent(json) }); + } + + if (_artifactStore is null) + { + return ToolResult.Error( + "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); + await _artifactStore.PutAsync( + new GameAgentArtifact( + artifactId, + context.Input.SessionId, + context.Input.ActorId, + "application/json", + json, + context.Input.Moment), + cancellationToken).ConfigureAwait(false); + return new ToolResult(new AgentContent[] + { + new JsonContent(JsonSerializer.Serialize(new + { + artifactId, + mediaType = "application/json", + totalCharacters = json.Length, + readTool = "read_agent_artifact", + })), + }); + }, + ToolRisk.ReadOnly); + + private static string Serialize(string source, IReadOnlyList items) => + JsonSerializer.Serialize(new + { + source, + items = items.Select(item => new + { + item.Id, + item.Title, + item.Summary, + item.Uri, + payload = Parse(item.PayloadJson), + item.Metadata, + }), + }); + + private static JsonElement Parse(string json) + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 128 }); + return document.RootElement.Clone(); + } + + private static long EstimateCharacters(GameExternalKnowledgeItem item) => + (long)item.Id.Length + + item.Title.Length + + item.PayloadJson.Length + + (item.Summary?.Length ?? 0) + + (item.Uri?.Length ?? 0) + + item.Metadata.Sum(pair => (long)pair.Key.Length + pair.Value.Length); +} + +public delegate ValueTask> GameKnowledgeHeaderProvider( + GameExternalKnowledgeRequest request, + CancellationToken cancellationToken); + +public sealed class JsonHttpGameKnowledgeSource : IGameExternalKnowledgeSource +{ + private readonly HttpClient _client; + private readonly Uri _endpoint; + private readonly GameKnowledgeHeaderProvider? _headers; + private readonly int _maximumResponseBytes; + private readonly bool _includeInputPayload; + + public JsonHttpGameKnowledgeSource( + string id, + HttpClient client, + Uri endpoint, + GameKnowledgeHeaderProvider? headers = null, + int maximumResponseBytes = 4_000_000, + bool includeInputPayload = false, + bool allowInsecureHttp = false) + { + Id = string.IsNullOrWhiteSpace(id) || id.Length > 512 + ? throw new ArgumentException("A source ID of at most 512 characters is required.", nameof(id)) + : id; + _client = client ?? throw new ArgumentNullException(nameof(client)); + _endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint)); + if (!_endpoint.IsAbsoluteUri + || _endpoint.UserInfo.Length > 0 + || (_endpoint.Scheme != Uri.UriSchemeHttps + && !(allowInsecureHttp && _endpoint.Scheme == Uri.UriSchemeHttp))) + { + throw new ArgumentException( + "An absolute HTTPS endpoint is required unless insecure HTTP is explicitly enabled.", + nameof(endpoint)); + } + + if (maximumResponseBytes < 1_024 || maximumResponseBytes > 100_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumResponseBytes)); + } + + _headers = headers; + _maximumResponseBytes = maximumResponseBytes; + _includeInputPayload = includeInputPayload; + } + + public string Id { get; } + + public async ValueTask> QueryAsync( + GameExternalKnowledgeRequest request, + CancellationToken cancellationToken) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + var body = JsonSerializer.Serialize(new + { + query = Parse(request.QueryJson), + request.Limit, + game = new + { + request.Input.Type, + payload = _includeInputPayload ? Parse(request.Input.PayloadJson) : (JsonElement?)null, + request.Input.Moment.TimelineId, + request.Input.Moment.Tick, + }, + }); + using var message = new HttpRequestMessage(HttpMethod.Post, _endpoint) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + if (_headers is not null) + { + var headers = await _headers(request, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The knowledge header provider returned null."); + if (headers.Count > 64) + { + throw new InvalidOperationException("The knowledge header provider returned too many headers."); + } + + foreach (var header in new List>(headers)) + { + if (string.IsNullOrWhiteSpace(header.Key) + || header.Key.Length > 256 + || header.Key.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0 + || header.Value is null + || header.Value.Length > 65_536 + || header.Value.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0) + { + throw new InvalidOperationException("The knowledge header provider returned an invalid header."); + } + + if (!message.Headers.TryAddWithoutValidation(header.Key, header.Value)) + { + throw new InvalidOperationException($"Knowledge header '{header.Key}' is invalid."); + } + } + } + + using var response = await _client.SendAsync( + message, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + if (response.Content.Headers.ContentLength is { } length && length > _maximumResponseBytes) + { + throw new InvalidOperationException("The knowledge response exceeded the configured size limit."); + } + + var bytes = await ReadBoundedAsync(response.Content, _maximumResponseBytes, cancellationToken).ConfigureAwait(false); + var text = Encoding.UTF8.GetString(bytes); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"Knowledge source '{Id}' returned HTTP {(int)response.StatusCode}."); + } + + using var document = JsonDocument.Parse(text, new JsonDocumentOptions { MaxDepth = 128 }); + EnsureUnambiguous(document.RootElement); + if (!document.RootElement.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException("The knowledge response must contain an items array."); + } + + var result = new List(); + foreach (var item in items.EnumerateArray()) + { + if (result.Count >= request.Limit) + { + throw new InvalidOperationException("The knowledge response exceeded the requested item limit."); + } + + result.Add(new GameExternalKnowledgeItem( + item.GetProperty("id").GetString() ?? string.Empty, + item.GetProperty("title").GetString() ?? string.Empty, + item.GetProperty("payload").GetRawText(), + item.TryGetProperty("summary", out var summary) ? summary.GetString() : null, + item.TryGetProperty("uri", out var uri) ? uri.GetString() : null, + ReadMetadata(item))); + } + + return Array.AsReadOnly(result.ToArray()); + } + + private static IReadOnlyDictionary ReadMetadata(JsonElement item) + { + if (!item.TryGetProperty("metadata", out var metadata)) + { + return new Dictionary(); + } + + if (metadata.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException("Knowledge metadata must be a string object."); + } + + return metadata.EnumerateObject().ToDictionary( + property => property.Name, + property => property.Value.GetString() + ?? throw new InvalidOperationException("Knowledge metadata values must be strings."), + StringComparer.Ordinal); + } + + private static async Task ReadBoundedAsync( + HttpContent content, + int maximumBytes, + CancellationToken cancellationToken) + { + using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); + using var output = new MemoryStream(); + var buffer = new byte[16_384]; + while (true) + { + var read = await stream.ReadAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false); + if (read == 0) + { + return output.ToArray(); + } + + if (output.Length + read > maximumBytes) + { + throw new InvalidOperationException("The knowledge response exceeded the configured size limit."); + } + + output.Write(buffer, 0, read); + } + } + + private static JsonElement Parse(string json) + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 128 }); + return document.RootElement.Clone(); + } + + private static void EnsureUnambiguous(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in value.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new InvalidOperationException("The knowledge response contains duplicate JSON properties."); + } + + EnsureUnambiguous(property.Value); + } + } + else if (value.ValueKind == JsonValueKind.Array) + { + foreach (var item in value.EnumerateArray()) + { + EnsureUnambiguous(item); + } + } + } +} diff --git a/src/OpenGameAgent.Extensions/GameMemoryExtension.cs b/src/OpenGameAgent.Extensions/GameMemoryExtension.cs new file mode 100644 index 0000000..7aaf3b9 --- /dev/null +++ b/src/OpenGameAgent.Extensions/GameMemoryExtension.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Extensions; + +public delegate ValueTask GameMemoryRecallQueryFactory( + GameAgentExtensionRunContext context, + CancellationToken cancellationToken); + +public sealed class GameMemoryExtension : IGameAgentExtension +{ + private const string RememberSchema = """ + {"type":"object","required":["scope","kind","payload"],"properties":{"memoryId":{"type":"string","minLength":1,"maxLength":512},"scope":{"type":"string","minLength":1,"maxLength":512},"kind":{"type":"string","enum":["event","fact","relationship","goal","reflection","procedure"]},"payload":{},"importance":{"type":"number","minimum":0,"maximum":1},"searchableText":{"type":"string","maxLength":65536},"tags":{"type":"array","maxItems":64,"items":{"type":"string","minLength":1,"maxLength":512},"uniqueItems":true},"expiresAtTick":{"type":"integer"}},"additionalProperties":false} + """; + private const string SearchSchema = """ + {"type":"object","properties":{"ownerId":{"type":"string","minLength":1,"maxLength":512},"scopes":{"type":"array","maxItems":64,"items":{"type":"string","minLength":1,"maxLength":512},"uniqueItems":true},"kinds":{"type":"array","maxItems":6,"items":{"type":"string","enum":["event","fact","relationship","goal","reflection","procedure"]},"uniqueItems":true},"tags":{"type":"array","maxItems":64,"items":{"type":"string","minLength":1,"maxLength":512},"uniqueItems":true},"text":{"type":"string","maxLength":65536},"limit":{"type":"integer","minimum":1,"maximum":64},"atOrBeforeTick":{"type":"integer"},"minimumImportance":{"type":"number","minimum":0,"maximum":1}},"additionalProperties":false} + """; + + private readonly IGameMemoryStore _store; + private readonly GameMemoryRecallQueryFactory? _recall; + private readonly bool _allowCrossActorSearch; + private readonly int _maximumResultCharacters; + + public GameMemoryExtension( + IGameMemoryStore store, + GameMemoryRecallQueryFactory? recall = null, + bool allowCrossActorSearch = false, + int maximumResultCharacters = 262_144) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _recall = recall; + _allowCrossActorSearch = allowCrossActorSearch; + if (maximumResultCharacters < 1_024 || maximumResultCharacters > 10_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumResultCharacters)); + } + + _maximumResultCharacters = maximumResultCharacters; + } + + public static GameAgentExtensionChannel MemoryAppended { get; } = new("memory.appended"); + + public GameAgentExtensionDescriptor Descriptor { get; } = new( + "opengameagent.memory", + "1.0.0", + "Game-time memory append, search, and optional bounded recall.", + new[] { "memory", "game-time", "search", "context-recall" }); + + public void Configure(GameAgentExtensionApi api) + { + api.RegisterToolProvider( + "memory-tools", + (context, _) => new ValueTask>(new[] + { + CreateRememberTool(api, context), + CreateSearchTool(context), + })); + if (_recall is not null) + { + api.RegisterContextProvider("memory-recall", RecallAsync, priority: 20); + } + } + + private AgentTool CreateRememberTool(GameAgentExtensionApi api, GameAgentExtensionRunContext context) => + new( + new ToolDefinition( + "remember_game_memory", + "Persist an actor memory on the current game timeline. Game state must be stored by game tools, not in memory.", + RememberSchema), + async (arguments, execution, cancellationToken) => + { + var id = arguments.TryGetProperty("memoryId", out var configuredId) + ? configuredId.GetString() ?? string.Empty + : string.Join(":", context.Input.InputId, execution.RunId, execution.Turn, execution.ToolCallIndex); + var expiresAt = arguments.TryGetProperty("expiresAtTick", out var expiry) + ? new GameMoment(context.Input.Moment.TimelineId, expiry.GetInt64()) + : (GameMoment?)null; + var memory = new GameMemory( + id, + context.Input.SessionId, + context.Input.ActorId, + arguments.GetProperty("scope").GetString() ?? string.Empty, + ParseKind(arguments.GetProperty("kind").GetString()), + arguments.GetProperty("payload").GetRawText(), + context.Input.Moment, + arguments.TryGetProperty("importance", out var importance) ? importance.GetDouble() : 0.5, + arguments.TryGetProperty("searchableText", out var searchableText) ? searchableText.GetString() : null, + ReadStrings(arguments, "tags"), + context.Input.InputId, + expiresAt); + await _store.AppendAsync(memory, cancellationToken).ConfigureAwait(false); + await api.PublishAsync(MemoryAppended, memory, cancellationToken).ConfigureAwait(false); + return JsonResult(new + { + memoryId = memory.MemoryId, + ownerId = memory.OwnerId, + timelineId = memory.Moment.TimelineId, + tick = memory.Moment.Tick, + }); + }, + ToolRisk.IdempotentWrite, + ToolExecutionMode.Sequential); + + private AgentTool CreateSearchTool(GameAgentExtensionRunContext context) => + new( + new ToolDefinition( + "search_game_memory", + "Search visible memories at a point on the game timeline.", + SearchSchema), + async (arguments, _, cancellationToken) => + { + var requestedOwner = arguments.TryGetProperty("ownerId", out var owner) + ? owner.GetString() + : null; + if (!_allowCrossActorSearch + && requestedOwner is not null + && !string.Equals(requestedOwner, context.Input.ActorId, StringComparison.Ordinal)) + { + return ToolResult.Error("Cross-actor memory search is disabled."); + } + + var moment = new GameMoment( + context.Input.Moment.TimelineId, + arguments.TryGetProperty("atOrBeforeTick", out var tick) + ? tick.GetInt64() + : context.Input.Moment.Tick); + if (moment.Tick > context.Input.Moment.Tick) + { + return ToolResult.Error("Memory search cannot read from the future of the current game timeline."); + } + + var query = new GameMemoryQuery( + context.Input.SessionId, + arguments.TryGetProperty("limit", out var limit) ? limit.GetInt32() : 8, + requestedOwner ?? context.Input.ActorId, + ReadStrings(arguments, "scopes"), + ReadKinds(arguments, "kinds"), + ReadStrings(arguments, "tags"), + arguments.TryGetProperty("text", out var text) ? text.GetString() : null, + moment, + arguments.TryGetProperty("minimumImportance", out var minimum) ? minimum.GetDouble() : 0); + var memories = await _store.SearchAsync(query, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The memory store returned null."); + ValidateResults(query, memories); + return SerializeMemories(memories, query.Limit); + }, + ToolRisk.ReadOnly); + + private async ValueTask> RecallAsync( + GameAgentExtensionRunContext context, + CancellationToken cancellationToken) + { + var query = await _recall!(context, cancellationToken).ConfigureAwait(false); + if (query is null || query.Limit == 0) + { + return Array.Empty(); + } + + if (!string.Equals(query.SessionId, context.Input.SessionId, StringComparison.Ordinal)) + { + throw new InvalidOperationException("A memory recall query cannot target another game session."); + } + + if (!_allowCrossActorSearch + && query.OwnerId is not null + && !string.Equals(query.OwnerId, context.Input.ActorId, StringComparison.Ordinal)) + { + throw new InvalidOperationException("A memory recall query cannot target another actor."); + } + + if (query.AtOrBefore is { } moment + && (!string.Equals(moment.TimelineId, context.Input.Moment.TimelineId, StringComparison.Ordinal) + || moment.Tick > context.Input.Moment.Tick)) + { + throw new InvalidOperationException("A memory recall query cannot read from another timeline or the future."); + } + + var effectiveQuery = new GameMemoryQuery( + query.SessionId, + query.Limit, + query.OwnerId ?? (_allowCrossActorSearch ? null : context.Input.ActorId), + query.Scopes, + query.Kinds, + query.Tags, + query.Text, + query.AtOrBefore ?? context.Input.Moment, + query.MinimumImportance); + var memories = await _store.SearchAsync(effectiveQuery, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The memory store returned null."); + ValidateResults(effectiveQuery, memories); + var json = SerializeMemoryJson(memories, effectiveQuery.Limit); + return new[] { new GameContextSlice("memory", json, priority: 20, version: context.Input.Moment.Tick.ToString()) }; + } + + private ToolResult SerializeMemories(IReadOnlyList memories, int requestedLimit) => + new(new AgentContent[] { new JsonContent(SerializeMemoryJson(memories, requestedLimit)) }); + + private string SerializeMemoryJson(IReadOnlyList memories, int requestedLimit) + { + var accepted = new List(); + foreach (var memory in memories.Take(requestedLimit)) + { + accepted.Add(memory); + var candidate = Serialize(accepted, truncated: accepted.Count < memories.Count); + if (candidate.Length <= _maximumResultCharacters) + { + continue; + } + + accepted.RemoveAt(accepted.Count - 1); + break; + } + + var json = Serialize(accepted, truncated: accepted.Count < Math.Min(memories.Count, requestedLimit)); + if (json.Length > _maximumResultCharacters) + { + return "{\"memories\":[],\"truncated\":true}"; + } + + return json; + } + + private static void ValidateResults(GameMemoryQuery query, IReadOnlyList memories) + { + if (memories.Count > query.Limit) + { + throw new InvalidOperationException("The memory store exceeded the requested result limit."); + } + + var ids = new HashSet<(string OwnerId, string MemoryId)>(); + foreach (var memory in memories) + { + if (memory is null || !ids.Add((memory.OwnerId, memory.MemoryId))) + { + throw new InvalidOperationException("The memory store returned a null or duplicate memory."); + } + + 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) + { + throw new InvalidOperationException("The memory store returned a memory outside the requested visibility filters."); + } + + if (query.AtOrBefore is { } moment + && (!string.Equals(memory.Moment.TimelineId, moment.TimelineId, StringComparison.Ordinal) + || memory.Moment.Tick > moment.Tick + || (memory.ExpiresAt is { } expiry && moment.Tick >= expiry.Tick))) + { + throw new InvalidOperationException("The memory store returned a memory outside the requested game-time boundary."); + } + } + } + + private static string Serialize(IReadOnlyList memories, bool truncated) => + JsonSerializer.Serialize(new + { + memories = memories.Select(memory => new + { + memoryId = memory.MemoryId, + ownerId = memory.OwnerId, + scope = memory.Scope, + kind = memory.Kind.ToString(), + payload = ParseElement(memory.PayloadJson), + timelineId = memory.Moment.TimelineId, + tick = memory.Moment.Tick, + importance = memory.Importance, + tags = memory.Tags, + expiresAtTick = memory.ExpiresAt?.Tick, + }), + truncated, + }); + + private static JsonElement ParseElement(string json) + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 128 }); + return document.RootElement.Clone(); + } + + private static IReadOnlyCollection ReadStrings(JsonElement arguments, string property) + { + if (!arguments.TryGetProperty(property, out var values)) + { + return Array.Empty(); + } + + return values.EnumerateArray().Select(value => value.GetString() ?? string.Empty).ToArray(); + } + + private static IReadOnlyCollection ReadKinds(JsonElement arguments, string property) => + !arguments.TryGetProperty(property, out var values) + ? Array.Empty() + : values.EnumerateArray().Select(value => ParseKind(value.GetString())).ToArray(); + + private static GameMemoryKind ParseKind(string? value) => value switch + { + "event" => GameMemoryKind.Event, + "fact" => GameMemoryKind.Fact, + "relationship" => GameMemoryKind.Relationship, + "goal" => GameMemoryKind.Goal, + "reflection" => GameMemoryKind.Reflection, + "procedure" => GameMemoryKind.Procedure, + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown memory kind."), + }; + + private static ToolResult JsonResult(object value) => + new(new AgentContent[] { new JsonContent(JsonSerializer.Serialize(value)) }); +} diff --git a/src/OpenGameAgent.Extensions/GoalLoopExtension.cs b/src/OpenGameAgent.Extensions/GoalLoopExtension.cs new file mode 100644 index 0000000..2751cf8 --- /dev/null +++ b/src/OpenGameAgent.Extensions/GoalLoopExtension.cs @@ -0,0 +1,519 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Extensions; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum GameGoalStatus +{ + Active, + Waiting, + Completed, + Failed, + Cancelled, +} + +public sealed class GameGoalWaitCondition +{ + public GameGoalWaitCondition( + string timelineId, + long? notBeforeTick = null, + IEnumerable? eventTypes = null) + { + if (string.IsNullOrWhiteSpace(timelineId)) + { + throw new ArgumentException("A timeline ID is required.", nameof(timelineId)); + } + + if (timelineId.Length > 1_024) + { + throw new ArgumentException("A timeline ID cannot exceed 1024 characters.", nameof(timelineId)); + } + + var copiedEventTypes = (eventTypes ?? Array.Empty()) + .Select(value => string.IsNullOrWhiteSpace(value) || value.Length > 256 + ? throw new ArgumentException("A wait event type must contain 1 to 256 characters.", nameof(eventTypes)) + : value) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (copiedEventTypes.Length > 32) + { + throw new ArgumentException("A wait condition can contain at most 32 event types.", nameof(eventTypes)); + } + + TimelineId = timelineId; + NotBeforeTick = notBeforeTick; + EventTypes = Array.AsReadOnly(copiedEventTypes); + } + + public string TimelineId { get; } + + public long? NotBeforeTick { get; } + + public IReadOnlyList EventTypes { get; } + + public bool IsSatisfied(GameInput input) => + string.Equals(TimelineId, input.Moment.TimelineId, StringComparison.Ordinal) + && (NotBeforeTick is null || input.Moment.Tick >= NotBeforeTick.Value) + && (EventTypes.Count == 0 || EventTypes.Contains(input.Type, StringComparer.Ordinal)); +} + +public sealed class GameGoalSnapshot +{ + internal GameGoalSnapshot(GoalDocument document) + { + Id = document.Id; + ObjectiveJson = document.ObjectiveJson; + ProgressJson = document.ProgressJson; + Status = document.Status; + Revision = document.Revision; + NonProgressUpdates = document.NonProgressUpdates; + LastTimelineId = document.LastTimelineId; + LastTick = document.LastTick; + Error = document.Error; + Wait = document.Wait is null + ? null + : new GameGoalWaitCondition(document.Wait.TimelineId, document.Wait.NotBeforeTick, document.Wait.EventTypes); + } + + public string Id { get; } + + public string ObjectiveJson { get; } + + public string ProgressJson { get; } + + public GameGoalStatus Status { get; } + + public long Revision { get; } + + public int NonProgressUpdates { get; } + + public string LastTimelineId { get; } + + public long LastTick { get; } + + public string? Error { get; } + + public GameGoalWaitCondition? Wait { get; } +} + +public sealed class GameGoalChanged +{ + public GameGoalChanged(GameGoalSnapshot goal, string reason) + { + Goal = goal ?? throw new ArgumentNullException(nameof(goal)); + Reason = reason ?? string.Empty; + } + + public GameGoalSnapshot Goal { get; } + + public string Reason { get; } +} + +public sealed class GoalLoopExtension : IGameAgentExtension +{ + private const string GoalPrefix = "goal/"; + private const string ManageSchema = """ + { + "type":"object", + "required":["action","goalId"], + "properties":{ + "action":{"type":"string","enum":["create","progress","wait","complete","fail","cancel"]}, + "goalId":{"type":"string","minLength":1,"maxLength":128}, + "expectedRevision":{"type":"integer","minimum":0}, + "objective":{}, + "progress":{}, + "reason":{"type":"string","maxLength":4096}, + "notBeforeTick":{"type":"integer"}, + "eventTypes":{"type":"array","maxItems":32,"items":{"type":"string","minLength":1,"maxLength":256},"uniqueItems":true} + }, + "additionalProperties":false + } + """; + private const string ListSchema = """ + {"type":"object","properties":{"includeTerminal":{"type":"boolean"}},"additionalProperties":false} + """; + + private readonly int _maximumGoals; + private readonly int _maximumNonProgressUpdates; + + public GoalLoopExtension(int maximumGoals = 64, int maximumNonProgressUpdates = 3) + { + if (maximumGoals < 1 || maximumGoals > 1_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumGoals)); + } + + if (maximumNonProgressUpdates < 1 || maximumNonProgressUpdates > 100) + { + throw new ArgumentOutOfRangeException(nameof(maximumNonProgressUpdates)); + } + + _maximumGoals = maximumGoals; + _maximumNonProgressUpdates = maximumNonProgressUpdates; + } + + public static GameAgentExtensionChannel GoalChanged { get; } = new("goal.changed"); + + public GameAgentExtensionDescriptor Descriptor { get; } = new( + "opengameagent.goals", + "1.0.0", + "Durable goal state that can wait on game time or game events and resume on later inputs.", + new[] { "goals", "durable-loop", "game-time", "game-events" }); + + public void Configure(GameAgentExtensionApi api) + { + api.RegisterPromptFragment( + "goal-guidance", + "Use manage_goal for work that must survive this turn. Waiting goals must name game-time or game-event conditions; never use real-world time for narrative progress."); + api.RegisterToolProvider( + "goal-tools", + (context, _) => new ValueTask>(new[] + { + CreateManageTool(api, context), + CreateListTool(context), + })); + api.RegisterPendingWorkProvider( + "active-goals", + (context, cancellationToken) => ResumeAndCheckPendingAsync(api, context, cancellationToken), + priority: 500); + } + + private async ValueTask ResumeAndCheckPendingAsync( + GameAgentExtensionApi api, + GameAgentExtensionRunContext context, + CancellationToken cancellationToken) + { + var pending = false; + foreach (var storedGoal in ReadAll(context.State)) + { + cancellationToken.ThrowIfCancellationRequested(); + var goal = storedGoal; + if (goal.Status == GameGoalStatus.Waiting + && goal.Wait is not null + && goal.Wait.IsSatisfied(context.Input)) + { + var resumed = ToDocument(goal); + resumed.Status = GameGoalStatus.Active; + resumed.Wait = null; + resumed.Revision = checked(resumed.Revision + 1); + resumed.LastTimelineId = context.Input.Moment.TimelineId; + resumed.LastTick = context.Input.Moment.Tick; + Write(context.State, resumed); + goal = new GameGoalSnapshot(resumed); + await api.PublishAsync( + GoalChanged, + new GameGoalChanged(goal, "resumed"), + cancellationToken).ConfigureAwait(false); + } + + pending |= goal.Status == GameGoalStatus.Active; + } + + return pending; + } + + private AgentTool CreateManageTool(GameAgentExtensionApi api, GameAgentExtensionRunContext context) => + new( + new ToolDefinition( + "manage_goal", + "Create, update, wait, complete, fail, or cancel a durable game-agent goal.", + ManageSchema), + async (arguments, _, cancellationToken) => + { + var action = arguments.GetProperty("action").GetString() ?? string.Empty; + var goalId = arguments.GetProperty("goalId").GetString() ?? string.Empty; + GoalDocument document; + if (string.Equals(action, "create", StringComparison.Ordinal)) + { + if (Read(context.State, goalId) is not null) + { + return ToolResult.Error($"Goal '{goalId}' already exists."); + } + + if (ReadAll(context.State).Count >= _maximumGoals) + { + return ToolResult.Error($"At most {_maximumGoals} goals may be stored in one actor session."); + } + + if (!arguments.TryGetProperty("objective", out var objective)) + { + return ToolResult.Error("Creating a goal requires objective JSON."); + } + + document = new GoalDocument + { + Id = goalId, + ObjectiveJson = objective.GetRawText(), + ProgressJson = "{}", + Status = GameGoalStatus.Active, + Revision = 1, + LastTimelineId = context.Input.Moment.TimelineId, + LastTick = context.Input.Moment.Tick, + }; + } + else + { + var existing = Read(context.State, goalId); + if (existing is null) + { + return ToolResult.Error($"Goal '{goalId}' does not exist."); + } + + document = existing; + + if (document.Status is GameGoalStatus.Completed or GameGoalStatus.Failed or GameGoalStatus.Cancelled) + { + return ToolResult.Error($"Goal '{goalId}' is terminal and immutable."); + } + + if (!arguments.TryGetProperty("expectedRevision", out var revision) + || revision.GetInt64() != document.Revision) + { + return ToolResult.Error($"Goal '{goalId}' revision conflict. Current revision is {document.Revision}."); + } + + document.Revision = checked(document.Revision + 1); + document.LastTimelineId = context.Input.Moment.TimelineId; + document.LastTick = context.Input.Moment.Tick; + switch (action) + { + case "progress": + if (!arguments.TryGetProperty("progress", out var progress)) + { + return ToolResult.Error("A progress update requires progress JSON."); + } + + var nextProgress = progress.GetRawText(); + document.NonProgressUpdates = string.Equals(document.ProgressJson, nextProgress, StringComparison.Ordinal) + ? checked(document.NonProgressUpdates + 1) + : 0; + if (document.NonProgressUpdates >= _maximumNonProgressUpdates) + { + return ToolResult.Error("The goal repeated the same progress without advancing."); + } + + document.ProgressJson = nextProgress; + document.Status = GameGoalStatus.Active; + document.Wait = null; + break; + case "wait": + var eventTypes = arguments.TryGetProperty("eventTypes", out var events) + ? events.EnumerateArray().Select(value => value.GetString() ?? string.Empty).ToArray() + : Array.Empty(); + var notBeforeTick = arguments.TryGetProperty("notBeforeTick", out var tick) + ? tick.GetInt64() + : (long?)null; + if (notBeforeTick is null && eventTypes.Length == 0) + { + return ToolResult.Error("A waiting goal requires a game tick or game event type."); + } + + document.Status = GameGoalStatus.Waiting; + document.Wait = new GoalWaitDocument + { + TimelineId = context.Input.Moment.TimelineId, + NotBeforeTick = notBeforeTick, + EventTypes = eventTypes, + }; + break; + case "complete": + document.Status = GameGoalStatus.Completed; + document.Wait = null; + break; + case "fail": + document.Status = GameGoalStatus.Failed; + document.Error = ReadReason(arguments, "The goal failed."); + document.Wait = null; + break; + case "cancel": + document.Status = GameGoalStatus.Cancelled; + document.Error = ReadReason(arguments, "The goal was cancelled."); + document.Wait = null; + break; + default: + return ToolResult.Error($"Unsupported goal action '{action}'."); + } + } + + Write(context.State, document); + var snapshot = new GameGoalSnapshot(document); + await api.PublishAsync( + GoalChanged, + new GameGoalChanged(snapshot, action), + cancellationToken).ConfigureAwait(false); + return JsonResult(snapshot); + }, + ToolRisk.IdempotentWrite, + ToolExecutionMode.Sequential, + conflictKey: arguments => arguments.TryGetProperty("goalId", out var goalId) + ? goalId.GetString() + : null); + + private AgentTool CreateListTool(GameAgentExtensionRunContext context) => + new( + new ToolDefinition("list_goals", "List durable goals for the current actor session.", ListSchema), + (arguments, _, _) => + { + var includeTerminal = arguments.TryGetProperty("includeTerminal", out var include) && include.GetBoolean(); + var goals = ReadAll(context.State) + .Where(goal => includeTerminal || goal.Status is GameGoalStatus.Active or GameGoalStatus.Waiting) + .OrderBy(goal => goal.Id, StringComparer.Ordinal) + .ToArray(); + return new ValueTask(JsonResult(new { goals })); + }, + ToolRisk.ReadOnly); + + private static string ReadReason(JsonElement arguments, string fallback) => + arguments.TryGetProperty("reason", out var reason) && !string.IsNullOrWhiteSpace(reason.GetString()) + ? reason.GetString()! + : fallback; + + private static GoalDocument? Read(GameAgentExtensionState state, string goalId) + { + var json = state.Get(GoalPrefix + goalId); + return json is null + ? null + : Decode(json, goalId); + } + + private static IReadOnlyList ReadAll(GameAgentExtensionState state) + { + var goals = state.Snapshot() + .Where(pair => pair.Key.StartsWith(GoalPrefix, StringComparison.Ordinal)) + .Select(pair => Decode(pair.Value, pair.Key.Substring(GoalPrefix.Length))) + .Select(document => new GameGoalSnapshot(document)) + .ToArray(); + var duplicate = goals.GroupBy(goal => goal.Id, StringComparer.Ordinal).FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new InvalidOperationException($"Goal state contains duplicate ID '{duplicate.Key}'."); + } + + return Array.AsReadOnly(goals); + } + + private static GoalDocument Decode(string json, string expectedId) + { + GoalDocument document; + try + { + document = JsonSerializer.Deserialize(json) + ?? throw new InvalidOperationException("The goal document is null."); + ValidateDocument(document, expectedId); + } + catch (Exception exception) when (exception is JsonException or ArgumentException or InvalidOperationException) + { + throw new InvalidOperationException($"Goal '{expectedId}' state is invalid.", exception); + } + + return document; + } + + private static void ValidateDocument(GoalDocument document, string expectedId) + { + if (string.IsNullOrWhiteSpace(document.Id) + || document.Id.Length > 128 + || !string.Equals(document.Id, expectedId, StringComparison.Ordinal) + || document.Revision < 1 + || document.NonProgressUpdates < 0 + || string.IsNullOrWhiteSpace(document.LastTimelineId) + || !Enum.IsDefined(typeof(GameGoalStatus), document.Status) + || (document.Error?.Length ?? 0) > 4_096) + { + throw new InvalidOperationException("The goal document contains invalid fields."); + } + + using (JsonDocument.Parse(document.ObjectiveJson, new JsonDocumentOptions { MaxDepth = 128 })) + using (JsonDocument.Parse(document.ProgressJson, new JsonDocumentOptions { MaxDepth = 128 })) + { + } + + if (document.Status == GameGoalStatus.Waiting) + { + if (document.Wait is null) + { + throw new InvalidOperationException("A waiting goal requires a wait condition."); + } + + _ = new GameGoalWaitCondition( + document.Wait.TimelineId, + document.Wait.NotBeforeTick, + document.Wait.EventTypes ?? Array.Empty()); + if (document.Wait.NotBeforeTick is null && (document.Wait.EventTypes?.Length ?? 0) == 0) + { + throw new InvalidOperationException("A waiting goal requires a tick or event type."); + } + } + else if (document.Wait is not null) + { + throw new InvalidOperationException("Only a waiting goal can contain a wait condition."); + } + } + + private static void Write(GameAgentExtensionState state, GoalDocument document) => + state.Set(GoalPrefix + document.Id, JsonSerializer.Serialize(document)); + + private static ToolResult JsonResult(object value) => + new(new AgentContent[] { new JsonContent(JsonSerializer.Serialize(value)) }); + + private static GoalDocument ToDocument(GameGoalSnapshot goal) => new() + { + Id = goal.Id, + ObjectiveJson = goal.ObjectiveJson, + ProgressJson = goal.ProgressJson, + Status = goal.Status, + Revision = goal.Revision, + NonProgressUpdates = goal.NonProgressUpdates, + LastTimelineId = goal.LastTimelineId, + LastTick = goal.LastTick, + Error = goal.Error, + Wait = goal.Wait is null + ? null + : new GoalWaitDocument + { + TimelineId = goal.Wait.TimelineId, + NotBeforeTick = goal.Wait.NotBeforeTick, + EventTypes = goal.Wait.EventTypes.ToArray(), + }, + }; +} + +internal sealed class GoalDocument +{ + public string Id { get; set; } = string.Empty; + + public string ObjectiveJson { get; set; } = "{}"; + + public string ProgressJson { get; set; } = "{}"; + + public GameGoalStatus Status { get; set; } + + public long Revision { get; set; } + + public int NonProgressUpdates { get; set; } + + public string LastTimelineId { get; set; } = string.Empty; + + public long LastTick { get; set; } + + public string? Error { get; set; } + + public GoalWaitDocument? Wait { get; set; } +} + +internal sealed class GoalWaitDocument +{ + public string TimelineId { get; set; } = string.Empty; + + public long? NotBeforeTick { get; set; } + + public string[] EventTypes { get; set; } = Array.Empty(); +} diff --git a/src/OpenGameAgent.Extensions/OpenGameAgent.Extensions.csproj b/src/OpenGameAgent.Extensions/OpenGameAgent.Extensions.csproj new file mode 100644 index 0000000..20b8fd7 --- /dev/null +++ b/src/OpenGameAgent.Extensions/OpenGameAgent.Extensions.csproj @@ -0,0 +1,10 @@ + + + netstandard2.1 + Official policy, tool catalog, interaction, goal, memory, artifact, knowledge, delegation, tracing, and workflow extensions for OpenGameAgent. + OpenGameAgent.Extensions + + + + + diff --git a/src/OpenGameAgent.Extensions/StructuredInteractionExtension.cs b/src/OpenGameAgent.Extensions/StructuredInteractionExtension.cs new file mode 100644 index 0000000..0e1aa82 --- /dev/null +++ b/src/OpenGameAgent.Extensions/StructuredInteractionExtension.cs @@ -0,0 +1,466 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Extensions; + +public sealed class GameInteractionOption +{ + public GameInteractionOption( + string id, + string label, + string description, + bool recommended = false, + string? payloadJson = null) + { + Id = RequireText(id, nameof(id), 128); + Label = RequireText(label, nameof(label), 256); + Description = RequireText(description, nameof(description), 4_096); + Recommended = recommended; + PayloadJson = payloadJson is null ? null : RequireJson(payloadJson, nameof(payloadJson)); + } + + public string Id { get; } + + public string Label { get; } + + public string Description { get; } + + public bool Recommended { get; } + + public string? PayloadJson { get; } + + private static string RequireText(string value, string name, int maximum) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > maximum) + { + throw new ArgumentException($"{name} must contain 1 to {maximum} characters.", name); + } + + return value; + } + + private static string RequireJson(string value, string name) + { + try + { + using var document = JsonDocument.Parse(value, new JsonDocumentOptions { MaxDepth = 64 }); + return value; + } + catch (JsonException exception) + { + throw new ArgumentException("The payload must be valid JSON.", name, exception); + } + } +} + +public sealed class GameInteractionQuestion +{ + public GameInteractionQuestion( + string id, + string prompt, + IEnumerable options, + bool multiSelect = false, + bool allowCustomAnswer = true, + string? payloadJson = null) + { + if (string.IsNullOrWhiteSpace(id) || id.Length > 128) + { + throw new ArgumentException("A question ID with at most 128 characters is required.", nameof(id)); + } + + if (string.IsNullOrWhiteSpace(prompt) || prompt.Length > 8_192) + { + throw new ArgumentException("A question prompt with at most 8192 characters is required.", nameof(prompt)); + } + + var copied = (options ?? throw new ArgumentNullException(nameof(options))).ToArray(); + if (copied.Length < 2 || copied.Length > 8 || copied.Any(value => value is null)) + { + throw new ArgumentException("A question requires 2 to 8 non-null options.", nameof(options)); + } + + var duplicate = copied.GroupBy(value => value.Id, StringComparer.Ordinal).FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new ArgumentException($"Duplicate option ID '{duplicate.Key}'.", nameof(options)); + } + + if (copied.Count(value => value.Recommended) > 1) + { + throw new ArgumentException("A question can recommend at most one option.", nameof(options)); + } + + Id = id; + Prompt = prompt; + Options = Array.AsReadOnly(copied); + MultiSelect = multiSelect; + AllowCustomAnswer = allowCustomAnswer; + PayloadJson = payloadJson is null ? null : RequireJson(payloadJson); + } + + public string Id { get; } + + public string Prompt { get; } + + public IReadOnlyList Options { get; } + + public bool MultiSelect { get; } + + public bool AllowCustomAnswer { get; } + + public string? PayloadJson { get; } + + private static string RequireJson(string value) + { + try + { + using var document = JsonDocument.Parse(value, new JsonDocumentOptions { MaxDepth = 64 }); + return value; + } + catch (JsonException exception) + { + throw new ArgumentException("The question payload must be valid JSON.", nameof(value), exception); + } + } +} + +public sealed class GameInteractionRequest +{ + public GameInteractionRequest( + string requestId, + GameInput input, + IEnumerable questions) + { + if (string.IsNullOrWhiteSpace(requestId)) + { + throw new ArgumentException("A request ID is required.", nameof(requestId)); + } + + RequestId = requestId; + Input = input ?? throw new ArgumentNullException(nameof(input)); + var copied = (questions ?? throw new ArgumentNullException(nameof(questions))).ToArray(); + if (copied.Length < 1 || copied.Length > 8 || copied.Any(value => value is null)) + { + throw new ArgumentException("An interaction requires 1 to 8 non-null questions.", nameof(questions)); + } + + var duplicate = copied.GroupBy(value => value.Id, StringComparer.Ordinal).FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new ArgumentException($"Duplicate question ID '{duplicate.Key}'.", nameof(questions)); + } + + Questions = Array.AsReadOnly(copied); + } + + public string RequestId { get; } + + public GameInput Input { get; } + + public IReadOnlyList Questions { get; } +} + +public sealed class GameInteractionAnswer +{ + public GameInteractionAnswer( + string questionId, + IEnumerable? selectedOptionIds = null, + string? customAnswer = null) + { + if (string.IsNullOrWhiteSpace(questionId) || questionId.Length > 128) + { + throw new ArgumentException("A question ID with at most 128 characters is required.", nameof(questionId)); + } + + if (customAnswer?.Length > 32_768) + { + throw new ArgumentException("A custom answer is too large.", nameof(customAnswer)); + } + + QuestionId = questionId; + var selected = (selectedOptionIds ?? Array.Empty()) + .Select(value => string.IsNullOrWhiteSpace(value) || value.Length > 128 + ? throw new ArgumentException("A selected option ID must contain 1 to 128 characters.", nameof(selectedOptionIds)) + : value) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (selected.Length > 8) + { + throw new ArgumentException("An answer can select at most 8 options.", nameof(selectedOptionIds)); + } + + SelectedOptionIds = Array.AsReadOnly(selected); + CustomAnswer = customAnswer; + } + + public string QuestionId { get; } + + public IReadOnlyList SelectedOptionIds { get; } + + public string? CustomAnswer { get; } +} + +public sealed class GameInteractionResponse +{ + public GameInteractionResponse(bool cancelled, IEnumerable? answers = null) + { + Cancelled = cancelled; + var copied = (answers ?? Array.Empty()).ToArray(); + if (copied.Any(value => value is null)) + { + throw new ArgumentException("Interaction answers cannot contain null values.", nameof(answers)); + } + + if (cancelled && copied.Length > 0) + { + throw new ArgumentException("A cancelled interaction cannot contain answers.", nameof(answers)); + } + + Answers = Array.AsReadOnly(copied); + } + + public bool Cancelled { get; } + + public IReadOnlyList Answers { get; } +} + +public sealed class GameInteractionCompleted +{ + public GameInteractionCompleted(GameInteractionRequest request, GameInteractionResponse response) + { + Request = request ?? throw new ArgumentNullException(nameof(request)); + Response = response ?? throw new ArgumentNullException(nameof(response)); + } + + public GameInteractionRequest Request { get; } + + public GameInteractionResponse Response { get; } +} + +public interface IGameInteractionBroker +{ + ValueTask PromptAsync( + GameInteractionRequest request, + CancellationToken cancellationToken); +} + +public sealed class StructuredInteractionExtension : IGameAgentExtension +{ + private const string InputSchema = """ + { + "type":"object", + "required":["questions"], + "properties":{ + "questions":{ + "type":"array", + "minItems":1, + "maxItems":8, + "items":{ + "type":"object", + "required":["id","prompt","options"], + "properties":{ + "id":{"type":"string","minLength":1,"maxLength":128}, + "prompt":{"type":"string","minLength":1,"maxLength":8192}, + "multiSelect":{"type":"boolean"}, + "allowCustomAnswer":{"type":"boolean"}, + "payload":{}, + "options":{ + "type":"array", + "minItems":2, + "maxItems":8, + "items":{ + "type":"object", + "required":["id","label","description"], + "properties":{ + "id":{"type":"string","minLength":1,"maxLength":128}, + "label":{"type":"string","minLength":1,"maxLength":256}, + "description":{"type":"string","minLength":1,"maxLength":4096}, + "recommended":{"type":"boolean"}, + "payload":{} + }, + "additionalProperties":false + } + } + }, + "additionalProperties":false + } + } + }, + "additionalProperties":false + } + """; + + private readonly IGameInteractionBroker _broker; + private readonly string _toolName; + + public StructuredInteractionExtension(IGameInteractionBroker broker, string toolName = "ask_player") + { + _broker = broker ?? throw new ArgumentNullException(nameof(broker)); + _toolName = string.IsNullOrWhiteSpace(toolName) + ? throw new ArgumentException("A tool name is required.", nameof(toolName)) + : toolName; + } + + public static GameAgentExtensionChannel InteractionStarted { get; } = + new("interaction.started"); + + public static GameAgentExtensionChannel InteractionCompleted { get; } = + new("interaction.completed"); + + public GameAgentExtensionDescriptor Descriptor { get; } = new( + "opengameagent.interaction", + "1.0.0", + "Structured player questions and choices for engine or server hosts.", + new[] { "interaction", "recommended-actions", "headless-host" }); + + public void Configure(GameAgentExtensionApi api) + { + if (api is null) + { + throw new ArgumentNullException(nameof(api)); + } + + api.RegisterPromptFragment( + "interaction-guidance", + $"Use {_toolName} only when the agent cannot safely continue without a player decision. " + + "Group related questions in one call. Mark at most one option per question as recommended and explain every option's trade-off."); + api.RegisterToolProvider( + "interaction-tool", + (context, _) => new ValueTask>( + new[] { CreateTool(api, context) })); + } + + private AgentTool CreateTool(GameAgentExtensionApi api, GameAgentExtensionRunContext runContext) => + new( + new ToolDefinition( + _toolName, + "Ask the player one or more bounded structured questions and wait for their answers.", + InputSchema), + async (arguments, execution, cancellationToken) => + { + GameInteractionRequest request; + try + { + request = ParseRequest(arguments, execution, runContext.Input); + } + catch (ArgumentException exception) + { + return ToolResult.Error(exception.Message); + } + + await api.PublishAsync(InteractionStarted, request, cancellationToken).ConfigureAwait(false); + var response = await _broker.PromptAsync(request, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The interaction broker returned null."); + ValidateResponse(request, response); + await api.PublishAsync( + InteractionCompleted, + new GameInteractionCompleted(request, response), + cancellationToken).ConfigureAwait(false); + return new ToolResult(new AgentContent[] + { + new JsonContent(JsonSerializer.Serialize(new + { + requestId = request.RequestId, + cancelled = response.Cancelled, + answers = response.Answers.Select(answer => new + { + questionId = answer.QuestionId, + selectedOptionIds = answer.SelectedOptionIds, + customAnswer = answer.CustomAnswer, + }), + })), + }); + }, + ToolRisk.NonIdempotentWrite, + ToolExecutionMode.Sequential); + + private static GameInteractionRequest ParseRequest( + JsonElement root, + ToolExecutionContext execution, + GameInput input) + { + var questions = new List(); + foreach (var element in root.GetProperty("questions").EnumerateArray()) + { + var options = new List(); + foreach (var option in element.GetProperty("options").EnumerateArray()) + { + options.Add(new GameInteractionOption( + option.GetProperty("id").GetString() ?? string.Empty, + option.GetProperty("label").GetString() ?? string.Empty, + option.GetProperty("description").GetString() ?? string.Empty, + option.TryGetProperty("recommended", out var recommended) && recommended.GetBoolean(), + option.TryGetProperty("payload", out var optionPayload) ? optionPayload.GetRawText() : null)); + } + + questions.Add(new GameInteractionQuestion( + element.GetProperty("id").GetString() ?? string.Empty, + element.GetProperty("prompt").GetString() ?? string.Empty, + options, + element.TryGetProperty("multiSelect", out var multiSelect) && multiSelect.GetBoolean(), + !element.TryGetProperty("allowCustomAnswer", out var allowCustom) || allowCustom.GetBoolean(), + element.TryGetProperty("payload", out var payload) ? payload.GetRawText() : null)); + } + + return new GameInteractionRequest( + string.Join(":", input.InputId, execution.RunId, execution.Turn, execution.ToolCallIndex), + input, + questions); + } + + private static void ValidateResponse(GameInteractionRequest request, GameInteractionResponse response) + { + if (response.Cancelled) + { + return; + } + + var questions = request.Questions.ToDictionary(value => value.Id, StringComparer.Ordinal); + var duplicate = response.Answers.GroupBy(value => value.QuestionId, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new InvalidOperationException($"The interaction broker returned duplicate answers for '{duplicate.Key}'."); + } + + if (response.Answers.Count != request.Questions.Count) + { + throw new InvalidOperationException("The interaction broker must answer every question or cancel the interaction."); + } + + foreach (var answer in response.Answers) + { + if (!questions.TryGetValue(answer.QuestionId, out var question)) + { + throw new InvalidOperationException($"The interaction broker answered unknown question '{answer.QuestionId}'."); + } + + if (!question.MultiSelect && answer.SelectedOptionIds.Count > 1) + { + throw new InvalidOperationException($"Question '{question.Id}' does not allow multiple selections."); + } + + var validIds = new HashSet(question.Options.Select(value => value.Id), StringComparer.Ordinal); + if (answer.SelectedOptionIds.Any(value => !validIds.Contains(value))) + { + throw new InvalidOperationException($"Question '{question.Id}' contains an unknown selected option."); + } + + if (!question.AllowCustomAnswer && answer.CustomAnswer is not null) + { + throw new InvalidOperationException($"Question '{question.Id}' does not allow a custom answer."); + } + + if (answer.SelectedOptionIds.Count == 0 && string.IsNullOrWhiteSpace(answer.CustomAnswer)) + { + throw new InvalidOperationException($"Question '{question.Id}' requires a selection or custom answer."); + } + } + } +} diff --git a/src/OpenGameAgent.Extensions/ToolCatalogExtension.cs b/src/OpenGameAgent.Extensions/ToolCatalogExtension.cs new file mode 100644 index 0000000..6ee7a47 --- /dev/null +++ b/src/OpenGameAgent.Extensions/ToolCatalogExtension.cs @@ -0,0 +1,395 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Extensions; + +public delegate ValueTask GameCatalogToolFactory( + GameAgentExtensionRunContext context, + CancellationToken cancellationToken); + +public sealed class GameToolCatalogEntry +{ + public GameToolCatalogEntry( + string name, + string description, + GameCatalogToolFactory createTool, + IEnumerable? tags = null, + IEnumerable? inputTypes = null, + int priority = 0) + { + if (string.IsNullOrWhiteSpace(name) || name.Length > 128) + { + throw new ArgumentException("A catalog tool name with at most 128 characters is required.", nameof(name)); + } + + if (string.IsNullOrWhiteSpace(description) || description.Length > 8_192) + { + throw new ArgumentException("A catalog tool description with at most 8192 characters is required.", nameof(description)); + } + + Name = name; + Description = description; + CreateTool = createTool ?? throw new ArgumentNullException(nameof(createTool)); + Tags = CopyIds(tags); + InputTypes = CopyIds(inputTypes); + Priority = priority; + } + + public string Name { get; } + + public string Description { get; } + + public GameCatalogToolFactory CreateTool { get; } + + public IReadOnlyList Tags { get; } + + public IReadOnlyList InputTypes { get; } + + public int Priority { get; } + + private static IReadOnlyList CopyIds(IEnumerable? values) + { + var copied = (values ?? Array.Empty()) + .Select(value => string.IsNullOrWhiteSpace(value) || value.Length > 128 + ? throw new ArgumentException("Catalog metadata values must contain 1 to 128 characters.", nameof(values)) + : value) + .Distinct(StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + if (copied.Length > 64) + { + throw new ArgumentException("Catalog metadata can contain at most 64 values.", nameof(values)); + } + + return Array.AsReadOnly(copied); + } +} + +public sealed class GameToolCatalogQuery +{ + public GameToolCatalogQuery( + string query, + string inputType, + IEnumerable? tags = null, + int maximumResults = 10) + { + if (query is null || query.Length > 4_096) + { + throw new ArgumentException("A catalog query cannot exceed 4096 characters.", nameof(query)); + } + + if (string.IsNullOrWhiteSpace(inputType) || inputType.Length > 256) + { + throw new ArgumentException("An input type with at most 256 characters is required.", nameof(inputType)); + } + + if (maximumResults < 1 || maximumResults > 100) + { + throw new ArgumentOutOfRangeException(nameof(maximumResults)); + } + + Query = query; + InputType = inputType; + var copiedTags = (tags ?? Array.Empty()) + .Select(value => string.IsNullOrWhiteSpace(value) || value.Length > 128 + ? throw new ArgumentException("Catalog query tags must contain 1 to 128 characters.", nameof(tags)) + : value) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (copiedTags.Length > 16) + { + throw new ArgumentException("A catalog query can contain at most 16 tags.", nameof(tags)); + } + + Tags = Array.AsReadOnly(copiedTags); + MaximumResults = maximumResults; + } + + public string Query { get; } + + public string InputType { get; } + + public IReadOnlyList Tags { get; } + + public int MaximumResults { get; } +} + +public interface IGameToolCatalog +{ + ValueTask> SearchAsync( + GameToolCatalogQuery query, + GameAgentExtensionRunContext context, + CancellationToken cancellationToken); + + ValueTask FindAsync( + string name, + GameAgentExtensionRunContext context, + CancellationToken cancellationToken); +} + +public sealed class InMemoryGameToolCatalog : IGameToolCatalog +{ + private readonly IReadOnlyDictionary _entries; + + public InMemoryGameToolCatalog(IEnumerable entries) + { + var copied = (entries ?? throw new ArgumentNullException(nameof(entries))).ToArray(); + if (copied.Any(value => value is null)) + { + throw new ArgumentException("Catalog entries cannot contain null values.", nameof(entries)); + } + + var duplicate = copied.GroupBy(value => value.Name, StringComparer.Ordinal).FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new ArgumentException($"Duplicate catalog tool '{duplicate.Key}'.", nameof(entries)); + } + + _entries = new ReadOnlyDictionary( + copied.ToDictionary(value => value.Name, StringComparer.Ordinal)); + } + + public ValueTask> SearchAsync( + GameToolCatalogQuery query, + GameAgentExtensionRunContext context, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var terms = query.Query.Split(new[] { ' ', '\t', '\r', '\n', '_', '-', '.' }, StringSplitOptions.RemoveEmptyEntries); + var requiredTags = new HashSet(query.Tags, StringComparer.OrdinalIgnoreCase); + var results = _entries.Values + .Where(entry => entry.InputTypes.Count == 0 || entry.InputTypes.Contains(query.InputType, StringComparer.Ordinal)) + .Where(entry => requiredTags.Count == 0 || requiredTags.IsSubsetOf(entry.Tags)) + .Select(entry => new + { + Entry = entry, + Score = Score(entry, terms), + }) + .Where(value => terms.Length == 0 || value.Score > 0) + .OrderByDescending(value => value.Score) + .ThenByDescending(value => value.Entry.Priority) + .ThenBy(value => value.Entry.Name, StringComparer.Ordinal) + .Take(query.MaximumResults) + .Select(value => value.Entry) + .ToArray(); + return new ValueTask>(Array.AsReadOnly(results)); + } + + public ValueTask FindAsync( + string name, + GameAgentExtensionRunContext context, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(_entries.TryGetValue(name, out var entry) ? entry : null); + } + + private static int Score(GameToolCatalogEntry entry, IReadOnlyList terms) + { + var score = 0; + foreach (var term in terms) + { + if (entry.Name.Contains(term, StringComparison.OrdinalIgnoreCase)) + { + score += 8; + } + + if (entry.Description.Contains(term, StringComparison.OrdinalIgnoreCase)) + { + score += 3; + } + + if (entry.Tags.Any(tag => tag.Contains(term, StringComparison.OrdinalIgnoreCase))) + { + score += 5; + } + } + + return score; + } +} + +public sealed class ToolCatalogExtension : IGameAgentExtension +{ + private const string StateKey = "active-tools"; + private const string SearchSchema = """ + {"type":"object","properties":{"query":{"type":"string","maxLength":4096},"tags":{"type":"array","maxItems":16,"items":{"type":"string","maxLength":128}},"limit":{"type":"integer","minimum":1,"maximum":20}},"additionalProperties":false} + """; + private const string ActivateSchema = """ + {"type":"object","required":["names"],"properties":{"names":{"type":"array","maxItems":64,"items":{"type":"string","minLength":1,"maxLength":128},"uniqueItems":true},"replace":{"type":"boolean"}},"additionalProperties":false} + """; + + private readonly IGameToolCatalog _catalog; + private readonly int _maximumActiveTools; + + public ToolCatalogExtension(IGameToolCatalog catalog, int maximumActiveTools = 32) + { + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + if (maximumActiveTools < 1 || maximumActiveTools > 256) + { + throw new ArgumentOutOfRangeException(nameof(maximumActiveTools)); + } + + _maximumActiveTools = maximumActiveTools; + } + + public GameAgentExtensionDescriptor Descriptor { get; } = new( + "opengameagent.tool-catalog", + "1.0.0", + "Search and activate large game tool catalogs without placing every schema in every request.", + new[] { "tool-discovery", "dynamic-tools", "context-control" }); + + public void Configure(GameAgentExtensionApi api) + { + api.RegisterPromptFragment( + "tool-catalog-guidance", + "When a needed game capability is not currently available, search the tool catalog and activate only the smallest relevant set. Activated tools appear on the next turn."); + api.RegisterToolProvider("catalog-tools", CreateToolsAsync, priority: 500); + } + + private async ValueTask> CreateToolsAsync( + GameAgentExtensionRunContext context, + CancellationToken cancellationToken) + { + var tools = new List + { + CreateSearchTool(context), + CreateActivationTool(context), + }; + foreach (var name in ReadActiveNames(context.State)) + { + var entry = await _catalog.FindAsync(name, context, cancellationToken).ConfigureAwait(false); + if (entry is null) + { + continue; + } + + var tool = await entry.CreateTool(context, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Catalog tool factory '{name}' returned null."); + if (!string.Equals(tool.Definition.Name, entry.Name, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Catalog tool factory '{name}' returned tool '{tool.Definition.Name}'."); + } + + tools.Add(tool); + } + + return Array.AsReadOnly(tools.ToArray()); + } + + private AgentTool CreateSearchTool(GameAgentExtensionRunContext context) => + new( + new ToolDefinition( + "search_game_tools", + "Search the available game capability catalog by name, description, input type, and tags.", + SearchSchema), + async (arguments, _, cancellationToken) => + { + var query = arguments.TryGetProperty("query", out var queryElement) + ? queryElement.GetString() ?? string.Empty + : string.Empty; + var tags = arguments.TryGetProperty("tags", out var tagsElement) + ? tagsElement.EnumerateArray().Select(value => value.GetString() ?? string.Empty).ToArray() + : Array.Empty(); + var limit = arguments.TryGetProperty("limit", out var limitElement) ? limitElement.GetInt32() : 10; + var results = await _catalog.SearchAsync( + new GameToolCatalogQuery(query, context.Input.Type, tags, limit), + context, + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The game tool catalog returned null."); + if (results.Count > limit) + { + throw new InvalidOperationException("The game tool catalog exceeded the requested result limit."); + } + + if (results.Any(value => value is null) + || results.Select(value => value.Name).Distinct(StringComparer.Ordinal).Count() != results.Count) + { + throw new InvalidOperationException("The game tool catalog returned null or duplicate entries."); + } + + return JsonResult(new + { + tools = results.Select(value => new + { + value.Name, + value.Description, + value.Tags, + value.Priority, + }), + active = ReadActiveNames(context.State), + }); + }, + ToolRisk.ReadOnly); + + private AgentTool CreateActivationTool(GameAgentExtensionRunContext context) => + new( + new ToolDefinition( + "set_active_game_tools", + "Activate or deactivate catalog tools. The selected tool schemas become available on the next turn.", + ActivateSchema), + async (arguments, _, cancellationToken) => + { + var requested = arguments.GetProperty("names").EnumerateArray() + .Select(value => value.GetString() ?? string.Empty) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var replace = !arguments.TryGetProperty("replace", out var replaceElement) || replaceElement.GetBoolean(); + var names = replace + ? requested.ToList() + : ReadActiveNames(context.State).Concat(requested).Distinct(StringComparer.Ordinal).ToList(); + if (names.Count > _maximumActiveTools) + { + return ToolResult.Error($"At most {_maximumActiveTools} catalog tools may be active."); + } + + foreach (var name in names) + { + if (await _catalog.FindAsync(name, context, cancellationToken).ConfigureAwait(false) is null) + { + return ToolResult.Error($"Catalog tool '{name}' does not exist."); + } + } + + context.State.Set(StateKey, JsonSerializer.Serialize(names)); + return JsonResult(new { active = names, availableOnNextTurn = true }); + }, + ToolRisk.IdempotentWrite, + ToolExecutionMode.Sequential); + + private IReadOnlyList ReadActiveNames(GameAgentExtensionState state) + { + var json = state.Get(StateKey); + if (json is null) + { + return Array.Empty(); + } + + try + { + var names = JsonSerializer.Deserialize(json) + ?? throw new InvalidOperationException("The active tool catalog state is null."); + if (names.Length > _maximumActiveTools + || names.Any(value => string.IsNullOrWhiteSpace(value) || value.Length > 128) + || names.Distinct(StringComparer.Ordinal).Count() != names.Length) + { + throw new InvalidOperationException("The active tool catalog state exceeds its configured limits."); + } + + return Array.AsReadOnly(names); + } + catch (JsonException exception) + { + throw new InvalidOperationException("The active tool catalog state is invalid.", exception); + } + } + + private static ToolResult JsonResult(object value) => + new(new AgentContent[] { new JsonContent(JsonSerializer.Serialize(value)) }); +} diff --git a/src/OpenGameAgent.Extensions/ToolPolicyExtension.cs b/src/OpenGameAgent.Extensions/ToolPolicyExtension.cs new file mode 100644 index 0000000..9c32307 --- /dev/null +++ b/src/OpenGameAgent.Extensions/ToolPolicyExtension.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Extensions; + +public enum GameToolPolicyOutcome +{ + NotApplicable, + Allow, + Deny, + Rewrite, +} + +public sealed class GameToolPolicyDecision +{ + private GameToolPolicyDecision( + GameToolPolicyOutcome outcome, + string reason, + string? replacementArgumentsJson) + { + if (!Enum.IsDefined(typeof(GameToolPolicyOutcome), outcome)) + { + throw new ArgumentOutOfRangeException(nameof(outcome)); + } + + if (outcome == GameToolPolicyOutcome.Deny && string.IsNullOrWhiteSpace(reason)) + { + throw new ArgumentException("A denial requires a reason.", nameof(reason)); + } + + if ((reason?.Length ?? 0) > 65_536) + { + throw new ArgumentException("A policy reason cannot exceed 65536 characters.", nameof(reason)); + } + + if (outcome == GameToolPolicyOutcome.Rewrite && string.IsNullOrWhiteSpace(replacementArgumentsJson)) + { + throw new ArgumentException("A rewrite requires replacement arguments.", nameof(replacementArgumentsJson)); + } + + if (replacementArgumentsJson is not null) + { + if (replacementArgumentsJson.Length > 1_000_000) + { + throw new ArgumentException("Replacement tool arguments cannot exceed 1000000 characters.", nameof(replacementArgumentsJson)); + } + + using var document = JsonDocument.Parse(replacementArgumentsJson, new JsonDocumentOptions { MaxDepth = 128 }); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new ArgumentException("Replacement tool arguments must be a JSON object.", nameof(replacementArgumentsJson)); + } + } + + Outcome = outcome; + Reason = reason ?? string.Empty; + ReplacementArgumentsJson = replacementArgumentsJson; + } + + public GameToolPolicyOutcome Outcome { get; } + + public string Reason { get; } + + public string? ReplacementArgumentsJson { get; } + + public static GameToolPolicyDecision NotApplicable() => + new(GameToolPolicyOutcome.NotApplicable, string.Empty, null); + + public static GameToolPolicyDecision Allow(string? reason = null) => + new(GameToolPolicyOutcome.Allow, reason ?? string.Empty, null); + + public static GameToolPolicyDecision Deny(string reason) => + new(GameToolPolicyOutcome.Deny, reason, null); + + public static GameToolPolicyDecision Rewrite(string replacementArgumentsJson, string? reason = null) => + new(GameToolPolicyOutcome.Rewrite, reason ?? string.Empty, replacementArgumentsJson); +} + +public sealed class GameToolPolicyContext +{ + public GameToolPolicyContext(GameInput input, ToolCallContent call, AgentContext agentContext) + { + Input = input ?? throw new ArgumentNullException(nameof(input)); + Call = call ?? throw new ArgumentNullException(nameof(call)); + AgentContext = agentContext ?? throw new ArgumentNullException(nameof(agentContext)); + } + + public GameInput Input { get; } + + public ToolCallContent Call { get; } + + public AgentContext AgentContext { get; } +} + +public interface IGameToolPolicy +{ + string Id { get; } + + ValueTask EvaluateAsync( + GameToolPolicyContext context, + CancellationToken cancellationToken); +} + +public sealed class GameToolPolicyAudit +{ + public GameToolPolicyAudit( + string policyId, + string toolName, + GameToolPolicyOutcome outcome, + string reason) + { + if (string.IsNullOrWhiteSpace(policyId) || policyId.Length > 256) + { + throw new ArgumentException("A bounded policy ID is required.", nameof(policyId)); + } + + if (string.IsNullOrWhiteSpace(toolName) || toolName.Length > 128) + { + throw new ArgumentException("A bounded tool name is required.", nameof(toolName)); + } + + if (!Enum.IsDefined(typeof(GameToolPolicyOutcome), outcome)) + { + throw new ArgumentOutOfRangeException(nameof(outcome)); + } + + PolicyId = policyId; + ToolName = toolName; + Outcome = outcome; + Reason = reason is null + ? string.Empty + : reason.Length <= 65_536 ? reason : reason.Substring(0, 65_536); + } + + public string PolicyId { get; } + + public string ToolName { get; } + + public GameToolPolicyOutcome Outcome { get; } + + public string Reason { get; } +} + +public sealed class ToolPolicyExtension : IGameAgentExtension +{ + private readonly IReadOnlyList _policies; + private readonly bool _denyWhenNoPolicyApplies; + private readonly bool _failClosed; + + public ToolPolicyExtension( + IEnumerable policies, + bool denyWhenNoPolicyApplies = false, + bool failClosed = true) + { + var copied = (policies ?? throw new ArgumentNullException(nameof(policies))).ToArray(); + if (copied.Any(value => value is null || string.IsNullOrWhiteSpace(value.Id) || value.Id.Length > 256)) + { + throw new ArgumentException("Policies require non-empty IDs.", nameof(policies)); + } + + var duplicate = copied.GroupBy(value => value.Id, StringComparer.Ordinal).FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new ArgumentException($"Duplicate policy ID '{duplicate.Key}'.", nameof(policies)); + } + + _policies = new ReadOnlyCollection(copied); + _denyWhenNoPolicyApplies = denyWhenNoPolicyApplies; + _failClosed = failClosed; + } + + public static GameAgentExtensionChannel DecisionRecorded { get; } = + new("policy.decision"); + + public GameAgentExtensionDescriptor Descriptor { get; } = new( + "opengameagent.tool-policy", + "1.0.0", + "Composable tool-call policy gates with auditable fail-closed behavior.", + new[] { "tool-policy", "audit" }); + + public void Configure(GameAgentExtensionApi api) + { + if (api is null) + { + throw new ArgumentNullException(nameof(api)); + } + + api.RegisterAgentHooks( + "tool-policy-gate", + runContext => new AgentHooks + { + BeforeToolCallAsync = async (call, agentContext, cancellationToken) => + { + var current = call; + string? replacement = null; + var applied = false; + foreach (var policy in _policies) + { + GameToolPolicyDecision decision; + try + { + decision = await policy.EvaluateAsync( + new GameToolPolicyContext(runContext.Input, current, agentContext), + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Policy '{policy.Id}' returned null."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + if (!_failClosed) + { + await api.PublishAsync( + DecisionRecorded, + new GameToolPolicyAudit(policy.Id, current.Name, GameToolPolicyOutcome.NotApplicable, exception.Message), + cancellationToken).ConfigureAwait(false); + continue; + } + + var reason = $"Policy '{policy.Id}' failed closed: {exception.Message}"; + await api.PublishAsync( + DecisionRecorded, + new GameToolPolicyAudit(policy.Id, current.Name, GameToolPolicyOutcome.Deny, reason), + cancellationToken).ConfigureAwait(false); + return ToolCallDecision.Block(reason); + } + + await api.PublishAsync( + DecisionRecorded, + new GameToolPolicyAudit(policy.Id, current.Name, decision.Outcome, decision.Reason), + cancellationToken).ConfigureAwait(false); + switch (decision.Outcome) + { + case GameToolPolicyOutcome.NotApplicable: + continue; + case GameToolPolicyOutcome.Allow: + applied = true; + continue; + case GameToolPolicyOutcome.Deny: + return ToolCallDecision.Block(decision.Reason); + case GameToolPolicyOutcome.Rewrite: + applied = true; + replacement = decision.ReplacementArgumentsJson; + current = new ToolCallContent(current.Id, current.Name, replacement!); + continue; + default: + throw new InvalidOperationException("Unsupported tool policy outcome."); + } + } + + if (!applied && _denyWhenNoPolicyApplies) + { + const string reason = "No registered policy allowed this tool call."; + await api.PublishAsync( + DecisionRecorded, + new GameToolPolicyAudit("default", current.Name, GameToolPolicyOutcome.Deny, reason), + cancellationToken).ConfigureAwait(false); + return ToolCallDecision.Block(reason); + } + + return replacement is null ? null : ToolCallDecision.Allow(replacement); + }, + }, + priority: 1_000); + } +} diff --git a/src/OpenGameAgent.Extensions/TracingExtension.cs b/src/OpenGameAgent.Extensions/TracingExtension.cs new file mode 100644 index 0000000..0e26a5f --- /dev/null +++ b/src/OpenGameAgent.Extensions/TracingExtension.cs @@ -0,0 +1,289 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Extensions; + +public sealed class GameAgentTraceEntry +{ + public GameAgentTraceEntry( + long sequence, + string kind, + string sessionId, + string actorId, + string inputId, + GameMoment moment, + DateTimeOffset operationalTimestamp, + string detailsJson) + { + if (sequence < 1) + { + throw new ArgumentOutOfRangeException(nameof(sequence)); + } + + Sequence = sequence; + Kind = Require(kind, nameof(kind)); + SessionId = Require(sessionId, nameof(sessionId)); + ActorId = Require(actorId, nameof(actorId)); + InputId = Require(inputId, nameof(inputId)); + if (string.IsNullOrWhiteSpace(moment.TimelineId) || moment.TimelineId.Length > 1_024) + { + throw new ArgumentException("A valid game moment is required.", nameof(moment)); + } + + Moment = moment; + if (operationalTimestamp == default) + { + throw new ArgumentException("An operational timestamp is required.", nameof(operationalTimestamp)); + } + + OperationalTimestamp = operationalTimestamp; + DetailsJson = RequireJson(detailsJson); + } + + public long Sequence { get; } + + public string Kind { get; } + + public string SessionId { get; } + + public string ActorId { get; } + + public string InputId { get; } + + public GameMoment Moment { get; } + + public DateTimeOffset OperationalTimestamp { get; } + + public string DetailsJson { get; } + + private static string Require(string value, string name) => + string.IsNullOrWhiteSpace(value) || value.Length > 1_024 + ? throw new ArgumentException("A value of at most 1,024 characters is required.", name) + : value; + + private static string RequireJson(string value) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 10_000_000) + { + throw new ArgumentException("Trace details must contain at most 10,000,000 characters.", nameof(value)); + } + + using var document = JsonDocument.Parse(value, new JsonDocumentOptions { MaxDepth = 128 }); + return value; + } +} + +public interface IGameAgentTraceSink +{ + ValueTask WriteAsync(GameAgentTraceEntry entry, CancellationToken cancellationToken); +} + +public sealed class InMemoryGameAgentTraceSink : IGameAgentTraceSink +{ + private readonly object _gate = new(); + private readonly Queue _entries; + private readonly int _capacity; + + public InMemoryGameAgentTraceSink(int capacity = 10_000) + { + if (capacity < 1) + { + throw new ArgumentOutOfRangeException(nameof(capacity)); + } + + _capacity = capacity; + _entries = new Queue(Math.Min(capacity, 1024)); + } + + public ValueTask WriteAsync(GameAgentTraceEntry entry, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (entry is null) + { + throw new ArgumentNullException(nameof(entry)); + } + + lock (_gate) + { + while (_entries.Count >= _capacity) + { + _entries.Dequeue(); + } + + _entries.Enqueue(entry); + } + + return default; + } + + public IReadOnlyList Snapshot() + { + lock (_gate) + { + return new ReadOnlyCollection(_entries.ToArray()); + } + } +} + +public sealed class GameAgentTracingOptions +{ + public bool IncludeInputPayload { get; set; } + + public bool IncludeToolArguments { get; set; } + + public int MaximumDetailsCharacters { get; set; } = 65_536; + + public Func OperationalClock { get; set; } = () => DateTimeOffset.UtcNow; + + internal GameAgentTracingOptions CopyAndValidate() + { + var copy = (GameAgentTracingOptions)MemberwiseClone(); + if (copy.MaximumDetailsCharacters < 256 || copy.MaximumDetailsCharacters > 10_000_000) + { + throw new ArgumentOutOfRangeException(nameof(MaximumDetailsCharacters)); + } + + if (copy.OperationalClock is null) + { + throw new ArgumentNullException(nameof(OperationalClock)); + } + + return copy; + } +} + +public sealed class GameAgentTracingExtension : IGameAgentExtension +{ + private readonly IGameAgentTraceSink _sink; + private readonly GameAgentTracingOptions _options; + private long _sequence; + + public GameAgentTracingExtension(IGameAgentTraceSink sink, GameAgentTracingOptions? options = null) + { + _sink = sink ?? throw new ArgumentNullException(nameof(sink)); + _options = (options ?? new GameAgentTracingOptions()).CopyAndValidate(); + } + + public GameAgentExtensionDescriptor Descriptor { get; } = new( + "opengameagent.tracing", + "1.0.0", + "Bounded structured traces that keep game time separate from operational time.", + new[] { "tracing", "observability", "diagnostics" }); + + public void Configure(GameAgentExtensionApi api) + { + api.On(GameAgentExtensionEvents.InputReceived, (value, context, token) => + WriteAsync( + "input.received", + context, + _options.IncludeInputPayload + ? (object)new { type = value.Input.Type, payload = Parse(value.Input.PayloadJson), metadataCount = value.Input.Metadata.Count } + : new { type = value.Input.Type, payloadOmitted = true, metadataCount = value.Input.Metadata.Count }, + token)); + api.On(GameAgentExtensionEvents.SessionLoaded, (value, context, token) => + WriteAsync( + "session.loaded", + context, + new { revision = value.Session.Revision, messages = value.Session.Messages.Count }, + token)); + api.On(GameAgentExtensionEvents.ContextCollected, (value, context, token) => + WriteAsync( + "context.collected", + context, + new { count = value.Context.Count, sources = value.Context.Select(slice => slice.Source).ToArray() }, + token)); + api.On(GameAgentExtensionEvents.ToolsCollected, (value, context, token) => + WriteAsync( + "tools.collected", + context, + new { count = value.Tools.Count, names = value.Tools.Select(tool => tool.Definition.Name).ToArray() }, + token)); + api.On(GameAgentExtensionEvents.RouteSelected, (value, context, token) => + WriteAsync( + "route.selected", + context, + new { route = value.Decision.Route.ToString(), value.Decision.Reason, value.Decision.Workflow }, + token)); + api.On(GameAgentExtensionEvents.SkillsSelected, (value, context, token) => + WriteAsync( + "skills.selected", + context, + new { count = value.Skills.Count, ids = value.Skills.Select(skill => skill.SkillId).ToArray() }, + token)); + api.On(GameAgentExtensionEvents.KernelEvent, (value, context, token) => + WriteAsync("kernel." + value.Value.Kind.ToString().ToLowerInvariant(), context, KernelDetails(value.Value), token)); + api.On(GameAgentExtensionEvents.RunCompleted, (value, context, token) => + WriteAsync( + "run.completed", + context, + new + { + status = value.Result.Status.ToString(), + route = value.Result.Route.Route.ToString(), + revision = value.Result.SessionRevision, + succeeded = value.Result.Succeeded, + turns = value.Result.AgentResult?.Turns, + toolCalls = value.Result.AgentResult?.ToolCalls, + }, + token)); + api.On(GameAgentExtensionEvents.RunFailed, (value, context, token) => + WriteAsync( + "run.failed", + context, + new { exception = value.Exception.GetType().FullName, value.Exception.Message }, + token)); + } + + private ValueTask WriteAsync( + string kind, + GameAgentExtensionRunContext context, + object details, + CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(details); + if (json.Length > _options.MaximumDetailsCharacters) + { + json = JsonSerializer.Serialize(new { truncated = true, originalCharacters = json.Length }); + } + + return _sink.WriteAsync( + new GameAgentTraceEntry( + Interlocked.Increment(ref _sequence), + kind, + context.Input.SessionId, + context.Input.ActorId, + context.Input.InputId, + context.Input.Moment, + _options.OperationalClock(), + json), + cancellationToken); + } + + private object KernelDetails(AgentEvent value) => new + { + value.RunId, + value.Turn, + tool = value.ToolCall?.Name, + toolCallId = value.ToolCall?.Id, + arguments = _options.IncludeToolArguments && value.ToolCall is not null + ? Parse(value.ToolCall.ArgumentsJson) + : (JsonElement?)null, + status = value.Status?.ToString(), + value.Error, + contentParts = value.Message?.Content.Count, + progressMessage = value.Progress?.Message, + toolError = value.ToolResult?.IsError, + }; + + private static JsonElement Parse(string json) + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 128 }); + return document.RootElement.Clone(); + } +} diff --git a/src/OpenGameAgent.Extensions/packages.lock.json b/src/OpenGameAgent.Extensions/packages.lock.json new file mode 100644 index 0000000..2d58bb3 --- /dev/null +++ b/src/OpenGameAgent.Extensions/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.1, )", + "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.Kernel/Agent.cs b/src/OpenGameAgent.Kernel/Agent.cs index 480cfbf..4c442b1 100644 --- a/src/OpenGameAgent.Kernel/Agent.cs +++ b/src/OpenGameAgent.Kernel/Agent.cs @@ -12,11 +12,11 @@ public sealed class Agent { private readonly object _gate = new(); private readonly SemaphoreSlim _eventGate = new(1, 1); - private readonly IModelProvider _provider; + private IModelProvider _provider; private readonly AgentLimits _limits; - private readonly AgentHooks _hooks; + private AgentHooks _hooks; private ModelParameters _parameters; - private readonly ToolExecutionMode _toolExecution; + private ToolExecutionMode _toolExecution; private readonly Func _clock; private readonly Func _runIdFactory; private readonly string? _sessionId; @@ -45,7 +45,8 @@ public Agent(AgentOptions options) throw new ArgumentNullException(nameof(options)); } - _limits = options.Limits?.Copy() ?? throw new ArgumentNullException(nameof(options.Limits)); + _limits = options.Limits?.Copy() + ?? throw new ArgumentException("Agent limits are required.", nameof(options)); ValidateQueueMode(options.SteeringMode, nameof(options.SteeringMode)); ValidateQueueMode(options.FollowUpMode, nameof(options.FollowUpMode)); ValidateToolExecutionMode(options.ToolExecution, nameof(options.ToolExecution)); @@ -58,10 +59,12 @@ public Agent(AgentOptions options) options.RunIdFactory); _provider = options.Provider; _model = options.Model; - _systemPrompt = options.SystemPrompt ?? throw new ArgumentNullException(nameof(options.SystemPrompt)); + _systemPrompt = options.SystemPrompt + ?? throw new ArgumentException("A system prompt value is required.", nameof(options)); _sessionId = options.SessionId; _parameters = options.Parameters.Copy(); - _hooks = CopyHooks(options.Hooks ?? throw new ArgumentNullException(nameof(options.Hooks))); + _hooks = CopyHooks(options.Hooks + ?? throw new ArgumentException("Agent hooks are required.", nameof(options))); _toolExecution = options.ToolExecution; _clock = options.Clock; _runIdFactory = options.RunIdFactory; @@ -80,6 +83,7 @@ public AgentState State { return new AgentState( _systemPrompt, + _provider, _model, _parameters, _tools, @@ -170,18 +174,21 @@ public Task ContinueAsync(CancellationToken cancellationToken = if (_messages[_messages.Count - 1].Role == AgentRole.Assistant) { IReadOnlyList queuedPrompts = _steering.Drain(); - if (queuedPrompts.Count == 0) + if (queuedPrompts.Count > 0) { - queuedPrompts = _followUps.Drain(); + return StartRun( + (context, options, emit, token) => AgentLoop.RunQueuedAsync(queuedPrompts, context, options, emit, token), + cancellationToken); } + queuedPrompts = _followUps.Drain(); if (queuedPrompts.Count == 0) { throw new InvalidOperationException("Cannot continue from an assistant message without queued input."); } return StartRun( - (context, options, emit, token) => AgentLoop.RunQueuedAsync(queuedPrompts, context, options, emit, token), + (context, options, emit, token) => AgentLoop.RunAsync(queuedPrompts, context, options, emit, token), cancellationToken); } @@ -252,6 +259,10 @@ public void Abort() { // Run completion won the race after the cancellation source was captured. } + catch (AggregateException) + { + // A cancellation callback cannot prevent the abort request from being recorded. + } } public bool TryAbort() @@ -276,6 +287,11 @@ public bool TryAbort() { return false; } + catch (AggregateException) + { + // Cancellation was requested even though a callback failed. + return true; + } } public Task WaitForIdleAsync() @@ -296,6 +312,22 @@ public void SetModel(string model) } } + public void SetModel(IModelProvider provider, string model) + { + if (provider is null) + { + throw new ArgumentNullException(nameof(provider)); + } + + lock (_gate) + { + EnsureIdle(); + AgentValidator.ValidateOptions(model, _sessionId, _parameters, _limits, _clock, _runIdFactory); + _provider = provider; + _model = model; + } + } + public void SetModelParameters(ModelParameters parameters) { if (parameters is null) @@ -311,6 +343,30 @@ public void SetModelParameters(ModelParameters parameters) } } + public void SetHooks(AgentHooks hooks) + { + if (hooks is null) + { + throw new ArgumentNullException(nameof(hooks)); + } + + lock (_gate) + { + EnsureIdle(); + _hooks = CopyHooks(hooks); + } + } + + public void SetToolExecution(ToolExecutionMode toolExecution) + { + ValidateToolExecutionMode(toolExecution, nameof(toolExecution)); + lock (_gate) + { + EnsureIdle(); + _toolExecution = toolExecution; + } + } + public void SetSystemPrompt(string systemPrompt) { if (systemPrompt is null) @@ -412,7 +468,7 @@ private async Task ExecuteRunAsync( lock (_gate) { subscriberErrors = _runSubscriberErrors.ToArray(); - if (subscriberErrors.Length > 0) + if (subscriberErrors.Length > 0 && _error is null) { _error = "One or more agent event subscribers failed."; } @@ -484,7 +540,7 @@ private void CloseActiveControl() private async ValueTask ProcessEventAsync(AgentEvent agentEvent, CancellationToken cancellationToken) { - await _eventGate.WaitAsync().ConfigureAwait(false); + await _eventGate.WaitAsync(CancellationToken.None).ConfigureAwait(false); try { Subscriber[] subscribers; @@ -548,7 +604,13 @@ private async ValueTask ProcessEventAsync(AgentEvent agentEvent, CancellationTok { lock (_gate) { - _runSubscriberErrors.Add(exception.Message); + var error = exception.Message ?? exception.GetType().Name; + if (error.Length > _limits.MaxTextCharactersPerPart) + { + error = error.Substring(0, _limits.MaxTextCharactersPerPart); + } + + _runSubscriberErrors.Add(error); _subscribers.RemoveAll(candidate => candidate.Id == subscriber.Id); } } diff --git a/src/OpenGameAgent.Kernel/AgentLoop.cs b/src/OpenGameAgent.Kernel/AgentLoop.cs index cd40db0..0040393 100644 --- a/src/OpenGameAgent.Kernel/AgentLoop.cs +++ b/src/OpenGameAgent.Kernel/AgentLoop.cs @@ -78,10 +78,11 @@ private static async Task RunCoreAsync( throw new ArgumentNullException(nameof(emit)); } - var limits = options.Limits?.Copy() ?? throw new ArgumentNullException(nameof(options.Limits)); + var limits = options.Limits?.Copy() + ?? throw new ArgumentException("Agent limits are required.", nameof(options)); if (!Enum.IsDefined(typeof(ToolExecutionMode), options.ToolExecution)) { - throw new ArgumentOutOfRangeException(nameof(options.ToolExecution)); + throw new ArgumentOutOfRangeException(nameof(options), "The tool execution mode is invalid."); } AgentValidator.ValidateOptions( @@ -109,6 +110,7 @@ private static async Task RunCoreAsync( var turns = 0; var toolCallCount = 0; var totalTokens = 0L; + var provider = options.Provider; var model = options.Model; var parameters = options.Parameters.Copy(); var ended = false; @@ -116,7 +118,7 @@ private static async Task RunCoreAsync( async ValueTask EmitCoreAsync(AgentEvent value, CancellationToken callbackToken) { - await emitGate.WaitAsync().ConfigureAwait(false); + await emitGate.WaitAsync(CancellationToken.None).ConfigureAwait(false); try { await emit(value, callbackToken).ConfigureAwait(false); @@ -164,18 +166,8 @@ await EmitTerminalAsync(new AgentEvent( try { - if (!isContinuation) - { - foreach (var prompt in prompts) - { - await EmitMessageAsync(prompt, runId, 0, EmitAsync).ConfigureAwait(false); - } - } - - IReadOnlyList pendingMessages = skipInitialSteeringPoll - ? Array.Empty() - : await DrainAsync(options.GetSteeringMessagesAsync, cancellationToken).ConfigureAwait(false); - ValidateQueuedMessages(pendingMessages, limits); + IReadOnlyList pendingMessages = Array.Empty(); + var firstTurn = true; while (true) { @@ -189,6 +181,29 @@ await EmitTerminalAsync(new AgentEvent( $"The run reached the maximum of {limits.MaxTurns} model turns.").ConfigureAwait(false); } + turns++; + await EmitAsync(new AgentEvent(AgentEventKind.TurnStarted, runId, turns)).ConfigureAwait(false); + + if (firstTurn) + { + firstTurn = false; + if (!isContinuation) + { + foreach (var prompt in prompts) + { + await EmitMessageAsync(prompt, runId, turns, EmitAsync).ConfigureAwait(false); + } + } + + if (!skipInitialSteeringPoll) + { + pendingMessages = await DrainAsync( + options.GetSteeringMessagesAsync, + cancellationToken).ConfigureAwait(false); + ValidateQueuedMessages(pendingMessages, limits); + } + } + if (pendingMessages.Count > 0) { EnsureMessageCapacity(current.Messages.Count, pendingMessages.Count, limits); @@ -197,28 +212,30 @@ await EmitTerminalAsync(new AgentEvent( AgentValidator.ValidateMessage(pending, limits); current.Messages.Add(pending); newMessages.Add(pending); - await EmitMessageAsync(pending, runId, turns + 1, EmitAsync).ConfigureAwait(false); + await EmitMessageAsync(pending, runId, turns, EmitAsync).ConfigureAwait(false); } pendingMessages = Array.Empty(); } - turns++; - await EmitAsync(new AgentEvent(AgentEventKind.TurnStarted, runId, turns)).ConfigureAwait(false); - - EnsureMessageCapacity(current.Messages.Count, 1, limits); + var responseMessageReserve = current.Tools.Count == 0 + ? 1 + : checked(1 + limits.MaxToolCallsPerTurn); + EnsureMessageCapacity(current.Messages.Count, responseMessageReserve, limits); - var response = await StreamAssistantAsync( + var streamed = await StreamAssistantAsync( runId, turns, current, + provider, model, parameters, options, limits, EmitAsync, cancellationToken).ConfigureAwait(false); - var assistantMessage = ToAssistantMessage(response, options.Clock(), model); + var response = streamed.Response; + var assistantMessage = ToAssistantMessage(response, options.Clock(), streamed.Model); current.Messages.Add(assistantMessage); newMessages.Add(assistantMessage); totalTokens = checked(totalTokens + response.Usage.TotalTokens); @@ -300,8 +317,6 @@ await EmitAsync(new AgentEvent( } } - cancellationToken.ThrowIfCancellationRequested(); - await EmitAsync(new AgentEvent( AgentEventKind.TurnEnded, runId, @@ -309,6 +324,8 @@ await EmitAsync(new AgentEvent( assistantMessage, messages: batch.Messages)).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (totalTokens > limits.MaxTotalTokens) { return await FinishAsync( @@ -327,12 +344,22 @@ await EmitAsync(new AgentEvent( if (options.Hooks.PrepareNextTurnAsync is not null) { var update = await options.Hooks.PrepareNextTurnAsync(afterTurn, cancellationToken).ConfigureAwait(false); + if (update?.Provider is not null && update.Model is null) + { + throw new InvalidOperationException("A next-turn provider replacement must include its model name."); + } + if (update?.Context is not null) { AgentValidator.ValidateContext(update.Context, limits); current = new MutableLoopContext(update.Context, Array.Empty()); } + if (update?.Provider is not null) + { + provider = update.Provider; + } + if (update?.Model is not null) { AgentValidator.ValidateOptions( @@ -392,7 +419,9 @@ await EmitAsync(new AgentEvent( } catch (AgentLimitException exception) { - return await FinishAsync(AgentRunStatus.LimitExceeded, exception.Message).ConfigureAwait(false); + return await FinishAsync( + AgentRunStatus.LimitExceeded, + BoundError(exception.Message, limits)).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -400,14 +429,17 @@ await EmitAsync(new AgentEvent( } catch (Exception exception) { - return await FinishAsync(AgentRunStatus.KernelError, exception.Message).ConfigureAwait(false); + return await FinishAsync( + AgentRunStatus.KernelError, + BoundError(exception.Message, limits)).ConfigureAwait(false); } } - private static async Task StreamAssistantAsync( + private static async Task StreamAssistantAsync( string runId, int turn, MutableLoopContext context, + IModelProvider provider, string model, ModelParameters parameters, AgentLoopOptions options, @@ -443,13 +475,51 @@ private static async Task StreamAssistantAsync( } AgentValidator.ValidateRequest(request, limits, options.Clock, options.RunIdFactory); + if (!string.Equals(request.RunId, runId, StringComparison.Ordinal) || request.Turn != turn) + { + throw new InvalidOperationException("BeforeModelRequestAsync cannot change the active run ID or turn number."); + } var started = false; ModelResponse? lastPartial = null; + AssistantStreamResult? completedStream = null; + using var requestCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var deadlineCancellation = new CancellationTokenSource(); + using var callerWaitCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var deadline = Task.Delay(limits.ModelTimeoutMilliseconds, deadlineCancellation.Token); + var callerCancellation = Task.Delay(Timeout.Infinite, callerWaitCancellation.Token); + IAsyncEnumerator? enumerator = null; + var disposeEnumerator = true; try { - await foreach (var modelEvent in options.Provider.StreamAsync(request, cancellationToken)) + enumerator = (provider.StreamAsync(request, requestCancellation.Token) + ?? throw new InvalidOperationException("The model provider returned a null stream.")) + .GetAsyncEnumerator(requestCancellation.Token) + ?? throw new InvalidOperationException("The model provider returned a null stream enumerator."); + while (true) { + var pendingMove = enumerator.MoveNextAsync().AsTask(); + var winner = await Task.WhenAny(pendingMove, deadline, callerCancellation).ConfigureAwait(false); + if (!ReferenceEquals(winner, pendingMove) && !pendingMove.IsCompleted) + { + disposeEnumerator = false; + TryCancel(requestCancellation); + _ = ObservePendingStreamAsync(enumerator, pendingMove); + if (cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + } + + throw new TimeoutException( + $"The model request exceeded {limits.ModelTimeoutMilliseconds} ms."); + } + + if (!await pendingMove.ConfigureAwait(false)) + { + break; + } + + var modelEvent = enumerator.Current; if (modelEvent is null) { throw new InvalidOperationException("The model provider emitted a null stream event."); @@ -464,14 +534,26 @@ private static async Task StreamAssistantAsync( AgentValidator.ValidateResponse(modelEvent.Partial, limits); lastPartial = modelEvent.Partial; - var partialMessage = ToAssistantMessage(lastPartial, options.Clock(), model); - if (!started) + var partialMessage = ToAssistantMessage(lastPartial, options.Clock(), request.Model); + if (modelEvent.Kind == ModelStreamEventKind.Started) { + if (started) + { + throw new InvalidOperationException("The model provider emitted more than one stream start event."); + } + started = true; await emit(new AgentEvent(AgentEventKind.MessageStarted, runId, turn, partialMessage, modelEvent)).ConfigureAwait(false); } + else + { + if (!started) + { + throw new InvalidOperationException("The model provider emitted a stream update before its start event."); + } - await emit(new AgentEvent(AgentEventKind.MessageUpdated, runId, turn, partialMessage, modelEvent)).ConfigureAwait(false); + await emit(new AgentEvent(AgentEventKind.MessageUpdated, runId, turn, partialMessage, modelEvent)).ConfigureAwait(false); + } } if (!modelEvent.IsTerminal) @@ -491,7 +573,7 @@ private static async Task StreamAssistantAsync( context.Messages.Count, 1 + response.Content.Count(part => part is ToolCallContent), limits); - var finalMessage = ToAssistantMessage(response, options.Clock(), model); + var finalMessage = ToAssistantMessage(response, options.Clock(), request.Model); if (!started) { started = true; @@ -499,20 +581,26 @@ private static async Task StreamAssistantAsync( } await emit(new AgentEvent(AgentEventKind.MessageEnded, runId, turn, finalMessage, modelEvent)).ConfigureAwait(false); - return response; + completedStream = new AssistantStreamResult(response, request.Model); + return completedStream; } - return await EmitSyntheticModelFailureAsync( + var syntheticResponse = await EmitSyntheticModelFailureAsync( "The model stream ended without a terminal response.", ModelStopReason.Error, lastPartial, started, runId, turn, - model, + request.Model, options.Clock, limits, emit).ConfigureAwait(false); + return new AssistantStreamResult(syntheticResponse, request.Model); + } + catch (Exception) when (completedStream is not null) + { + return completedStream; } catch (AgentLimitException) { @@ -520,34 +608,121 @@ private static async Task StreamAssistantAsync( } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - return await EmitSyntheticModelFailureAsync( + var syntheticResponse = await EmitSyntheticModelFailureAsync( "The model request was aborted.", ModelStopReason.Aborted, lastPartial, started, runId, turn, - model, + request.Model, options.Clock, limits, emit).ConfigureAwait(false); + return new AssistantStreamResult(syntheticResponse, request.Model); } catch (Exception exception) { - return await EmitSyntheticModelFailureAsync( + var syntheticResponse = await EmitSyntheticModelFailureAsync( exception.Message, ModelStopReason.Error, lastPartial, started, runId, turn, - model, + request.Model, options.Clock, limits, emit).ConfigureAwait(false); + return new AssistantStreamResult(syntheticResponse, request.Model); + } + finally + { + TryCancel(deadlineCancellation); + TryCancel(callerWaitCancellation); + if (disposeEnumerator && enumerator is not null) + { + TryCancel(requestCancellation); + var cleanup = enumerator.DisposeAsync().AsTask(); + if (cleanup.IsCompleted) + { + try + { + await cleanup.ConfigureAwait(false); + } + catch when (completedStream is not null || cancellationToken.IsCancellationRequested) + { + // Stream cleanup cannot replace a completed or cancelled request outcome. + } + } + else + { + _ = IgnoreFailureAsync(cleanup); + } + } + } + } + + private static void TryCancel(CancellationTokenSource cancellation) + { + try + { + cancellation.Cancel(); + } + catch (ObjectDisposedException) + { + } + catch (AggregateException) + { + // A provider cancellation callback cannot replace the request outcome. + } + } + + private static async Task ObservePendingStreamAsync( + IAsyncEnumerator enumerator, + Task pendingMove) + { + try + { + _ = await pendingMove.ConfigureAwait(false); + } + catch + { + } + + try + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + catch + { + } + } + + private static async Task IgnoreFailureAsync(Task task) + { + try + { + await task.ConfigureAwait(false); + } + catch + { } } + private sealed class AssistantStreamResult + { + public AssistantStreamResult(ModelResponse response, string model) + { + Response = response; + Model = model; + } + + public ModelResponse Response { get; } + + public string Model { get; } + } + private static async Task EmitSyntheticModelFailureAsync( string error, ModelStopReason reason, @@ -924,7 +1099,9 @@ private static async Task ExecutePreparedToolCallAsync( CancellationToken cancellationToken) { var progressCount = 0; - var acceptingProgress = 1; + var progressGate = new object(); + var acceptedProgress = new List(); + var acceptingProgress = true; var dispatched = false; var uncertainSideEffect = false; var executionContext = new ToolExecutionContext( @@ -936,32 +1113,41 @@ private static async Task ExecutePreparedToolCallAsync( { progressCancellation.ThrowIfCancellationRequested(); AgentValidator.ValidateProgress(progress, limits); - if (Volatile.Read(ref acceptingProgress) == 0) + TaskCompletionSource completion; + lock (progressGate) { - return; - } + if (!acceptingProgress) + { + return; + } - var count = Interlocked.Increment(ref progressCount); - if (count > limits.MaxProgressEventsPerTool) - { - return; + progressCount++; + if (progressCount > limits.MaxProgressEventsPerTool) + { + return; + } + + completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + acceptedProgress.Add(completion.Task); } - await emit(new AgentEvent( - AgentEventKind.ToolProgressed, - runId, - turn, - toolCall: prepared.Call, - progress: progress)).ConfigureAwait(false); + _ = CompleteProgressAsync(completion, emit, new AgentEvent( + AgentEventKind.ToolProgressed, + runId, + turn, + toolCall: prepared.Call, + progress: progress)); + await completion.Task.ConfigureAwait(false); }); ToolResult result; + Task? execution = null; try { using var timeoutCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); using var timeoutDelayCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); dispatched = true; - var execution = prepared.Tool.ExecuteAsync( + execution = prepared.Tool.ExecuteAsync( prepared.Arguments, executionContext, timeoutCancellation.Token).AsTask(); @@ -969,15 +1155,15 @@ await emit(new AgentEvent( var completed = await Task.WhenAny(execution, timeout).ConfigureAwait(false); if (completed == execution) { - timeoutDelayCancellation.Cancel(); + TryCancel(timeoutDelayCancellation); result = await execution.ConfigureAwait(false) ?? CreateToolError("The tool returned no result.", limits); uncertainSideEffect |= result.OutcomeUncertain; } else { - cancellationToken.ThrowIfCancellationRequested(); - timeoutCancellation.Cancel(); _ = ObserveToolCompletionAsync(execution); + cancellationToken.ThrowIfCancellationRequested(); + TryCancel(timeoutCancellation); uncertainSideEffect = prepared.Tool.Risk != ToolRisk.ReadOnly; result = CreateToolError( prepared.Tool.Risk == ToolRisk.ReadOnly @@ -989,6 +1175,11 @@ await emit(new AgentEvent( } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { + if (execution is not null) + { + _ = ObserveToolCompletionAsync(execution); + } + uncertainSideEffect = dispatched && prepared.Tool.Risk != ToolRisk.ReadOnly; result = CreateToolError( prepared.Tool.Risk == ToolRisk.ReadOnly @@ -1004,9 +1195,20 @@ await emit(new AgentEvent( } finally { - Interlocked.Exchange(ref acceptingProgress, 0); + lock (progressGate) + { + acceptingProgress = false; + } + } + + Task[] progressToSettle; + lock (progressGate) + { + progressToSettle = acceptedProgress.ToArray(); } + await Task.WhenAll(progressToSettle).ConfigureAwait(false); + try { if (options.Hooks.AfterToolCallAsync is not null) @@ -1041,6 +1243,22 @@ await emit(new AgentEvent( return new ToolCallOutcome(prepared.Index, prepared.Call, result, uncertainSideEffect); } + private static async Task CompleteProgressAsync( + TaskCompletionSource completion, + Func emit, + AgentEvent agentEvent) + { + try + { + await emit(agentEvent).ConfigureAwait(false); + completion.TrySetResult(null); + } + catch (Exception exception) + { + completion.TrySetException(exception); + } + } + private static async Task ObserveToolCompletionAsync(Task execution) { try @@ -1072,6 +1290,14 @@ private static ToolResult CreateToolError( outcomeUncertain: uncertain); } + private static string BoundError(string? message, AgentLimits limits) + { + var value = string.IsNullOrWhiteSpace(message) ? "The agent run failed." : message; + return value.Length <= limits.MaxTextCharactersPerPart + ? value + : value.Substring(0, limits.MaxTextCharactersPerPart); + } + private static bool ShouldExecuteInParallel(IReadOnlyList calls, ToolExecutionMode mode) { if (calls.Count < 2 || mode == ToolExecutionMode.Sequential) diff --git a/src/OpenGameAgent.Kernel/AgentOptions.cs b/src/OpenGameAgent.Kernel/AgentOptions.cs index 0ae0b0d..01a950c 100644 --- a/src/OpenGameAgent.Kernel/AgentOptions.cs +++ b/src/OpenGameAgent.Kernel/AgentOptions.cs @@ -59,6 +59,8 @@ public sealed class AgentLimits public int ToolTimeoutMilliseconds { get; set; } = 120_000; + public int ModelTimeoutMilliseconds { get; set; } = 120_000; + public int MaxProgressEventsPerTool { get; set; } = 256; public int MaxSubscribers { get; set; } = 32; @@ -94,6 +96,7 @@ internal void Validate() RequireRange(MaxQueuedMessages, 1, 100_000, nameof(MaxQueuedMessages)); RequireRange(MaxConcurrentTools, 1, 1024, nameof(MaxConcurrentTools)); RequireRange(ToolTimeoutMilliseconds, 1, 86_400_000, nameof(ToolTimeoutMilliseconds)); + RequireRange(ModelTimeoutMilliseconds, 1, 86_400_000, nameof(ModelTimeoutMilliseconds)); RequireRange(MaxProgressEventsPerTool, 0, 1_000_000, nameof(MaxProgressEventsPerTool)); RequireRange(MaxSubscribers, 0, 10_000, nameof(MaxSubscribers)); } @@ -154,9 +157,11 @@ internal AfterTurnContext( RunId = runId; Turn = turn; Response = response; - ToolResults = toolResults; - Context = context; - NewMessages = newMessages; + ToolResults = Array.AsReadOnly( + (toolResults ?? throw new ArgumentNullException(nameof(toolResults))).ToArray()); + Context = context ?? throw new ArgumentNullException(nameof(context)); + NewMessages = Array.AsReadOnly( + (newMessages ?? throw new ArgumentNullException(nameof(newMessages))).ToArray()); } public string RunId { get; } @@ -176,6 +181,8 @@ public sealed class NextTurnUpdate { public AgentContext? Context { get; set; } + public IModelProvider? Provider { get; set; } + public string? Model { get; set; } public ModelParameters? Parameters { get; set; } @@ -239,6 +246,7 @@ public sealed class AgentState { internal AgentState( string systemPrompt, + IModelProvider provider, string model, ModelParameters parameters, IReadOnlyList tools, @@ -250,19 +258,22 @@ internal AgentState( string? error) { SystemPrompt = systemPrompt; + Provider = provider ?? throw new ArgumentNullException(nameof(provider)); Model = model; Parameters = parameters?.Copy() ?? throw new ArgumentNullException(nameof(parameters)); - Tools = tools.ToArray(); - Messages = messages.ToArray(); + Tools = Array.AsReadOnly(tools.ToArray()); + Messages = Array.AsReadOnly(messages.ToArray()); IsRunning = isRunning; StreamingMessage = streamingMessage; StreamingEvent = streamingEvent; - PendingToolCallIds = pendingToolCallIds.ToArray(); + PendingToolCallIds = Array.AsReadOnly(pendingToolCallIds.ToArray()); Error = error; } public string SystemPrompt { get; } + public IModelProvider Provider { get; } + public string Model { get; } public ModelParameters Parameters { get; } diff --git a/src/OpenGameAgent.Kernel/AgentValidator.cs b/src/OpenGameAgent.Kernel/AgentValidator.cs index 167cb6e..3f75e7a 100644 --- a/src/OpenGameAgent.Kernel/AgentValidator.cs +++ b/src/OpenGameAgent.Kernel/AgentValidator.cs @@ -328,6 +328,11 @@ public static void ValidateRequest( } ValidateOptions(request.Model, request.SessionId, request.Parameters, limits, clock, runIdFactory); + if (request.RunId.Length > limits.MaxSessionIdCharacters) + { + throw new AgentLimitException(nameof(limits.MaxSessionIdCharacters), "The provider run ID is too large."); + } + if (request.SystemPrompt.Length > limits.MaxSystemPromptCharacters) { throw new AgentLimitException(nameof(limits.MaxSystemPromptCharacters), "The provider system prompt is too large."); @@ -418,10 +423,9 @@ public static void ValidateTranscript(IReadOnlyList messages) throw new ArgumentException("A tool-use assistant message must contain a tool call.", nameof(messages)); } - if (calls.Length > 0 - && message.StopReason is ModelStopReason.Stop or ModelStopReason.Error or ModelStopReason.Aborted) + if (calls.Length > 0 && message.StopReason == ModelStopReason.Stop) { - throw new ArgumentException("A stopped or failed assistant message cannot contain tool calls.", nameof(messages)); + throw new ArgumentException("A stopped assistant message cannot contain tool calls.", nameof(messages)); } foreach (var call in calls) diff --git a/src/OpenGameAgent.Kernel/Events.cs b/src/OpenGameAgent.Kernel/Events.cs index 2b88be8..1c5a3b8 100644 --- a/src/OpenGameAgent.Kernel/Events.cs +++ b/src/OpenGameAgent.Kernel/Events.cs @@ -54,7 +54,9 @@ internal AgentEvent( ToolResult = toolResult; Error = error; Status = status; - Messages = messages is null ? Array.Empty() : messages.ToArray(); + Messages = messages is null + ? Array.Empty() + : Array.AsReadOnly(messages.ToArray()); } public AgentEventKind Kind { get; } @@ -93,11 +95,14 @@ internal AgentRunResult( { RunId = runId; Status = status; - NewMessages = newMessages.ToArray(); + NewMessages = Array.AsReadOnly( + (newMessages ?? throw new ArgumentNullException(nameof(newMessages))).ToArray()); Turns = turns; ToolCalls = toolCalls; Error = error; - SubscriberErrors = subscriberErrors?.ToArray() ?? Array.Empty(); + SubscriberErrors = subscriberErrors is null + ? Array.Empty() + : Array.AsReadOnly(subscriberErrors.ToArray()); } public string RunId { get; } diff --git a/src/OpenGameAgent.Kernel/JsonSchemaValidator.cs b/src/OpenGameAgent.Kernel/JsonSchemaValidator.cs index 8e13f33..782f05b 100644 --- a/src/OpenGameAgent.Kernel/JsonSchemaValidator.cs +++ b/src/OpenGameAgent.Kernel/JsonSchemaValidator.cs @@ -13,6 +13,7 @@ internal static class JsonSchemaValidator { private const int MaxDepth = 64; private const int MaxNumberCharacters = 4096; + private static readonly char[] ExponentMarkers = { 'e', 'E' }; private static readonly string[] UnsupportedAssertionKeywords = { "$ref", @@ -717,7 +718,7 @@ private static NumberValue ParseNumber(string raw) var negative = raw[0] == '-'; var start = negative ? 1 : 0; - var exponentIndex = raw.IndexOfAny(new[] { 'e', 'E' }, start); + var exponentIndex = raw.IndexOfAny(ExponentMarkers, start); var mantissaEnd = exponentIndex < 0 ? raw.Length : exponentIndex; var decimalIndex = raw.IndexOf('.', start, mantissaEnd - start); var fractionalDigits = decimalIndex < 0 ? 0 : mantissaEnd - decimalIndex - 1; diff --git a/src/OpenGameAgent.Kernel/Models.cs b/src/OpenGameAgent.Kernel/Models.cs index de3ab98..df5957e 100644 --- a/src/OpenGameAgent.Kernel/Models.cs +++ b/src/OpenGameAgent.Kernel/Models.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Net.Http; using System.Threading; namespace OpenGameAgent.Kernel; @@ -342,3 +343,30 @@ public interface IModelProvider { IAsyncEnumerable StreamAsync(ModelRequest request, CancellationToken cancellationToken); } + +public sealed class ModelProviderException : HttpRequestException +{ + public ModelProviderException( + string message, + bool isTransient, + TimeSpan? retryAfter = null, + int? statusCode = null, + Exception? innerException = null) + : base(message, innerException) + { + if (retryAfter is { } delay && delay < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(retryAfter)); + } + + IsTransient = isTransient; + RetryAfter = retryAfter; + StatusCode = statusCode; + } + + public bool IsTransient { get; } + + public TimeSpan? RetryAfter { get; } + + public int? StatusCode { get; } +} diff --git a/src/OpenGameAgent.Kernel/Tools.cs b/src/OpenGameAgent.Kernel/Tools.cs index 2ba31d4..071cded 100644 --- a/src/OpenGameAgent.Kernel/Tools.cs +++ b/src/OpenGameAgent.Kernel/Tools.cs @@ -86,6 +86,13 @@ public ToolResult( throw new ArgumentException("Tool result content cannot contain null parts.", nameof(content)); } + if (copied.Any(part => part is ReasoningContent or ToolCallContent)) + { + throw new ArgumentException( + "Tool results cannot contain assistant-only reasoning or tool-call parts.", + nameof(content)); + } + Content = Array.AsReadOnly(copied); IsError = isError; DetailsJson = detailsJson is null ? null : JsonValue.RequireValid(detailsJson, nameof(detailsJson)); @@ -185,6 +192,13 @@ public AgentTool( public Func? ConflictKey { get; } + public string? ValidateArguments(string argumentsJson) + { + var valid = JsonValue.RequireObject(argumentsJson, nameof(argumentsJson)); + using var document = JsonDocument.Parse(valid); + return Validate(document.RootElement); + } + internal string? Validate(JsonElement arguments) { var schemaError = JsonSchemaValidator.Validate(Definition.InputSchemaJson, arguments); diff --git a/src/OpenGameAgent.Models/Credentials.cs b/src/OpenGameAgent.Models/Credentials.cs new file mode 100644 index 0000000..25b254a --- /dev/null +++ b/src/OpenGameAgent.Models/Credentials.cs @@ -0,0 +1,615 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenGameAgent.Models; + +public enum GameCredentialKind +{ + ApiKey, + BearerToken, + OAuth, + DeveloperHostedToken, + Ambient, +} + +public sealed class GameCredential +{ + public GameCredential( + GameCredentialKind kind, + string secret, + DateTimeOffset? expiresAt = null, + IReadOnlyDictionary? metadata = null) + { + if (!Enum.IsDefined(typeof(GameCredentialKind), kind)) + { + throw new ArgumentOutOfRangeException(nameof(kind)); + } + + if (string.IsNullOrWhiteSpace(secret) + || secret.Length > 65_536 + || secret.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0) + { + throw new ArgumentException("A credential secret is required and cannot contain line breaks or null characters.", nameof(secret)); + } + + Kind = kind; + Secret = secret; + ExpiresAt = expiresAt; + if (metadata is { Count: > 256 }) + { + throw new ArgumentException("Credential metadata cannot contain more than 256 entries.", nameof(metadata)); + } + + var copy = new Dictionary(StringComparer.Ordinal); + foreach (var pair in metadata ?? new Dictionary()) + { + var key = GameModelDescriptor.RequireId(pair.Key, nameof(metadata)); + if (pair.Value is null || pair.Value.Length > 16_384 || !copy.TryAdd(key, pair.Value)) + { + throw new ArgumentException("Credential metadata is invalid or contains duplicate keys.", nameof(metadata)); + } + } + + Metadata = new ReadOnlyDictionary(copy); + } + + public GameCredentialKind Kind { get; } + + public string Secret { get; } + + public DateTimeOffset? ExpiresAt { get; } + + public IReadOnlyDictionary Metadata { get; } + + public bool IsExpired(DateTimeOffset now, TimeSpan? refreshSkew = null) + { + var skew = refreshSkew ?? TimeSpan.Zero; + if (skew < TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(refreshSkew)); + } + + return ExpiresAt is { } expiration + && (expiration <= now || expiration - now <= skew); + } + + public override string ToString() => $"{Kind} credential (redacted)"; +} + +public readonly struct GameCredentialKey : IEquatable +{ + public GameCredentialKey(string providerId, string profile = "default") + { + ProviderId = GameModelDescriptor.RequireId(providerId, nameof(providerId)); + Profile = GameModelDescriptor.RequireId(profile, nameof(profile)); + } + + public string ProviderId { get; } + + public string Profile { get; } + + public bool Equals(GameCredentialKey other) => + string.Equals(ProviderId, other.ProviderId, StringComparison.Ordinal) + && string.Equals(Profile, other.Profile, StringComparison.Ordinal); + + public override bool Equals(object? obj) => obj is GameCredentialKey other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + return ((ProviderId is null ? 0 : StringComparer.Ordinal.GetHashCode(ProviderId)) * 397) + ^ (Profile is null ? 0 : StringComparer.Ordinal.GetHashCode(Profile)); + } + } + + public static bool operator ==(GameCredentialKey left, GameCredentialKey right) => left.Equals(right); + + public static bool operator !=(GameCredentialKey left, GameCredentialKey right) => !left.Equals(right); + + internal void EnsureValid(string parameterName) + { + if (string.IsNullOrWhiteSpace(ProviderId) || string.IsNullOrWhiteSpace(Profile)) + { + throw new ArgumentException("A valid credential key is required.", parameterName); + } + } +} + +public interface IGameCredentialStore +{ + ValueTask GetAsync(GameCredentialKey key, CancellationToken cancellationToken); + + ValueTask SetAsync(GameCredentialKey key, GameCredential credential, CancellationToken cancellationToken); + + ValueTask RemoveAsync(GameCredentialKey key, CancellationToken cancellationToken); + + ValueTask ModifyAsync( + GameCredentialKey key, + Func> mutation, + CancellationToken cancellationToken); +} + +public sealed class InMemoryGameCredentialStore : IGameCredentialStore +{ + private readonly Dictionary _credentials = new(); + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly int _capacity; + + public InMemoryGameCredentialStore(int capacity = 128) + { + if (capacity <= 0 || capacity > 100_000) + { + throw new ArgumentOutOfRangeException(nameof(capacity)); + } + + _capacity = capacity; + } + + public async ValueTask GetAsync(GameCredentialKey key, CancellationToken cancellationToken) + { + key.EnsureValid(nameof(key)); + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return _credentials.TryGetValue(key, out var value) ? value : null; + } + finally + { + _gate.Release(); + } + } + + public async ValueTask SetAsync( + GameCredentialKey key, + GameCredential credential, + CancellationToken cancellationToken) + { + key.EnsureValid(nameof(key)); + if (credential is null) + { + throw new ArgumentNullException(nameof(credential)); + } + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!_credentials.ContainsKey(key) && _credentials.Count >= _capacity) + { + throw new InvalidOperationException("The credential store reached its capacity."); + } + + _credentials[key] = credential; + } + finally + { + _gate.Release(); + } + } + + public async ValueTask RemoveAsync(GameCredentialKey key, CancellationToken cancellationToken) + { + key.EnsureValid(nameof(key)); + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return _credentials.Remove(key); + } + finally + { + _gate.Release(); + } + } + + public async ValueTask ModifyAsync( + GameCredentialKey key, + Func> mutation, + CancellationToken cancellationToken) + { + key.EnsureValid(nameof(key)); + if (mutation is null) + { + throw new ArgumentNullException(nameof(mutation)); + } + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + _credentials.TryGetValue(key, out var current); + var next = await mutation(current, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + if (next is null) + { + _credentials.Remove(key); + } + else + { + if (current is null && _credentials.Count >= _capacity) + { + throw new InvalidOperationException("The credential store reached its capacity."); + } + + _credentials[key] = next; + } + + return next; + } + finally + { + _gate.Release(); + } + } +} + +public sealed class GameProviderAuthStatus +{ + public GameProviderAuthStatus( + bool configured, + string source, + GameCredentialKind? kind = null, + DateTimeOffset? expiresAt = null, + string? error = null) + { + if (configured && error is not null) + { + throw new ArgumentException("Configured authentication cannot carry an error.", nameof(error)); + } + + if (error is not null && (string.IsNullOrWhiteSpace(error) || error.Length > 65_536)) + { + throw new ArgumentException("An authentication error must contain at most 65,536 characters.", nameof(error)); + } + + if (kind is { } credentialKind && !Enum.IsDefined(typeof(GameCredentialKind), credentialKind)) + { + throw new ArgumentOutOfRangeException(nameof(kind)); + } + + Configured = configured; + Source = GameModelDescriptor.RequireId(source, nameof(source)); + Kind = kind; + ExpiresAt = expiresAt; + Error = error; + } + + public bool Configured { get; } + + public string Source { get; } + + public GameCredentialKind? Kind { get; } + + public DateTimeOffset? ExpiresAt { get; } + + public string? Error { get; } +} + +public sealed class GameProviderAuthResolution +{ + public GameProviderAuthResolution(GameCredential credential, string source) + { + Credential = credential ?? throw new ArgumentNullException(nameof(credential)); + Source = GameModelDescriptor.RequireId(source, nameof(source)); + } + + public GameCredential Credential { get; } + + public string Source { get; } +} + +public sealed class GameAuthInteraction +{ + public Func? OpenBrowserAsync { get; set; } + + public Func>? PromptAsync { get; set; } + + public Func? NotifyAsync { get; set; } +} + +public interface IGameProviderAuthentication +{ + IReadOnlyCollection Schemes { get; } + + ValueTask CheckAsync(CancellationToken cancellationToken); + + ValueTask ResolveAsync(CancellationToken cancellationToken); + + ValueTask LoginAsync( + string scheme, + GameAuthInteraction interaction, + CancellationToken cancellationToken); + + ValueTask LogoutAsync(CancellationToken cancellationToken); +} + +public sealed class StaticGameProviderAuthentication : IGameProviderAuthentication +{ + private readonly GameProviderAuthStatus _status; + private readonly GameProviderAuthResolution? _resolution; + + public StaticGameProviderAuthentication( + bool configured = true, + string source = "ambient", + GameCredential? credential = null) + { + if (!configured && credential is not null) + { + throw new ArgumentException("Unconfigured static authentication cannot expose a credential.", nameof(credential)); + } + + _status = new GameProviderAuthStatus( + configured, + source, + credential?.Kind, + credential?.ExpiresAt, + configured ? null : "The provider is not configured."); + _resolution = configured && credential is not null + ? new GameProviderAuthResolution(credential, source) + : null; + } + + public IReadOnlyCollection Schemes { get; } = Array.AsReadOnly(new[] { "ambient" }); + + public ValueTask CheckAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(_status); + } + + public ValueTask ResolveAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(_resolution); + } + + public ValueTask LoginAsync( + string scheme, + GameAuthInteraction interaction, + CancellationToken cancellationToken) => + throw new InvalidOperationException("Static authentication does not expose a login flow."); + + public ValueTask LogoutAsync(CancellationToken cancellationToken) => + throw new InvalidOperationException("Static authentication cannot be logged out."); +} + +public sealed class EnvironmentGameProviderAuthentication : IGameProviderAuthentication +{ + private readonly string _variableName; + private readonly GameCredentialKind _kind; + private readonly string _source; + private readonly Func _read; + + public EnvironmentGameProviderAuthentication( + string variableName, + GameCredentialKind kind = GameCredentialKind.ApiKey, + string source = "environment", + Func? read = null) + { + if (string.IsNullOrWhiteSpace(variableName) + || variableName.Length > 512 + || variableName.Contains('=') + || variableName.Contains('\0')) + { + throw new ArgumentException("A valid environment variable name is required.", nameof(variableName)); + } + + if (!Enum.IsDefined(typeof(GameCredentialKind), kind)) + { + throw new ArgumentOutOfRangeException(nameof(kind)); + } + + _variableName = variableName; + _kind = kind; + _source = GameModelDescriptor.RequireId(source, nameof(source)); + _read = read ?? Environment.GetEnvironmentVariable; + } + + public IReadOnlyCollection Schemes { get; } = Array.Empty(); + + public ValueTask CheckAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + GameCredential? credential; + try + { + credential = ReadCredential(); + } + catch (ArgumentException) + { + return new ValueTask(new GameProviderAuthStatus( + false, + _source, + error: $"Environment variable '{_variableName}' contains an invalid credential.")); + } + + var configured = credential is not null; + return new ValueTask(new GameProviderAuthStatus( + configured, + _source, + credential?.Kind, + credential?.ExpiresAt, + error: configured ? null : $"Environment variable '{_variableName}' is not configured.")); + } + + public ValueTask ResolveAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var credential = ReadCredential(); + return new ValueTask(credential is null + ? null + : new GameProviderAuthResolution(credential, _source)); + } + + public ValueTask LoginAsync( + string scheme, + GameAuthInteraction interaction, + CancellationToken cancellationToken) => + throw new InvalidOperationException("Environment authentication does not expose a login flow."); + + public ValueTask LogoutAsync(CancellationToken cancellationToken) => + throw new InvalidOperationException("Environment authentication cannot modify the process environment."); + + private GameCredential? ReadCredential() + { + var secret = _read(_variableName); + return string.IsNullOrWhiteSpace(secret) ? null : new GameCredential(_kind, secret); + } +} + +public sealed class StoredGameProviderAuthentication : IGameProviderAuthentication +{ + private readonly GameCredentialKey _key; + private readonly IGameCredentialStore _store; + private readonly IReadOnlyCollection _schemes; + private readonly Func>? _login; + private readonly Func>? _refresh; + private readonly Func _clock; + private readonly TimeSpan _refreshSkew; + private readonly int _credentialCommitTimeoutMilliseconds; + + public StoredGameProviderAuthentication( + string providerId, + IGameCredentialStore store, + IReadOnlyCollection? schemes = null, + Func>? login = null, + Func>? refresh = null, + string profile = "default", + Func? clock = null, + TimeSpan? refreshSkew = null, + int credentialCommitTimeoutMilliseconds = 10_000) + { + _key = new GameCredentialKey(providerId, profile); + _store = store ?? throw new ArgumentNullException(nameof(store)); + var copiedSchemes = (schemes ?? new[] { "api-key" }) + .Select(scheme => GameModelDescriptor.RequireId(scheme, nameof(schemes))) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (copiedSchemes.Length == 0) + { + throw new ArgumentException("At least one authentication scheme is required.", nameof(schemes)); + } + + if (copiedSchemes.Length > 64) + { + throw new ArgumentException("At most 64 authentication schemes can be registered.", nameof(schemes)); + } + + _schemes = Array.AsReadOnly(copiedSchemes); + _login = login; + _refresh = refresh; + _clock = clock ?? (() => DateTimeOffset.UtcNow); + _refreshSkew = refreshSkew ?? TimeSpan.FromMinutes(1); + if (_refreshSkew < TimeSpan.Zero || _refreshSkew > TimeSpan.FromHours(24)) + { + throw new ArgumentOutOfRangeException(nameof(refreshSkew)); + } + + if (credentialCommitTimeoutMilliseconds < 100 || credentialCommitTimeoutMilliseconds > 300_000) + { + throw new ArgumentOutOfRangeException(nameof(credentialCommitTimeoutMilliseconds)); + } + + _credentialCommitTimeoutMilliseconds = credentialCommitTimeoutMilliseconds; + } + + public IReadOnlyCollection Schemes => _schemes; + + public async ValueTask CheckAsync(CancellationToken cancellationToken) + { + var credential = await _store.GetAsync(_key, cancellationToken).ConfigureAwait(false); + if (credential is null) + { + return new GameProviderAuthStatus(false, "credential-store"); + } + + if (credential.IsExpired(_clock(), _refreshSkew)) + { + if (_refresh is not null) + { + return new GameProviderAuthStatus( + true, + "credential-store", + credential.Kind, + credential.ExpiresAt); + } + + return new GameProviderAuthStatus( + false, + "credential-store", + credential.Kind, + credential.ExpiresAt, + "The stored credential is expired."); + } + + return new GameProviderAuthStatus( + true, + "credential-store", + credential.Kind, + credential.ExpiresAt); + } + + public async ValueTask ResolveAsync(CancellationToken cancellationToken) + { + var credential = await _store.ModifyAsync( + _key, + async (current, token) => + { + if (current is null || !current.IsExpired(_clock(), _refreshSkew) || _refresh is null) + { + return current; + } + + var refreshed = await _refresh(current, token).ConfigureAwait(false) + ?? throw new InvalidOperationException("The credential refresh returned no credential."); + if (refreshed.IsExpired(_clock(), _refreshSkew)) + { + throw new InvalidOperationException("The credential refresh returned an expired credential."); + } + + return refreshed; + }, + cancellationToken).ConfigureAwait(false); + return credential is null || credential.IsExpired(_clock(), _refreshSkew) + ? null + : new GameProviderAuthResolution(credential, "credential-store"); + } + + public async ValueTask LoginAsync( + string scheme, + GameAuthInteraction interaction, + CancellationToken cancellationToken) + { + var validScheme = GameModelDescriptor.RequireId(scheme, nameof(scheme)); + if (!_schemes.Contains(validScheme, StringComparer.Ordinal)) + { + throw new InvalidOperationException($"Authentication scheme '{validScheme}' is not supported."); + } + + if (_login is null) + { + throw new InvalidOperationException("This authentication provider does not expose an interactive login flow."); + } + + var credential = await _login( + validScheme, + interaction ?? throw new ArgumentNullException(nameof(interaction)), + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The login flow returned no credential."); + if (credential.IsExpired(_clock(), _refreshSkew)) + { + throw new InvalidOperationException("The login flow returned an expired credential."); + } + + using var settlement = new CancellationTokenSource(_credentialCommitTimeoutMilliseconds); + await _store.SetAsync(_key, credential, settlement.Token).ConfigureAwait(false); + return credential; + } + + public async ValueTask LogoutAsync(CancellationToken cancellationToken) + { + _ = await _store.RemoveAsync(_key, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/OpenGameAgent.Models/ModelCatalogExtension.cs b/src/OpenGameAgent.Models/ModelCatalogExtension.cs new file mode 100644 index 0000000..1c48002 --- /dev/null +++ b/src/OpenGameAgent.Models/ModelCatalogExtension.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Models; + +public sealed class GameModelCatalogExtension : IGameAgentExtension +{ + private readonly GameModelCatalog _catalog; + + public GameModelCatalogExtension(GameModelCatalog catalog, string extensionId = "models") + { + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + Descriptor = new GameAgentExtensionDescriptor( + GameModelDescriptor.RequireId(extensionId, nameof(extensionId)), + "1.0.0", + "Provider discovery, model capabilities, authentication state, and model selection.", + new[] { "model-catalog", "provider-auth", "dynamic-models" }); + } + + public GameAgentExtensionDescriptor Descriptor { get; } + + public void Configure(GameAgentExtensionApi api) + { + if (api is null) + { + throw new ArgumentNullException(nameof(api)); + } + + api.RegisterService("catalog", _catalog); + foreach (var provider in _catalog.GetProviders()) + { + api.RegisterModelProvider( + provider.Descriptor.ProviderId, + _catalog.CreateDispatchProvider(provider.Descriptor.ProviderId)); + } + } + + public GameModelSelection Select( + string providerId, + string modelId, + GameReasoningLevel reasoning = GameReasoningLevel.Off, + ModelParameters? baseline = null, + GameModelInputCapabilities requiredInput = GameModelInputCapabilities.None, + GameModelOutputCapabilities requiredOutput = GameModelOutputCapabilities.None) + { + var resolution = _catalog.Resolve( + providerId, + modelId, + reasoning, + requiredInput, + requiredOutput); + return new GameModelSelection( + resolution.Model.ModelId, + parameters: resolution.CreateParameters(baseline), + provider: _catalog.CreateDispatchProvider(resolution.Model.ProviderId), + contextWindowTokens: resolution.Model.ContextWindowTokens, + maximumOutputTokens: resolution.Model.MaximumOutputTokens); + } +} diff --git a/src/OpenGameAgent.Models/ModelCatalogStore.cs b/src/OpenGameAgent.Models/ModelCatalogStore.cs new file mode 100644 index 0000000..72eb272 --- /dev/null +++ b/src/OpenGameAgent.Models/ModelCatalogStore.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenGameAgent.Models; + +public sealed class GameStoredModelCatalog +{ + public GameStoredModelCatalog( + string providerId, + string catalogVersion, + IReadOnlyList models, + DateTimeOffset checkedAt, + long revision = 0) + { + ProviderId = GameModelDescriptor.RequireId(providerId, nameof(providerId)); + CatalogVersion = GameModelDescriptor.RequireId(catalogVersion, nameof(catalogVersion)); + Models = GameModelProviderRegistration.ValidateModels(ProviderId, models); + if (revision < 0) + { + throw new ArgumentOutOfRangeException(nameof(revision)); + } + + if (checkedAt == default) + { + throw new ArgumentException("A catalog check time is required.", nameof(checkedAt)); + } + + CheckedAt = checkedAt; + Revision = revision; + } + + public string ProviderId { get; } + + public string CatalogVersion { get; } + + public IReadOnlyList Models { get; } + + public DateTimeOffset CheckedAt { get; } + + public long Revision { get; } +} + +public enum GameModelCatalogSaveStatus +{ + Saved, + Conflict, +} + +public sealed class GameModelCatalogSaveResult +{ + public GameModelCatalogSaveResult(GameModelCatalogSaveStatus status, long revision) + { + if (!Enum.IsDefined(typeof(GameModelCatalogSaveStatus), status)) + { + throw new ArgumentOutOfRangeException(nameof(status)); + } + + if (revision < 0) + { + throw new ArgumentOutOfRangeException(nameof(revision)); + } + + Status = status; + Revision = revision; + } + + public GameModelCatalogSaveStatus Status { get; } + + public long Revision { get; } +} + +public interface IGameModelCatalogStore +{ + ValueTask LoadAsync(string providerId, CancellationToken cancellationToken); + + ValueTask SaveAsync( + GameStoredModelCatalog catalog, + long expectedRevision, + CancellationToken cancellationToken); +} + +public sealed class InMemoryGameModelCatalogStore : IGameModelCatalogStore +{ + private readonly Dictionary _catalogs = new(StringComparer.Ordinal); + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly int _capacity; + + public InMemoryGameModelCatalogStore(int capacity = 128) + { + if (capacity <= 0 || capacity > 100_000) + { + throw new ArgumentOutOfRangeException(nameof(capacity)); + } + + _capacity = capacity; + } + + public async ValueTask LoadAsync( + string providerId, + CancellationToken cancellationToken) + { + var id = GameModelDescriptor.RequireId(providerId, nameof(providerId)); + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return _catalogs.TryGetValue(id, out var catalog) ? catalog : null; + } + finally + { + _gate.Release(); + } + } + + public async ValueTask SaveAsync( + GameStoredModelCatalog catalog, + long expectedRevision, + CancellationToken cancellationToken) + { + if (catalog is null) + { + throw new ArgumentNullException(nameof(catalog)); + } + + if (expectedRevision < 0) + { + throw new ArgumentOutOfRangeException(nameof(expectedRevision)); + } + + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + cancellationToken.ThrowIfCancellationRequested(); + var currentRevision = _catalogs.TryGetValue(catalog.ProviderId, out var current) + ? current.Revision + : 0; + if (currentRevision != expectedRevision) + { + return new GameModelCatalogSaveResult(GameModelCatalogSaveStatus.Conflict, currentRevision); + } + + if (current is null && _catalogs.Count >= _capacity) + { + throw new InvalidOperationException("The model catalog store reached its capacity."); + } + + var nextRevision = checked(currentRevision + 1); + _catalogs[catalog.ProviderId] = new GameStoredModelCatalog( + catalog.ProviderId, + catalog.CatalogVersion, + catalog.Models, + catalog.CheckedAt, + nextRevision); + return new GameModelCatalogSaveResult(GameModelCatalogSaveStatus.Saved, nextRevision); + } + finally + { + _gate.Release(); + } + } +} diff --git a/src/OpenGameAgent.Models/ModelDescriptors.cs b/src/OpenGameAgent.Models/ModelDescriptors.cs new file mode 100644 index 0000000..c81dfd2 --- /dev/null +++ b/src/OpenGameAgent.Models/ModelDescriptors.cs @@ -0,0 +1,299 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace OpenGameAgent.Models; + +[Flags] +public enum GameModelInputCapabilities +{ + None = 0, + Text = 1 << 0, + Image = 1 << 1, + Audio = 1 << 2, + Video = 1 << 3, + StructuredData = 1 << 4, +} + +[Flags] +public enum GameModelOutputCapabilities +{ + None = 0, + Text = 1 << 0, + Image = 1 << 1, + Audio = 1 << 2, + Video = 1 << 3, + StructuredData = 1 << 4, + ToolCalls = 1 << 5, + Reasoning = 1 << 6, +} + +public enum GameReasoningLevel +{ + Off, + Minimal, + Low, + Medium, + High, + ExtraHigh, + Maximum, +} + +public sealed class GameModelCost +{ + public GameModelCost( + decimal inputPerMillionTokens = 0, + decimal outputPerMillionTokens = 0, + decimal cacheReadPerMillionTokens = 0, + decimal cacheWritePerMillionTokens = 0) + { + InputPerMillionTokens = RequireCost(inputPerMillionTokens, nameof(inputPerMillionTokens)); + OutputPerMillionTokens = RequireCost(outputPerMillionTokens, nameof(outputPerMillionTokens)); + CacheReadPerMillionTokens = RequireCost(cacheReadPerMillionTokens, nameof(cacheReadPerMillionTokens)); + CacheWritePerMillionTokens = RequireCost(cacheWritePerMillionTokens, nameof(cacheWritePerMillionTokens)); + } + + public decimal InputPerMillionTokens { get; } + + public decimal OutputPerMillionTokens { get; } + + public decimal CacheReadPerMillionTokens { get; } + + public decimal CacheWritePerMillionTokens { get; } + + private static decimal RequireCost(decimal value, string parameterName) => + value is >= 0 and <= 1_000_000 + ? value + : throw new ArgumentOutOfRangeException(parameterName); +} + +public sealed class GameModelDescriptor +{ + private static readonly GameReasoningLevel[] ReasoningOrder = + { + GameReasoningLevel.Off, + GameReasoningLevel.Minimal, + GameReasoningLevel.Low, + GameReasoningLevel.Medium, + GameReasoningLevel.High, + GameReasoningLevel.ExtraHigh, + GameReasoningLevel.Maximum, + }; + + public GameModelDescriptor( + string providerId, + string modelId, + string? displayName = null, + int contextWindowTokens = 0, + int maximumOutputTokens = 0, + GameModelInputCapabilities inputCapabilities = GameModelInputCapabilities.Text | GameModelInputCapabilities.StructuredData, + GameModelOutputCapabilities outputCapabilities = GameModelOutputCapabilities.Text | GameModelOutputCapabilities.ToolCalls, + IReadOnlyCollection? reasoningLevels = null, + GameModelCost? cost = null, + IReadOnlyDictionary? metadata = null, + IReadOnlyDictionary? reasoningLevelValues = null) + { + ProviderId = RequireId(providerId, nameof(providerId)); + ModelId = RequireId(modelId, nameof(modelId)); + DisplayName = displayName is null ? ModelId : RequireId(displayName, nameof(displayName)); + if (contextWindowTokens < 0 || maximumOutputTokens < 0) + { + throw new ArgumentOutOfRangeException(nameof(contextWindowTokens)); + } + + if (contextWindowTokens > 0 && maximumOutputTokens >= contextWindowTokens) + { + throw new ArgumentException("A model's maximum output must be smaller than its context window."); + } + + ValidateFlags(inputCapabilities, nameof(inputCapabilities)); + ValidateFlags(outputCapabilities, nameof(outputCapabilities)); + var levels = (reasoningLevels ?? Array.Empty()) + .Distinct() + .OrderBy(level => Array.IndexOf(ReasoningOrder, level)) + .ToArray(); + if (levels.Any(level => !Enum.IsDefined(typeof(GameReasoningLevel), level))) + { + throw new ArgumentOutOfRangeException(nameof(reasoningLevels)); + } + + if (levels.Length == 0) + { + levels = new[] { GameReasoningLevel.Off }; + } + else if (!levels.Contains(GameReasoningLevel.Off)) + { + levels = new[] { GameReasoningLevel.Off }.Concat(levels).ToArray(); + } + + if (levels.Any(level => level != GameReasoningLevel.Off) + && !outputCapabilities.HasFlag(GameModelOutputCapabilities.Reasoning)) + { + throw new ArgumentException("Reasoning levels require the reasoning output capability.", nameof(reasoningLevels)); + } + + ContextWindowTokens = contextWindowTokens; + MaximumOutputTokens = maximumOutputTokens; + InputCapabilities = inputCapabilities; + OutputCapabilities = outputCapabilities; + ReasoningLevels = Array.AsReadOnly(levels); + var values = new Dictionary(); + foreach (var pair in reasoningLevelValues ?? new Dictionary()) + { + if (!Enum.IsDefined(typeof(GameReasoningLevel), pair.Key) + || pair.Key == GameReasoningLevel.Off + || !levels.Contains(pair.Key) + || string.IsNullOrWhiteSpace(pair.Value) + || pair.Value.Length > 128) + { + throw new ArgumentException("A reasoning-level value must target a supported non-off level and contain at most 128 characters.", nameof(reasoningLevelValues)); + } + + values.Add(pair.Key, pair.Value); + } + + ReasoningLevelValues = new ReadOnlyDictionary(values); + Cost = cost ?? new GameModelCost(); + Metadata = CopyMetadata(metadata); + } + + public string ProviderId { get; } + + public string ModelId { get; } + + public string DisplayName { get; } + + public int ContextWindowTokens { get; } + + public int MaximumOutputTokens { get; } + + public GameModelInputCapabilities InputCapabilities { get; } + + public GameModelOutputCapabilities OutputCapabilities { get; } + + public IReadOnlyList ReasoningLevels { get; } + + public IReadOnlyDictionary ReasoningLevelValues { get; } + + public GameModelCost Cost { get; } + + public IReadOnlyDictionary Metadata { get; } + + public GameReasoningLevel ClampReasoning(GameReasoningLevel requested) + { + if (!Enum.IsDefined(typeof(GameReasoningLevel), requested)) + { + throw new ArgumentOutOfRangeException(nameof(requested)); + } + + if (ReasoningLevels.Contains(requested)) + { + return requested; + } + + var requestedIndex = Array.IndexOf(ReasoningOrder, requested); + for (var index = requestedIndex; index < ReasoningOrder.Length; index++) + { + if (ReasoningLevels.Contains(ReasoningOrder[index])) + { + return ReasoningOrder[index]; + } + } + + for (var index = requestedIndex - 1; index >= 0; index--) + { + if (ReasoningLevels.Contains(ReasoningOrder[index])) + { + return ReasoningOrder[index]; + } + } + + return GameReasoningLevel.Off; + } + + public bool Supports( + GameModelInputCapabilities requiredInput, + GameModelOutputCapabilities requiredOutput) + { + ValidateFlags(requiredInput, nameof(requiredInput)); + ValidateFlags(requiredOutput, nameof(requiredOutput)); + return (InputCapabilities & requiredInput) == requiredInput + && (OutputCapabilities & requiredOutput) == requiredOutput; + } + + public string? GetReasoningValue(GameReasoningLevel level) + { + if (!Enum.IsDefined(typeof(GameReasoningLevel), level)) + { + throw new ArgumentOutOfRangeException(nameof(level)); + } + + if (level == GameReasoningLevel.Off) + { + return null; + } + + if (!ReasoningLevels.Contains(level)) + { + throw new InvalidOperationException($"Reasoning level '{level}' is not supported by this model."); + } + + if (ReasoningLevelValues.TryGetValue(level, out var configured)) + { + return configured; + } + + return level switch + { + GameReasoningLevel.Minimal => "minimal", + GameReasoningLevel.Low => "low", + GameReasoningLevel.Medium => "medium", + GameReasoningLevel.High => "high", + GameReasoningLevel.ExtraHigh => "xhigh", + GameReasoningLevel.Maximum => "max", + _ => throw new InvalidOperationException("The reasoning level is invalid."), + }; + } + + internal static string RequireId(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 512) + { + throw new ArgumentException("A non-empty identifier of at most 512 characters is required.", parameterName); + } + + return value; + } + + private static IReadOnlyDictionary CopyMetadata(IReadOnlyDictionary? metadata) + { + if (metadata is { Count: > 256 }) + { + throw new ArgumentException("Model metadata cannot contain more than 256 entries.", nameof(metadata)); + } + + var copy = new Dictionary(StringComparer.Ordinal); + foreach (var pair in metadata ?? new Dictionary()) + { + var key = RequireId(pair.Key, nameof(metadata)); + if (pair.Value is null || pair.Value.Length > 16_384 || !copy.TryAdd(key, pair.Value)) + { + throw new ArgumentException("Model metadata is invalid or contains duplicate keys.", nameof(metadata)); + } + } + + return new ReadOnlyDictionary(copy); + } + + internal static void ValidateFlags(T value, string parameterName) + where T : struct, Enum + { + var numeric = Convert.ToUInt64(value); + var allowed = Enum.GetValues(typeof(T)).Cast().Aggregate(0UL, (current, item) => current | Convert.ToUInt64(item)); + if ((numeric & ~allowed) != 0) + { + throw new ArgumentOutOfRangeException(parameterName); + } + } +} diff --git a/src/OpenGameAgent.Models/OpenGameAgent.Models.csproj b/src/OpenGameAgent.Models/OpenGameAgent.Models.csproj new file mode 100644 index 0000000..1c00f6d --- /dev/null +++ b/src/OpenGameAgent.Models/OpenGameAgent.Models.csproj @@ -0,0 +1,10 @@ + + + netstandard2.1 + Optional provider catalog, model capability, reasoning, and credential runtime for OpenGameAgent. + OpenGameAgent.Models + + + + + diff --git a/src/OpenGameAgent.Models/ProviderCatalog.cs b/src/OpenGameAgent.Models/ProviderCatalog.cs new file mode 100644 index 0000000..3611091 --- /dev/null +++ b/src/OpenGameAgent.Models/ProviderCatalog.cs @@ -0,0 +1,867 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Models; + +public sealed class GameProviderDescriptor +{ + public GameProviderDescriptor( + string providerId, + string? displayName = null, + Uri? endpoint = null, + bool isLocal = false, + bool supportsDynamicModels = false, + IReadOnlyDictionary? metadata = null) + { + ProviderId = GameModelDescriptor.RequireId(providerId, nameof(providerId)); + DisplayName = displayName is null ? ProviderId : GameModelDescriptor.RequireId(displayName, nameof(displayName)); + if (endpoint is not null && (!endpoint.IsAbsoluteUri || endpoint.UserInfo.Length > 0)) + { + throw new ArgumentException("A provider endpoint must be absolute and cannot contain user information.", nameof(endpoint)); + } + + Endpoint = endpoint; + IsLocal = isLocal; + SupportsDynamicModels = supportsDynamicModels; + if (metadata is { Count: > 256 }) + { + throw new ArgumentException("Provider metadata cannot contain more than 256 entries.", nameof(metadata)); + } + + var copy = new Dictionary(StringComparer.Ordinal); + foreach (var pair in metadata ?? new Dictionary()) + { + var key = GameModelDescriptor.RequireId(pair.Key, nameof(metadata)); + if (pair.Value is null || pair.Value.Length > 16_384 || !copy.TryAdd(key, pair.Value)) + { + throw new ArgumentException("Provider metadata is invalid or contains duplicate keys.", nameof(metadata)); + } + } + + Metadata = new ReadOnlyDictionary(copy); + } + + public string ProviderId { get; } + + public string DisplayName { get; } + + public Uri? Endpoint { get; } + + public bool IsLocal { get; } + + public bool SupportsDynamicModels { get; } + + public IReadOnlyDictionary Metadata { get; } +} + +public sealed class GameModelRefreshContext +{ + internal GameModelRefreshContext( + GameProviderDescriptor provider, + IReadOnlyList currentModels, + GameProviderAuthResolution? authentication, + bool allowNetwork, + bool force) + { + Provider = provider ?? throw new ArgumentNullException(nameof(provider)); + CurrentModels = Array.AsReadOnly( + (currentModels ?? throw new ArgumentNullException(nameof(currentModels))).ToArray()); + Authentication = authentication; + AllowNetwork = allowNetwork; + Force = allowNetwork && force; + } + + public GameProviderDescriptor Provider { get; } + + public IReadOnlyList CurrentModels { get; } + + public GameProviderAuthResolution? Authentication { get; } + + public bool AllowNetwork { get; } + + public bool Force { get; } +} + +public delegate ValueTask> GameModelRefresh( + GameModelRefreshContext context, + CancellationToken cancellationToken); + +public delegate ValueTask> GameModelAvailabilityFilter( + IReadOnlyList models, + GameProviderAuthResolution? authentication, + CancellationToken cancellationToken); + +public delegate IAsyncEnumerable GameModelStream( + ModelRequest request, + GameProviderAuthResolution? authentication, + CancellationToken cancellationToken); + +public sealed class GameModelProviderRegistration +{ + public GameModelProviderRegistration( + GameProviderDescriptor descriptor, + IModelProvider provider, + IGameProviderAuthentication authentication, + IReadOnlyList? models = null, + GameModelRefresh? refreshModels = null, + GameModelAvailabilityFilter? filterModels = null, + GameModelStream? stream = null, + string catalogVersion = "1") + { + Descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor)); + Provider = provider ?? throw new ArgumentNullException(nameof(provider)); + Authentication = authentication ?? throw new ArgumentNullException(nameof(authentication)); + Models = ValidateModels(descriptor.ProviderId, models ?? Array.Empty()); + if (refreshModels is not null && !descriptor.SupportsDynamicModels) + { + throw new ArgumentException("A provider with model refresh must declare dynamic model support.", nameof(refreshModels)); + } + + RefreshModels = refreshModels; + FilterModels = filterModels; + Stream = stream ?? ((request, _, cancellationToken) => provider.StreamAsync(request, cancellationToken)); + CatalogVersion = GameModelDescriptor.RequireId(catalogVersion, nameof(catalogVersion)); + } + + public GameProviderDescriptor Descriptor { get; } + + public IModelProvider Provider { get; } + + public IGameProviderAuthentication Authentication { get; } + + public IReadOnlyList Models { get; } + + public GameModelRefresh? RefreshModels { get; } + + public GameModelAvailabilityFilter? FilterModels { get; } + + public GameModelStream Stream { get; } + + public string CatalogVersion { get; } + + internal static IReadOnlyList ValidateModels( + string providerId, + IReadOnlyList models) + { + if (models is null) + { + throw new ArgumentNullException(nameof(models)); + } + + var copy = models.ToArray(); + if (copy.Length > 100_000) + { + throw new ArgumentException("A provider cannot expose more than 100,000 models.", nameof(models)); + } + if (copy.Any(model => model is null)) + { + throw new ArgumentException("A model catalog cannot contain null entries.", nameof(models)); + } + + if (copy.Any(model => !string.Equals(model.ProviderId, providerId, StringComparison.Ordinal))) + { + throw new ArgumentException("Every model must belong to its registered provider.", nameof(models)); + } + + var duplicate = copy.GroupBy(model => model.ModelId, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new ArgumentException($"Duplicate model ID '{duplicate.Key}'.", nameof(models)); + } + + return Array.AsReadOnly(copy); + } +} + +public enum GameModelRefreshStatus +{ + Updated, + Unchanged, + SkippedStatic, + SkippedUnconfigured, + StaleRegistration, + StoreConflict, + Failed, + Canceled, +} + +public sealed class GameModelRefreshResult +{ + internal GameModelRefreshResult( + string providerId, + GameModelRefreshStatus status, + int modelCount, + Exception? error = null) + { + ProviderId = providerId; + Status = status; + ModelCount = modelCount; + Error = error; + } + + public string ProviderId { get; } + + public GameModelRefreshStatus Status { get; } + + public int ModelCount { get; } + + public Exception? Error { get; } +} + +public sealed class GameModelResolution +{ + internal GameModelResolution( + GameModelProviderRegistration registration, + GameModelDescriptor model, + GameReasoningLevel reasoning) + { + Registration = registration; + Model = model; + Reasoning = reasoning; + } + + public GameModelProviderRegistration Registration { get; } + + public IModelProvider Provider => Registration.Provider; + + public GameModelDescriptor Model { get; } + + public GameReasoningLevel Reasoning { get; } + + public ModelParameters CreateParameters(ModelParameters? baseline = null) + { + var parameters = baseline?.Clone() ?? new ModelParameters(); + parameters.ReasoningLevel = Model.GetReasoningValue(Reasoning); + if (parameters.MaxOutputTokens is { } outputLimit + && Model.MaximumOutputTokens > 0 + && outputLimit > Model.MaximumOutputTokens) + { + parameters.MaxOutputTokens = Model.MaximumOutputTokens; + } + + return parameters; + } + + public decimal EstimateCost(ModelUsage usage) + { + if (usage is null) + { + throw new ArgumentNullException(nameof(usage)); + } + + const decimal scale = 1_000_000m; + return usage.InputTokens / scale * Model.Cost.InputPerMillionTokens + + usage.OutputTokens / scale * Model.Cost.OutputPerMillionTokens + + usage.CacheReadTokens / scale * Model.Cost.CacheReadPerMillionTokens + + usage.CacheWriteTokens / scale * Model.Cost.CacheWritePerMillionTokens; + } +} + +public sealed class GameModelCatalog +{ + private readonly object _gate = new(); + private readonly Dictionary _providers = new(StringComparer.Ordinal); + private readonly int _capacity; + private readonly IGameModelCatalogStore _store; + private readonly Func _clock; + private long _generation; + + public GameModelCatalog( + int capacity = 128, + IGameModelCatalogStore? store = null, + Func? clock = null) + { + if (capacity <= 0 || capacity > 100_000) + { + throw new ArgumentOutOfRangeException(nameof(capacity)); + } + + _capacity = capacity; + _store = store ?? new InMemoryGameModelCatalogStore(capacity); + _clock = clock ?? (() => DateTimeOffset.UtcNow); + } + + public void Register(GameModelProviderRegistration registration, bool replace = false) + { + if (registration is null) + { + throw new ArgumentNullException(nameof(registration)); + } + + lock (_gate) + { + var id = registration.Descriptor.ProviderId; + if (_providers.TryGetValue(id, out var existing) && !replace) + { + throw new InvalidOperationException($"Provider '{id}' is already registered."); + } + + if (existing is null && _providers.Count >= _capacity) + { + throw new InvalidOperationException("The provider catalog reached its capacity."); + } + + existing?.Supersede(); + _providers[id] = new Entry(registration, checked(++_generation)); + } + } + + public bool Unregister(string providerId) + { + var id = GameModelDescriptor.RequireId(providerId, nameof(providerId)); + lock (_gate) + { + if (!_providers.Remove(id, out var removed)) + { + return false; + } + + removed.Supersede(); + _generation = checked(_generation + 1); + return true; + } + } + + public IReadOnlyList GetProviders() + { + lock (_gate) + { + return Array.AsReadOnly(_providers.Values + .OrderBy(entry => entry.Registration.Descriptor.ProviderId, StringComparer.Ordinal) + .Select(entry => entry.Registration) + .ToArray()); + } + } + + public GameModelProviderRegistration? GetProvider(string providerId) + { + var id = GameModelDescriptor.RequireId(providerId, nameof(providerId)); + lock (_gate) + { + return _providers.TryGetValue(id, out var entry) ? entry.Registration : null; + } + } + + public IReadOnlyList GetModels(string? providerId = null) + { + lock (_gate) + { + if (providerId is not null) + { + var id = GameModelDescriptor.RequireId(providerId, nameof(providerId)); + return _providers.TryGetValue(id, out var entry) + ? Array.AsReadOnly(entry.CurrentModels.ToArray()) + : Array.Empty(); + } + + return Array.AsReadOnly(_providers.Values + .OrderBy(entry => entry.Registration.Descriptor.ProviderId, StringComparer.Ordinal) + .SelectMany(entry => entry.CurrentModels) + .ToArray()); + } + } + + public GameModelDescriptor? GetModel(string providerId, string modelId) + { + var provider = GameModelDescriptor.RequireId(providerId, nameof(providerId)); + var model = GameModelDescriptor.RequireId(modelId, nameof(modelId)); + lock (_gate) + { + return _providers.TryGetValue(provider, out var entry) + ? entry.CurrentModels.FirstOrDefault(candidate => string.Equals(candidate.ModelId, model, StringComparison.Ordinal)) + : null; + } + } + + public GameModelResolution Resolve( + string providerId, + string modelId, + GameReasoningLevel reasoning = GameReasoningLevel.Off, + GameModelInputCapabilities requiredInput = GameModelInputCapabilities.None, + GameModelOutputCapabilities requiredOutput = GameModelOutputCapabilities.None) + { + var provider = GameModelDescriptor.RequireId(providerId, nameof(providerId)); + var model = GameModelDescriptor.RequireId(modelId, nameof(modelId)); + lock (_gate) + { + if (!_providers.TryGetValue(provider, out var entry)) + { + throw new KeyNotFoundException($"Provider '{provider}' is not registered."); + } + + var descriptor = entry.CurrentModels.FirstOrDefault( + candidate => string.Equals(candidate.ModelId, model, StringComparison.Ordinal)) + ?? throw new KeyNotFoundException($"Model '{provider}/{model}' is not registered."); + if (!descriptor.Supports(requiredInput, requiredOutput)) + { + throw new InvalidOperationException($"Model '{provider}/{model}' does not satisfy the required capabilities."); + } + + return new GameModelResolution(entry.Registration, descriptor, descriptor.ClampReasoning(reasoning)); + } + } + + public async ValueTask> GetAvailableModelsAsync( + string? providerId = null, + CancellationToken cancellationToken = default) + { + var selectedProvider = providerId is null + ? null + : GameModelDescriptor.RequireId(providerId, nameof(providerId)); + EntrySnapshot[] entries; + lock (_gate) + { + entries = _providers.Values + .Where(entry => selectedProvider is null + || string.Equals(entry.Registration.Descriptor.ProviderId, selectedProvider, StringComparison.Ordinal)) + .Select(entry => new EntrySnapshot(entry)) + .ToArray(); + } + + var checks = entries.Select(entry => GetAvailableModelsAsync(entry, cancellationToken).AsTask()).ToArray(); + var available = await Task.WhenAll(checks).ConfigureAwait(false); + return Array.AsReadOnly(available.SelectMany(models => models).ToArray()); + } + + private static async ValueTask> GetAvailableModelsAsync( + EntrySnapshot entry, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var status = await entry.Registration.Authentication.CheckAsync(cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The provider authentication check returned null."); + if (!status.Configured) + { + return Array.Empty(); + } + + var auth = await entry.Registration.Authentication.ResolveAsync(cancellationToken).ConfigureAwait(false); + var models = entry.Models; + if (entry.Registration.FilterModels is null) + { + return models; + } + + models = await entry.Registration.FilterModels(models, auth, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The provider model filter returned null."); + return GameModelProviderRegistration.ValidateModels( + entry.Registration.Descriptor.ProviderId, + models); + } + + internal IModelProvider CreateDispatchProvider(string providerId) => + new CatalogDispatchProvider(this, GameModelDescriptor.RequireId(providerId, nameof(providerId))); + + private async IAsyncEnumerable StreamAsync( + string providerId, + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + GameModelProviderRegistration registration; + lock (_gate) + { + if (!_providers.TryGetValue(providerId, out var entry)) + { + throw new ModelProviderException( + $"Provider '{providerId}' is no longer registered.", + isTransient: false); + } + + if (!entry.CurrentModels.Any(model => string.Equals(model.ModelId, request.Model, StringComparison.Ordinal))) + { + throw new ModelProviderException( + $"Model '{providerId}/{request.Model}' is no longer registered.", + isTransient: false); + } + + registration = entry.Registration; + } + + var status = await registration.Authentication.CheckAsync(cancellationToken).ConfigureAwait(false) + ?? throw new ModelProviderException( + $"Provider '{providerId}' returned no authentication status.", + isTransient: false); + if (!status.Configured) + { + throw new ModelProviderException( + status.Error ?? $"Provider '{providerId}' is not configured.", + isTransient: false); + } + + var authentication = await registration.Authentication.ResolveAsync(cancellationToken).ConfigureAwait(false); + await foreach (var streamEvent in registration.Stream( + request, + authentication, + cancellationToken).WithCancellation(cancellationToken).ConfigureAwait(false)) + { + yield return streamEvent + ?? throw new InvalidOperationException("A registered model provider emitted a null stream event."); + } + } + + public async ValueTask RefreshAsync( + string providerId, + bool allowNetwork = true, + bool force = false, + CancellationToken cancellationToken = default) + { + var id = GameModelDescriptor.RequireId(providerId, nameof(providerId)); + EntrySnapshot snapshot; + lock (_gate) + { + if (!_providers.TryGetValue(id, out var entry)) + { + throw new KeyNotFoundException($"Provider '{id}' is not registered."); + } + + snapshot = entry.Registration.RefreshModels is null + ? new EntrySnapshot(entry) + : entry.BeginRefresh(cancellationToken); + } + + if (snapshot.Registration.RefreshModels is null) + { + return new GameModelRefreshResult(id, GameModelRefreshStatus.SkippedStatic, snapshot.Models.Count); + } + + var refreshGateAcquired = false; + try + { + await snapshot.RefreshGate.WaitAsync(snapshot.RefreshToken).ConfigureAwait(false); + refreshGateAcquired = true; + var stored = await _store.LoadAsync(id, snapshot.RefreshToken).ConfigureAwait(false); + var currentModels = snapshot.Models; + if (stored is not null + && string.Equals( + stored.CatalogVersion, + snapshot.Registration.CatalogVersion, + StringComparison.Ordinal)) + { + var restored = GameModelProviderRegistration.ValidateModels(id, stored.Models); + lock (_gate) + { + if (!_providers.TryGetValue(id, out var current) + || current.Generation != snapshot.Generation + || current.RefreshGeneration != snapshot.RefreshGeneration + || !ReferenceEquals(current.Registration, snapshot.Registration)) + { + return new GameModelRefreshResult( + id, + GameModelRefreshStatus.StaleRegistration, + snapshot.Models.Count); + } + + current.DynamicModels = restored; + current.CurrentModels = Merge(current.Registration.Models, restored); + currentModels = current.CurrentModels; + } + } + + var status = await snapshot.Registration.Authentication.CheckAsync(snapshot.RefreshToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The provider authentication check returned null."); + if (!status.Configured) + { + return new GameModelRefreshResult(id, GameModelRefreshStatus.SkippedUnconfigured, currentModels.Count); + } + + var authentication = await snapshot.Registration.Authentication.ResolveAsync(snapshot.RefreshToken).ConfigureAwait(false); + var refreshed = await snapshot.Registration.RefreshModels( + new GameModelRefreshContext( + snapshot.Registration.Descriptor, + currentModels, + authentication, + allowNetwork, + force), + snapshot.RefreshToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The provider model refresh returned null."); + var dynamicModels = GameModelProviderRegistration.ValidateModels(id, refreshed); + lock (_gate) + { + if (!_providers.TryGetValue(id, out var current) + || current.Generation != snapshot.Generation + || current.RefreshGeneration != snapshot.RefreshGeneration + || !ReferenceEquals(current.Registration, snapshot.Registration)) + { + return new GameModelRefreshResult( + id, + GameModelRefreshStatus.StaleRegistration, + snapshot.Models.Count); + } + } + + var save = await _store.SaveAsync( + new GameStoredModelCatalog( + id, + snapshot.Registration.CatalogVersion, + dynamicModels, + _clock()), + stored?.Revision ?? 0, + snapshot.RefreshToken).ConfigureAwait(false); + if (save.Status == GameModelCatalogSaveStatus.Conflict) + { + return new GameModelRefreshResult(id, GameModelRefreshStatus.StoreConflict, currentModels.Count); + } + + lock (_gate) + { + if (!_providers.TryGetValue(id, out var current) + || current.Generation != snapshot.Generation + || current.RefreshGeneration != snapshot.RefreshGeneration + || !ReferenceEquals(current.Registration, snapshot.Registration)) + { + return new GameModelRefreshResult(id, GameModelRefreshStatus.StaleRegistration, snapshot.Models.Count); + } + + var merged = Merge(current.Registration.Models, dynamicModels); + var changed = !Equivalent(current.CurrentModels, merged); + current.DynamicModels = dynamicModels; + current.CurrentModels = merged; + return new GameModelRefreshResult( + id, + changed ? GameModelRefreshStatus.Updated : GameModelRefreshStatus.Unchanged, + merged.Count); + } + } + catch (OperationCanceledException) when (snapshot.RefreshToken.IsCancellationRequested) + { + lock (_gate) + { + var stale = !_providers.TryGetValue(id, out var current) + || current.Generation != snapshot.Generation + || current.RefreshGeneration != snapshot.RefreshGeneration; + return new GameModelRefreshResult( + id, + stale ? GameModelRefreshStatus.StaleRegistration : GameModelRefreshStatus.Canceled, + snapshot.Models.Count); + } + } + catch (Exception exception) + { + return new GameModelRefreshResult(id, GameModelRefreshStatus.Failed, snapshot.Models.Count, exception); + } + finally + { + if (refreshGateAcquired) + { + snapshot.RefreshGate.Release(); + } + + lock (_gate) + { + if (_providers.TryGetValue(id, out var current) + && current.Generation == snapshot.Generation) + { + current.CompleteRefresh(snapshot.RefreshGeneration); + } + } + } + } + + public async ValueTask> RefreshAsync( + IReadOnlyCollection? providerIds = null, + bool allowNetwork = true, + bool force = false, + CancellationToken cancellationToken = default) + { + string[] ids; + lock (_gate) + { + ids = (providerIds ?? _providers.Keys.ToArray()) + .Select(id => GameModelDescriptor.RequireId(id, nameof(providerIds))) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .ToArray(); + } + + var tasks = ids.Select(id => RefreshAsync(id, allowNetwork, force, cancellationToken).AsTask()).ToArray(); + return Array.AsReadOnly(await Task.WhenAll(tasks).ConfigureAwait(false)); + } + + private static IReadOnlyList Merge( + IReadOnlyList baseline, + IReadOnlyList dynamicModels) + { + var merged = baseline.ToList(); + foreach (var model in dynamicModels) + { + var index = merged.FindIndex(candidate => string.Equals(candidate.ModelId, model.ModelId, StringComparison.Ordinal)); + if (index >= 0) + { + merged[index] = model; + } + else + { + merged.Add(model); + } + } + + return Array.AsReadOnly(merged.ToArray()); + } + + private static bool Equivalent( + IReadOnlyList left, + IReadOnlyList right) + { + if (left.Count != right.Count) + { + return false; + } + + for (var index = 0; index < left.Count; index++) + { + if (!Equivalent(left[index], right[index])) + { + return false; + } + } + + return true; + } + + private static bool Equivalent(GameModelDescriptor left, GameModelDescriptor right) => + string.Equals(left.ProviderId, right.ProviderId, StringComparison.Ordinal) + && string.Equals(left.ModelId, right.ModelId, StringComparison.Ordinal) + && string.Equals(left.DisplayName, right.DisplayName, StringComparison.Ordinal) + && left.ContextWindowTokens == right.ContextWindowTokens + && left.MaximumOutputTokens == right.MaximumOutputTokens + && left.InputCapabilities == right.InputCapabilities + && left.OutputCapabilities == right.OutputCapabilities + && left.ReasoningLevels.SequenceEqual(right.ReasoningLevels) + && left.ReasoningLevelValues.Count == right.ReasoningLevelValues.Count + && left.ReasoningLevelValues.All(pair => right.ReasoningLevelValues.TryGetValue(pair.Key, out var value) + && string.Equals(pair.Value, value, StringComparison.Ordinal)) + && left.Cost.InputPerMillionTokens == right.Cost.InputPerMillionTokens + && left.Cost.OutputPerMillionTokens == right.Cost.OutputPerMillionTokens + && left.Cost.CacheReadPerMillionTokens == right.Cost.CacheReadPerMillionTokens + && left.Cost.CacheWritePerMillionTokens == right.Cost.CacheWritePerMillionTokens + && left.Metadata.Count == right.Metadata.Count + && left.Metadata.All(pair => right.Metadata.TryGetValue(pair.Key, out var value) + && string.Equals(pair.Value, value, StringComparison.Ordinal)); + + private sealed class CatalogDispatchProvider : IModelProvider + { + private readonly GameModelCatalog _catalog; + private readonly string _providerId; + + public CatalogDispatchProvider(GameModelCatalog catalog, string providerId) + { + _catalog = catalog; + _providerId = providerId; + } + + public IAsyncEnumerable StreamAsync( + ModelRequest request, + CancellationToken cancellationToken) => + _catalog.StreamAsync(_providerId, request, cancellationToken); + } + + private sealed class Entry + { + private CancellationTokenSource? _refreshCancellation; + + public Entry(GameModelProviderRegistration registration, long generation) + { + Registration = registration; + Generation = generation; + DynamicModels = Array.Empty(); + CurrentModels = registration.Models; + } + + public GameModelProviderRegistration Registration { get; } + + public long Generation { get; } + + public long RefreshGeneration { get; private set; } + + public IReadOnlyList DynamicModels { get; set; } + + public IReadOnlyList CurrentModels { get; set; } + + public SemaphoreSlim RefreshGate { get; } = new(1, 1); + + public EntrySnapshot BeginRefresh(CancellationToken cancellationToken) + { + SupersedeRefresh(); + RefreshGeneration = checked(RefreshGeneration + 1); + _refreshCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + return new EntrySnapshot(this, RefreshGeneration, _refreshCancellation.Token); + } + + public void CompleteRefresh(long refreshGeneration) + { + if (RefreshGeneration != refreshGeneration) + { + return; + } + + _refreshCancellation?.Dispose(); + _refreshCancellation = null; + } + + public void Supersede() + { + RefreshGeneration = checked(RefreshGeneration + 1); + SupersedeRefresh(); + } + + private void SupersedeRefresh() + { + var cancellation = _refreshCancellation; + _refreshCancellation = null; + if (cancellation is null) + { + return; + } + + try + { + cancellation.Cancel(); + } + catch (AggregateException) + { + // A refresh callback cannot block provider replacement or removal. + } + finally + { + cancellation.Dispose(); + } + } + } + + private sealed class EntrySnapshot + { + public EntrySnapshot(Entry entry) + : this(entry, entry.RefreshGeneration, default) + { + } + + public EntrySnapshot(Entry entry, long refreshGeneration, CancellationToken refreshToken) + { + Registration = entry.Registration; + Generation = entry.Generation; + RefreshGeneration = refreshGeneration; + RefreshToken = refreshToken; + RefreshGate = entry.RefreshGate; + Models = entry.CurrentModels.ToArray(); + } + + public GameModelProviderRegistration Registration { get; } + + public long Generation { get; } + + public long RefreshGeneration { get; } + + public CancellationToken RefreshToken { get; } + + public SemaphoreSlim RefreshGate { get; } + + public IReadOnlyList Models { get; } + } +} diff --git a/src/OpenGameAgent.Models/packages.lock.json b/src/OpenGameAgent.Models/packages.lock.json new file mode 100644 index 0000000..2d58bb3 --- /dev/null +++ b/src/OpenGameAgent.Models/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.1, )", + "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.Persistence/DirectoryGameSkillSource.cs b/src/OpenGameAgent.Persistence/DirectoryGameSkillSource.cs index 01a2577..289cf53 100644 --- a/src/OpenGameAgent.Persistence/DirectoryGameSkillSource.cs +++ b/src/OpenGameAgent.Persistence/DirectoryGameSkillSource.cs @@ -10,16 +10,22 @@ namespace OpenGameAgent.Persistence; public sealed class DirectoryGameSkillSource : IGameSkillSource { + private static readonly JsonSerializerOptions ManifestSerializerOptions = new() + { + PropertyNameCaseInsensitive = true, + }; private readonly string _root; private readonly int _maximumSkills; private readonly int _maximumManifestCharacters; private readonly int _maximumInstructionsCharacters; + private readonly int _maximumScannedDirectories; public DirectoryGameSkillSource( string directory, int maximumSkills = 1_000, int maximumManifestCharacters = 100_000, - int maximumInstructionsCharacters = 1_000_000) + int maximumInstructionsCharacters = 1_000_000, + int maximumScannedDirectories = 10_000) { if (string.IsNullOrWhiteSpace(directory)) { @@ -41,6 +47,11 @@ public DirectoryGameSkillSource( throw new ArgumentOutOfRangeException(nameof(maximumInstructionsCharacters)); } + if (maximumScannedDirectories <= 0 || maximumScannedDirectories > 1_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumScannedDirectories)); + } + _root = Path.GetFullPath(directory); if (!Directory.Exists(_root)) { @@ -50,6 +61,7 @@ public DirectoryGameSkillSource( _maximumSkills = maximumSkills; _maximumManifestCharacters = maximumManifestCharacters; _maximumInstructionsCharacters = maximumInstructionsCharacters; + _maximumScannedDirectories = maximumScannedDirectories; _ = LoadManifests(); } @@ -79,7 +91,7 @@ public ValueTask> SelectAsync( private IReadOnlyList LoadManifests() { - var manifests = EnumerateSkillDescriptors() + var manifests = EnumerateSkillDescriptors(_maximumScannedDirectories) .OrderBy(path => path, StringComparer.Ordinal) .Take(_maximumSkills + 1) .ToArray(); @@ -141,10 +153,9 @@ private static Manifest LoadJsonManifest( { using var document = JsonDocument.Parse(manifestText, new JsonDocumentOptions { MaxDepth = 128 }); EnsureManifestIsUnambiguous(document.RootElement, manifestPath); - manifest = JsonSerializer.Deserialize(document.RootElement.GetRawText(), new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true, - }) ?? throw new PersistenceException("A skill manifest is empty."); + manifest = JsonSerializer.Deserialize( + document.RootElement.GetRawText(), + ManifestSerializerOptions) ?? throw new PersistenceException("A skill manifest is empty."); } catch (JsonException exception) { @@ -259,6 +270,12 @@ private static Manifest LoadMarkdownSkill( throw new PersistenceException($"Skill file '{path}' requires a description."); } + ValidatePortableSkillName(name, path); + if (description.Length > 1_024) + { + throw new PersistenceException($"Skill file '{path}' has a description longer than 1024 characters."); + } + var document = new ManifestDocument { Id = name, @@ -298,7 +315,7 @@ private static IReadOnlyDictionary ParseScalarFrontMatter(string foreach (var rawLine in text.Split('\n')) { var line = rawLine.Trim(); - if (line.Length == 0 || line.StartsWith("#", StringComparison.Ordinal)) + if (line.Length == 0 || line.StartsWith('#')) { continue; } @@ -312,8 +329,8 @@ private static IReadOnlyDictionary ParseScalarFrontMatter(string var key = line.Substring(0, separator).Trim(); var value = line.Substring(separator + 1).Trim(); if (value.Length >= 2 - && ((value.StartsWith("\"", StringComparison.Ordinal) && value.EndsWith("\"", StringComparison.Ordinal)) - || (value.StartsWith("'", StringComparison.Ordinal) && value.EndsWith("'", StringComparison.Ordinal))) + && ((value.StartsWith('"') && value.EndsWith('"')) + || (value.StartsWith('\'') && value.EndsWith('\''))) ) { value = value.Substring(1, value.Length - 2); @@ -328,21 +345,22 @@ private static IReadOnlyDictionary ParseScalarFrontMatter(string return values; } - private IEnumerable EnumerateSkillDescriptors() + private IEnumerable EnumerateSkillDescriptors(int maximumScannedDirectories) { - var rootJson = Path.Combine(_root, "skill.json"); - var rootMarkdown = Path.Combine(_root, "SKILL.md"); - if (File.Exists(rootJson)) - { - yield return rootJson; - } - else if (File.Exists(rootMarkdown)) - { - yield return rootMarkdown; - } + var pending = new Stack(); + pending.Push(_root); + var scanned = 0; + while (pending.Count > 0) + { + var directory = pending.Pop(); + scanned++; + if (scanned > maximumScannedDirectories) + { + throw new GameRuntimeLimitException( + nameof(maximumScannedDirectories), + "The skill directory tree exceeds its configured scan limit."); + } - foreach (var directory in Directory.EnumerateDirectories(_root, "*", SearchOption.TopDirectoryOnly)) - { if ((File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0) { continue; @@ -353,11 +371,45 @@ private IEnumerable EnumerateSkillDescriptors() if (File.Exists(json)) { yield return json; + continue; } - else if (File.Exists(markdown)) + + if (File.Exists(markdown)) { yield return markdown; + continue; } + + var children = Directory.EnumerateDirectories(directory, "*", SearchOption.TopDirectoryOnly) + .Where(path => + { + var name = Path.GetFileName(path); + return !name.StartsWith('.') + && !string.Equals(name, "node_modules", StringComparison.OrdinalIgnoreCase) + && (File.GetAttributes(path) & FileAttributes.ReparsePoint) == 0; + }) + .OrderByDescending(path => path, StringComparer.Ordinal) + .ToArray(); + foreach (var child in children) + { + pending.Push(child); + } + } + } + + private static void ValidatePortableSkillName(string name, string path) + { + if (name.Length > 64 + || name.StartsWith('-') + || name.EndsWith('-') + || name.Contains("--", StringComparison.Ordinal) + || name.Any(character => + character is not (>= 'a' and <= 'z') + && character is not (>= '0' and <= '9') + && character != '-')) + { + throw new PersistenceException( + $"Skill file '{path}' requires a lowercase name of at most 64 letters, digits, or single hyphens."); } } diff --git a/src/OpenGameAgent.Persistence/FileGameActionJournal.cs b/src/OpenGameAgent.Persistence/FileGameActionJournal.cs index e511d59..fd43bc9 100644 --- a/src/OpenGameAgent.Persistence/FileGameActionJournal.cs +++ b/src/OpenGameAgent.Persistence/FileGameActionJournal.cs @@ -42,6 +42,7 @@ public async ValueTask ReserveAsync( await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { + using var processLease = await _files.AcquireProcessLeaseAsync(intent.OperationId + Suffix, cancellationToken).ConfigureAwait(false); var path = _files.PathFor(intent.OperationId, Suffix); var existing = await _files.ReadAsync(path, cancellationToken).ConfigureAwait(false); if (existing is not null) @@ -58,6 +59,9 @@ public async ValueTask ReserveAsync( await _capacityGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { + using var capacityLease = await _files.AcquireProcessLeaseAsync( + "action-journal-capacity", + cancellationToken).ConfigureAwait(false); var raced = await _files.ReadAsync(path, cancellationToken).ConfigureAwait(false); if (raced is not null) { @@ -105,6 +109,7 @@ await _files.WriteAtomicAsync( await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { + using var processLease = await _files.AcquireProcessLeaseAsync(operationId + Suffix, cancellationToken).ConfigureAwait(false); var document = await _files.ReadAsync( _files.PathFor(operationId, Suffix), cancellationToken).ConfigureAwait(false); @@ -144,6 +149,7 @@ public async ValueTask MarkDispatchedAsync( await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { + using var processLease = await _files.AcquireProcessLeaseAsync(operationId + Suffix, cancellationToken).ConfigureAwait(false); var path = _files.PathFor(operationId, Suffix); var existing = await _files.ReadAsync(path, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Cannot dispatch an action without a matching intent."); @@ -186,6 +192,7 @@ public async ValueTask SaveReceiptAsync(GameActionReceipt receipt, CancellationT await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { + using var processLease = await _files.AcquireProcessLeaseAsync(receipt.OperationId + Suffix, cancellationToken).ConfigureAwait(false); var path = _files.PathFor(receipt.OperationId, Suffix); var existing = await _files.ReadAsync(path, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("Cannot save a receipt without a matching action intent."); diff --git a/src/OpenGameAgent.Persistence/FileGameAgentArtifactStore.cs b/src/OpenGameAgent.Persistence/FileGameAgentArtifactStore.cs new file mode 100644 index 0000000..576745c --- /dev/null +++ b/src/OpenGameAgent.Persistence/FileGameAgentArtifactStore.cs @@ -0,0 +1,160 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Extensions; + +namespace OpenGameAgent.Persistence; + +public sealed class FileGameAgentArtifactStore : IGameAgentArtifactStore +{ + private const string Suffix = ".artifact.json"; + private readonly FileStore _files; + + public FileGameAgentArtifactStore( + string directory, + long maximumFileBytes = 20_000_000, + int concurrencyStripes = 64) + { + _files = new FileStore(directory, maximumFileBytes, concurrencyStripes); + } + + public async ValueTask PutAsync(GameAgentArtifact artifact, CancellationToken cancellationToken) + { + if (artifact is null) + { + throw new ArgumentNullException(nameof(artifact)); + } + + var storageKey = StorageKey(artifact.SessionId, artifact.ActorId, artifact.ArtifactId); + var gate = _files.GateFor(storageKey); + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + using var processLease = await _files.AcquireProcessLeaseAsync(storageKey + Suffix, cancellationToken).ConfigureAwait(false); + var path = _files.PathFor(storageKey, Suffix); + var currentDocument = await _files.ReadAsync(path, cancellationToken).ConfigureAwait(false); + if (currentDocument is not null) + { + var current = Decode(currentDocument); + if (!Equivalent(current, artifact)) + { + throw new PersistenceException("An artifact ID cannot be reused for different content."); + } + + return; + } + + await _files.WriteAtomicAsync(path, Encode(artifact), cancellationToken).ConfigureAwait(false); + } + finally + { + gate.Release(); + } + } + + public async ValueTask GetAsync( + string sessionId, + string actorId, + string artifactId, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(sessionId) + || string.IsNullOrWhiteSpace(actorId) + || string.IsNullOrWhiteSpace(artifactId)) + { + throw new ArgumentException("Artifact IDs and owners are required."); + } + + var storageKey = StorageKey(sessionId, actorId, artifactId); + var gate = _files.GateFor(storageKey); + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + using var processLease = await _files.AcquireProcessLeaseAsync(storageKey + Suffix, cancellationToken).ConfigureAwait(false); + var document = await _files.ReadAsync( + _files.PathFor(storageKey, Suffix), + cancellationToken).ConfigureAwait(false); + if (document is null) + { + return null; + } + + var artifact = Decode(document); + if (!string.Equals(artifact.SessionId, sessionId, StringComparison.Ordinal) + || !string.Equals(artifact.ActorId, actorId, StringComparison.Ordinal) + || !string.Equals(artifact.ArtifactId, artifactId, StringComparison.Ordinal)) + { + throw new PersistenceException("The artifact identity does not match its storage key."); + } + + return artifact; + } + finally + { + gate.Release(); + } + } + + private static string StorageKey(string sessionId, string actorId, string artifactId) => + string.Concat(sessionId, "\n", actorId, "\n", artifactId); + + private static ArtifactDocument Encode(GameAgentArtifact artifact) => new() + { + FormatVersion = 1, + ArtifactId = artifact.ArtifactId, + SessionId = artifact.SessionId, + ActorId = artifact.ActorId, + MediaType = artifact.MediaType, + Content = artifact.Content, + TimelineId = artifact.CreatedAt.TimelineId, + Tick = artifact.CreatedAt.Tick, + CalendarJson = artifact.CreatedAt.CalendarJson, + }; + + private static GameAgentArtifact Decode(ArtifactDocument document) + { + if (document.FormatVersion != 1) + { + throw new PersistenceException("The artifact document has an unsupported format."); + } + + return FileStore.DecodeDocument( + "artifact document", + () => new GameAgentArtifact( + document.ArtifactId, + document.SessionId, + document.ActorId, + document.MediaType, + document.Content, + new GameMoment(document.TimelineId, document.Tick, document.CalendarJson))); + } + + private static bool Equivalent(GameAgentArtifact left, GameAgentArtifact right) => + string.Equals(left.ArtifactId, right.ArtifactId, StringComparison.Ordinal) + && string.Equals(left.SessionId, right.SessionId, StringComparison.Ordinal) + && string.Equals(left.ActorId, right.ActorId, StringComparison.Ordinal) + && string.Equals(left.MediaType, right.MediaType, StringComparison.Ordinal) + && string.Equals(left.Content, right.Content, StringComparison.Ordinal) + && left.CreatedAt == right.CreatedAt; + + private sealed class ArtifactDocument + { + public int FormatVersion { get; set; } + + public string ArtifactId { get; set; } = string.Empty; + + public string SessionId { get; set; } = string.Empty; + + public string ActorId { get; set; } = string.Empty; + + public string MediaType { get; set; } = string.Empty; + + public string Content { get; set; } = string.Empty; + + public string TimelineId { get; set; } = string.Empty; + + public long Tick { get; set; } + + public string? CalendarJson { get; set; } + } +} diff --git a/src/OpenGameAgent.Persistence/FileGameAgentDelegationStore.cs b/src/OpenGameAgent.Persistence/FileGameAgentDelegationStore.cs new file mode 100644 index 0000000..5cc83b0 --- /dev/null +++ b/src/OpenGameAgent.Persistence/FileGameAgentDelegationStore.cs @@ -0,0 +1,210 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Extensions; + +namespace OpenGameAgent.Persistence; + +public sealed class FileGameAgentDelegationStore : IGameAgentDelegationStore +{ + private const string Suffix = ".delegation.json"; + private readonly FileStore _files; + + public FileGameAgentDelegationStore( + string directory, + long maximumFileBytes = 4_000_000, + int concurrencyStripes = 64) + { + _files = new FileStore(directory, maximumFileBytes, concurrencyStripes); + } + + public async ValueTask LoadAsync( + string sessionId, + string actorId, + string id, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(sessionId) + || string.IsNullOrWhiteSpace(actorId) + || string.IsNullOrWhiteSpace(id)) + { + throw new ArgumentException("Delegation IDs and owners are required."); + } + + var storageKey = StorageKey(sessionId, actorId, id); + var gate = _files.GateFor(storageKey); + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + using var processLease = await _files.AcquireProcessLeaseAsync(storageKey + Suffix, cancellationToken).ConfigureAwait(false); + var document = await _files.ReadAsync( + _files.PathFor(storageKey, Suffix), + cancellationToken).ConfigureAwait(false); + if (document is null) + { + return null; + } + + var record = Decode(document); + if (!string.Equals(record.SessionId, sessionId, StringComparison.Ordinal) + || !string.Equals(record.ActorId, actorId, StringComparison.Ordinal) + || !string.Equals(record.Id, id, StringComparison.Ordinal)) + { + throw new PersistenceException("The delegation identity does not match its storage key."); + } + + return record; + } + finally + { + gate.Release(); + } + } + + public async ValueTask SaveAsync( + GameAgentDelegationRecord record, + long expectedRevision, + CancellationToken cancellationToken) + { + if (record is null) + { + throw new ArgumentNullException(nameof(record)); + } + + var storageKey = StorageKey(record.SessionId, record.ActorId, record.Id); + var gate = _files.GateFor(storageKey); + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + using var processLease = await _files.AcquireProcessLeaseAsync(storageKey + Suffix, cancellationToken).ConfigureAwait(false); + var path = _files.PathFor(storageKey, Suffix); + var document = await _files.ReadAsync(path, cancellationToken).ConfigureAwait(false); + var current = document is null ? null : Decode(document); + if (current is not null) + { + EnsureSameIdentity(current, record); + } + + if ((current?.Revision ?? 0) != expectedRevision) + { + return new GameAgentDelegationSaveResult( + false, + current ?? new GameAgentDelegationRecord( + record.Id, + record.SessionId, + record.ActorId, + 0, + GameAgentDelegationStatus.Pending, + record.TaskJson, + record.Depth, + record.CreatedAt)); + } + + if (current is not null && IsTerminal(current.Status)) + { + throw new PersistenceException("A terminal delegation record is immutable."); + } + + if (record.Revision != checked(expectedRevision + 1)) + { + throw new ArgumentException("A delegation revision must advance by exactly one.", nameof(record)); + } + + await _files.WriteAtomicAsync(path, Encode(record), cancellationToken).ConfigureAwait(false); + return new GameAgentDelegationSaveResult(true, record); + } + finally + { + gate.Release(); + } + } + + private static string StorageKey(string sessionId, string actorId, string id) => + string.Concat(sessionId, "\n", actorId, "\n", id); + + private static DelegationDocument Encode(GameAgentDelegationRecord record) => new() + { + FormatVersion = 1, + Id = record.Id, + SessionId = record.SessionId, + ActorId = record.ActorId, + Revision = record.Revision, + Status = record.Status, + TaskJson = record.TaskJson, + Depth = record.Depth, + TimelineId = record.CreatedAt.TimelineId, + Tick = record.CreatedAt.Tick, + CalendarJson = record.CreatedAt.CalendarJson, + ResultJson = record.ResultJson, + Error = record.Error, + }; + + private static GameAgentDelegationRecord Decode(DelegationDocument document) + { + if (document.FormatVersion != 1) + { + throw new PersistenceException("The delegation document has an unsupported format."); + } + + return FileStore.DecodeDocument( + "delegation document", + () => new GameAgentDelegationRecord( + document.Id, + document.SessionId, + document.ActorId, + document.Revision, + document.Status, + document.TaskJson, + document.Depth, + new GameMoment(document.TimelineId, document.Tick, document.CalendarJson), + document.ResultJson, + document.Error)); + } + + private static void EnsureSameIdentity(GameAgentDelegationRecord current, GameAgentDelegationRecord next) + { + if (!string.Equals(current.Id, next.Id, StringComparison.Ordinal) + || !string.Equals(current.SessionId, next.SessionId, StringComparison.Ordinal) + || !string.Equals(current.ActorId, next.ActorId, StringComparison.Ordinal) + || !string.Equals(current.TaskJson, next.TaskJson, StringComparison.Ordinal) + || current.Depth != next.Depth + || current.CreatedAt != next.CreatedAt) + { + throw new PersistenceException("A delegation cannot change its task identity or owner."); + } + } + + private static bool IsTerminal(GameAgentDelegationStatus status) => + status is GameAgentDelegationStatus.Completed + or GameAgentDelegationStatus.Failed + or GameAgentDelegationStatus.Cancelled; + + private sealed class DelegationDocument + { + public int FormatVersion { get; set; } + + public string Id { get; set; } = string.Empty; + + public string SessionId { get; set; } = string.Empty; + + public string ActorId { get; set; } = string.Empty; + + public long Revision { get; set; } + + public GameAgentDelegationStatus Status { get; set; } + + public string TaskJson { get; set; } = "{}"; + + public int Depth { get; set; } + + public string TimelineId { get; set; } = string.Empty; + + public long Tick { get; set; } + + public string? CalendarJson { get; set; } + + public string? ResultJson { get; set; } + + public string? Error { get; set; } + } +} diff --git a/src/OpenGameAgent.Persistence/FileGameMailbox.cs b/src/OpenGameAgent.Persistence/FileGameMailbox.cs index 4034ccd..10a6753 100644 --- a/src/OpenGameAgent.Persistence/FileGameMailbox.cs +++ b/src/OpenGameAgent.Persistence/FileGameMailbox.cs @@ -42,6 +42,7 @@ public async ValueTask EnqueueAsync( await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { + using var processLease = await _files.AcquireProcessLeaseAsync(message.MessageId + Suffix, cancellationToken).ConfigureAwait(false); var path = _files.PathFor(message.MessageId, Suffix); var existing = await _files.ReadAsync(path, cancellationToken).ConfigureAwait(false); if (existing is not null) @@ -53,6 +54,9 @@ public async ValueTask EnqueueAsync( await _capacityGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { + using var capacityLease = await _files.AcquireProcessLeaseAsync( + "mailbox-capacity", + cancellationToken).ConfigureAwait(false); var raced = await _files.ReadAsync(path, cancellationToken).ConfigureAwait(false); if (raced is not null) { @@ -157,6 +161,7 @@ public async ValueTask> ClaimAsync( await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { + using var processLease = await _files.AcquireProcessLeaseAsync(candidate.MessageId + Suffix, cancellationToken).ConfigureAwait(false); var current = await _files.ReadAsync(candidate.Path, cancellationToken).ConfigureAwait(false); if (current is not null) { @@ -210,6 +215,7 @@ private async ValueTask SettleAsync( await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { + using var processLease = await _files.AcquireProcessLeaseAsync(messageId + Suffix, cancellationToken).ConfigureAwait(false); var path = _files.PathFor(messageId, Suffix); var current = await _files.ReadAsync(path, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The mailbox message does not exist."); diff --git a/src/OpenGameAgent.Persistence/FileGameMemoryStore.cs b/src/OpenGameAgent.Persistence/FileGameMemoryStore.cs index 7a5ada0..c01d080 100644 --- a/src/OpenGameAgent.Persistence/FileGameMemoryStore.cs +++ b/src/OpenGameAgent.Persistence/FileGameMemoryStore.cs @@ -36,11 +36,13 @@ public async ValueTask AppendAsync(GameMemory memory, CancellationToken cancella throw new ArgumentNullException(nameof(memory)); } - var gate = _files.GateFor(memory.MemoryId); + var storageKey = StorageKey(memory.SessionId, memory.OwnerId, memory.MemoryId); + var gate = _files.GateFor(storageKey); await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { - var path = _files.PathFor(memory.MemoryId, Suffix); + using var processLease = await _files.AcquireProcessLeaseAsync(storageKey + Suffix, cancellationToken).ConfigureAwait(false); + var path = _files.PathFor(storageKey, Suffix); var existing = await _files.ReadAsync(path, cancellationToken).ConfigureAwait(false); if (existing is not null) { @@ -51,6 +53,9 @@ public async ValueTask AppendAsync(GameMemory memory, CancellationToken cancella await _capacityGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { + using var capacityLease = await _files.AcquireProcessLeaseAsync( + "memory-capacity", + cancellationToken).ConfigureAwait(false); existing = await _files.ReadAsync(path, cancellationToken).ConfigureAwait(false); if (existing is not null) { @@ -97,7 +102,11 @@ public async ValueTask> SearchAsync( if (document is not null) { var memory = Decode(document); - _files.EnsurePathFor(path, memory.MemoryId, Suffix, "memory"); + _files.EnsurePathFor( + path, + StorageKey(memory.SessionId, memory.OwnerId, memory.MemoryId), + Suffix, + "memory"); await inMemory.AppendAsync(memory, cancellationToken).ConfigureAwait(false); } } @@ -123,6 +132,9 @@ public async ValueTask> SearchAsync( Metadata = new Dictionary(memory.Metadata, StringComparer.Ordinal), }; + private static string StorageKey(string sessionId, string ownerId, string memoryId) => + sessionId + "\n" + ownerId + "\n" + memoryId; + private static GameMemory Decode(MemoryDocument document) { if (document.FormatVersion != 1 diff --git a/src/OpenGameAgent.Persistence/FileGameSessionStore.cs b/src/OpenGameAgent.Persistence/FileGameSessionStore.cs index e21d5ef..ec15e3d 100644 --- a/src/OpenGameAgent.Persistence/FileGameSessionStore.cs +++ b/src/OpenGameAgent.Persistence/FileGameSessionStore.cs @@ -30,6 +30,7 @@ public FileGameSessionStore( await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { + using var processLease = await _files.AcquireProcessLeaseAsync(identity + Suffix, cancellationToken).ConfigureAwait(false); var session = Decode(await _files.ReadAsync(_files.PathFor(identity, Suffix), cancellationToken).ConfigureAwait(false)); if (session is not null && !session.Key.Equals(key)) { @@ -60,6 +61,7 @@ public async ValueTask SaveAsync( await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { + using var processLease = await _files.AcquireProcessLeaseAsync(identity + Suffix, cancellationToken).ConfigureAwait(false); var current = Decode(await _files.ReadAsync(path, cancellationToken).ConfigureAwait(false)); if (current is not null && !current.Key.Equals(snapshot.Key)) { @@ -90,13 +92,15 @@ public async ValueTask SaveAsync( private static SessionDocument Encode(GameSessionSnapshot snapshot) => new() { - FormatVersion = 1, + FormatVersion = 2, SessionId = snapshot.Key.SessionId, ActorId = snapshot.Key.ActorId, Revision = snapshot.Revision, Messages = snapshot.Messages.Select(AgentMessageCodec.Encode).ToList(), ProcessedInputIds = snapshot.ProcessedInputIds.ToList(), + PendingInputId = snapshot.PendingInputId, LastMoment = snapshot.LastMoment is null ? null : MomentDocument.Encode(snapshot.LastMoment.Value), + ExtensionState = new Dictionary(snapshot.ExtensionState, StringComparer.Ordinal), }; private static void ValidateKey(GameSessionKey key) @@ -122,7 +126,7 @@ private static string IdentityFor(GameSessionKey key) => string.Concat( return null; } - if (document.FormatVersion != 1) + if (document.FormatVersion is not (1 or 2)) { throw new PersistenceException($"Unsupported session format version '{document.FormatVersion}'."); } @@ -134,7 +138,9 @@ private static string IdentityFor(GameSessionKey key) => string.Concat( document.Revision, (document.Messages ?? new List()).Select(AgentMessageCodec.Decode).ToArray(), document.ProcessedInputIds ?? new List(), - document.LastMoment?.Decode())); + document.LastMoment?.Decode(), + document.ExtensionState ?? new Dictionary(StringComparer.Ordinal), + document.FormatVersion >= 2 ? document.PendingInputId : null)); } private sealed class SessionDocument @@ -151,7 +157,11 @@ private sealed class SessionDocument public List? ProcessedInputIds { get; set; } + public string? PendingInputId { get; set; } + public MomentDocument? LastMoment { get; set; } + + public Dictionary? ExtensionState { get; set; } } } diff --git a/src/OpenGameAgent.Persistence/FileGameWorkflowCheckpointStore.cs b/src/OpenGameAgent.Persistence/FileGameWorkflowCheckpointStore.cs index 667677a..ff140c5 100644 --- a/src/OpenGameAgent.Persistence/FileGameWorkflowCheckpointStore.cs +++ b/src/OpenGameAgent.Persistence/FileGameWorkflowCheckpointStore.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -30,6 +32,7 @@ public FileGameWorkflowCheckpointStore( await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { + using var processLease = await _files.AcquireProcessLeaseAsync(instanceId + Suffix, cancellationToken).ConfigureAwait(false); var document = await _files.ReadAsync( _files.PathFor(instanceId, Suffix), cancellationToken).ConfigureAwait(false); @@ -66,6 +69,7 @@ public async ValueTask SaveAsync( await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { + using var processLease = await _files.AcquireProcessLeaseAsync(checkpoint.InstanceId + Suffix, cancellationToken).ConfigureAwait(false); var path = _files.PathFor(checkpoint.InstanceId, Suffix); var document = await _files.ReadAsync(path, cancellationToken).ConfigureAwait(false); var current = document is null ? null : Decode(document); @@ -109,7 +113,7 @@ public async ValueTask SaveAsync( private static CheckpointDocument Encode(GameWorkflowCheckpoint checkpoint) => new() { - FormatVersion = 1, + FormatVersion = 2, InstanceId = checkpoint.InstanceId, Workflow = checkpoint.Workflow, Revision = checkpoint.Revision, @@ -117,11 +121,19 @@ public async ValueTask SaveAsync( StateJson = checkpoint.StateJson, Completed = checkpoint.Completed, Error = checkpoint.Error, + Invocation = checkpoint.Invocation is null ? null : new InvocationDocument + { + InputId = checkpoint.Invocation.InputId, + Messages = checkpoint.Invocation.Messages.Select(AgentMessageCodec.Encode).ToList(), + Complete = checkpoint.Invocation.Complete, + Succeeded = checkpoint.Invocation.Succeeded, + Error = checkpoint.Invocation.Error, + }, }; private static GameWorkflowCheckpoint Decode(CheckpointDocument document) { - if (document.FormatVersion != 1) + if (document.FormatVersion is not (1 or 2)) { throw new PersistenceException("The workflow checkpoint has an unsupported format."); } @@ -135,7 +147,17 @@ private static GameWorkflowCheckpoint Decode(CheckpointDocument document) document.NextStep, document.StateJson, document.Completed, - document.Error)); + document.Error, + document.FormatVersion >= 2 && document.Invocation is not null + ? new GameWorkflowInvocationResult( + document.Invocation.InputId, + (document.Invocation.Messages ?? throw new PersistenceException("Workflow invocation messages are missing.")) + .Select(AgentMessageCodec.Decode) + .ToArray(), + document.Invocation.Complete, + document.Invocation.Succeeded, + document.Invocation.Error) + : null)); } private sealed class CheckpointDocument @@ -155,5 +177,20 @@ private sealed class CheckpointDocument public bool Completed { get; set; } public string? Error { get; set; } + + public InvocationDocument? Invocation { get; set; } + } + + private sealed class InvocationDocument + { + public string InputId { get; set; } = string.Empty; + + public List? Messages { get; set; } + + public bool Complete { get; set; } + + public bool Succeeded { get; set; } + + public string? Error { get; set; } } } diff --git a/src/OpenGameAgent.Persistence/FileStore.cs b/src/OpenGameAgent.Persistence/FileStore.cs index 7f9626b..f7bd656 100644 --- a/src/OpenGameAgent.Persistence/FileStore.cs +++ b/src/OpenGameAgent.Persistence/FileStore.cs @@ -59,6 +59,31 @@ public SemaphoreSlim GateFor(string identity) return _gates[hash % _gates.Length]; } + public async ValueTask AcquireProcessLeaseAsync( + string identity, + CancellationToken cancellationToken) + { + var path = PathFor(identity, ".lock"); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + return new FileStream( + path, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + 1, + FileOptions.None); + } + catch (IOException) + { + await Task.Delay(20, cancellationToken).ConfigureAwait(false); + } + } + } + public void EnsurePathFor(string path, string identity, string suffix, string documentKind) { if (!string.Equals(Path.GetFullPath(path), PathFor(identity, suffix), StringComparison.Ordinal)) diff --git a/src/OpenGameAgent.Persistence/OpenGameAgent.Persistence.csproj b/src/OpenGameAgent.Persistence/OpenGameAgent.Persistence.csproj index f548ea5..1e77585 100644 --- a/src/OpenGameAgent.Persistence/OpenGameAgent.Persistence.csproj +++ b/src/OpenGameAgent.Persistence/OpenGameAgent.Persistence.csproj @@ -2,12 +2,13 @@ netstandard2.1 OpenGameAgent.Persistence - Crash-tolerant local persistence for OpenGameAgent sessions, actions, workflows, memories, mailboxes, and skills. + Crash-tolerant local persistence for OpenGameAgent sessions, actions, workflows, memories, mailboxes, artifacts, delegations, and skills. + diff --git a/src/OpenGameAgent.Persistence/packages.lock.json b/src/OpenGameAgent.Persistence/packages.lock.json index 8da3c96..484c31c 100644 --- a/src/OpenGameAgent.Persistence/packages.lock.json +++ b/src/OpenGameAgent.Persistence/packages.lock.json @@ -71,6 +71,12 @@ "System.Text.Json": "[8.0.6, )" } }, + "opengameagent.extensions": { + "type": "Project", + "dependencies": { + "OpenGameAgent": "[0.3.0-alpha.1, )" + } + }, "opengameagent.kernel": { "type": "Project", "dependencies": { diff --git a/src/OpenGameAgent.Providers.MediaHttp/HttpMediaGenerator.cs b/src/OpenGameAgent.Providers.MediaHttp/HttpMediaGenerator.cs index f436d54..d74dd57 100644 --- a/src/OpenGameAgent.Providers.MediaHttp/HttpMediaGenerator.cs +++ b/src/OpenGameAgent.Providers.MediaHttp/HttpMediaGenerator.cs @@ -21,9 +21,9 @@ public HttpMediaGeneratorOptions(HttpClient httpClient, Uri endpoint) { HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint)); - if (!endpoint.IsAbsoluteUri) + if (!endpoint.IsAbsoluteUri || endpoint.UserInfo.Length > 0) { - throw new ArgumentException("The media endpoint must be an absolute URI.", nameof(endpoint)); + throw new ArgumentException("The media endpoint must be absolute and cannot contain user information.", nameof(endpoint)); } @@ -61,6 +61,8 @@ public HttpMediaGeneratorOptions(HttpClient httpClient, Uri endpoint) public bool RestrictStatusUrlToEndpointOrigin { get; set; } = true; public bool SendAuthorizationToCrossOriginStatusUrls { get; set; } + + public bool AllowInsecureHttp { get; set; } } public sealed class HttpMediaGenerator : IGameMediaGenerator @@ -79,6 +81,7 @@ public sealed class HttpMediaGenerator : IGameMediaGenerator private readonly TimeSpan _pollInterval; private readonly bool _restrictStatusOrigin; private readonly bool _sendCrossOriginAuthorization; + private readonly bool _allowInsecureHttp; public HttpMediaGenerator(HttpMediaGeneratorOptions options) { @@ -89,32 +92,32 @@ public HttpMediaGenerator(HttpMediaGeneratorOptions options) if (options.MaxResponseBytes < 2 || options.MaxResponseBytes > 100_000_000) { - throw new ArgumentOutOfRangeException(nameof(options.MaxResponseBytes)); + throw new ArgumentOutOfRangeException(nameof(options), "The maximum response size is invalid."); } if (options.MaxRequestBytes < 2 || options.MaxRequestBytes > 100_000_000) { - throw new ArgumentOutOfRangeException(nameof(options.MaxRequestBytes)); + throw new ArgumentOutOfRangeException(nameof(options), "The maximum request size is invalid."); } if (options.MaxSources < 0 || options.MaxSources > 10_000) { - throw new ArgumentOutOfRangeException(nameof(options.MaxSources)); + throw new ArgumentOutOfRangeException(nameof(options), "The maximum source count is invalid."); } if (options.MaxOutputs < 1 || options.MaxOutputs > 10_000) { - throw new ArgumentOutOfRangeException(nameof(options.MaxOutputs)); + throw new ArgumentOutOfRangeException(nameof(options), "The maximum output count is invalid."); } if (options.MaxPollAttempts < 1 || options.MaxPollAttempts > 100_000) { - throw new ArgumentOutOfRangeException(nameof(options.MaxPollAttempts)); + throw new ArgumentOutOfRangeException(nameof(options), "The maximum poll count is invalid."); } if (options.PollInterval < TimeSpan.Zero || options.PollInterval > TimeSpan.FromMinutes(5)) { - throw new ArgumentOutOfRangeException(nameof(options.PollInterval)); + throw new ArgumentOutOfRangeException(nameof(options), "The polling interval is invalid."); } @@ -124,25 +127,38 @@ public HttpMediaGenerator(HttpMediaGeneratorOptions options) || (!string.Equals(options.Endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && !string.Equals(options.Endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))) { - throw new ArgumentException("The media endpoint must be an absolute HTTP or HTTPS URI.", nameof(options.Endpoint)); + throw new ArgumentException("The media endpoint must be an absolute HTTP or HTTPS URI.", nameof(options)); + } + + if (string.Equals(options.Endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + && !options.Endpoint.IsLoopback + && !options.AllowInsecureHttp) + { + throw new ArgumentException( + "Remote media endpoints must use HTTPS unless insecure HTTP is explicitly enabled.", + nameof(options)); } - if (!IsValidHeaderName(options.ApiKeyHeader)) + if (!IsValidHeaderName(options.ApiKeyHeader) || options.ApiKeyHeader.Length > 256) { - throw new ArgumentException("A valid media API key header is required.", nameof(options.ApiKeyHeader)); + throw new ArgumentException("A valid media API key header is required.", nameof(options)); } if ((options.ApiKey?.Contains('\r') ?? false) || (options.ApiKey?.Contains('\n') ?? false) + || (options.ApiKey?.Contains('\0') ?? false) + || (options.ApiKey?.Length ?? 0) > 65_536 || (options.ApiKeyScheme?.Contains('\r') ?? false) - || (options.ApiKeyScheme?.Contains('\n') ?? false)) + || (options.ApiKeyScheme?.Contains('\n') ?? false) + || (options.ApiKeyScheme?.Contains('\0') ?? false) + || (options.ApiKeyScheme?.Length ?? 0) > 256) { - throw new ArgumentException("Media API credentials cannot contain line breaks.", nameof(options.ApiKey)); + throw new ArgumentException("Media API credentials cannot contain line breaks.", nameof(options)); } if (options.ApiKey is { Length: > 0 } && string.IsNullOrWhiteSpace(options.ApiKey)) { - throw new ArgumentException("A configured media API key cannot contain only whitespace.", nameof(options.ApiKey)); + throw new ArgumentException("A configured media API key cannot contain only whitespace.", nameof(options)); } _httpClient = options.HttpClient; @@ -150,7 +166,7 @@ public HttpMediaGenerator(HttpMediaGeneratorOptions options) _apiKey = options.ApiKey; _getApiKey = options.GetApiKeyAsync; _apiKeyHeader = string.IsNullOrWhiteSpace(options.ApiKeyHeader) - ? throw new ArgumentException("A media API key header is required.", nameof(options.ApiKeyHeader)) + ? throw new ArgumentException("A media API key header is required.", nameof(options)) : options.ApiKeyHeader; _apiKeyScheme = options.ApiKeyScheme ?? string.Empty; _maxResponseBytes = options.MaxResponseBytes; @@ -161,6 +177,7 @@ public HttpMediaGenerator(HttpMediaGeneratorOptions options) _pollInterval = options.PollInterval; _restrictStatusOrigin = options.RestrictStatusUrlToEndpointOrigin; _sendCrossOriginAuthorization = options.SendAuthorizationToCrossOriginStatusUrls; + _allowInsecureHttp = options.AllowInsecureHttp; } private static bool IsValidHeaderName(string? name) @@ -240,7 +257,7 @@ public async ValueTask GenerateAsync( if (current.Status == MediaJobStatus.Failed) { - throw new MediaGenerationException(current.Error ?? "The media generation job failed."); + throw new MediaGenerationException(BoundError(current.Error ?? "The media generation job failed.")); } if (attempt >= _maxPollAttempts) @@ -373,7 +390,8 @@ private JobStatus ParseStatus(JsonElement root, HttpStatusCode httpStatus) var httpError = root.TryGetProperty("error", out var errorElement) ? errorElement.ToString() : root.GetRawText(); - throw new MediaGenerationException($"The media endpoint returned HTTP {(int)httpStatus}. {httpError}"); + throw new MediaGenerationException(BoundError( + $"The media endpoint returned HTTP {(int)httpStatus}. {httpError}")); } var statusText = root.TryGetProperty("status", out var statusElement) @@ -459,6 +477,13 @@ private void ValidateStatusUrl(Uri statusUrl) throw new MediaGenerationException("The media status URL must use HTTP or HTTPS."); } + if (string.Equals(statusUrl.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + && !statusUrl.IsLoopback + && !_allowInsecureHttp) + { + throw new MediaGenerationException("Remote media status URLs must use HTTPS."); + } + if (_restrictStatusOrigin && !IsSameOrigin(statusUrl)) { throw new MediaGenerationException("The media status URL points to a different origin."); @@ -483,7 +508,10 @@ private async ValueTask ApplyAuthorizationAsync( var apiKey = _getApiKey is null ? _apiKey : await _getApiKey(cancellationToken).ConfigureAwait(false); - if ((apiKey?.Contains('\r') ?? false) || (apiKey?.Contains('\n') ?? false)) + if ((apiKey?.Contains('\r') ?? false) + || (apiKey?.Contains('\n') ?? false) + || (apiKey?.Contains('\0') ?? false) + || (apiKey?.Length ?? 0) > 65_536) { throw new InvalidOperationException("The media API key provider returned a credential containing line breaks."); } @@ -518,7 +546,7 @@ private async ValueTask ReadDocumentAsync( while (true) { cancellationToken.ThrowIfCancellationRequested(); - var read = await source.ReadAsync(rented, 0, rented.Length, cancellationToken).ConfigureAwait(false); + var read = await source.ReadAsync(rented.AsMemory(), cancellationToken).ConfigureAwait(false); if (read == 0) { break; @@ -529,7 +557,7 @@ private async ValueTask ReadDocumentAsync( throw new MediaGenerationException("The media endpoint response exceeded the configured size limit."); } - await buffer.WriteAsync(rented, 0, read, cancellationToken).ConfigureAwait(false); + await buffer.WriteAsync(rented.AsMemory(0, read), cancellationToken).ConfigureAwait(false); } buffer.Position = 0; @@ -579,6 +607,9 @@ private static void EnsureUnambiguous(JsonElement value) } } + private static string BoundError(string value) => + value.Length <= 65_536 ? value : value.Substring(0, 65_536); + private sealed class RequestDocument { public RequestDocument(GameMediaGenerationRequest request) diff --git a/src/OpenGameAgent.Providers.OpenAICompatible/DeveloperGatewayCredentials.cs b/src/OpenGameAgent.Providers.OpenAICompatible/DeveloperGatewayCredentials.cs new file mode 100644 index 0000000..16f830a --- /dev/null +++ b/src/OpenGameAgent.Providers.OpenAICompatible/DeveloperGatewayCredentials.cs @@ -0,0 +1,379 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenGameAgent.Providers.OpenAICompatible; + +/// +/// A short-lived credential issued by a game developer's model gateway. This is not the +/// upstream model-provider key and may be scoped, rate-limited, revoked, and rotated by the gateway. +/// +public sealed class DeveloperGatewayCredential +{ + public DeveloperGatewayCredential( + string accessToken, + DateTimeOffset expiresAt, + string? scope = null) + { + if (string.IsNullOrWhiteSpace(accessToken) + || accessToken.Length > 65_536 + || accessToken.Contains('\r') + || accessToken.Contains('\n') + || accessToken.Contains('\0')) + { + throw new ArgumentException("A non-empty single-line access token is required.", nameof(accessToken)); + } + + if (scope?.Length > 4_096) + { + throw new ArgumentException("A credential scope cannot exceed 4096 characters.", nameof(scope)); + } + + AccessToken = accessToken; + ExpiresAt = expiresAt; + Scope = scope; + } + + public string AccessToken { get; } + + public DateTimeOffset ExpiresAt { get; } + + public string? Scope { get; } +} + +/// +/// Implemented by the game's account/login layer. It exchanges the player's authenticated game +/// session for a short-lived model-gateway credential without exposing the upstream provider key. +/// +public interface IDeveloperGatewayCredentialSource +{ + ValueTask GetCredentialAsync( + bool forceRefresh, + CancellationToken cancellationToken); +} + +public delegate ValueTask> DeveloperGatewayHeaderProvider( + bool forceRefresh, + CancellationToken cancellationToken); + +/// +/// Exchanges the game's existing authenticated player session for a short-lived gateway token. +/// The endpoint is developer-controlled and never returns the upstream model-provider key. +/// +public sealed class HttpDeveloperGatewayCredentialSource : IDeveloperGatewayCredentialSource +{ + private static readonly char[] InvalidHeaderNameCharacters = { '\r', '\n', '\0' }; + private readonly HttpClient _client; + private readonly Uri _endpoint; + private readonly DeveloperGatewayHeaderProvider _headers; + private readonly int _maximumResponseBytes; + + public HttpDeveloperGatewayCredentialSource( + HttpClient client, + Uri endpoint, + DeveloperGatewayHeaderProvider headers, + int maximumResponseBytes = 65_536, + bool allowInsecureHttp = false) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint)); + _headers = headers ?? throw new ArgumentNullException(nameof(headers)); + if (!_endpoint.IsAbsoluteUri + || _endpoint.UserInfo.Length > 0 + || (_endpoint.Scheme != Uri.UriSchemeHttps + && !(allowInsecureHttp && _endpoint.Scheme == Uri.UriSchemeHttp))) + { + throw new ArgumentException( + "An absolute HTTPS endpoint without embedded credentials is required unless insecure HTTP is explicitly enabled.", + nameof(endpoint)); + } + + if (maximumResponseBytes < 256 || maximumResponseBytes > 1_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumResponseBytes)); + } + + _maximumResponseBytes = maximumResponseBytes; + } + + public async ValueTask GetCredentialAsync( + bool forceRefresh, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, _endpoint) + { + Content = new StringContent( + JsonSerializer.Serialize(new { forceRefresh }), + Encoding.UTF8, + "application/json"), + }; + var headers = await _headers(forceRefresh, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The developer gateway header provider returned null."); + if (headers.Count > 64) + { + throw new InvalidOperationException("The developer gateway header provider returned too many headers."); + } + + foreach (var header in new List>(headers)) + { + if (string.IsNullOrWhiteSpace(header.Key) + || header.Key.Length > 256 + || header.Key.IndexOfAny(InvalidHeaderNameCharacters) >= 0 + || header.Value is null + || header.Value.Length > 65_536 + || header.Value.Contains('\r') + || header.Value.Contains('\n') + || header.Value.Contains('\0') + || !request.Headers.TryAddWithoutValidation(header.Key, header.Value)) + { + throw new InvalidOperationException("The developer gateway header provider returned an invalid header."); + } + } + + using var response = await _client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + if (response.Content.Headers.ContentLength is { } length && length > _maximumResponseBytes) + { + throw new InvalidDataException("The developer gateway credential response is too large."); + } + + var bytes = await ReadBoundedAsync(response.Content, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"The developer gateway credential endpoint returned HTTP {(int)response.StatusCode}."); + } + + using var document = JsonDocument.Parse(bytes, new JsonDocumentOptions { MaxDepth = 16 }); + var root = document.RootElement; + EnsureUnambiguous(root); + return new DeveloperGatewayCredential( + root.GetProperty("accessToken").GetString() ?? string.Empty, + root.GetProperty("expiresAt").GetDateTimeOffset(), + root.TryGetProperty("scope", out var scope) && scope.ValueKind != JsonValueKind.Null + ? scope.GetString() + : null); + } + + private static void EnsureUnambiguous(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in value.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new InvalidDataException( + "The developer gateway credential response contains duplicate JSON properties."); + } + + EnsureUnambiguous(property.Value); + } + } + else if (value.ValueKind == JsonValueKind.Array) + { + foreach (var item in value.EnumerateArray()) + { + EnsureUnambiguous(item); + } + } + } + + private async Task ReadBoundedAsync(HttpContent content, CancellationToken cancellationToken) + { + using var stream = await content.ReadAsStreamAsync().ConfigureAwait(false); + using var output = new MemoryStream(); + var buffer = new byte[4096]; + while (true) + { + var read = await stream.ReadAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false); + if (read == 0) + { + return output.ToArray(); + } + + if (output.Length + read > _maximumResponseBytes) + { + throw new InvalidDataException("The developer gateway credential response is too large."); + } + + output.Write(buffer, 0, read); + } + } +} + +/// +/// Thread-safe cache and refresh coordinator for a developer gateway credential source. +/// +public sealed class CachedDeveloperGatewayCredentialSource : IDisposable +{ + private readonly IDeveloperGatewayCredentialSource _source; + private readonly TimeSpan _refreshBeforeExpiry; + private readonly Func _clock; + private readonly SemaphoreSlim _refreshGate = new(1, 1); + private readonly object _stateGate = new(); + private DeveloperGatewayCredential? _cached; + private long _invalidationGeneration; + private long _resolvedGeneration; + private int _disposed; + + public CachedDeveloperGatewayCredentialSource( + IDeveloperGatewayCredentialSource source, + TimeSpan? refreshBeforeExpiry = null, + Func? clock = null) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _refreshBeforeExpiry = refreshBeforeExpiry ?? TimeSpan.FromMinutes(1); + if (_refreshBeforeExpiry < TimeSpan.Zero || _refreshBeforeExpiry > TimeSpan.FromHours(1)) + { + throw new ArgumentOutOfRangeException(nameof(refreshBeforeExpiry)); + } + + _clock = clock ?? (() => DateTimeOffset.UtcNow); + } + + public async ValueTask GetAccessTokenAsync(CancellationToken cancellationToken) + { + while (true) + { + ThrowIfDisposed(); + var cached = Volatile.Read(ref _cached); + if (IsUsable(cached)) + { + return cached!.AccessToken; + } + + await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ThrowIfDisposed(); + cached = _cached; + if (IsUsable(cached)) + { + return cached!.AccessToken; + } + + var generation = Volatile.Read(ref _invalidationGeneration); + var credential = await _source.GetCredentialAsync( + forceRefresh: cached is not null || generation != Volatile.Read(ref _resolvedGeneration), + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The developer gateway credential source returned null."); + if (credential.ExpiresAt <= _clock()) + { + throw new InvalidOperationException("The developer gateway returned an expired credential."); + } + + lock (_stateGate) + { + if (_disposed != 0) + { + throw new ObjectDisposedException(nameof(CachedDeveloperGatewayCredentialSource)); + } + + if (generation != _invalidationGeneration) + { + continue; + } + + Volatile.Write(ref _cached, credential); + Volatile.Write(ref _resolvedGeneration, generation); + return credential.AccessToken; + } + } + finally + { + _refreshGate.Release(); + } + } + } + + /// + /// Invalidates the local credential after logout, revocation, or an authentication failure. + /// The next model request obtains a fresh credential. + /// + public void Invalidate() + { + ThrowIfDisposed(); + lock (_stateGate) + { + ThrowIfDisposed(); + _invalidationGeneration = checked(_invalidationGeneration + 1); + Volatile.Write(ref _cached, null); + } + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + lock (_stateGate) + { + Volatile.Write(ref _cached, null); + } + } + + private bool IsUsable(DeveloperGatewayCredential? credential) + { + if (credential is null) + { + return false; + } + + var now = _clock(); + return credential.ExpiresAt > now + && credential.ExpiresAt - now > _refreshBeforeExpiry; + } + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) != 0) + { + throw new ObjectDisposedException(nameof(CachedDeveloperGatewayCredentialSource)); + } + } +} + +public static class DeveloperGatewayProvider +{ + /// + /// Configures an OpenAI-compatible developer gateway. The access-token callback is evaluated + /// for every request; only the short-lived token reaches the game client. + /// + public static OpenAICompatibleProvider Create( + System.Net.Http.HttpClient httpClient, + Uri endpoint, + CachedDeveloperGatewayCredentialSource credentials, + Action? configure = null) + { + if (credentials is null) + { + throw new ArgumentNullException(nameof(credentials)); + } + + var options = new OpenAICompatibleProviderOptions( + httpClient ?? throw new ArgumentNullException(nameof(httpClient)), + endpoint ?? throw new ArgumentNullException(nameof(endpoint))) + { + GetApiKeyAsync = credentials.GetAccessTokenAsync, + OnAuthenticationFailure = _ => credentials.Invalidate(), + }; + configure?.Invoke(options); + if (options.ApiKey is not null) + { + throw new InvalidOperationException("A developer gateway provider cannot also contain a static upstream API key."); + } + + return new OpenAICompatibleProvider(options); + } +} diff --git a/src/OpenGameAgent.Providers.OpenAICompatible/OpenAICompatibleProvider.cs b/src/OpenGameAgent.Providers.OpenAICompatible/OpenAICompatibleProvider.cs index d0a5172..1ca7518 100644 --- a/src/OpenGameAgent.Providers.OpenAICompatible/OpenAICompatibleProvider.cs +++ b/src/OpenGameAgent.Providers.OpenAICompatible/OpenAICompatibleProvider.cs @@ -1,8 +1,10 @@ using System; using System.Buffers; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; +using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Runtime.CompilerServices; @@ -16,6 +18,8 @@ namespace OpenGameAgent.Providers.OpenAICompatible; public delegate ValueTask ApiKeyProvider(CancellationToken cancellationToken); +public delegate string? OpenAICompatibleResourcePartProjector(ResourceContent resource); + public sealed class OpenAICompatibleProviderOptions { public OpenAICompatibleProviderOptions(HttpClient httpClient, Uri endpoint) @@ -47,6 +51,8 @@ public OpenAICompatibleProviderOptions(HttpClient httpClient, Uri endpoint) public string ApiKeyScheme { get; set; } = "Bearer"; + public bool AllowInsecureHttp { get; set; } + public IDictionary Headers { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); public int MaxEventCharacters { get; set; } = 4_000_000; @@ -60,6 +66,26 @@ public OpenAICompatibleProviderOptions(HttpClient httpClient, Uri endpoint) public int MaxToolCallsPerResponse { get; set; } = 256; public bool IncludeUsage { get; set; } = true; + + public bool AllowDoneWithoutFinishReason { get; set; } + + public IList ReasoningDeltaFields { get; } = new List + { + "reasoning_content", + "reasoning", + }; + + /// + /// Called after an HTTP 401 or 403 response. Use this to invalidate a cached short-lived + /// gateway credential. The failed streamed request is not retried automatically. + /// + public Action? OnAuthenticationFailure { get; set; } + + /// + /// Converts a resource into a provider-specific multimodal content-part JSON object. + /// Return null to use the built-in image projection or plain resource text fallback. + /// + public OpenAICompatibleResourcePartProjector? ProjectResourcePart { get; set; } } public sealed class OpenAICompatibleProvider : IModelProvider @@ -77,6 +103,10 @@ public sealed class OpenAICompatibleProvider : IModelProvider private readonly int _maxResponseCharacters; private readonly int _maxToolCallsPerResponse; private readonly bool _includeUsage; + private readonly bool _allowDoneWithoutFinishReason; + private readonly IReadOnlyList _reasoningDeltaFields; + private readonly Action? _onAuthenticationFailure; + private readonly OpenAICompatibleResourcePartProjector? _projectResourcePart; public OpenAICompatibleProvider(OpenAICompatibleProviderOptions options) { @@ -87,27 +117,48 @@ public OpenAICompatibleProvider(OpenAICompatibleProviderOptions options) if (options.MaxEventCharacters < 1 || options.MaxEventCharacters > 100_000_000) { - throw new ArgumentOutOfRangeException(nameof(options.MaxEventCharacters)); + throw new ArgumentOutOfRangeException(nameof(options), "The maximum event size is invalid."); } if (options.MaxErrorCharacters < 1 || options.MaxErrorCharacters > 10_000_000) { - throw new ArgumentOutOfRangeException(nameof(options.MaxErrorCharacters)); + throw new ArgumentOutOfRangeException(nameof(options), "The maximum error size is invalid."); } if (options.MaxRequestBytes < 2 || options.MaxRequestBytes > 100_000_000) { - throw new ArgumentOutOfRangeException(nameof(options.MaxRequestBytes)); + throw new ArgumentOutOfRangeException(nameof(options), "The maximum request size is invalid."); } if (options.MaxResponseCharacters < 1 || options.MaxResponseCharacters > 100_000_000) { - throw new ArgumentOutOfRangeException(nameof(options.MaxResponseCharacters)); + throw new ArgumentOutOfRangeException(nameof(options), "The maximum response size is invalid."); } if (options.MaxToolCallsPerResponse < 1 || options.MaxToolCallsPerResponse > 10_000) { - throw new ArgumentOutOfRangeException(nameof(options.MaxToolCallsPerResponse)); + throw new ArgumentOutOfRangeException(nameof(options), "The maximum tool-call count is invalid."); + } + + var reasoningFields = options.ReasoningDeltaFields + .Select(field => string.IsNullOrWhiteSpace(field) || field.Length > 128 + ? throw new ArgumentException("Reasoning delta field names must contain 1 to 128 characters.", nameof(options)) + : field) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (reasoningFields.Length == 0) + { + throw new ArgumentException("At least one reasoning delta field is required.", nameof(options)); + } + + if (options.Headers.Count > 64) + { + throw new ArgumentException("At most 64 custom headers may be configured.", nameof(options)); + } + + if (reasoningFields.Any(field => field is "content" or "tool_calls" or "role")) + { + throw new ArgumentException("Reasoning delta fields cannot reuse core stream fields.", nameof(options)); } @@ -117,21 +168,30 @@ public OpenAICompatibleProvider(OpenAICompatibleProviderOptions options) || (!string.Equals(options.Endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && !string.Equals(options.Endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))) { - throw new ArgumentException("The model endpoint must be an absolute HTTP or HTTPS URI.", nameof(options.Endpoint)); + throw new ArgumentException("The model endpoint must be an absolute HTTP or HTTPS URI.", nameof(options)); + } + + if (string.Equals(options.Endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + && !options.Endpoint.IsLoopback + && !options.AllowInsecureHttp) + { + throw new ArgumentException( + "Remote model endpoints must use HTTPS unless insecure HTTP is explicitly enabled.", + nameof(options)); } - ValidateHeader(options.ApiKeyHeader, string.Empty, nameof(options.ApiKeyHeader)); - ValidateCredential(options.ApiKey, nameof(options.ApiKey)); - ValidateCredential(options.ApiKeyScheme, nameof(options.ApiKeyScheme)); + ValidateHeader(options.ApiKeyHeader, string.Empty, nameof(options)); + ValidateCredential(options.ApiKey, nameof(options)); + ValidateCredential(options.ApiKeyScheme, nameof(options)); foreach (var header in options.Headers) { - ValidateHeader(header.Key, header.Value, nameof(options.Headers)); + ValidateHeader(header.Key, header.Value, nameof(options)); } if ((!string.IsNullOrEmpty(options.ApiKey) || options.GetApiKeyAsync is not null) && options.Headers.ContainsKey(options.ApiKeyHeader)) { - throw new ArgumentException("Custom headers cannot also define the configured API key header.", nameof(options.Headers)); + throw new ArgumentException("Custom headers cannot also define the configured API key header.", nameof(options)); } _httpClient = options.HttpClient; @@ -139,7 +199,7 @@ public OpenAICompatibleProvider(OpenAICompatibleProviderOptions options) _apiKey = options.ApiKey; _getApiKey = options.GetApiKeyAsync; _apiKeyHeader = string.IsNullOrWhiteSpace(options.ApiKeyHeader) - ? throw new ArgumentException("An API key header is required.", nameof(options.ApiKeyHeader)) + ? throw new ArgumentException("An API key header is required.", nameof(options)) : options.ApiKeyHeader; _apiKeyScheme = options.ApiKeyScheme ?? string.Empty; _headers = new Dictionary(options.Headers, StringComparer.OrdinalIgnoreCase); @@ -149,14 +209,21 @@ public OpenAICompatibleProvider(OpenAICompatibleProviderOptions options) _maxResponseCharacters = options.MaxResponseCharacters; _maxToolCallsPerResponse = options.MaxToolCallsPerResponse; _includeUsage = options.IncludeUsage; + _allowDoneWithoutFinishReason = options.AllowDoneWithoutFinishReason; + _reasoningDeltaFields = Array.AsReadOnly(reasoningFields); + _onAuthenticationFailure = options.OnAuthenticationFailure; + _projectResourcePart = options.ProjectResourcePart; } private static void ValidateHeader(string name, string value, string parameterName) { if (string.IsNullOrWhiteSpace(name) + || name.Length > 256 || value is null + || value.Length > 65_536 || value.Contains('\r') - || value.Contains('\n')) + || value.Contains('\n') + || value.Contains('\0')) { throw new ArgumentException("HTTP header names must be non-empty, and names and values cannot contain line breaks.", parameterName); } @@ -182,9 +249,16 @@ private static void ValidateCredential(string? value, string parameterName) throw new ArgumentException("A configured credential cannot contain only whitespace.", parameterName); } - if ((value?.Contains('\r') ?? false) || (value?.Contains('\n') ?? false)) + if ((value?.Length ?? 0) > 65_536) { - throw new ArgumentException("Credentials cannot contain line breaks.", parameterName); + throw new ArgumentException("Credentials cannot exceed 65536 characters.", parameterName); + } + + if ((value?.Contains('\r') ?? false) + || (value?.Contains('\n') ?? false) + || (value?.Contains('\0') ?? false)) + { + throw new ArgumentException("Credentials contain invalid control characters.", parameterName); } } @@ -215,15 +289,36 @@ public async IAsyncEnumerable StreamAsync( cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { + Exception? authenticationFailureException = null; + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + { + try + { + _onAuthenticationFailure?.Invoke(response.StatusCode); + } + catch (Exception exception) + { + authenticationFailureException = exception; + } + } + var error = await ReadBoundedAsync(response.Content, _maxErrorCharacters, cancellationToken).ConfigureAwait(false); - throw new HttpRequestException( - $"The model endpoint returned HTTP {(int)response.StatusCode} ({response.ReasonPhrase}). {error}"); + throw new ModelProviderException( + $"The model endpoint returned HTTP {(int)response.StatusCode} ({response.ReasonPhrase}). {error}", + IsTransient(response), + GetRetryAfter(response), + (int)response.StatusCode, + authenticationFailureException); } using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); using var cancellationRegistration = cancellationToken.Register(stream.Dispose); using var reader = new StreamReader(stream, Encoding.UTF8, true, 4096, leaveOpen: false); - var state = new StreamState(request.Model, _maxResponseCharacters, _maxToolCallsPerResponse); + var state = new StreamState( + request.Model, + _maxResponseCharacters, + _maxToolCallsPerResponse, + _reasoningDeltaFields); var sawDone = false; yield return ModelStreamEvent.Update(ModelStreamEventKind.Started, state.Partial()); @@ -253,9 +348,9 @@ public async IAsyncEnumerable StreamAsync( } } - if (!sawDone && !state.HasFinishReason) + if (!state.HasFinishReason && !(sawDone && _allowDoneWithoutFinishReason)) { - throw new InvalidDataException("The model stream ended before a terminal marker or finish reason was received."); + throw new InvalidDataException("The model stream ended before receiving a finish reason."); } yield return ModelStreamEvent.Terminal(state.Complete()); @@ -283,6 +378,56 @@ private void ApplyHeaders(HttpRequestMessage request, string? apiKey) } } + private static bool IsTransient(HttpResponseMessage response) + { + if (response.Headers.TryGetValues("x-should-retry", out var values)) + { + var directive = values.FirstOrDefault(); + if (string.Equals(directive, "true", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (string.Equals(directive, "false", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + var status = (int)response.StatusCode; + return status is 408 or 409 or 429 || status >= 500; + } + + private static TimeSpan? GetRetryAfter(HttpResponseMessage response) + { + if (response.Headers.TryGetValues("retry-after-ms", out var millisecondValues) + && double.TryParse( + millisecondValues.FirstOrDefault(), + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var milliseconds) + && !double.IsNaN(milliseconds) + && !double.IsInfinity(milliseconds)) + { + return milliseconds >= TimeSpan.MaxValue.TotalMilliseconds + ? TimeSpan.MaxValue + : TimeSpan.FromMilliseconds(Math.Max(0, milliseconds)); + } + + if (response.Headers.RetryAfter?.Delta is { } delta) + { + return delta < TimeSpan.Zero ? TimeSpan.Zero : delta; + } + + if (response.Headers.RetryAfter?.Date is { } date) + { + var delay = date - DateTimeOffset.UtcNow; + return delay < TimeSpan.Zero ? TimeSpan.Zero : delay; + } + + return null; + } + private byte[] SerializeRequest(ModelRequest request) { EnsureRequestCanFit(request); @@ -445,7 +590,7 @@ private static JsonElement ParseElement(string json) return document.RootElement.Clone(); } - private static IReadOnlyList ProjectMessages(ModelRequest request) + private IReadOnlyList ProjectMessages(ModelRequest request) { var projected = new List { @@ -455,15 +600,56 @@ private static IReadOnlyList ProjectMessages(ModelRequest request) ["content"] = request.SystemPrompt, }, }; - foreach (var message in request.Messages) + for (var index = 0; index < request.Messages.Count; index++) { - projected.Add(ProjectMessage(message)); + var message = request.Messages[index]; + if (message.Role != AgentRole.Tool) + { + projected.Add(ProjectMessage(message)); + continue; + } + + var attachments = new List(); + while (index < request.Messages.Count && request.Messages[index].Role == AgentRole.Tool) + { + var toolMessage = request.Messages[index]; + projected.Add(ProjectMessage(toolMessage)); + foreach (var resource in toolMessage.Content.OfType()) + { + var attachment = ProjectNativeResource(resource); + if (attachment is not null) + { + attachments.Add(attachment); + } + } + + index++; + } + + index--; + if (attachments.Count > 0) + { + var content = new List + { + new Dictionary + { + ["type"] = "text", + ["text"] = "Attached resource(s) returned by the preceding tool results:", + }, + }; + content.AddRange(attachments); + projected.Add(new Dictionary + { + ["role"] = "user", + ["content"] = content, + }); + } } return projected; } - private static object ProjectMessage(AgentMessage message) + private object ProjectMessage(AgentMessage message) { if (message.Role == AgentRole.Assistant) { @@ -487,6 +673,19 @@ private static object ProjectMessage(AgentMessage message) assistant["tool_calls"] = calls; } + foreach (var reasoning in message.Content + .OfType() + .Where(content => !string.IsNullOrWhiteSpace(content.Signature)) + .GroupBy(content => content.Signature!, StringComparer.Ordinal)) + { + if (assistant.ContainsKey(reasoning.Key)) + { + throw new InvalidDataException("A reasoning signature cannot override a core assistant message field."); + } + + assistant[reasoning.Key] = string.Join("\n", reasoning.Select(content => content.Text)); + } + return assistant; } @@ -501,10 +700,10 @@ private static object ProjectMessage(AgentMessage message) } const string role = "user"; - var content = JoinContent(message.Content); + object content = ProjectUserContent(message); if (message.Role == AgentRole.Custom) { - content = "[" + message.CustomRole + "]\n" + content; + content = PrefixCustomRole(content, message.CustomRole!); } return new Dictionary @@ -514,18 +713,119 @@ private static object ProjectMessage(AgentMessage message) }; } + private object ProjectUserContent(AgentMessage message) + { + var visible = message.Content.Where(part => part is not ReasoningContent and not ToolCallContent).ToArray(); + if (!visible.Any(part => part is ResourceContent)) + { + return JoinContent(visible); + } + + var parts = new List(); + foreach (var part in visible) + { + if (part is ResourceContent resource) + { + parts.Add(ProjectResource(resource)); + continue; + } + + var text = ContentText(part); + if (text.Length > 0) + { + parts.Add(new Dictionary + { + ["type"] = "text", + ["text"] = text, + }); + } + } + + return parts; + } + + private object ProjectResource(ResourceContent resource) + { + return ProjectNativeResource(resource) ?? new Dictionary + { + ["type"] = "text", + ["text"] = ResourceText(resource), + }; + } + + private object? ProjectNativeResource(ResourceContent resource) + { + var custom = _projectResourcePart?.Invoke(resource); + if (custom is not null) + { + using var document = JsonDocument.Parse(custom); + EnsureUnambiguous(document.RootElement, "A projected resource part contains duplicate JSON property names."); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException("A projected resource part must be a JSON object."); + } + + return document.RootElement.Clone(); + } + + if (resource.MediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + return new Dictionary + { + ["type"] = "image_url", + ["image_url"] = new Dictionary + { + ["url"] = resource.Uri, + }, + }; + } + + return null; + } + + private static object PrefixCustomRole(object content, string customRole) + { + var prefix = "[" + customRole + "]"; + if (content is string text) + { + return prefix + "\n" + text; + } + + if (content is not IEnumerable existing) + { + throw new InvalidDataException("A custom-role message produced an unsupported content projection."); + } + + return new object[] + { + new Dictionary + { + ["type"] = "text", + ["text"] = prefix, + }, + }.Concat(existing).ToArray(); + } + private static string JoinContent(IEnumerable content) { return string.Join("\n", content.Where(part => part is not ReasoningContent).Select(part => part switch { - TextContent text => text.Text, - JsonContent json => json.Json, - ResourceContent resource => $"[resource name={resource.Name ?? "unnamed"} media_type={resource.MediaType}] {resource.Uri}", - ToolCallContent call => $"[tool_call {call.Name}] {call.ArgumentsJson}", - _ => string.Empty, + _ => ContentText(part), })); } + private static string ContentText(AgentContent content) => content switch + { + TextContent text => text.Text, + JsonContent json => json.Json, + ResourceContent resource => ResourceText(resource), + ToolCallContent call => $"[tool_call {call.Name}] {call.ArgumentsJson}", + _ => string.Empty, + }; + + private static string ResourceText(ResourceContent resource) => + $"[resource name={resource.Name ?? "unnamed"} media_type={resource.MediaType}] {resource.Uri}"; + private static object? ParseExtension(string value) { try @@ -683,13 +983,20 @@ private sealed class StreamState private bool _hasFinishReason; private readonly int _maximumCharacters; private readonly int _maximumToolCalls; + private readonly IReadOnlyList _reasoningDeltaFields; + private string? _reasoningSignature; private long _characters; - public StreamState(string model, int maximumCharacters, int maximumToolCalls) + public StreamState( + string model, + int maximumCharacters, + int maximumToolCalls, + IReadOnlyList reasoningDeltaFields) { _ = model; _maximumCharacters = maximumCharacters; _maximumToolCalls = maximumToolCalls; + _reasoningDeltaFields = reasoningDeltaFields; } public IReadOnlyList Apply(string json) @@ -739,7 +1046,7 @@ public IReadOnlyList Apply(string json) if (choice.TryGetProperty("delta", out var delta)) { RequireKind(delta, JsonValueKind.Object, "A model stream delta must be an object."); - ApplyText(delta, "reasoning_content", _reasoning, ref _reasoningStarted, ModelStreamEventKind.ReasoningStarted, ModelStreamEventKind.ReasoningDelta, updates); + ApplyReasoning(delta, updates); ApplyText(delta, "content", _text, ref _textStarted, ModelStreamEventKind.TextStarted, ModelStreamEventKind.TextDelta, updates); if (delta.TryGetProperty("tool_calls", out var calls)) { @@ -771,13 +1078,26 @@ private void ApplyToolCalls(JsonElement calls, ICollection upd foreach (var call in calls.EnumerateArray()) { RequireKind(call, JsonValueKind.Object, "Each model tool call must be an object."); - var index = _tools.Count; - if (call.TryGetProperty("index", out var indexElement) - && (!indexElement.TryGetInt32(out index))) + int? explicitIndex = null; + if (call.TryGetProperty("index", out var indexElement)) { - throw new InvalidDataException("A model tool call index must be an integer."); + if (!indexElement.TryGetInt32(out var parsedIndex)) + { + throw new InvalidDataException("A model tool call index must be an integer."); + } + + explicitIndex = parsedIndex; + } + + string? incomingId = null; + if (call.TryGetProperty("id", out var incomingIdElement)) + { + RequireKind(incomingIdElement, JsonValueKind.String, "A model tool call ID must be a string."); + incomingId = incomingIdElement.GetString(); } + var index = ResolveToolIndex(explicitIndex, incomingId); + if (index < 0 || index >= _maximumToolCalls) { throw new InvalidDataException("A model tool call used a negative index or exceeded the configured tool call limit."); @@ -796,17 +1116,15 @@ private void ApplyToolCalls(JsonElement calls, ICollection upd created = true; } - if (call.TryGetProperty("id", out var id)) + if (incomingId is not null) { - RequireKind(id, JsonValueKind.String, "A model tool call ID must be a string."); - var idText = id.GetString()!; - if (builder.Id is not null && !string.Equals(builder.Id, idText, StringComparison.Ordinal)) + if (builder.Id is not null && !string.Equals(builder.Id, incomingId, StringComparison.Ordinal)) { throw new InvalidDataException("A streamed model tool call changed its ID."); } - AddCharacters(idText.Length); - builder.Id = idText; + AddCharacters(incomingId.Length); + builder.Id = incomingId; } if (call.TryGetProperty("function", out var function)) @@ -856,6 +1174,45 @@ private void ApplyToolCalls(JsonElement calls, ICollection upd } } + private int ResolveToolIndex(int? explicitIndex, string? incomingId) + { + if (explicitIndex is { } index) + { + return index; + } + + if (!string.IsNullOrEmpty(incomingId)) + { + foreach (var pair in _tools) + { + if (string.Equals(pair.Value.Id, incomingId, StringComparison.Ordinal)) + { + return pair.Key; + } + } + + var candidate = 0; + while (_tools.ContainsKey(candidate)) + { + candidate++; + } + + return candidate; + } + + if (_tools.Count == 0) + { + return 0; + } + + if (_tools.Count == 1) + { + return _tools.Keys.Single(); + } + + throw new InvalidDataException("A model tool call delta omitted both index and ID while multiple tool calls were active."); + } + private void AddEndedEvents(ICollection updates) { if (_reasoningStarted) @@ -902,7 +1259,7 @@ private IReadOnlyList CurrentContent(bool includeTools) var content = new List(); if (_reasoning.Length > 0) { - content.Add(new ReasoningContent(_reasoning.ToString())); + content.Add(new ReasoningContent(_reasoning.ToString(), _reasoningSignature)); } if (_text.Length > 0) @@ -964,6 +1321,34 @@ private void ApplyText( updates.Add(ModelStreamEvent.Update(deltaKind, Partial(), text)); } + private void ApplyReasoning(JsonElement delta, ICollection updates) + { + foreach (var property in _reasoningDeltaFields) + { + if (!delta.TryGetProperty(property, out var value) || value.ValueKind == JsonValueKind.Null) + { + continue; + } + + if (_reasoningSignature is not null + && !string.Equals(_reasoningSignature, property, StringComparison.Ordinal)) + { + throw new InvalidDataException("A model stream changed its reasoning delta field during one response."); + } + + _reasoningSignature = property; + ApplyText( + delta, + property, + _reasoning, + ref _reasoningStarted, + ModelStreamEventKind.ReasoningStarted, + ModelStreamEventKind.ReasoningDelta, + updates); + return; + } + } + private void ReadFinishReason(JsonElement choice) { if (!choice.TryGetProperty("finish_reason", out var reason) || reason.ValueKind == JsonValueKind.Null) diff --git a/src/OpenGameAgent.Server/ServerEndpoints.cs b/src/OpenGameAgent.Server/ServerEndpoints.cs index 47a64c1..b45ee36 100644 --- a/src/OpenGameAgent.Server/ServerEndpoints.cs +++ b/src/OpenGameAgent.Server/ServerEndpoints.cs @@ -1,3 +1,4 @@ +using System.Buffers; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -7,16 +8,15 @@ namespace OpenGameAgent.Server; public static class ServerEndpoints { + public const int DefaultMaximumRequestBodyBytes = 8_000_000; + public static IApplicationBuilder UseOpenGameAgentApiKey( this IApplicationBuilder app, string? apiKey, string headerName = "Authorization", string scheme = "Bearer") { - if (app is null) - { - throw new ArgumentNullException(nameof(app)); - } + ArgumentNullException.ThrowIfNull(app); if (string.IsNullOrEmpty(apiKey)) { @@ -28,17 +28,25 @@ public static IApplicationBuilder UseOpenGameAgentApiKey( throw new ArgumentException("A configured API key cannot contain only whitespace.", nameof(apiKey)); } - if (!IsValidHeaderName(headerName)) + if (apiKey.Length > 65_536) + { + throw new ArgumentException("A configured API key cannot exceed 65536 characters.", nameof(apiKey)); + } + + if (!IsValidHeaderName(headerName) || headerName.Length > 256) { throw new ArgumentException("A valid API key header name is required.", nameof(headerName)); } if (apiKey.Contains('\r') || apiKey.Contains('\n') + || apiKey.Contains('\0') || (scheme?.Contains('\r') ?? false) - || (scheme?.Contains('\n') ?? false)) + || (scheme?.Contains('\n') ?? false) + || (scheme?.Contains('\0') ?? false) + || (scheme?.Length ?? 0) > 256) { - throw new ArgumentException("API key credentials cannot contain line breaks.", nameof(apiKey)); + throw new ArgumentException("API key credentials contain invalid characters or exceed their size limit.", nameof(apiKey)); } var expected = string.IsNullOrWhiteSpace(scheme) ? apiKey : scheme + " " + apiKey; @@ -80,8 +88,17 @@ private static bool IsValidHeaderName(string? name) } } - public static IEndpointRouteBuilder MapOpenGameAgent(this IEndpointRouteBuilder endpoints) + public static IEndpointRouteBuilder MapOpenGameAgent( + this IEndpointRouteBuilder endpoints, + int maximumRequestBodyBytes = DefaultMaximumRequestBodyBytes) { + ArgumentNullException.ThrowIfNull(endpoints); + + if (maximumRequestBodyBytes < 2 || maximumRequestBodyBytes > 100_000_000) + { + throw new ArgumentOutOfRangeException(nameof(maximumRequestBodyBytes)); + } + endpoints.MapGet("/healthz", () => Results.Ok(new { status = "healthy" })); endpoints.MapGet("/v1/capabilities", () => Results.Ok(new { @@ -93,18 +110,38 @@ public static IEndpointRouteBuilder MapOpenGameAgent(this IEndpointRouteBuilder execution = new[] { "in-process", "server" }, control = new[] { "steer", "abort" }, })); - endpoints.MapPost("/v1/run", RunAsync); - endpoints.MapPost("/v1/run/stream", StreamAsync); - endpoints.MapPost("/v1/control/steer", Steer); - endpoints.MapPost("/v1/control/abort", Abort); + endpoints.MapPost( + "/v1/run", + (HttpRequest request, GameAgentRuntime runtime, CancellationToken cancellationToken) => + RunAsync(request, runtime, maximumRequestBodyBytes, cancellationToken)); + endpoints.MapPost( + "/v1/run/stream", + (HttpRequest request, GameAgentRuntime runtime, HttpResponse response, CancellationToken cancellationToken) => + StreamAsync(request, runtime, response, maximumRequestBodyBytes, cancellationToken)); + endpoints.MapPost( + "/v1/control/steer", + (HttpRequest request, GameAgentRuntime runtime, CancellationToken cancellationToken) => + SteerAsync(request, runtime, maximumRequestBodyBytes, cancellationToken)); + endpoints.MapPost( + "/v1/control/abort", + (HttpRequest request, GameAgentRuntime runtime, CancellationToken cancellationToken) => + AbortAsync(request, runtime, maximumRequestBodyBytes, cancellationToken)); return endpoints; } - private static IResult Steer(JsonElement requestDocument, GameAgentRuntime runtime) + private static async Task SteerAsync( + HttpRequest httpRequest, + GameAgentRuntime runtime, + int maximumRequestBodyBytes, + CancellationToken cancellationToken) { try { - var request = ParseRequest(requestDocument); + using var requestDocument = await ReadRequestDocumentAsync( + httpRequest, + maximumRequestBodyBytes, + cancellationToken); + var request = ParseRequest(requestDocument.RootElement); var accepted = runtime.TrySteer( request.ToKey(), AgentMessage.UserJson(request.GetPayloadJson())); @@ -112,6 +149,14 @@ private static IResult Steer(JsonElement requestDocument, GameAgentRuntime runti ? Results.Ok(new { accepted = true }) : Results.NotFound(new { accepted = false, error = "actor_not_running" }); } + catch (RequestBodyTooLargeException exception) + { + return RequestError(StatusCodes.Status413PayloadTooLarge, "request_too_large", exception.Message); + } + catch (UnsupportedRequestContentTypeException exception) + { + return RequestError(StatusCodes.Status415UnsupportedMediaType, "unsupported_media_type", exception.Message); + } catch (Exception exception) when (exception is ArgumentException or AgentLimitException or GameRuntimeLimitException @@ -123,17 +168,33 @@ or GameRuntimeLimitException } } - private static IResult Abort(JsonElement requestDocument, GameAgentRuntime runtime) + private static async Task AbortAsync( + HttpRequest httpRequest, + GameAgentRuntime runtime, + int maximumRequestBodyBytes, + CancellationToken cancellationToken) { try { - var request = ParseRequest(requestDocument); + using var requestDocument = await ReadRequestDocumentAsync( + httpRequest, + maximumRequestBodyBytes, + cancellationToken); + var request = ParseRequest(requestDocument.RootElement); var accepted = runtime.TryAbort(request.ToKey()); return accepted ? Results.Ok(new { accepted = true }) : Results.NotFound(new { accepted = false, error = "actor_not_running" }); } - catch (ArgumentException exception) + catch (RequestBodyTooLargeException exception) + { + return RequestError(StatusCodes.Status413PayloadTooLarge, "request_too_large", exception.Message); + } + catch (UnsupportedRequestContentTypeException exception) + { + return RequestError(StatusCodes.Status415UnsupportedMediaType, "unsupported_media_type", exception.Message); + } + catch (Exception exception) when (exception is ArgumentException or JsonException) { return Results.Json( new { accepted = false, error = "invalid_request", message = exception.Message }, @@ -142,15 +203,27 @@ private static IResult Abort(JsonElement requestDocument, GameAgentRuntime runti } private static async Task RunAsync( - JsonElement requestDocument, + HttpRequest httpRequest, GameAgentRuntime runtime, + int maximumRequestBodyBytes, CancellationToken cancellationToken) { GameInput input; try { - var request = ParseRequest(requestDocument); - input = request.ToInput(); + using var requestDocument = await ReadRequestDocumentAsync( + httpRequest, + maximumRequestBodyBytes, + cancellationToken); + input = GameAgentWire.ParseInput(requestDocument.RootElement.GetRawText()); + } + catch (RequestBodyTooLargeException exception) + { + return RequestError(StatusCodes.Status413PayloadTooLarge, "request_too_large", exception.Message); + } + catch (UnsupportedRequestContentTypeException exception) + { + return RequestError(StatusCodes.Status415UnsupportedMediaType, "unsupported_media_type", exception.Message); } catch (Exception exception) when (exception is ArgumentException or GameRuntimeLimitException or JsonException) { @@ -176,16 +249,36 @@ private static async Task RunAsync( } private static async Task StreamAsync( - JsonElement requestDocument, + HttpRequest httpRequest, GameAgentRuntime runtime, HttpResponse response, + int maximumRequestBodyBytes, CancellationToken cancellationToken) { GameInput input; try { - var request = ParseRequest(requestDocument); - input = request.ToInput(); + using var requestDocument = await ReadRequestDocumentAsync( + httpRequest, + maximumRequestBodyBytes, + cancellationToken); + input = GameAgentWire.ParseInput(requestDocument.RootElement.GetRawText()); + } + catch (RequestBodyTooLargeException exception) + { + response.StatusCode = StatusCodes.Status413PayloadTooLarge; + await response.WriteAsJsonAsync( + new { error = "request_too_large", message = exception.Message }, + cancellationToken); + return; + } + catch (UnsupportedRequestContentTypeException exception) + { + response.StatusCode = StatusCodes.Status415UnsupportedMediaType; + await response.WriteAsJsonAsync( + new { error = "unsupported_media_type", message = exception.Message }, + cancellationToken); + return; } catch (Exception exception) when (exception is ArgumentException or GameRuntimeLimitException or JsonException) { @@ -223,8 +316,22 @@ await response.WriteAsJsonAsync( return; } - var result = await pendingRun; - await WriteEventAsync(response, "result", GameAgentWire.SerializeResult(result), cancellationToken); + try + { + var result = await pendingRun; + await WriteEventAsync(response, "result", GameAgentWire.SerializeResult(result), cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception) + { + await WriteEventAsync( + response, + "error", + "{\"error\":\"run_failed\"}", + cancellationToken).ConfigureAwait(false); + } } private static async Task WriteEventAsync( @@ -240,15 +347,75 @@ private static async Task WriteEventAsync( private static bool FixedTimeEquals(string supplied, string expected) { - using var algorithm = SHA256.Create(); - var suppliedHash = algorithm.ComputeHash(Encoding.UTF8.GetBytes(supplied)); - var expectedHash = algorithm.ComputeHash(Encoding.UTF8.GetBytes(expected)); + var suppliedHash = SHA256.HashData(Encoding.UTF8.GetBytes(supplied)); + var expectedHash = SHA256.HashData(Encoding.UTF8.GetBytes(expected)); return CryptographicOperations.FixedTimeEquals(suppliedHash, expectedHash); } private static bool IsInvalidRequest(Exception exception) => exception is ArgumentException or AgentLimitException or GameRuntimeLimitException or JsonException; + private static IResult RequestError(int statusCode, string error, string message) => + Results.Json(new { error, message }, statusCode: statusCode); + + private static async Task ReadRequestDocumentAsync( + HttpRequest request, + int maximumRequestBodyBytes, + CancellationToken cancellationToken) + { + if (!request.HasJsonContentType()) + { + throw new UnsupportedRequestContentTypeException(); + } + + if (request.ContentLength > maximumRequestBodyBytes) + { + throw new RequestBodyTooLargeException(maximumRequestBodyBytes); + } + + var initialCapacity = request.ContentLength is > 0 + ? (int)Math.Min(request.ContentLength.Value, maximumRequestBodyBytes) + : Math.Min(4096, maximumRequestBodyBytes); + using var body = new MemoryStream(initialCapacity); + var buffer = ArrayPool.Shared.Rent(Math.Min(81_920, maximumRequestBodyBytes + 1)); + try + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var remaining = maximumRequestBodyBytes - body.Length; + var requested = (int)Math.Min(buffer.Length, remaining + 1); + var read = await request.Body.ReadAsync( + buffer.AsMemory(0, requested), + cancellationToken); + if (read == 0) + { + break; + } + + if (body.Length + read > maximumRequestBodyBytes) + { + throw new RequestBodyTooLargeException(maximumRequestBodyBytes); + } + + body.Write(buffer, 0, read); + } + + if (body.Length == 0) + { + throw new JsonException("The request body is empty."); + } + + return JsonDocument.Parse( + body.ToArray(), + new JsonDocumentOptions { MaxDepth = 128 }); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + private static T ParseRequest(JsonElement document) { EnsureRequestIsUnambiguous(document); @@ -300,41 +467,22 @@ private static void EnsureObject(JsonElement value, StringComparer comparer) PropertyNameCaseInsensitive = true, }; -} - -public sealed class RunRequest -{ - public string? InputId { get; set; } - - public string SessionId { get; set; } = string.Empty; - - public string ActorId { get; set; } = string.Empty; - - public string Type { get; set; } = string.Empty; - - public JsonElement Payload { get; set; } - - public string TimelineId { get; set; } = "default"; - - public long Tick { get; set; } - - public JsonElement? Calendar { get; set; } + private sealed class RequestBodyTooLargeException : Exception + { + public RequestBodyTooLargeException(int maximumRequestBodyBytes) + : base($"The request body exceeded {maximumRequestBodyBytes} bytes.") + { + } + } - public Dictionary? Metadata { get; set; } + private sealed class UnsupportedRequestContentTypeException : Exception + { + public UnsupportedRequestContentTypeException() + : base("The request content type must be application/json.") + { + } + } - public GameInput ToInput() => new( - SessionId, - ActorId, - Type, - Payload.ValueKind == JsonValueKind.Undefined ? "{}" : Payload.GetRawText(), - new GameMoment( - TimelineId, - Tick, - Calendar is { ValueKind: not JsonValueKind.Undefined and not JsonValueKind.Null } calendar - ? calendar.GetRawText() - : null), - InputId, - Metadata); } public sealed class ControlRequest diff --git a/src/OpenGameAgent.Server/packages.lock.json b/src/OpenGameAgent.Server/packages.lock.json index c2bec9c..a01264c 100644 --- a/src/OpenGameAgent.Server/packages.lock.json +++ b/src/OpenGameAgent.Server/packages.lock.json @@ -14,6 +14,12 @@ "System.Text.Json": "[8.0.6, )" } }, + "opengameagent.extensions": { + "type": "Project", + "dependencies": { + "OpenGameAgent": "[0.3.0-alpha.1, )" + } + }, "opengameagent.kernel": { "type": "Project", "dependencies": { @@ -24,6 +30,7 @@ "type": "Project", "dependencies": { "OpenGameAgent": "[0.3.0-alpha.1, )", + "OpenGameAgent.Extensions": "[0.3.0-alpha.1, )", "System.Text.Json": "[8.0.6, )" } }, diff --git a/src/OpenGameAgent/Actions.cs b/src/OpenGameAgent/Actions.cs index ea9ebdf..e8c08d6 100644 --- a/src/OpenGameAgent/Actions.cs +++ b/src/OpenGameAgent/Actions.cs @@ -63,6 +63,9 @@ public GameActionIntent( public sealed class GameActionReceipt { + private const int MaximumCodeCharacters = 1_024; + private const int MaximumMessageCharacters = 64_000; + public GameActionReceipt( string operationId, GameActionStatus status, @@ -87,6 +90,16 @@ public GameActionReceipt( } StateRevision = stateRevision; + if ((code?.Length ?? 0) > MaximumCodeCharacters) + { + throw new ArgumentException("An action receipt code is too large.", nameof(code)); + } + + if ((message?.Length ?? 0) > MaximumMessageCharacters) + { + throw new ArgumentException("An action receipt message is too large.", nameof(message)); + } + Code = code; Message = message; } @@ -123,7 +136,19 @@ public static GameActionReceipt Rejected( new(intent.OperationId, GameActionStatus.Rejected, resultJson, intent.Moment, null, code, message); public static GameActionReceipt Uncertain(GameActionIntent intent, string message) => - new(intent.OperationId, GameActionStatus.Uncertain, "{}", intent.Moment, null, "outcome_uncertain", message); + new( + intent.OperationId, + GameActionStatus.Uncertain, + "{}", + intent.Moment, + null, + "outcome_uncertain", + TruncateDiagnostic(message)); + + private static string? TruncateDiagnostic(string? message) => + message is null || message.Length <= MaximumMessageCharacters + ? message + : message.Substring(0, MaximumMessageCharacters); } public sealed class GameActionJournalEntry @@ -380,11 +405,13 @@ public sealed class DurableGameActionDispatcher private readonly IGameActionJournal _journal; private readonly IGameActionHandler _handler; private readonly SemaphoreSlim[] _operationGates; + private readonly int _receiptCommitTimeoutMilliseconds; public DurableGameActionDispatcher( IGameActionJournal journal, IGameActionHandler handler, - int concurrencyStripes = 64) + int concurrencyStripes = 64, + int receiptCommitTimeoutMilliseconds = 10_000) { _journal = journal ?? throw new ArgumentNullException(nameof(journal)); _handler = handler ?? throw new ArgumentNullException(nameof(handler)); @@ -393,9 +420,15 @@ public DurableGameActionDispatcher( throw new ArgumentOutOfRangeException(nameof(concurrencyStripes)); } + if (receiptCommitTimeoutMilliseconds < 100 || receiptCommitTimeoutMilliseconds > 300_000) + { + throw new ArgumentOutOfRangeException(nameof(receiptCommitTimeoutMilliseconds)); + } + _operationGates = Enumerable.Range(0, concurrencyStripes) .Select(_ => new SemaphoreSlim(1, 1)) .ToArray(); + _receiptCommitTimeoutMilliseconds = receiptCommitTimeoutMilliseconds; } public async ValueTask ExecuteAsync( @@ -445,7 +478,7 @@ public async ValueTask ExecuteAsync( try { var receipt = await _handler.ExecuteAsync(intent, cancellationToken).ConfigureAwait(false); - return await CloseAsync(intent, receipt, cancellationToken).ConfigureAwait(false); + return await CloseDurablyAsync(intent, receipt).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -490,7 +523,7 @@ public async ValueTask ReconcileAsync( try { var receipt = await _handler.ExecuteAsync(entry.Intent, cancellationToken).ConfigureAwait(false); - return await CloseAsync(entry.Intent, receipt, cancellationToken).ConfigureAwait(false); + return await CloseDurablyAsync(entry.Intent, receipt).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -564,6 +597,30 @@ private async ValueTask CloseAsync( return receipt; } + private async ValueTask CloseDurablyAsync( + GameActionIntent intent, + GameActionReceipt receipt) + { + using var settlementCancellation = new CancellationTokenSource(_receiptCommitTimeoutMilliseconds); + try + { + return await CloseAsync(intent, receipt, settlementCancellation.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (settlementCancellation.IsCancellationRequested) + { + return GameActionReceipt.Uncertain( + intent, + "The game action returned a final receipt, but its durable journal commit timed out. Reconcile the operation before retrying."); + } + catch (Exception exception) + { + return GameActionReceipt.Uncertain( + intent, + "The game action returned a receipt, but its durable journal commit failed. Reconcile the operation before retrying. " + + exception.Message); + } + } + private async ValueTask RecoverAsync( GameActionIntent intent, CancellationToken cancellationToken) @@ -573,7 +630,7 @@ private async ValueTask RecoverAsync( ? GameActionReceipt.Uncertain( intent, "The action was dispatched, but its outcome is not yet known.") - : await CloseAsync(intent, recovered, cancellationToken).ConfigureAwait(false); + : await CloseDurablyAsync(intent, recovered).ConfigureAwait(false); } private async ValueTask TryRecoverAsync( diff --git a/src/OpenGameAgent/DurableWorkflows.cs b/src/OpenGameAgent/DurableWorkflows.cs index f2c084a..5b4ad2c 100644 --- a/src/OpenGameAgent/DurableWorkflows.cs +++ b/src/OpenGameAgent/DurableWorkflows.cs @@ -134,7 +134,8 @@ public GameWorkflowCheckpoint( int nextStep, string stateJson, bool completed = false, - string? error = null) + string? error = null, + GameWorkflowInvocationResult? invocation = null) { if (revision < 0 || nextStep < 0) { @@ -158,6 +159,7 @@ public GameWorkflowCheckpoint( Completed = completed; Error = error; + Invocation = invocation; } public string InstanceId { get; } @@ -173,6 +175,61 @@ public GameWorkflowCheckpoint( public bool Completed { get; } public string? Error { get; } + + public GameWorkflowInvocationResult? Invocation { get; } +} + +public sealed class GameWorkflowInvocationResult +{ + public GameWorkflowInvocationResult( + string inputId, + IReadOnlyList messages, + bool complete, + bool succeeded = false, + string? error = null) + { + InputId = GameJson.RequireId(inputId, nameof(inputId)); + var copied = (messages ?? throw new ArgumentNullException(nameof(messages))).ToArray(); + if (copied.Any(message => message is null)) + { + throw new ArgumentException("Workflow invocation messages cannot contain null entries.", nameof(messages)); + } + + if (succeeded && (!complete || error is not null)) + { + throw new ArgumentException("Only a completed successful invocation can be marked successful.", nameof(succeeded)); + } + + if (complete && !succeeded && string.IsNullOrWhiteSpace(error)) + { + throw new ArgumentException("A completed failed invocation requires an error.", nameof(error)); + } + + if (!complete && error is not null) + { + throw new ArgumentException("An incomplete invocation cannot carry an error.", nameof(error)); + } + + if (error is { Length: > 65_536 }) + { + throw new ArgumentException("A workflow invocation error cannot exceed 65,536 characters.", nameof(error)); + } + + Messages = Array.AsReadOnly(copied); + Complete = complete; + Succeeded = succeeded; + Error = error; + } + + public string InputId { get; } + + public IReadOnlyList Messages { get; } + + public bool Complete { get; } + + public bool Succeeded { get; } + + public string? Error { get; } } public sealed class GameWorkflowCheckpointSaveResult @@ -366,17 +423,51 @@ public async ValueTask RunAsync( throw new InvalidOperationException("The workflow checkpoint points past the end of the workflow."); } + var invocation = checkpoint.Invocation; + if (invocation is not null + && !string.Equals(invocation.InputId, context.Input.InputId, StringComparison.Ordinal) + && !context.Session.ProcessedInputIds.Contains(invocation.InputId, StringComparer.Ordinal)) + { + throw new InvalidOperationException( + $"Workflow input '{invocation.InputId}' has durable progress that must be replayed before another input can continue this instance."); + } + + if (invocation is { Complete: true } + && string.Equals(invocation.InputId, context.Input.InputId, StringComparison.Ordinal)) + { + context.ValidateOutput(invocation.Messages); + return new GameWorkflowResult(invocation.Messages, invocation.Succeeded, invocation.Error); + } + if (checkpoint.Completed) { return new GameWorkflowResult(Array.Empty(), checkpoint.Error is null, checkpoint.Error); } - var messages = new List(); + if (invocation is null + || !string.Equals(invocation.InputId, context.Input.InputId, StringComparison.Ordinal)) + { + invocation = new GameWorkflowInvocationResult( + context.Input.InputId, + Array.Empty(), + complete: false); + } + + var messages = invocation.Messages.ToList(); + context.ValidateOutput(messages); for (var executed = 0; executed < _maximumStepsPerRun; executed++) { if (checkpoint.NextStep >= _steps.Count) { - checkpoint = await SaveAsync(checkpoint, checkpoint.NextStep, checkpoint.StateJson, completed: true, null, cancellationToken).ConfigureAwait(false); + invocation = CompleteInvocation(context.Input.InputId, messages, succeeded: true, error: null); + checkpoint = await SaveAsync( + checkpoint, + checkpoint.NextStep, + checkpoint.StateJson, + completed: true, + error: null, + invocation: invocation, + cancellationToken: cancellationToken).ConfigureAwait(false); return new GameWorkflowResult(messages, true); } @@ -396,12 +487,25 @@ public async ValueTask RunAsync( ? checkpoint.NextStep : checkpoint.NextStep + 1; var completed = result.Status is GameWorkflowStepStatus.Complete or GameWorkflowStepStatus.Failed; + invocation = result.Status is GameWorkflowStepStatus.Wait + or GameWorkflowStepStatus.Complete + or GameWorkflowStepStatus.Failed + ? CompleteInvocation( + context.Input.InputId, + messages, + succeeded: result.Status != GameWorkflowStepStatus.Failed, + error: result.Error) + : new GameWorkflowInvocationResult( + context.Input.InputId, + messages, + complete: false); checkpoint = await SaveAsync( checkpoint, nextStep, result.StateJson, completed, result.Error, + invocation, cancellationToken).ConfigureAwait(false); if (result.Status == GameWorkflowStepStatus.Wait) @@ -420,7 +524,17 @@ public async ValueTask RunAsync( } } - return new GameWorkflowResult(messages, false, "The workflow reached its per-run step limit."); + const string limitError = "The workflow reached its per-run step limit."; + invocation = CompleteInvocation(context.Input.InputId, messages, succeeded: false, error: limitError); + _ = await SaveAsync( + checkpoint, + checkpoint.NextStep, + checkpoint.StateJson, + completed: false, + error: null, + invocation: invocation, + cancellationToken: cancellationToken).ConfigureAwait(false); + return new GameWorkflowResult(messages, false, limitError); } private async ValueTask SaveAsync( @@ -429,6 +543,7 @@ private async ValueTask SaveAsync( string stateJson, bool completed, string? error, + GameWorkflowInvocationResult invocation, CancellationToken cancellationToken) { var next = new GameWorkflowCheckpoint( @@ -438,7 +553,8 @@ private async ValueTask SaveAsync( nextStep, stateJson, completed, - error); + error, + invocation); var save = await _checkpoints.SaveAsync(next, current.Revision, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The workflow checkpoint store returned null."); if (!save.Saved) @@ -452,11 +568,20 @@ private async ValueTask SaveAsync( || save.Current.NextStep != next.NextStep || save.Current.Completed != next.Completed || !string.Equals(save.Current.StateJson, next.StateJson, StringComparison.Ordinal) - || !string.Equals(save.Current.Error, next.Error, StringComparison.Ordinal)) + || !string.Equals(save.Current.Error, next.Error, StringComparison.Ordinal) + || !GameAgentValueComparer.WorkflowInvocationEquals(save.Current.Invocation, next.Invocation)) { throw new InvalidOperationException("The workflow checkpoint store returned a different saved checkpoint."); } return save.Current; } + + private static GameWorkflowInvocationResult CompleteInvocation( + string inputId, + IReadOnlyList messages, + bool succeeded, + string? error) => + new(inputId, messages, complete: true, succeeded, error); + } diff --git a/src/OpenGameAgent/ExtensionContracts.cs b/src/OpenGameAgent/ExtensionContracts.cs new file mode 100644 index 0000000..a26385a --- /dev/null +++ b/src/OpenGameAgent/ExtensionContracts.cs @@ -0,0 +1,638 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent; + +/// +/// Describes a runtime extension without coupling it to an engine or deployment model. +/// +public sealed class GameAgentExtensionDescriptor +{ + public GameAgentExtensionDescriptor( + string id, + string version, + string? description = null, + IEnumerable? capabilities = null) + { + Id = GameJson.RequireId(id, nameof(id)); + Version = GameJson.RequireId(version, nameof(version)); + Description = description ?? string.Empty; + var copied = (capabilities ?? Array.Empty()) + .Select(value => GameJson.RequireId(value, nameof(capabilities))) + .Distinct(StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + Capabilities = Array.AsReadOnly(copied); + } + + public string Id { get; } + + public string Version { get; } + + public string Description { get; } + + public IReadOnlyList Capabilities { get; } +} + +/// +/// A package-level extension. First-party and third-party features use this same contract. +/// +public interface IGameAgentExtension +{ + GameAgentExtensionDescriptor Descriptor { get; } + + void Configure(GameAgentExtensionApi api); +} + +public sealed class DelegateGameAgentExtension : IGameAgentExtension +{ + private readonly Action _configure; + + public DelegateGameAgentExtension( + GameAgentExtensionDescriptor descriptor, + Action configure) + { + Descriptor = descriptor ?? throw new ArgumentNullException(nameof(descriptor)); + _configure = configure ?? throw new ArgumentNullException(nameof(configure)); + } + + public GameAgentExtensionDescriptor Descriptor { get; } + + public void Configure(GameAgentExtensionApi api) => + _configure(api ?? throw new ArgumentNullException(nameof(api))); +} + +public enum GameAgentExtensionResourceKind +{ + ContextProvider, + Tool, + ToolProvider, + SkillProvider, + RouteRule, + PendingWorkProvider, + Workflow, + AgentHooks, + PromptFragment, + ModelProvider, + Service, + EventHandler, +} + +public sealed class GameAgentExtensionResource +{ + internal GameAgentExtensionResource( + string extensionId, + string name, + GameAgentExtensionResourceKind kind, + int priority, + long sequence) + { + ExtensionId = extensionId; + Name = name; + Kind = kind; + Priority = priority; + Sequence = sequence; + } + + public string ExtensionId { get; } + + public string Name { get; } + + public GameAgentExtensionResourceKind Kind { get; } + + public int Priority { get; } + + internal long Sequence { get; } +} + +public enum GameAgentExtensionDiagnosticSeverity +{ + Information, + Warning, + Error, +} + +public sealed class GameAgentExtensionDiagnostic +{ + public GameAgentExtensionDiagnostic( + GameAgentExtensionDiagnosticSeverity severity, + string code, + string message, + string? extensionId = null, + string? resourceName = null) + { + if (!Enum.IsDefined(typeof(GameAgentExtensionDiagnosticSeverity), severity)) + { + throw new ArgumentOutOfRangeException(nameof(severity)); + } + + Severity = severity; + Code = GameJson.RequireId(code, nameof(code)); + Message = string.IsNullOrWhiteSpace(message) + ? throw new ArgumentException("A diagnostic message is required.", nameof(message)) + : message; + ExtensionId = extensionId; + ResourceName = resourceName; + } + + public GameAgentExtensionDiagnosticSeverity Severity { get; } + + public string Code { get; } + + public string Message { get; } + + public string? ExtensionId { get; } + + public string? ResourceName { get; } +} + +public interface IGameAgentExtensionRegistration : IDisposable +{ + GameAgentExtensionResource Resource { get; } + + bool IsActive { get; } +} + +/// +/// A typed lifecycle event key. Matching is by object identity so unrelated extensions cannot +/// accidentally reuse a textual channel with an incompatible payload type. +/// +public sealed class GameAgentExtensionEvent +{ + public GameAgentExtensionEvent(string name) + { + Name = GameJson.RequireId(name, nameof(name)); + } + + public string Name { get; } +} + +/// +/// Typed cross-extension channel. Channels are explicit objects, avoiding string-only payload contracts. +/// +public sealed class GameAgentExtensionChannel +{ + public GameAgentExtensionChannel(string name) + { + Name = GameJson.RequireId(name, nameof(name)); + } + + public string Name { get; } +} + +public static class GameAgentExtensionEvents +{ + public static GameAgentExtensionEvent InputReceived { get; } = new("input.received"); + + public static GameAgentExtensionEvent SessionLoaded { get; } = new("session.loaded"); + + public static GameAgentExtensionEvent ContextCollected { get; } = new("context.collected"); + + public static GameAgentExtensionEvent ToolsCollected { get; } = new("tools.collected"); + + public static GameAgentExtensionEvent RouteSelected { get; } = new("route.selected"); + + public static GameAgentExtensionEvent SkillsSelected { get; } = new("skills.selected"); + + public static GameAgentExtensionEvent KernelEvent { get; } = new("kernel.event"); + + public static GameAgentExtensionEvent RunCompleted { get; } = new("run.completed"); + + public static GameAgentExtensionEvent SessionSaving { get; } = new("session.saving"); + + public static GameAgentExtensionEvent SessionSaved { get; } = new("session.saved"); + + public static GameAgentExtensionEvent RunFailed { get; } = new("run.failed"); +} + +public sealed class GameAgentInputEvent +{ + public GameAgentInputEvent(GameInput input) + { + Input = input ?? throw new ArgumentNullException(nameof(input)); + } + + public GameInput Input { get; } +} + +public sealed class GameAgentSessionEvent +{ + public GameAgentSessionEvent(GameSessionSnapshot session) + { + Session = session ?? throw new ArgumentNullException(nameof(session)); + } + + public GameSessionSnapshot Session { get; } +} + +public sealed class GameAgentContextEvent +{ + public GameAgentContextEvent(IReadOnlyList context) + { + var copy = (context ?? throw new ArgumentNullException(nameof(context))).ToArray(); + if (copy.Any(value => value is null)) + { + throw new ArgumentException("Context events cannot contain null slices.", nameof(context)); + } + + Context = Array.AsReadOnly(copy); + } + + public IReadOnlyList Context { get; } +} + +public sealed class GameAgentToolsEvent +{ + public GameAgentToolsEvent(IReadOnlyList tools) + { + var copy = (tools ?? throw new ArgumentNullException(nameof(tools))).ToArray(); + if (copy.Any(value => value is null)) + { + throw new ArgumentException("Tool events cannot contain null tools.", nameof(tools)); + } + + Tools = Array.AsReadOnly(copy); + } + + public IReadOnlyList Tools { get; } +} + +public sealed class GameAgentRouteEvent +{ + public GameAgentRouteEvent(GameRouteDecision decision) + { + Decision = decision ?? throw new ArgumentNullException(nameof(decision)); + } + + public GameRouteDecision Decision { get; set; } +} + +public sealed class GameAgentSkillsEvent +{ + public GameAgentSkillsEvent(IReadOnlyList skills) + { + var copy = (skills ?? throw new ArgumentNullException(nameof(skills))).ToArray(); + if (copy.Any(value => value is null)) + { + throw new ArgumentException("Skill events cannot contain null skills.", nameof(skills)); + } + + Skills = Array.AsReadOnly(copy); + } + + public IReadOnlyList Skills { get; } +} + +public sealed class GameAgentKernelEvent +{ + public GameAgentKernelEvent(AgentEvent value) + { + Value = value ?? throw new ArgumentNullException(nameof(value)); + } + + public AgentEvent Value { get; } +} + +public sealed class GameAgentRunEvent +{ + public GameAgentRunEvent(GameAgentRunResult result) + { + Result = result ?? throw new ArgumentNullException(nameof(result)); + } + + public GameAgentRunResult Result { get; } +} + +public sealed class GameAgentFailureEvent +{ + public GameAgentFailureEvent(Exception exception) + { + Exception = exception ?? throw new ArgumentNullException(nameof(exception)); + } + + public Exception Exception { get; } +} + +/// +/// Mutable, namespaced session state for one extension. Values are JSON and are not added to +/// model context unless the owning extension explicitly contributes them. +/// +public sealed class GameAgentExtensionState +{ + private readonly GameAgentSessionState _state; + private readonly string _extensionId; + private readonly GameAgentExtensionRunLease _lease; + + internal GameAgentExtensionState( + GameAgentSessionState state, + string extensionId, + GameAgentExtensionRunLease lease) + { + _state = state; + _extensionId = extensionId; + _lease = lease; + } + + public IReadOnlyDictionary Snapshot() + { + _lease.EnsureActive(); + return _state.Snapshot(_extensionId); + } + + public bool TryGet(string key, out string json) + { + _lease.EnsureActive(); + return _state.TryGet(_extensionId, key, out json); + } + + public string? Get(string key) => TryGet(key, out var json) ? json : null; + + public void Set(string key, string json) + { + _lease.EnsureActive(); + _state.Set(_extensionId, key, json); + } + + public bool Remove(string key) + { + _lease.EnsureActive(); + return _state.Remove(_extensionId, key); + } +} + +public interface IGameAgentServiceProvider +{ + bool TryGet(string name, out T service) where T : class; + + T GetRequired(string name) where T : class; +} + +public sealed class GameAgentExtensionRunContext +{ + internal GameAgentExtensionRunContext( + GameInput input, + GameSessionSnapshot session, + GameAgentSessionState sessionState, + GameAgentExtensionState state, + GameAgentExtensionRunLease lease, + IGameAgentServiceProvider services, + IReadOnlyList resources) + { + Input = input ?? throw new ArgumentNullException(nameof(input)); + Session = session ?? throw new ArgumentNullException(nameof(session)); + SessionState = sessionState ?? throw new ArgumentNullException(nameof(sessionState)); + State = state ?? throw new ArgumentNullException(nameof(state)); + Lease = lease ?? throw new ArgumentNullException(nameof(lease)); + Services = services ?? throw new ArgumentNullException(nameof(services)); + Resources = Array.AsReadOnly( + (resources ?? throw new ArgumentNullException(nameof(resources))).ToArray()); + } + + public GameInput Input { get; } + + public GameSessionSnapshot Session { get; } + + public GameAgentExtensionState State { get; } + + public IGameAgentServiceProvider Services { get; } + + public IReadOnlyList Resources { get; } + + public bool IsActive => Lease.IsActive; + + internal GameAgentSessionState SessionState { get; } + + internal GameAgentExtensionRunLease Lease { get; } + + internal void EnsureActive() => Lease.EnsureActive(); + + internal void Invalidate() => Lease.Invalidate(); +} + +public delegate ValueTask> GameExtensionContextProvider( + GameAgentExtensionRunContext context, + CancellationToken cancellationToken); + +public delegate ValueTask> GameExtensionToolProvider( + GameAgentExtensionRunContext context, + CancellationToken cancellationToken); + +public delegate ValueTask> GameExtensionSkillProvider( + GameAgentExtensionRunContext context, + IReadOnlyCollection activeToolNames, + int maximumSkills, + CancellationToken cancellationToken); + +public delegate ValueTask GameExtensionRouteRule( + GameAgentExtensionRunContext context, + int availableToolCount, + bool hasPendingWork, + CancellationToken cancellationToken); + +public delegate ValueTask GameExtensionPendingWorkProvider( + GameAgentExtensionRunContext context, + CancellationToken cancellationToken); + +public delegate AgentHooks GameExtensionHookFactory(GameAgentExtensionRunContext context); + +public delegate ValueTask GameAgentExtensionEventHandler( + TEvent value, + GameAgentExtensionRunContext context, + CancellationToken cancellationToken); + +public delegate ValueTask GameAgentExtensionChannelHandler( + TMessage message, + CancellationToken cancellationToken); + +/// +/// API exposed to extensions. Registrations remain live until their returned handle is disposed. +/// +public sealed class GameAgentExtensionApi +{ + private readonly GameAgentExtensionHost _host; + private readonly string _extensionId; + + internal GameAgentExtensionApi(GameAgentExtensionHost host, string extensionId) + { + _host = host; + _extensionId = extensionId; + } + + public string ExtensionId => _extensionId; + + public IReadOnlyList GetResources() => _host.GetResources(); + + public IReadOnlyList GetDiagnostics() => _host.GetDiagnostics(); + + public IGameAgentExtensionRegistration RegisterContextProvider( + string name, + GameExtensionContextProvider provider, + int priority = 0) => + _host.Register(_extensionId, name, GameAgentExtensionResourceKind.ContextProvider, provider, priority, unique: true); + + public IGameAgentExtensionRegistration RegisterTool( + AgentTool tool, + int priority = 0) + { + if (tool is null) + { + throw new ArgumentNullException(nameof(tool)); + } + + return _host.Register( + _extensionId, + tool.Definition.Name, + GameAgentExtensionResourceKind.Tool, + tool, + priority, + unique: true); + } + + public IGameAgentExtensionRegistration RegisterToolProvider( + string name, + GameExtensionToolProvider provider, + int priority = 0) => + _host.Register(_extensionId, name, GameAgentExtensionResourceKind.ToolProvider, provider, priority, unique: true); + + public IGameAgentExtensionRegistration RegisterSkillProvider( + string name, + GameExtensionSkillProvider provider, + int priority = 0) => + _host.Register(_extensionId, name, GameAgentExtensionResourceKind.SkillProvider, provider, priority, unique: true); + + public IGameAgentExtensionRegistration RegisterRouteRule( + string name, + GameExtensionRouteRule rule, + int priority = 0) => + _host.Register(_extensionId, name, GameAgentExtensionResourceKind.RouteRule, rule, priority, unique: true); + + public IGameAgentExtensionRegistration RegisterPendingWorkProvider( + string name, + GameExtensionPendingWorkProvider provider, + int priority = 0) => + _host.Register(_extensionId, name, GameAgentExtensionResourceKind.PendingWorkProvider, provider, priority, unique: true); + + public IGameAgentExtensionRegistration RegisterWorkflow(IGameWorkflow workflow, int priority = 0) + { + if (workflow is null) + { + throw new ArgumentNullException(nameof(workflow)); + } + + return _host.Register( + _extensionId, + workflow.Name, + GameAgentExtensionResourceKind.Workflow, + workflow, + priority, + unique: true); + } + + public IGameAgentExtensionRegistration RegisterAgentHooks( + string name, + GameExtensionHookFactory factory, + int priority = 0) => + _host.Register(_extensionId, name, GameAgentExtensionResourceKind.AgentHooks, factory, priority, unique: true); + + public IGameAgentExtensionRegistration RegisterPromptFragment( + string name, + string instructions, + int priority = 0) + { + if (instructions is null) + { + throw new ArgumentNullException(nameof(instructions)); + } + + return _host.Register( + _extensionId, + name, + GameAgentExtensionResourceKind.PromptFragment, + instructions, + priority, + unique: true); + } + + public IGameAgentExtensionRegistration RegisterModelProvider( + string name, + IModelProvider provider, + int priority = 0) + { + if (provider is null) + { + throw new ArgumentNullException(nameof(provider)); + } + + return _host.Register( + _extensionId, + name, + GameAgentExtensionResourceKind.ModelProvider, + provider, + priority, + unique: true); + } + + public IGameAgentExtensionRegistration RegisterService( + string name, + T service, + int priority = 0) + where T : class + { + if (service is null) + { + throw new ArgumentNullException(nameof(service)); + } + + return _host.RegisterService(_extensionId, name, typeof(T), service, priority); + } + + public IGameAgentExtensionRegistration On( + GameAgentExtensionEvent eventKey, + GameAgentExtensionEventHandler handler, + int priority = 0) + { + if (eventKey is null) + { + throw new ArgumentNullException(nameof(eventKey)); + } + + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + return _host.RegisterEvent(_extensionId, eventKey, handler, priority); + } + + public IGameAgentExtensionRegistration Subscribe( + GameAgentExtensionChannel channel, + GameAgentExtensionChannelHandler handler, + int priority = 0) + { + if (channel is null) + { + throw new ArgumentNullException(nameof(channel)); + } + + if (handler is null) + { + throw new ArgumentNullException(nameof(handler)); + } + + return _host.RegisterChannel(_extensionId, channel, handler, priority); + } + + public ValueTask PublishAsync( + GameAgentExtensionChannel channel, + TMessage message, + CancellationToken cancellationToken = default) => + _host.PublishChannelAsync( + channel ?? throw new ArgumentNullException(nameof(channel)), + message, + cancellationToken); +} diff --git a/src/OpenGameAgent/ExtensionHost.cs b/src/OpenGameAgent/ExtensionHost.cs new file mode 100644 index 0000000..bfc7d1a --- /dev/null +++ b/src/OpenGameAgent/ExtensionHost.cs @@ -0,0 +1,1129 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent; + +internal static class GameAgentAsyncBridge +{ + public static void Run(Func operation) + { + if (operation is null) + { + throw new ArgumentNullException(nameof(operation)); + } + + Task.Run(async () => await operation().ConfigureAwait(false)).GetAwaiter().GetResult(); + } +} + +internal sealed class GameAgentExtensionRunLease +{ + private int _active = 1; + + public bool IsActive => Volatile.Read(ref _active) != 0; + + public void EnsureActive() + { + if (!IsActive) + { + throw new ObjectDisposedException(nameof(GameAgentExtensionRunContext), "The extension run context is no longer active."); + } + } + + public void Invalidate() => Interlocked.Exchange(ref _active, 0); +} + +internal sealed class GameAgentSessionState +{ + private readonly object _gate = new(); + private readonly Dictionary _entries; + private readonly int _maximumEntries; + private readonly int _maximumKeyCharacters; + private readonly int _maximumValueCharacters; + private readonly int _maximumTotalCharacters; + + public GameAgentSessionState( + IReadOnlyDictionary entries, + GameRuntimeLimits limits) + { + if (entries is null) + { + throw new ArgumentNullException(nameof(entries)); + } + + if (limits is null) + { + throw new ArgumentNullException(nameof(limits)); + } + + _maximumEntries = limits.MaxExtensionStateEntries; + _maximumKeyCharacters = limits.MaxExtensionStateKeyCharacters; + _maximumValueCharacters = limits.MaxExtensionStateValueCharacters; + _maximumTotalCharacters = limits.MaxExtensionStateCharacters; + _entries = new Dictionary(entries, StringComparer.Ordinal); + ValidateSnapshot(_entries); + } + + public IReadOnlyDictionary Snapshot(string extensionId) + { + var prefix = Prefix(extensionId); + lock (_gate) + { + return new ReadOnlyDictionary( + _entries + .Where(pair => pair.Key.StartsWith(prefix, StringComparison.Ordinal)) + .ToDictionary( + pair => Uri.UnescapeDataString(pair.Key.Substring(prefix.Length)), + pair => pair.Value, + StringComparer.Ordinal)); + } + } + + public IReadOnlyDictionary SnapshotAll() + { + lock (_gate) + { + return new ReadOnlyDictionary( + new Dictionary(_entries, StringComparer.Ordinal)); + } + } + + public bool TryGet(string extensionId, string key, out string json) + { + lock (_gate) + { + return _entries.TryGetValue(NamespacedKey(extensionId, key), out json!); + } + } + + public void Set(string extensionId, string key, string json) + { + var namespaced = NamespacedKey(extensionId, key); + var valid = GameJson.RequireValid(json, nameof(json)); + if (namespaced.Length > _maximumKeyCharacters) + { + throw new GameRuntimeLimitException( + nameof(GameRuntimeLimits.MaxExtensionStateKeyCharacters), + "An extension state key is too large."); + } + + if (valid.Length > _maximumValueCharacters) + { + throw new GameRuntimeLimitException( + nameof(GameRuntimeLimits.MaxExtensionStateValueCharacters), + "An extension state value is too large."); + } + + lock (_gate) + { + if (!_entries.ContainsKey(namespaced) && _entries.Count >= _maximumEntries) + { + throw new GameRuntimeLimitException( + nameof(GameRuntimeLimits.MaxExtensionStateEntries), + "The session has too many extension state entries."); + } + + var total = _entries.Sum(pair => (long)pair.Key.Length + pair.Value.Length); + if (_entries.TryGetValue(namespaced, out var previous)) + { + total -= namespaced.Length + previous.Length; + } + + total += namespaced.Length + valid.Length; + if (total > _maximumTotalCharacters) + { + throw new GameRuntimeLimitException( + nameof(GameRuntimeLimits.MaxExtensionStateCharacters), + "The combined extension state is too large."); + } + + _entries[namespaced] = valid; + } + } + + public bool Remove(string extensionId, string key) + { + lock (_gate) + { + return _entries.Remove(NamespacedKey(extensionId, key)); + } + } + + private void ValidateSnapshot(IReadOnlyDictionary entries) + { + if (entries.Count > _maximumEntries) + { + throw new GameRuntimeLimitException( + nameof(GameRuntimeLimits.MaxExtensionStateEntries), + "The loaded session has too many extension state entries."); + } + + var total = 0L; + foreach (var pair in entries) + { + if (string.IsNullOrWhiteSpace(pair.Key) || pair.Key.Length > _maximumKeyCharacters) + { + throw new GameRuntimeLimitException( + nameof(GameRuntimeLimits.MaxExtensionStateKeyCharacters), + "The loaded session has an invalid extension state key."); + } + + if (pair.Value is null || pair.Value.Length > _maximumValueCharacters) + { + throw new GameRuntimeLimitException( + nameof(GameRuntimeLimits.MaxExtensionStateValueCharacters), + "The loaded session has an invalid extension state value."); + } + + GameJson.RequireValid(pair.Value, nameof(entries)); + total += pair.Key.Length + pair.Value.Length; + if (total > _maximumTotalCharacters) + { + throw new GameRuntimeLimitException( + nameof(GameRuntimeLimits.MaxExtensionStateCharacters), + "The loaded session extension state is too large."); + } + } + } + + private static string Prefix(string extensionId) => + Uri.EscapeDataString(GameJson.RequireId(extensionId, nameof(extensionId))) + ":"; + + private static string NamespacedKey(string extensionId, string key) => + Prefix(extensionId) + Uri.EscapeDataString(GameJson.RequireId(key, nameof(key))); +} + +internal sealed class GameAgentExtensionHost : IGameAgentServiceProvider, IAsyncDisposable +{ + private readonly object _gate = new(); + private readonly List _registrations = new(); + private readonly List _diagnostics = new(); + private readonly List _extensions = new(); + private readonly HashSet _extensionIds = new(StringComparer.Ordinal); + private readonly int _maximumExtensions; + private readonly int _maximumResources; + private readonly int _maximumDiagnostics; + private readonly int _maximumDiagnosticCharacters; + private long _nextSequence; + private bool _disposed; + + public GameAgentExtensionHost( + IEnumerable extensions, + GameRuntimeLimits? limits = null) + { + if (extensions is null) + { + throw new ArgumentNullException(nameof(extensions)); + } + + var validatedLimits = (limits ?? new GameRuntimeLimits()).CopyAndValidate(); + _maximumExtensions = validatedLimits.MaxExtensions; + _maximumResources = validatedLimits.MaxExtensionResources; + _maximumDiagnostics = validatedLimits.MaxExtensionDiagnostics; + _maximumDiagnosticCharacters = validatedLimits.MaxExtensionDiagnosticCharacters; + + try + { + foreach (var extension in extensions) + { + AddExtension(extension ?? throw new ArgumentException("An extension cannot be null.", nameof(extensions))); + } + } + catch + { + try + { + GameAgentAsyncBridge.Run(DisposeAsync); + } + catch + { + // Preserve the extension configuration failure. Cleanup is best effort here. + } + + throw; + } + } + + public bool HasExtensions + { + get + { + lock (_gate) + { + return _extensions.Count > 0; + } + } + } + + public IReadOnlyList GetResources() + { + lock (_gate) + { + return Array.AsReadOnly(SnapshotEntriesLocked().Select(entry => entry.Resource).ToArray()); + } + } + + public IReadOnlyList GetDiagnostics() + { + lock (_gate) + { + return Array.AsReadOnly(_diagnostics.ToArray()); + } + } + + public IGameAgentExtensionRegistration Register( + string extensionId, + string name, + GameAgentExtensionResourceKind kind, + T value, + int priority, + bool unique) + where T : class + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + lock (_gate) + { + EnsureActive(); + EnsureKnownExtension(extensionId); + var resourceName = GameJson.RequireId(name, nameof(name)); + if (unique) + { + var conflict = _registrations.FirstOrDefault( + entry => entry.Active + && entry.Resource.Kind == kind + && string.Equals(entry.Resource.Name, resourceName, StringComparison.Ordinal)); + if (conflict is not null) + { + var message = $"{kind} '{resourceName}' is already registered by extension '{conflict.Resource.ExtensionId}'."; + AddDiagnosticLocked(new GameAgentExtensionDiagnostic( + GameAgentExtensionDiagnosticSeverity.Error, + "extension.resource_conflict", + message, + extensionId, + resourceName)); + throw new InvalidOperationException(message); + } + } + + EnsureResourceCapacityLocked(); + + var resource = new GameAgentExtensionResource( + extensionId, + resourceName, + kind, + priority, + checked(++_nextSequence)); + var registration = new Registration(this, resource, value, serviceType: null, eventKey: null); + _registrations.Add(registration); + return registration; + } + } + + public IGameAgentExtensionRegistration RegisterService( + string extensionId, + string name, + Type serviceType, + object service, + int priority) + { + if (serviceType is null) + { + throw new ArgumentNullException(nameof(serviceType)); + } + + lock (_gate) + { + EnsureActive(); + EnsureKnownExtension(extensionId); + var resourceName = GameJson.RequireId(name, nameof(name)); + var conflict = _registrations.FirstOrDefault( + entry => entry.Active + && entry.Resource.Kind == GameAgentExtensionResourceKind.Service + && entry.ServiceType == serviceType + && string.Equals(entry.Resource.Name, resourceName, StringComparison.Ordinal)); + if (conflict is not null) + { + var message = $"Service '{resourceName}' for '{serviceType.FullName}' is already registered by extension '{conflict.Resource.ExtensionId}'."; + AddDiagnosticLocked(new GameAgentExtensionDiagnostic( + GameAgentExtensionDiagnosticSeverity.Error, + "extension.service_conflict", + message, + extensionId, + resourceName)); + throw new InvalidOperationException(message); + } + + EnsureResourceCapacityLocked(); + + var resource = new GameAgentExtensionResource( + extensionId, + resourceName, + GameAgentExtensionResourceKind.Service, + priority, + checked(++_nextSequence)); + var registration = new Registration(this, resource, service, serviceType, eventKey: null); + _registrations.Add(registration); + return registration; + } + } + + public IGameAgentExtensionRegistration RegisterEvent( + string extensionId, + GameAgentExtensionEvent eventKey, + GameAgentExtensionEventHandler handler, + int priority) + { + lock (_gate) + { + EnsureActive(); + EnsureKnownExtension(extensionId); + EnsureResourceCapacityLocked(); + var resource = new GameAgentExtensionResource( + extensionId, + eventKey.Name, + GameAgentExtensionResourceKind.EventHandler, + priority, + checked(++_nextSequence)); + var registration = new Registration(this, resource, handler, serviceType: null, eventKey); + _registrations.Add(registration); + return registration; + } + } + + public IGameAgentExtensionRegistration RegisterChannel( + string extensionId, + GameAgentExtensionChannel channel, + GameAgentExtensionChannelHandler handler, + int priority) + { + lock (_gate) + { + EnsureActive(); + EnsureKnownExtension(extensionId); + EnsureResourceCapacityLocked(); + var resource = new GameAgentExtensionResource( + extensionId, + channel.Name, + GameAgentExtensionResourceKind.EventHandler, + priority, + checked(++_nextSequence)); + var registration = new Registration(this, resource, handler, serviceType: null, channel); + _registrations.Add(registration); + return registration; + } + } + + public bool TryGet(string name, out T service) where T : class + { + var resourceName = GameJson.RequireId(name, nameof(name)); + lock (_gate) + { + var match = SnapshotEntriesLocked().FirstOrDefault( + entry => entry.Resource.Kind == GameAgentExtensionResourceKind.Service + && entry.ServiceType == typeof(T) + && string.Equals(entry.Resource.Name, resourceName, StringComparison.Ordinal)); + service = match?.Value as T ?? null!; + return match is not null; + } + } + + public T GetRequired(string name) where T : class => + TryGet(name, out var service) + ? service + : throw new KeyNotFoundException($"Service '{name}' for '{typeof(T).FullName}' is not registered."); + + public IReadOnlyList GetWorkflows() => + GetValues(GameAgentExtensionResourceKind.Workflow); + + public string ComposePrompt(string instructions) + { + var fragments = GetValues(GameAgentExtensionResourceKind.PromptFragment) + .Where(value => !string.IsNullOrWhiteSpace(value)); + return string.Join("\n\n", new[] { instructions ?? string.Empty }.Concat(fragments).Where(value => value.Length > 0)); + } + + public IModelProvider ResolveModelProvider(string? providerName, IModelProvider fallback) + { + if (providerName is null) + { + return fallback; + } + + var entries = GetEntries(GameAgentExtensionResourceKind.ModelProvider); + var match = entries.FirstOrDefault( + entry => string.Equals(entry.Resource.Name, providerName, StringComparison.Ordinal)); + return match?.Value as IModelProvider + ?? throw new KeyNotFoundException($"Model provider '{providerName}' is not registered."); + } + + public async ValueTask> CollectContextAsync( + GameAgentExtensionRunContext baseContext, + IReadOnlyList initial, + CancellationToken cancellationToken) + { + var values = new List(initial); + foreach (var entry in GetEntries(GameAgentExtensionResourceKind.ContextProvider)) + { + var provider = (GameExtensionContextProvider)entry.Value; + var contributed = await provider(ForOwner(baseContext, entry.Resource.ExtensionId), cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Context provider '{entry.Resource.Name}' returned null."); + if (contributed.Any(value => value is null)) + { + throw new InvalidOperationException($"Context provider '{entry.Resource.Name}' returned a null slice."); + } + + values.AddRange(contributed); + } + + return Array.AsReadOnly(values.ToArray()); + } + + public async ValueTask> CollectToolsAsync( + GameAgentExtensionRunContext baseContext, + IReadOnlyList initial, + CancellationToken cancellationToken) + { + var values = new List<(AgentTool Tool, string Owner)>( + initial.Select(tool => (tool, "runtime"))); + foreach (var entry in GetEntries(GameAgentExtensionResourceKind.Tool)) + { + values.Add(((AgentTool)entry.Value, entry.Resource.ExtensionId)); + } + + foreach (var entry in GetEntries(GameAgentExtensionResourceKind.ToolProvider)) + { + var provider = (GameExtensionToolProvider)entry.Value; + var contributed = await provider(ForOwner(baseContext, entry.Resource.ExtensionId), cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Tool provider '{entry.Resource.Name}' returned null."); + if (contributed.Any(value => value is null)) + { + throw new InvalidOperationException($"Tool provider '{entry.Resource.Name}' returned a null tool."); + } + + values.AddRange(contributed.Select(tool => (tool, entry.Resource.ExtensionId))); + } + + var duplicate = values.GroupBy(value => value.Tool.Definition.Name, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + var owners = string.Join(", ", duplicate.Select(value => value.Owner).Distinct(StringComparer.Ordinal)); + var message = $"Tool '{duplicate.Key}' was contributed more than once ({owners})."; + AddDiagnostic(new GameAgentExtensionDiagnostic( + GameAgentExtensionDiagnosticSeverity.Error, + "extension.tool_conflict", + message, + resourceName: duplicate.Key)); + throw new InvalidOperationException(message); + } + + return Array.AsReadOnly(values.Select(value => value.Tool).ToArray()); + } + + public async ValueTask> CollectSkillsAsync( + GameAgentExtensionRunContext baseContext, + IReadOnlyList initial, + IReadOnlyCollection activeToolNames, + int maximumSkills, + CancellationToken cancellationToken) + { + var values = new List(initial); + foreach (var entry in GetEntries(GameAgentExtensionResourceKind.SkillProvider)) + { + var provider = (GameExtensionSkillProvider)entry.Value; + var remaining = Math.Max(0, maximumSkills - values.Count); + if (remaining == 0) + { + break; + } + + var contributed = await provider( + ForOwner(baseContext, entry.Resource.ExtensionId), + activeToolNames, + remaining, + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException($"Skill provider '{entry.Resource.Name}' returned null."); + if (contributed.Any(value => value is null)) + { + throw new InvalidOperationException($"Skill provider '{entry.Resource.Name}' returned a null skill."); + } + + values.AddRange(contributed); + } + + var duplicate = values.GroupBy(value => value.SkillId, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new InvalidOperationException($"Skill '{duplicate.Key}' was contributed more than once."); + } + + return Array.AsReadOnly(values.ToArray()); + } + + public async ValueTask HasPendingWorkAsync( + GameAgentExtensionRunContext baseContext, + bool initial, + CancellationToken cancellationToken) + { + if (initial) + { + return true; + } + + foreach (var entry in GetEntries(GameAgentExtensionResourceKind.PendingWorkProvider)) + { + if (await ((GameExtensionPendingWorkProvider)entry.Value)( + ForOwner(baseContext, entry.Resource.ExtensionId), + cancellationToken).ConfigureAwait(false)) + { + return true; + } + } + + return false; + } + + public async ValueTask SelectRouteAsync( + GameAgentExtensionRunContext baseContext, + int availableToolCount, + bool hasPendingWork, + CancellationToken cancellationToken) + { + foreach (var entry in GetEntries(GameAgentExtensionResourceKind.RouteRule)) + { + var decision = await ((GameExtensionRouteRule)entry.Value)( + ForOwner(baseContext, entry.Resource.ExtensionId), + availableToolCount, + hasPendingWork, + cancellationToken).ConfigureAwait(false); + if (decision is not null) + { + return decision; + } + } + + return null; + } + + public AgentHooks ComposeHooks(GameAgentExtensionRunContext baseContext, AgentHooks baseline) + { + var hooks = GetEntries(GameAgentExtensionResourceKind.AgentHooks) + .Select(entry => ((GameExtensionHookFactory)entry.Value)(ForOwner(baseContext, entry.Resource.ExtensionId))) + .ToList(); + if (baseline is not null) + { + hooks.Add(baseline); + } + + if (hooks.Any(value => value is null)) + { + throw new InvalidOperationException("An extension hook factory returned null."); + } + + return AgentHookComposer.Compose(hooks); + } + + public async ValueTask PublishAsync( + GameAgentExtensionEvent eventKey, + TEvent value, + GameAgentExtensionRunContext baseContext, + CancellationToken cancellationToken) + { + var handlers = GetEntries(GameAgentExtensionResourceKind.EventHandler) + .Where(entry => ReferenceEquals(entry.EventKey, eventKey)) + .ToArray(); + foreach (var entry in handlers) + { + try + { + await ((GameAgentExtensionEventHandler)entry.Value)( + value, + ForOwner(baseContext, entry.Resource.ExtensionId), + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + AddDiagnostic(new GameAgentExtensionDiagnostic( + GameAgentExtensionDiagnosticSeverity.Error, + "extension.event_handler_failed", + $"Handler for '{eventKey.Name}' failed: {exception.Message}", + entry.Resource.ExtensionId, + eventKey.Name)); + } + } + } + + public async ValueTask PublishChannelAsync( + GameAgentExtensionChannel channel, + TMessage message, + CancellationToken cancellationToken) + { + if (channel is null) + { + throw new ArgumentNullException(nameof(channel)); + } + + Registration[] handlers; + lock (_gate) + { + if (_disposed) + { + return; + } + + handlers = SnapshotEntriesLocked() + .Where(entry => entry.Resource.Kind == GameAgentExtensionResourceKind.EventHandler) + .Where(entry => ReferenceEquals(entry.EventKey, channel)) + .ToArray(); + } + foreach (var entry in handlers) + { + try + { + await ((GameAgentExtensionChannelHandler)entry.Value)(message, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + AddDiagnostic(new GameAgentExtensionDiagnostic( + GameAgentExtensionDiagnosticSeverity.Error, + "extension.channel_handler_failed", + $"Handler for channel '{channel.Name}' failed: {exception.Message}", + entry.Resource.ExtensionId, + channel.Name)); + } + } + } + + public async ValueTask DisposeAsync() + { + IGameAgentExtension[] extensions; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + foreach (var registration in _registrations) + { + registration.Deactivate(); + } + + extensions = _extensions.AsEnumerable().Reverse().ToArray(); + } + + var failures = new List(); + foreach (var extension in extensions) + { + try + { + if (extension is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else if (extension is IDisposable disposable) + { + disposable.Dispose(); + } + } + catch (Exception exception) + { + failures.Add(exception); + } + } + + if (failures.Count == 1) + { + throw failures[0]; + } + + if (failures.Count > 1) + { + throw new AggregateException("One or more game agent extensions failed during disposal.", failures); + } + } + + private void AddExtension(IGameAgentExtension extension) + { + var descriptor = extension.Descriptor + ?? throw new InvalidOperationException("An extension returned a null descriptor."); + lock (_gate) + { + EnsureActive(); + if (_extensions.Count >= _maximumExtensions) + { + throw new GameRuntimeLimitException( + nameof(GameRuntimeLimits.MaxExtensions), + "The runtime reached its extension limit."); + } + + if (!_extensionIds.Add(descriptor.Id)) + { + throw new InvalidOperationException($"Extension '{descriptor.Id}' is registered more than once."); + } + + _extensions.Add(extension); + } + + try + { + extension.Configure(new GameAgentExtensionApi(this, descriptor.Id)); + } + catch + { + lock (_gate) + { + foreach (var registration in _registrations.Where( + value => string.Equals(value.Resource.ExtensionId, descriptor.Id, StringComparison.Ordinal))) + { + registration.Deactivate(); + } + + _registrations.RemoveAll(value => + string.Equals(value.Resource.ExtensionId, descriptor.Id, StringComparison.Ordinal)); + + _extensions.Remove(extension); + _extensionIds.Remove(descriptor.Id); + } + + try + { + if (extension is IAsyncDisposable asyncDisposable) + { + GameAgentAsyncBridge.Run(asyncDisposable.DisposeAsync); + } + else if (extension is IDisposable disposable) + { + disposable.Dispose(); + } + } + catch + { + // Preserve the extension configuration failure. Cleanup is best effort here. + } + + throw; + } + } + + private GameAgentExtensionRunContext ForOwner(GameAgentExtensionRunContext context, string extensionId) => + CreateOwnerContext(context, extensionId); + + private GameAgentExtensionRunContext CreateOwnerContext( + GameAgentExtensionRunContext context, + string extensionId) + { + context.EnsureActive(); + return new GameAgentExtensionRunContext( + context.Input, + context.Session, + context.SessionState, + new GameAgentExtensionState(context.SessionState, extensionId, context.Lease), + context.Lease, + this, + GetResources()); + } + + internal GameAgentExtensionRunContext CreateRunContext( + GameInput input, + GameSessionSnapshot session, + GameAgentSessionState state) + { + var lease = new GameAgentExtensionRunLease(); + return new GameAgentExtensionRunContext( + input, + session, + state, + new GameAgentExtensionState(state, "runtime", lease), + lease, + this, + GetResources()); + } + + private IReadOnlyList GetValues(GameAgentExtensionResourceKind kind) where T : class => + Array.AsReadOnly(GetEntries(kind).Select(entry => (T)entry.Value).ToArray()); + + private IReadOnlyList GetEntries(GameAgentExtensionResourceKind kind) + { + lock (_gate) + { + return Array.AsReadOnly(SnapshotEntriesLocked().Where(entry => entry.Resource.Kind == kind).ToArray()); + } + } + + private List SnapshotEntriesLocked() => + _registrations + .Where(entry => entry.Active) + .OrderByDescending(entry => entry.Resource.Priority) + .ThenBy(entry => entry.Resource.Sequence) + .ToList(); + + private void AddDiagnostic(GameAgentExtensionDiagnostic diagnostic) + { + lock (_gate) + { + AddDiagnosticLocked(diagnostic); + } + } + + private void AddDiagnosticLocked(GameAgentExtensionDiagnostic diagnostic) + { + if (_maximumDiagnostics == 0) + { + return; + } + + var bounded = diagnostic.Message.Length <= _maximumDiagnosticCharacters + ? diagnostic + : new GameAgentExtensionDiagnostic( + diagnostic.Severity, + diagnostic.Code, + diagnostic.Message.Substring(0, _maximumDiagnosticCharacters), + diagnostic.ExtensionId, + diagnostic.ResourceName); + if (_diagnostics.Count >= _maximumDiagnostics) + { + _diagnostics.RemoveAt(0); + } + + _diagnostics.Add(bounded); + } + + private void EnsureResourceCapacityLocked() + { + if (_registrations.Count(entry => entry.Active) >= _maximumResources) + { + throw new GameRuntimeLimitException( + nameof(GameRuntimeLimits.MaxExtensionResources), + "The runtime reached its extension resource limit."); + } + } + + private void Remove(Registration registration) + { + lock (_gate) + { + registration.Deactivate(); + _registrations.Remove(registration); + } + } + + private void EnsureKnownExtension(string extensionId) + { + if (!_extensionIds.Contains(extensionId)) + { + throw new InvalidOperationException($"Extension '{extensionId}' is not active."); + } + } + + private void EnsureActive() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(GameAgentExtensionHost)); + } + } + + private sealed class Registration : IGameAgentExtensionRegistration + { + private readonly GameAgentExtensionHost _owner; + private int _active = 1; + + public Registration( + GameAgentExtensionHost owner, + GameAgentExtensionResource resource, + object value, + Type? serviceType, + object? eventKey) + { + _owner = owner; + Resource = resource; + Value = value; + ServiceType = serviceType; + EventKey = eventKey; + } + + public GameAgentExtensionResource Resource { get; } + + public object Value { get; } + + public Type? ServiceType { get; } + + public object? EventKey { get; } + + public bool IsActive => Volatile.Read(ref _active) != 0; + + public bool Active => IsActive; + + public void Dispose() => _owner.Remove(this); + + public void Deactivate() => Interlocked.Exchange(ref _active, 0); + } +} + +internal static class AgentHookComposer +{ + public static AgentHooks Compose(IReadOnlyList hooks) + { + if (hooks is null) + { + throw new ArgumentNullException(nameof(hooks)); + } + + return new AgentHooks + { + TransformContextAsync = hooks.Any(hook => hook.TransformContextAsync is not null) + ? async (messages, cancellationToken) => + { + var current = messages; + foreach (var hook in hooks) + { + if (hook.TransformContextAsync is not null) + { + current = await hook.TransformContextAsync(current, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("An extension context transform returned null."); + } + } + + return current; + } + : null, + BeforeModelRequestAsync = hooks.Any(hook => hook.BeforeModelRequestAsync is not null) + ? async (request, cancellationToken) => + { + var current = request; + foreach (var hook in hooks) + { + if (hook.BeforeModelRequestAsync is not null) + { + current = await hook.BeforeModelRequestAsync(current, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("An extension model request transform returned null."); + } + } + + return current; + } + : null, + ShouldStopAfterTurnAsync = hooks.Any(hook => hook.ShouldStopAfterTurnAsync is not null) + ? async (context, cancellationToken) => + { + foreach (var hook in hooks) + { + if (hook.ShouldStopAfterTurnAsync is not null + && await hook.ShouldStopAfterTurnAsync(context, cancellationToken).ConfigureAwait(false)) + { + return true; + } + } + + return false; + } + : null, + PrepareNextTurnAsync = hooks.Any(hook => hook.PrepareNextTurnAsync is not null) + ? async (context, cancellationToken) => + { + NextTurnUpdate? combined = null; + var modelTargetClaimed = false; + foreach (var hook in hooks) + { + if (hook.PrepareNextTurnAsync is null) + { + continue; + } + + var update = await hook.PrepareNextTurnAsync(context, cancellationToken).ConfigureAwait(false); + if (update is null) + { + continue; + } + + combined ??= new NextTurnUpdate(); + combined.Context ??= update.Context; + if (!modelTargetClaimed && (update.Provider is not null || update.Model is not null)) + { + combined.Provider = update.Provider; + combined.Model = update.Model; + modelTargetClaimed = true; + } + + combined.Parameters ??= update.Parameters; + } + + return combined; + } + : null, + BeforeToolCallAsync = hooks.Any(hook => hook.BeforeToolCallAsync is not null) + ? async (call, context, cancellationToken) => + { + var current = call; + var replaced = false; + foreach (var hook in hooks) + { + if (hook.BeforeToolCallAsync is null) + { + continue; + } + + var decision = await hook.BeforeToolCallAsync(current, context, cancellationToken).ConfigureAwait(false); + if (decision?.Blocked == true) + { + return decision; + } + + if (decision?.ReplacementArgumentsJson is not null) + { + var tool = context.Tools.FirstOrDefault(candidate => + string.Equals(candidate.Definition.Name, current.Name, StringComparison.Ordinal)) + ?? throw new InvalidOperationException($"Tool '{current.Name}' is not available during hook composition."); + var validationError = tool.ValidateArguments(decision.ReplacementArgumentsJson); + if (validationError is not null) + { + throw new InvalidOperationException("Invalid tool arguments: " + validationError); + } + + current = new ToolCallContent(current.Id, current.Name, decision.ReplacementArgumentsJson); + replaced = true; + } + } + + return replaced ? ToolCallDecision.Allow(current.ArgumentsJson) : null; + } + : null, + AfterToolCallAsync = hooks.Any(hook => hook.AfterToolCallAsync is not null) + ? async (call, result, context, cancellationToken) => + { + var current = result; + foreach (var hook in hooks) + { + if (hook.AfterToolCallAsync is not null) + { + current = await hook.AfterToolCallAsync(call, current, context, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("An extension tool result transform returned null."); + } + } + + return current; + } + : null, + }; + } +} diff --git a/src/OpenGameAgent/GameAgentBuilder.cs b/src/OpenGameAgent/GameAgentBuilder.cs new file mode 100644 index 0000000..d1d19a4 --- /dev/null +++ b/src/OpenGameAgent/GameAgentBuilder.cs @@ -0,0 +1,88 @@ +using System; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent; + +/// +/// Primary composition API for an in-engine or server-hosted runtime. +/// +public sealed class GameAgentBuilder +{ + private readonly GameAgentRuntimeOptions _options; + private bool _built; + + public GameAgentBuilder(IModelProvider provider, string model) + { + _options = new GameAgentRuntimeOptions( + provider ?? throw new ArgumentNullException(nameof(provider)), + model); + } + + public GameAgentBuilder Configure(Action configure) + { + EnsureMutable(); + (configure ?? throw new ArgumentNullException(nameof(configure)))(_options); + return this; + } + + public GameAgentBuilder UseInstructions(string instructions) + { + EnsureMutable(); + _options.Instructions = instructions ?? throw new ArgumentNullException(nameof(instructions)); + return this; + } + + public GameAgentBuilder UseSessionStore(IGameSessionStore store) + { + EnsureMutable(); + _options.SessionStore = store ?? throw new ArgumentNullException(nameof(store)); + return this; + } + + public GameAgentBuilder UseRouting(IGameRoutePolicy routePolicy) + { + EnsureMutable(); + _options.RoutePolicy = routePolicy ?? throw new ArgumentNullException(nameof(routePolicy)); + return this; + } + + public GameAgentBuilder UseModelSelector(GameModelSelector selector) + { + EnsureMutable(); + _options.ModelSelector = selector ?? throw new ArgumentNullException(nameof(selector)); + return this; + } + + public GameAgentBuilder UseExtension(IGameAgentExtension extension) + { + EnsureMutable(); + _options.Extensions.Add(extension ?? throw new ArgumentNullException(nameof(extension))); + return this; + } + + public GameAgentBuilder UseExtension( + string id, + string version, + Action configure, + string? description = null) + { + return UseExtension(new DelegateGameAgentExtension( + new GameAgentExtensionDescriptor(id, version, description), + configure)); + } + + public GameAgentRuntime Build() + { + EnsureMutable(); + _built = true; + return new GameAgentRuntime(_options); + } + + private void EnsureMutable() + { + if (_built) + { + throw new InvalidOperationException("A game agent builder can build only one runtime."); + } + } +} diff --git a/src/OpenGameAgent/GameAgentRuntime.cs b/src/OpenGameAgent/GameAgentRuntime.cs index 8ab4e4e..b08acdc 100644 --- a/src/OpenGameAgent/GameAgentRuntime.cs +++ b/src/OpenGameAgent/GameAgentRuntime.cs @@ -23,6 +23,65 @@ public delegate ValueTask GamePendingWorkProvider( GameInput input, CancellationToken cancellationToken); +public sealed class GameModelSelection +{ + private readonly ModelParameters? _parameters; + + public GameModelSelection( + string model, + string? registeredProviderName = null, + ModelParameters? parameters = null, + IModelProvider? provider = null, + int contextWindowTokens = 0, + int maximumOutputTokens = 0) + { + if (registeredProviderName is not null && provider is not null) + { + throw new ArgumentException("A model selection cannot specify both a registered provider name and a direct provider."); + } + + Model = GameJson.RequireId(model, nameof(model)); + RegisteredProviderName = registeredProviderName is null + ? null + : GameJson.RequireId(registeredProviderName, nameof(registeredProviderName)); + if (contextWindowTokens < 0) + { + throw new ArgumentOutOfRangeException(nameof(contextWindowTokens)); + } + + if (maximumOutputTokens < 0) + { + throw new ArgumentOutOfRangeException(nameof(maximumOutputTokens)); + } + + if (contextWindowTokens > 0 && maximumOutputTokens >= contextWindowTokens) + { + throw new ArgumentException("The model output limit must be smaller than its context window."); + } + + _parameters = parameters?.Clone(); + Provider = provider; + ContextWindowTokens = contextWindowTokens; + MaximumOutputTokens = maximumOutputTokens; + } + + public string Model { get; } + + public string? RegisteredProviderName { get; } + + public ModelParameters? Parameters => _parameters?.Clone(); + + public IModelProvider? Provider { get; } + + public int ContextWindowTokens { get; } + + public int MaximumOutputTokens { get; } +} + +public delegate ValueTask GameModelSelector( + GameInput input, + CancellationToken cancellationToken); + public delegate ValueTask GameAgentEventHandler( GameInput input, AgentEvent agentEvent, @@ -118,7 +177,12 @@ internal GameWorkflowContext( public GameSessionSnapshot Session { get; } - internal void ValidateOutput(IReadOnlyList messages) => _validateOutput(messages); + /// + /// Validates cumulative workflow output against the active runtime transcript limits. + /// Long-running workflows should call this before committing a node or step checkpoint. + /// + public void ValidateOutput(IReadOnlyList messages) => + _validateOutput(messages ?? throw new ArgumentNullException(nameof(messages))); private static void ValidateNonNullOutput(IReadOnlyList messages) { @@ -198,8 +262,16 @@ public GameAgentRuntimeOptions(IModelProvider provider, string model) public GamePendingWorkProvider? PendingWorkProvider { get; set; } + public GameModelSelector? ModelSelector { get; set; } + public IList Workflows { get; } = new List(); + /// + /// Runtime extensions. Extensions are configured in list order and all features register + /// through the same public extension API. + /// + public IList Extensions { get; } = new List(); + public GameRuntimeLimits Limits { get; set; } = new(); public AgentLimits AgentLimits { get; set; } = new(); @@ -208,6 +280,23 @@ public GameAgentRuntimeOptions(IModelProvider provider, string model) public IGameTranscriptCompactor? TranscriptCompactor { get; set; } + /// + /// Model context window used when a selector does not provide model metadata. Zero disables + /// request-size admission and leaves message-count compaction as the only transcript budget. + /// + public int ContextWindowTokens { get; set; } + + /// + /// Tokens reserved for model output when the active model or request does not provide an output limit. + /// + public int ContextWindowReserveTokens { get; set; } = 16_384; + + public GameModelRequestTokenEstimator RequestTokenEstimator { get; set; } = + ApproximateGameTokenEstimator.EstimateRequest; + + public GameTranscriptTokenEstimator TranscriptTokenEstimator { get; set; } = + ApproximateGameTokenEstimator.EstimateMessages; + public AgentHooks AgentHooks { get; set; } = new(); public bool RefreshContextAfterToolTurns { get; set; } = true; @@ -215,11 +304,25 @@ public GameAgentRuntimeOptions(IModelProvider provider, string model) public ToolExecutionMode ToolExecution { get; set; } = ToolExecutionMode.SafeParallel; public int RecentProcessedInputCapacity { get; set; } = 256; + + /// + /// Maximum time allowed to durably settle a completed or aborted agent run after execution has begun. + /// This commit is intentionally independent from the caller's cancellation token so tool receipts and + /// terminal transcript state are not lost when cancellation stops model or tool work. + /// + public int SessionCommitTimeoutMilliseconds { get; set; } = 10_000; + + /// + /// Persists a canonical checkpoint after each fully settled tool turn. This bounds crash recovery + /// without writing partial model streams or marking the input complete before the run finishes. + /// + public bool PersistToolTurnCheckpoints { get; set; } = true; } -public sealed class GameAgentRuntime +public sealed class GameAgentRuntime : IDisposable, IAsyncDisposable { private readonly object _activeAgentsGate = new(); + private readonly CancellationTokenSource _lifetimeCancellation = new(); private readonly Dictionary _activeAgents = new(); private readonly IModelProvider _provider; private readonly string _model; @@ -230,16 +333,25 @@ public sealed class GameAgentRuntime private readonly IGameSkillSource? _skillSource; private readonly GameToolProvider? _toolProvider; private readonly GamePendingWorkProvider? _pendingWorkProvider; + private readonly GameModelSelector? _modelSelector; private readonly IReadOnlyDictionary _workflows; private readonly GameRuntimeLimits _limits; private readonly AgentLimits _agentLimits; private readonly ModelParameters _modelParameters; private readonly IGameTranscriptCompactor? _transcriptCompactor; + private readonly int _contextWindowTokens; + private readonly int _contextWindowReserveTokens; + private readonly GameModelRequestTokenEstimator _requestTokenEstimator; + private readonly GameTranscriptTokenEstimator _transcriptTokenEstimator; private readonly AgentHooks _agentHooks; private readonly bool _refreshContextAfterToolTurns; private readonly ToolExecutionMode _toolExecution; private readonly MultiActorScheduler _actors; private readonly int _recentProcessedInputCapacity; + private readonly int _sessionCommitTimeoutMilliseconds; + private readonly bool _persistToolTurnCheckpoints; + private readonly GameAgentExtensionHost _extensions; + private int _disposed; public GameAgentRuntime(GameAgentRuntimeOptions options) { @@ -250,54 +362,120 @@ public GameAgentRuntime(GameAgentRuntimeOptions options) _provider = options.Provider; _model = GameJson.RequireId(options.Model, nameof(options.Model)); - _instructions = options.Instructions ?? throw new ArgumentNullException(nameof(options.Instructions)); - _routePolicy = options.RoutePolicy ?? throw new ArgumentNullException(nameof(options.RoutePolicy)); - _sessionStore = options.SessionStore ?? throw new ArgumentNullException(nameof(options.SessionStore)); + _instructions = options.Instructions + ?? throw new ArgumentException("Runtime instructions are required.", nameof(options)); + _routePolicy = options.RoutePolicy + ?? throw new ArgumentException("A route policy is required.", nameof(options)); + _sessionStore = options.SessionStore + ?? throw new ArgumentException("A session store is required.", nameof(options)); _contextProvider = options.ContextProvider; _skillSource = options.SkillSource; _toolProvider = options.ToolProvider; _pendingWorkProvider = options.PendingWorkProvider; - _limits = options.Limits?.CopyAndValidate() ?? throw new ArgumentNullException(nameof(options.Limits)); - _agentLimits = CopyAgentLimits(options.AgentLimits ?? throw new ArgumentNullException(nameof(options.AgentLimits))); - _modelParameters = options.ModelParameters?.Clone() ?? throw new ArgumentNullException(nameof(options.ModelParameters)); + _modelSelector = options.ModelSelector; + _limits = options.Limits?.CopyAndValidate() + ?? throw new ArgumentException("Runtime limits are required.", nameof(options)); + _agentLimits = CopyAgentLimits(options.AgentLimits + ?? throw new ArgumentException("Agent limits are required.", nameof(options))); + _modelParameters = options.ModelParameters?.Clone() + ?? throw new ArgumentException("Model parameters are required.", nameof(options)); _transcriptCompactor = options.TranscriptCompactor; - _agentHooks = CopyHooks(options.AgentHooks ?? throw new ArgumentNullException(nameof(options.AgentHooks))); + if (options.ContextWindowTokens < 0 || options.ContextWindowTokens > 1_000_000_000) + { + throw new ArgumentOutOfRangeException(nameof(options), "The context-window size is invalid."); + } + + if (options.ContextWindowReserveTokens <= 0 + || options.ContextWindowReserveTokens > 1_000_000_000) + { + throw new ArgumentOutOfRangeException(nameof(options), "The context-window reserve is invalid."); + } + + if (options.ContextWindowTokens > 0 + && options.ContextWindowReserveTokens >= options.ContextWindowTokens) + { + throw new ArgumentException("The context-window reserve must be smaller than the configured context window."); + } + + if (options.ContextWindowTokens > 0 + && _modelParameters.MaxOutputTokens is { } configuredOutput + && configuredOutput >= options.ContextWindowTokens) + { + throw new ArgumentException("The configured model output limit must be smaller than the context window."); + } + + _contextWindowTokens = options.ContextWindowTokens; + _contextWindowReserveTokens = options.ContextWindowReserveTokens; + _requestTokenEstimator = options.RequestTokenEstimator + ?? throw new ArgumentException("A model-request token estimator is required.", nameof(options)); + _transcriptTokenEstimator = options.TranscriptTokenEstimator + ?? throw new ArgumentException("A transcript token estimator is required.", nameof(options)); + _agentHooks = CopyHooks(options.AgentHooks + ?? throw new ArgumentException("Agent hooks are required.", nameof(options))); _refreshContextAfterToolTurns = options.RefreshContextAfterToolTurns; if (!Enum.IsDefined(typeof(ToolExecutionMode), options.ToolExecution)) { - throw new ArgumentOutOfRangeException(nameof(options.ToolExecution)); + throw new ArgumentOutOfRangeException(nameof(options), "The tool execution mode is invalid."); } _toolExecution = options.ToolExecution; if (options.RecentProcessedInputCapacity <= 0 || options.RecentProcessedInputCapacity > 100_000) { - throw new ArgumentOutOfRangeException(nameof(options.RecentProcessedInputCapacity)); + throw new ArgumentOutOfRangeException(nameof(options), "The processed-input retention capacity is invalid."); } _recentProcessedInputCapacity = options.RecentProcessedInputCapacity; - _actors = new MultiActorScheduler( - _limits.MaxConcurrentActors, - maximumActors: checked(_limits.MaxConcurrentActors * 16), - _limits.MaxQueuedInputsPerActor); - - var workflows = options.Workflows.ToArray(); - if (workflows.Any(workflow => workflow is null || string.IsNullOrWhiteSpace(workflow.Name))) + if (options.SessionCommitTimeoutMilliseconds < 100 || options.SessionCommitTimeoutMilliseconds > 300_000) { - throw new ArgumentException("Every workflow must have a name.", nameof(options.Workflows)); + throw new ArgumentOutOfRangeException(nameof(options), "The session commit timeout is invalid."); } - var duplicate = workflows.GroupBy(workflow => workflow.Name, StringComparer.Ordinal).FirstOrDefault(group => group.Count() > 1); - if (duplicate is not null) + _sessionCommitTimeoutMilliseconds = options.SessionCommitTimeoutMilliseconds; + _persistToolTurnCheckpoints = options.PersistToolTurnCheckpoints; + var extensions = new GameAgentExtensionHost(options.Extensions, _limits); + try { - throw new ArgumentException($"Duplicate workflow name '{duplicate.Key}'.", nameof(options.Workflows)); + var workflows = options.Workflows.Concat(extensions.GetWorkflows()).ToArray(); + if (workflows.Any(workflow => workflow is null || string.IsNullOrWhiteSpace(workflow.Name))) + { + throw new ArgumentException("Every workflow must have a name.", nameof(options)); + } + + var duplicate = workflows.GroupBy(workflow => workflow.Name, StringComparer.Ordinal).FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new ArgumentException($"Duplicate workflow name '{duplicate.Key}'.", nameof(options)); + } + + _workflows = workflows.ToDictionary(workflow => workflow.Name, StringComparer.Ordinal); + _actors = new MultiActorScheduler( + _limits.MaxConcurrentActors, + maximumActors: _limits.MaxScheduledActors, + _limits.MaxQueuedInputsPerActor); + _extensions = extensions; } + catch + { + try + { + GameAgentAsyncBridge.Run(extensions.DisposeAsync); + } + catch + { + // Preserve the runtime construction failure. Cleanup is best effort here. + } - _workflows = workflows.ToDictionary(workflow => workflow.Name, StringComparer.Ordinal); + throw; + } } public Task RunAsync(GameInput input, CancellationToken cancellationToken = default) => RunAsync(input, observer: null, cancellationToken); + public IReadOnlyList ExtensionResources => _extensions.GetResources(); + + public IReadOnlyList ExtensionDiagnostics => _extensions.GetDiagnostics(); + public Task RunAsync( GameInput input, GameAgentEventHandler? observer, @@ -308,11 +486,30 @@ public Task RunAsync( throw new ArgumentNullException(nameof(input)); } + if (Volatile.Read(ref _disposed) != 0) + { + throw new ObjectDisposedException(nameof(GameAgentRuntime)); + } + _limits.Validate(input); - return _actors.EnqueueAsync( + return EnqueueRunAsync( + input, + observer, + cancellationToken); + } + + private async Task EnqueueRunAsync( + GameInput input, + GameAgentEventHandler? observer, + CancellationToken cancellationToken) + { + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _lifetimeCancellation.Token); + return await _actors.EnqueueAsync( GameJson.JoinIds(input.SessionId, input.ActorId), token => RunCoreAsync(input, observer, token), - cancellationToken); + linkedCancellation.Token).ConfigureAwait(false); } /// @@ -367,144 +564,383 @@ private async ValueTask RunCoreAsync( GameAgentEventHandler? observer, CancellationToken cancellationToken) { - var key = new GameSessionKey(input.SessionId, input.ActorId); - var loaded = await _sessionStore.LoadAsync(key, cancellationToken).ConfigureAwait(false) - ?? new GameSessionSnapshot(key, 0); - if (!loaded.Key.Equals(key)) + GameAgentExtensionRunContext? failureContext = null; + try { - throw new InvalidOperationException("The game session store returned a snapshot for a different session key."); - } + var key = new GameSessionKey(input.SessionId, input.ActorId); + var loaded = await _sessionStore.LoadAsync(key, cancellationToken).ConfigureAwait(false) + ?? new GameSessionSnapshot(key, 0); + if (!loaded.Key.Equals(key)) + { + throw new InvalidOperationException("The game session store returned a snapshot for a different session key."); + } - if (loaded.ProcessedInputIds.Count > _recentProcessedInputCapacity) - { - throw new InvalidOperationException("The game session store returned more processed input IDs than the configured retention capacity."); - } + if (loaded.ProcessedInputIds.Count > _recentProcessedInputCapacity) + { + throw new InvalidOperationException("The game session store returned more processed input IDs than the configured retention capacity."); + } - AgentValidation.ValidateTranscript(loaded.Messages, _agentLimits); - if (loaded.ProcessedInputIds.Contains(input.InputId, StringComparer.Ordinal)) - { - return new GameAgentRunResult( - GameAgentRunStatus.Duplicate, - GameRouteDecision.Quick("duplicate-input"), - loaded.Revision); - } + if (loaded.ProcessedInputIds.Any(id => id.Length > _limits.MaxIdentifierCharacters) + || (loaded.PendingInputId?.Length ?? 0) > _limits.MaxIdentifierCharacters + || (loaded.LastMoment?.TimelineId.Length ?? 0) > _limits.MaxIdentifierCharacters + || (loaded.LastMoment?.CalendarJson?.Length ?? 0) > _limits.MaxCalendarJsonCharacters) + { + throw new InvalidOperationException("The game session store returned state that exceeds the configured runtime limits."); + } - var context = _contextProvider is null - ? Array.Empty() - : (await _contextProvider.GetContextAsync(input, cancellationToken).ConfigureAwait(false) - ?? throw new InvalidOperationException("The game context provider returned null.")).ToArray(); - _limits.Validate(context); + AgentValidation.ValidateTranscript(loaded.Messages, _agentLimits); + var extensionState = new GameAgentSessionState(loaded.ExtensionState, _limits); + var extensionContext = _extensions.CreateRunContext(input, loaded, extensionState); + failureContext = extensionContext; + await _extensions.PublishAsync( + GameAgentExtensionEvents.InputReceived, + new GameAgentInputEvent(input), + extensionContext, + cancellationToken).ConfigureAwait(false); + await _extensions.PublishAsync( + GameAgentExtensionEvents.SessionLoaded, + new GameAgentSessionEvent(loaded), + extensionContext, + cancellationToken).ConfigureAwait(false); + if (loaded.ProcessedInputIds.Contains(input.InputId, StringComparer.Ordinal)) + { + var duplicate = new GameAgentRunResult( + GameAgentRunStatus.Duplicate, + GameRouteDecision.Quick("duplicate-input"), + loaded.Revision); + await PublishCompletedAsync(duplicate, extensionContext, cancellationToken).ConfigureAwait(false); + return duplicate; + } - var tools = _toolProvider is null - ? Array.Empty() - : (await _toolProvider(input, cancellationToken).ConfigureAwait(false) - ?? throw new InvalidOperationException("The game tool provider returned null.")).ToArray(); - if (tools.Any(tool => tool is null)) - { - throw new InvalidOperationException("The game tool provider returned a null tool."); - } + if (loaded.PendingInputId is not null + && !string.Equals(loaded.PendingInputId, input.InputId, StringComparison.Ordinal)) + { + var pending = new GameAgentRunResult( + GameAgentRunStatus.SessionConflict, + GameRouteDecision.Quick("pending-input"), + loaded.Revision, + error: $"Input '{loaded.PendingInputId}' has a durable tool-turn checkpoint and must be resumed before another input can run."); + await PublishCompletedAsync(pending, extensionContext, cancellationToken).ConfigureAwait(false); + return pending; + } - var hasPendingWork = _pendingWorkProvider is not null - && await _pendingWorkProvider(input, cancellationToken).ConfigureAwait(false); - var route = await _routePolicy.RouteAsync( - new GameRouteContext(input, tools.Length, hasPendingWork), - cancellationToken).ConfigureAwait(false) - ?? throw new InvalidOperationException("The game route policy returned null."); + var resumingCheckpoint = loaded.PendingInputId is not null; + if (resumingCheckpoint) + { + ValidatePendingInput(loaded, input); + } - if (route.Route == GameRouteKind.Workflow) - { - return await RunWorkflowAsync(input, loaded, context, tools, route, cancellationToken).ConfigureAwait(false); - } + var baseContext = _contextProvider is null + ? Array.Empty() + : (await _contextProvider.GetContextAsync(input, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The game context provider returned null.")).ToArray(); + var context = await _extensions.CollectContextAsync( + extensionContext, + baseContext, + cancellationToken).ConfigureAwait(false); + _limits.Validate(context); + await _extensions.PublishAsync( + GameAgentExtensionEvents.ContextCollected, + new GameAgentContextEvent(context), + extensionContext, + cancellationToken).ConfigureAwait(false); + + var baseTools = _toolProvider is null + ? Array.Empty() + : (await _toolProvider(input, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The game tool provider returned null.")).ToArray(); + if (baseTools.Any(tool => tool is null)) + { + throw new InvalidOperationException("The game tool provider returned a null tool."); + } - var activeTools = route.Route == GameRouteKind.QuickResponse ? Array.Empty() : tools; - var skills = _skillSource is null - ? Array.Empty() - : (await _skillSource.SelectAsync( - new GameSkillQuery(input, activeTools.Select(tool => tool.Definition.Name).ToArray(), _limits.MaxSkillsPerRun), - cancellationToken).ConfigureAwait(false) - ?? throw new InvalidOperationException("The game skill source returned null.")).ToArray(); - _limits.Validate(skills); + var tools = await _extensions.CollectToolsAsync( + extensionContext, + baseTools, + cancellationToken).ConfigureAwait(false); + await _extensions.PublishAsync( + GameAgentExtensionEvents.ToolsCollected, + new GameAgentToolsEvent(tools), + extensionContext, + cancellationToken).ConfigureAwait(false); + + var basePendingWork = _pendingWorkProvider is not null + && await _pendingWorkProvider(input, cancellationToken).ConfigureAwait(false); + var hasPendingWork = await _extensions.HasPendingWorkAsync( + extensionContext, + basePendingWork, + cancellationToken).ConfigureAwait(false); + var route = resumingCheckpoint + ? GameRouteDecision.Agent("durable-tool-checkpoint") + : await _extensions.SelectRouteAsync( + extensionContext, + tools.Count, + hasPendingWork, + cancellationToken).ConfigureAwait(false) + ?? await _routePolicy.RouteAsync( + new GameRouteContext(input, tools.Count, hasPendingWork), + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The game route policy returned null."); + var routeEvent = new GameAgentRouteEvent(route); + await _extensions.PublishAsync( + GameAgentExtensionEvents.RouteSelected, + routeEvent, + extensionContext, + cancellationToken).ConfigureAwait(false); + route = routeEvent.Decision ?? throw new InvalidOperationException("An extension cleared the route decision."); + if (resumingCheckpoint && route.Route != GameRouteKind.Agent) + { + throw new InvalidOperationException("A durable tool-turn checkpoint must resume through the agent route."); + } - var agentLimits = CopyAgentLimits(_agentLimits); - IReadOnlyList initialMessages = loaded.Messages; - var minimumMessageReserve = 2; - var preferredMessageReserve = activeTools.Length == 0 - ? minimumMessageReserve - : checked(agentLimits.MaxToolCallsPerTurn + 3); - if (initialMessages.Count + preferredMessageReserve > agentLimits.MaxMessages - && _transcriptCompactor is not null) - { - var target = Math.Max(1, agentLimits.MaxMessages - preferredMessageReserve); - initialMessages = await _transcriptCompactor.CompactAsync( - new GameTranscriptCompactionContext(loaded.Key, initialMessages, target), - cancellationToken).ConfigureAwait(false) - ?? throw new InvalidOperationException("The transcript compactor returned null."); - if (initialMessages.Count > target) + if (route.Route == GameRouteKind.Workflow) { - throw new InvalidOperationException("The transcript compactor exceeded its requested target."); + var workflowRun = await RunWorkflowAsync( + input, + loaded, + context, + tools, + route, + extensionState, + extensionContext, + cancellationToken).ConfigureAwait(false); + await PublishCompletedAsync(workflowRun, extensionContext, CancellationToken.None).ConfigureAwait(false); + return workflowRun; } - } - if (initialMessages.Count + minimumMessageReserve > agentLimits.MaxMessages) - { - return new GameAgentRunResult( - GameAgentRunStatus.Failed, + var activeTools = route.Route == GameRouteKind.QuickResponse + ? (IReadOnlyList)Array.Empty() + : tools; + var baseSkills = _skillSource is null + ? Array.Empty() + : (await _skillSource.SelectAsync( + new GameSkillQuery(input, activeTools.Select(tool => tool.Definition.Name).ToArray(), _limits.MaxSkillsPerRun), + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The game skill source returned null.")).ToArray(); + var skills = await _extensions.CollectSkillsAsync( + extensionContext, + baseSkills, + activeTools.Select(tool => tool.Definition.Name).ToArray(), + _limits.MaxSkillsPerRun, + cancellationToken).ConfigureAwait(false); + _limits.Validate(skills); + await _extensions.PublishAsync( + GameAgentExtensionEvents.SkillsSelected, + new GameAgentSkillsEvent(skills), + extensionContext, + cancellationToken).ConfigureAwait(false); + + var selection = _modelSelector is null + ? null + : await _modelSelector(input, cancellationToken).ConfigureAwait(false); + var provider = selection?.Provider + ?? _extensions.ResolveModelProvider(selection?.RegisteredProviderName, _provider); + var model = selection?.Model ?? _model; + var parameters = selection?.Parameters?.Clone() ?? _modelParameters.Clone(); + var contextWindowTokens = selection?.ContextWindowTokens > 0 + ? selection.ContextWindowTokens + : _contextWindowTokens; + var maximumOutputTokens = selection?.MaximumOutputTokens ?? 0; + var systemPrompt = ComposeSystemPrompt(context, skills); + var agentLimits = CopyAgentLimits(_agentLimits); + IReadOnlyList initialMessages = loaded.Messages; + var minimumMessageReserve = resumingCheckpoint ? 1 : 2; + var preferredMessageReserve = activeTools.Count == 0 + ? minimumMessageReserve + : checked(agentLimits.MaxToolCallsPerTurn + (resumingCheckpoint ? 2 : 3)); + var additionalMessages = resumingCheckpoint + ? Array.Empty() + : new[] { CreateInputMessage(input) }; + initialMessages = await FitTranscriptAsync( + loaded.Key, + initialMessages, + Math.Max(1, agentLimits.MaxMessages - preferredMessageReserve), + additionalMessages, + model, + systemPrompt, + activeTools.Select(tool => tool.Definition).ToArray(), + parameters, + contextWindowTokens, + maximumOutputTokens, + cancellationToken).ConfigureAwait(false); + + if (resumingCheckpoint) + { + ValidatePendingInput(loaded, input, initialMessages); + } + + if (initialMessages.Count + minimumMessageReserve > agentLimits.MaxMessages) + { + var exhausted = new GameAgentRunResult( + GameAgentRunStatus.Failed, + route, + loaded.Revision, + error: "The session transcript cannot reserve space for the next input and model response."); + await PublishCompletedAsync(exhausted, extensionContext, cancellationToken).ConfigureAwait(false); + return exhausted; + } + + var commitBase = loaded; + GameSessionSaveResult? checkpointConflict = null; + var runHooks = CreateRunHooks( + route.Route, + input, + extensionContext, + model, + parameters, + contextWindowTokens, + maximumOutputTokens); + if (_persistToolTurnCheckpoints && route.Route == GameRouteKind.Agent) + { + var configured = runHooks.PrepareNextTurnAsync; + runHooks.PrepareNextTurnAsync = async (turnContext, token) => + { + if (!turnContext.Response.Content.OfType().Any()) + { + return configured is null + ? null + : await configured(turnContext, token).ConfigureAwait(false); + } + + var checkpoint = new GameSessionSnapshot( + commitBase.Key, + checked(commitBase.Revision + 1), + turnContext.Context.Messages, + commitBase.ProcessedInputIds, + commitBase.LastMoment, + extensionState.SnapshotAll(), + input.InputId); + var checkpointSave = await _sessionStore.SaveAsync( + checkpoint, + commitBase.Revision, + token).ConfigureAwait(false) + ?? throw new InvalidOperationException("The game session store returned null."); + ValidateSaveResult(commitBase, checkpoint, checkpointSave); + if (!checkpointSave.Saved) + { + checkpointConflict = checkpointSave; + throw new InvalidOperationException( + "The session changed while a tool turn was being checkpointed."); + } + + commitBase = checkpointSave.Current; + return configured is null + ? null + : await configured(turnContext, token).ConfigureAwait(false); + }; + } + + var options = new AgentOptions(provider, model) + { + SystemPrompt = systemPrompt, + SessionId = input.SessionId, + Limits = agentLimits, + Parameters = parameters, + Hooks = runHooks, + ToolExecution = _toolExecution, + }; + foreach (var message in initialMessages) + { + options.InitialMessages.Add(message); + } + + foreach (var tool in activeTools) + { + options.Tools.Add(tool); + } + + var agent = new Agent(options); + using var subscription = agent.Subscribe(async (agentEvent, token) => + { + if (observer is not null) + { + await observer(input, agentEvent, token).ConfigureAwait(false); + } + + await _extensions.PublishAsync( + GameAgentExtensionEvents.KernelEvent, + new GameAgentKernelEvent(agentEvent), + extensionContext, + token).ConfigureAwait(false); + }); + RegisterActiveAgent(key, agent); + AgentRunResult run; + try + { + run = resumingCheckpoint + ? await agent.ContinueAsync(cancellationToken).ConfigureAwait(false) + : await agent.RunAsync(CreateInputMessage(input), cancellationToken).ConfigureAwait(false); + } + finally + { + UnregisterActiveAgent(key, agent); + } + + if (checkpointConflict is not null) + { + var conflict = new GameAgentRunResult( + GameAgentRunStatus.SessionConflict, + route, + checkpointConflict.Current.Revision, + run, + "The session changed while this input was running. Committed game actions must be reconciled before retrying."); + await PublishCompletedAsync(conflict, extensionContext, CancellationToken.None).ConfigureAwait(false); + return conflict; + } + + GameSessionSaveResult save; + using (var settlementCancellation = new CancellationTokenSource(_sessionCommitTimeoutMilliseconds)) + { + save = await SaveAsync( + input, + commitBase, + agent.State.Messages, + extensionState, + extensionContext, + settlementCancellation.Token).ConfigureAwait(false); + } + if (!save.Saved) + { + var conflict = new GameAgentRunResult( + GameAgentRunStatus.SessionConflict, + route, + save.Current.Revision, + run, + "The session changed while this input was running. Committed game actions must be reconciled before retrying."); + await PublishCompletedAsync(conflict, extensionContext, CancellationToken.None).ConfigureAwait(false); + return conflict; + } + + var completed = new GameAgentRunResult( + run.Succeeded ? GameAgentRunStatus.Completed : GameAgentRunStatus.Failed, route, - loaded.Revision, - error: "The session transcript cannot reserve space for the next input and model response."); + save.Current.Revision, + run, + run.Error); + await PublishCompletedAsync(completed, extensionContext, CancellationToken.None).ConfigureAwait(false); + return completed; } - - var options = new AgentOptions(_provider, _model) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - SystemPrompt = ComposeSystemPrompt(context, skills), - SessionId = input.SessionId, - Limits = agentLimits, - Parameters = _modelParameters.Clone(), - Hooks = CreateRunHooks(route.Route, input), - ToolExecution = _toolExecution, - }; - foreach (var message in initialMessages) - { - options.InitialMessages.Add(message); + throw; } - - foreach (var tool in activeTools) + catch (Exception exception) { - options.Tools.Add(tool); - } + if (failureContext is not null) + { + await _extensions.PublishAsync( + GameAgentExtensionEvents.RunFailed, + new GameAgentFailureEvent(exception), + failureContext, + CancellationToken.None).ConfigureAwait(false); + } - var agent = new Agent(options); - using var subscription = observer is null - ? null - : agent.Subscribe((agentEvent, token) => observer(input, agentEvent, token)); - RegisterActiveAgent(key, agent); - AgentRunResult run; - try - { - run = await agent.RunAsync(CreateInputMessage(input), cancellationToken).ConfigureAwait(false); + throw; } finally { - UnregisterActiveAgent(key, agent); - } - - var save = await SaveAsync(input, loaded, agent.State.Messages, cancellationToken).ConfigureAwait(false); - if (!save.Saved) - { - return new GameAgentRunResult( - GameAgentRunStatus.SessionConflict, - route, - save.Current.Revision, - run, - "The session changed while this input was running. Committed game actions must be reconciled before retrying."); + failureContext?.Invalidate(); } - - return new GameAgentRunResult( - run.Succeeded ? GameAgentRunStatus.Completed : GameAgentRunStatus.Failed, - route, - save.Current.Revision, - run, - run.Error); } private void RegisterActiveAgent(GameSessionKey key, Agent agent) @@ -537,6 +973,8 @@ private async ValueTask RunWorkflowAsync( IReadOnlyList context, IReadOnlyList tools, GameRouteDecision route, + GameAgentSessionState extensionState, + GameAgentExtensionRunContext extensionContext, CancellationToken cancellationToken) { if (route.Workflow is null || !_workflows.TryGetValue(route.Workflow, out var workflow)) @@ -569,7 +1007,17 @@ void ValidateWorkflowOutput(IReadOnlyList output) ?? throw new InvalidOperationException($"Workflow '{workflow.Name}' returned null."); workflowContext.ValidateOutput(result.Messages); var messages = loaded.Messages.Concat(new[] { CreateInputMessage(input) }).Concat(result.Messages).ToArray(); - var save = await SaveAsync(input, loaded, messages, cancellationToken).ConfigureAwait(false); + GameSessionSaveResult save; + using (var settlementCancellation = new CancellationTokenSource(_sessionCommitTimeoutMilliseconds)) + { + save = await SaveAsync( + input, + loaded, + messages, + extensionState, + extensionContext, + settlementCancellation.Token).ConfigureAwait(false); + } return !save.Saved ? new GameAgentRunResult(GameAgentRunStatus.SessionConflict, route, save.Current.Revision, error: "The session changed while the workflow was running.") : new GameAgentRunResult( @@ -583,8 +1031,15 @@ private async ValueTask SaveAsync( GameInput input, GameSessionSnapshot loaded, IReadOnlyList messages, + GameAgentSessionState extensionState, + GameAgentExtensionRunContext extensionContext, CancellationToken cancellationToken) { + await _extensions.PublishAsync( + GameAgentExtensionEvents.SessionSaving, + new GameAgentSessionEvent(loaded), + extensionContext, + cancellationToken).ConfigureAwait(false); var processed = loaded.ProcessedInputIds .Where(id => !string.Equals(id, input.InputId, StringComparison.Ordinal)) .Concat(new[] { input.InputId }) @@ -595,25 +1050,44 @@ private async ValueTask SaveAsync( checked(loaded.Revision + 1), messages, processed, - input.Moment); + input.Moment, + extensionState.SnapshotAll(), + pendingInputId: null); var save = await _sessionStore.SaveAsync(snapshot, loaded.Revision, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The game session store returned null."); - if (!save.Current.Key.Equals(loaded.Key)) + ValidateSaveResult(loaded, snapshot, save); + + if (save.Saved) + { + await _extensions.PublishAsync( + GameAgentExtensionEvents.SessionSaved, + new GameAgentSessionEvent(save.Current), + extensionContext, + CancellationToken.None).ConfigureAwait(false); + } + + return save; + } + + private static void ValidateSaveResult( + GameSessionSnapshot expectedBase, + GameSessionSnapshot candidate, + GameSessionSaveResult save) + { + if (!save.Current.Key.Equals(expectedBase.Key)) { throw new InvalidOperationException("The game session store returned a result for a different session key."); } - if (save.Saved && !SessionSnapshotEquals(save.Current, snapshot)) + if (save.Saved && !SessionSnapshotEquals(save.Current, candidate)) { throw new InvalidOperationException("The game session store returned a different saved snapshot."); } - if (!save.Saved && save.Current.Revision <= loaded.Revision) + if (!save.Saved && save.Current.Revision <= expectedBase.Revision) { throw new InvalidOperationException("The game session store reported a conflict without a newer revision."); } - - return save; } private static bool SessionSnapshotEquals(GameSessionSnapshot left, GameSessionSnapshot right) @@ -621,7 +1095,10 @@ private static bool SessionSnapshotEquals(GameSessionSnapshot left, GameSessionS if (!left.Key.Equals(right.Key) || left.Revision != right.Revision || left.LastMoment != right.LastMoment + || !string.Equals(left.PendingInputId, right.PendingInputId, StringComparison.Ordinal) || !left.ProcessedInputIds.SequenceEqual(right.ProcessedInputIds, StringComparer.Ordinal) + || !left.ExtensionState.OrderBy(pair => pair.Key, StringComparer.Ordinal) + .SequenceEqual(right.ExtensionState.OrderBy(pair => pair.Key, StringComparer.Ordinal)) || left.Messages.Count != right.Messages.Count) { return false; @@ -629,7 +1106,7 @@ private static bool SessionSnapshotEquals(GameSessionSnapshot left, GameSessionS for (var index = 0; index < left.Messages.Count; index++) { - if (!MessageEquals(left.Messages[index], right.Messages[index])) + if (!GameAgentValueComparer.MessageEquals(left.Messages[index], right.Messages[index])) { return false; } @@ -638,79 +1115,11 @@ private static bool SessionSnapshotEquals(GameSessionSnapshot left, GameSessionS return true; } - private static bool MessageEquals(AgentMessage left, AgentMessage right) - { - if (left.Role != right.Role - || left.Timestamp != right.Timestamp - || !string.Equals(left.CustomRole, right.CustomRole, StringComparison.Ordinal) - || !string.Equals(left.ToolCallId, right.ToolCallId, StringComparison.Ordinal) - || !string.Equals(left.ToolName, right.ToolName, StringComparison.Ordinal) - || left.IsError != right.IsError - || !string.Equals(left.DetailsJson, right.DetailsJson, StringComparison.Ordinal) - || !string.Equals(left.Model, right.Model, StringComparison.Ordinal) - || left.StopReason != right.StopReason - || !string.Equals(left.ErrorMessage, right.ErrorMessage, StringComparison.Ordinal) - || !UsageEquals(left.Usage, right.Usage) - || left.Metadata.Count != right.Metadata.Count - || left.Content.Count != right.Content.Count) - { - return false; - } - - foreach (var pair in left.Metadata) - { - if (!right.Metadata.TryGetValue(pair.Key, out var value) - || !string.Equals(pair.Value, value, StringComparison.Ordinal)) - { - return false; - } - } - - for (var index = 0; index < left.Content.Count; index++) - { - if (!ContentEquals(left.Content[index], right.Content[index])) - { - return false; - } - } - - return true; - } - - private static bool UsageEquals(ModelUsage? left, ModelUsage? right) => - left is null - ? right is null - : right is not null - && left.InputTokens == right.InputTokens - && left.OutputTokens == right.OutputTokens - && left.CacheReadTokens == right.CacheReadTokens - && left.CacheWriteTokens == right.CacheWriteTokens; - - private static bool ContentEquals(AgentContent left, AgentContent right) => (left, right) switch - { - (TextContent first, TextContent second) => - string.Equals(first.Text, second.Text, StringComparison.Ordinal), - (JsonContent first, JsonContent second) => - string.Equals(first.Json, second.Json, StringComparison.Ordinal), - (ReasoningContent first, ReasoningContent second) => - string.Equals(first.Text, second.Text, StringComparison.Ordinal) - && string.Equals(first.Signature, second.Signature, StringComparison.Ordinal), - (ResourceContent first, ResourceContent second) => - string.Equals(first.Uri, second.Uri, StringComparison.Ordinal) - && string.Equals(first.MediaType, second.MediaType, StringComparison.Ordinal) - && string.Equals(first.Name, second.Name, StringComparison.Ordinal), - (ToolCallContent first, ToolCallContent second) => - string.Equals(first.Id, second.Id, StringComparison.Ordinal) - && string.Equals(first.Name, second.Name, StringComparison.Ordinal) - && string.Equals(first.ArgumentsJson, second.ArgumentsJson, StringComparison.Ordinal), - _ => false, - }; - private string ComposeSystemPrompt( IReadOnlyList context, IReadOnlyList skills) { - return _instructions + return _extensions.ComposePrompt(_instructions) + "\n\nReusable skill instructions for this run:\n" + JsonSerializer.Serialize( skills @@ -736,9 +1145,52 @@ private static AgentMessage CreateInputMessage(GameInput input) ["game.timeline_id"] = input.Moment.TimelineId, ["game.tick"] = input.Moment.Tick.ToString(System.Globalization.CultureInfo.InvariantCulture), }; - return AgentMessage.UserJson(payload, DateTimeOffset.UtcNow, metadata); + var content = new List(input.Resources.Count + 1) + { + new JsonContent(payload), + }; + content.AddRange(input.Resources); + return new AgentMessage(AgentRole.User, content, DateTimeOffset.UtcNow, metadata: metadata); } + private static void ValidatePendingInput( + GameSessionSnapshot loaded, + GameInput input, + IReadOnlyList? transcript = null) + { + var messages = transcript ?? loaded.Messages; + var matches = messages.Where(message => + message.Role == AgentRole.User + && message.Metadata.TryGetValue("game.input_id", out var value) + && string.Equals(value, input.InputId, StringComparison.Ordinal)) + .ToArray(); + if (matches.Length != 1) + { + throw new InvalidOperationException("The pending input checkpoint does not contain exactly one matching input message."); + } + + var expected = CreateInputMessage(input); + if (!InputMessageEquals(matches[0], expected)) + { + throw new InvalidOperationException("The resubmitted input does not match its durable checkpoint."); + } + + if (messages.Count == 0 || messages[messages.Count - 1].Role == AgentRole.Assistant) + { + throw new InvalidOperationException("The pending input checkpoint is not resumable."); + } + + } + + private static bool InputMessageEquals(AgentMessage left, AgentMessage right) => + left.Role == right.Role + && string.Equals(left.CustomRole, right.CustomRole, StringComparison.Ordinal) + && left.Metadata.Count == right.Metadata.Count + && left.Metadata.All(pair => right.Metadata.TryGetValue(pair.Key, out var value) + && string.Equals(pair.Value, value, StringComparison.Ordinal)) + && left.Content.Count == right.Content.Count + && left.Content.Zip(right.Content, GameAgentValueComparer.ContentEquals).All(equal => equal); + private static AgentLimits CopyAgentLimits(AgentLimits value) => new() { MaxSystemPromptCharacters = value.MaxSystemPromptCharacters, @@ -763,11 +1215,19 @@ private static AgentMessage CreateInputMessage(GameInput input) MaxQueuedMessages = value.MaxQueuedMessages, MaxConcurrentTools = value.MaxConcurrentTools, ToolTimeoutMilliseconds = value.ToolTimeoutMilliseconds, + ModelTimeoutMilliseconds = value.ModelTimeoutMilliseconds, MaxProgressEventsPerTool = value.MaxProgressEventsPerTool, MaxSubscribers = value.MaxSubscribers, }; - private AgentHooks CreateRunHooks(GameRouteKind route, GameInput input) + private AgentHooks CreateRunHooks( + GameRouteKind route, + GameInput input, + GameAgentExtensionRunContext extensionContext, + string model, + ModelParameters parameters, + int contextWindowTokens, + int maximumOutputTokens) { var hooks = CopyHooks(_agentHooks); if (route == GameRouteKind.QuickResponse) @@ -784,6 +1244,8 @@ private AgentHooks CreateRunHooks(GameRouteKind route, GameInput input) }; } + hooks = _extensions.ComposeHooks(extensionContext, hooks); + if (route == GameRouteKind.Agent && _refreshContextAfterToolTurns) { var configured = hooks.PrepareNextTurnAsync; @@ -792,58 +1254,287 @@ private AgentHooks CreateRunHooks(GameRouteKind route, GameInput input) var update = configured is null ? null : await configured(context, cancellationToken).ConfigureAwait(false); - if (update?.Context is not null - || !context.Response.Content.OfType().Any()) + if (!context.Response.Content.OfType().Any()) { return update; } + var nextModel = update?.Model ?? model; + var nextParameters = update?.Parameters ?? parameters; + if (update?.Context is { } replacement) + { + var preferredMessageReserve = replacement.Tools.Count == 0 + ? 1 + : checked(_agentLimits.MaxToolCallsPerTurn + 2); + var compacted = await FitTranscriptAsync( + new GameSessionKey(input.SessionId, input.ActorId), + replacement.Messages, + Math.Max(1, _agentLimits.MaxMessages - preferredMessageReserve), + Array.Empty(), + nextModel, + replacement.SystemPrompt, + replacement.Tools.Select(tool => tool.Definition).ToArray(), + nextParameters, + contextWindowTokens, + maximumOutputTokens, + cancellationToken).ConfigureAwait(false); + return new NextTurnUpdate + { + Context = new AgentContext(replacement.SystemPrompt, compacted, replacement.Tools), + Provider = update.Provider, + Model = update.Model, + Parameters = update.Parameters, + }; + } + var refreshed = await RefreshTurnContextAsync( input, context.Context.Messages, + extensionContext, + nextModel, + nextParameters, + contextWindowTokens, + maximumOutputTokens, cancellationToken).ConfigureAwait(false); return new NextTurnUpdate { Context = refreshed, + Provider = update?.Provider, Model = update?.Model, Parameters = update?.Parameters, }; }; } + if (contextWindowTokens > 0) + { + var configured = hooks.BeforeModelRequestAsync; + hooks.BeforeModelRequestAsync = async (request, cancellationToken) => + { + var prepared = configured is null + ? request + : await configured(request, cancellationToken).ConfigureAwait(false); + var available = GetAvailableInputTokens( + prepared.Parameters, + contextWindowTokens, + maximumOutputTokens); + if (EstimateRequestTokens( + prepared.Model, + prepared.SystemPrompt, + prepared.Messages, + prepared.Tools) > available) + { + throw new GameRuntimeLimitException( + nameof(GameAgentRuntimeOptions.ContextWindowTokens), + "The prepared model request exceeds the active context window."); + } + + return prepared; + }; + } + return hooks; } private async ValueTask RefreshTurnContextAsync( GameInput input, IReadOnlyList messages, + GameAgentExtensionRunContext extensionContext, + string model, + ModelParameters parameters, + int contextWindowTokens, + int maximumOutputTokens, CancellationToken cancellationToken) { - var context = _contextProvider is null + var baseContext = _contextProvider is null ? Array.Empty() : (await _contextProvider.GetContextAsync(input, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The game context provider returned null.")).ToArray(); + var context = await _extensions.CollectContextAsync( + extensionContext, + baseContext, + cancellationToken).ConfigureAwait(false); _limits.Validate(context); - var tools = _toolProvider is null + var baseTools = _toolProvider is null ? Array.Empty() : (await _toolProvider(input, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The game tool provider returned null.")).ToArray(); - if (tools.Any(tool => tool is null)) + if (baseTools.Any(tool => tool is null)) { throw new InvalidOperationException("The game tool provider returned a null tool."); } - var skills = _skillSource is null + var tools = await _extensions.CollectToolsAsync( + extensionContext, + baseTools, + cancellationToken).ConfigureAwait(false); + + var baseSkills = _skillSource is null ? Array.Empty() : (await _skillSource.SelectAsync( new GameSkillQuery(input, tools.Select(tool => tool.Definition.Name).ToArray(), _limits.MaxSkillsPerRun), cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException("The game skill source returned null.")).ToArray(); + var skills = await _extensions.CollectSkillsAsync( + extensionContext, + baseSkills, + tools.Select(tool => tool.Definition.Name).ToArray(), + _limits.MaxSkillsPerRun, + cancellationToken).ConfigureAwait(false); _limits.Validate(skills); - return new AgentContext(ComposeSystemPrompt(context, skills), messages, tools); + var systemPrompt = ComposeSystemPrompt(context, skills); + var preferredMessageReserve = tools.Count == 0 + ? 1 + : checked(_agentLimits.MaxToolCallsPerTurn + 2); + var compacted = await FitTranscriptAsync( + new GameSessionKey(input.SessionId, input.ActorId), + messages, + Math.Max(1, _agentLimits.MaxMessages - preferredMessageReserve), + Array.Empty(), + model, + systemPrompt, + tools.Select(tool => tool.Definition).ToArray(), + parameters, + contextWindowTokens, + maximumOutputTokens, + cancellationToken).ConfigureAwait(false); + return new AgentContext(systemPrompt, compacted, tools); + } + + private async ValueTask> FitTranscriptAsync( + GameSessionKey session, + IReadOnlyList messages, + int targetMessageCount, + IReadOnlyList additionalMessages, + string model, + string systemPrompt, + IReadOnlyList tools, + ModelParameters parameters, + int contextWindowTokens, + int maximumOutputTokens, + CancellationToken cancellationToken) + { + var tokenTarget = GetTranscriptTokenTarget( + model, + systemPrompt, + additionalMessages, + tools, + parameters, + contextWindowTokens, + maximumOutputTokens); + var messageCompactionRequired = messages.Count > targetMessageCount; + var tokenCompactionRequired = tokenTarget is { } target + && EstimateTranscriptTokens(messages) > target; + IReadOnlyList fitted = messages; + if ((messageCompactionRequired || tokenCompactionRequired) && _transcriptCompactor is not null) + { + fitted = await _transcriptCompactor.CompactAsync( + new GameTranscriptCompactionContext( + session, + messages, + targetMessageCount, + tokenTarget, + tokenTarget is null ? null : _transcriptTokenEstimator), + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The transcript compactor returned null."); + } + + if (fitted.Count > targetMessageCount) + { + throw new GameRuntimeLimitException( + nameof(AgentLimits.MaxMessages), + _transcriptCompactor is null + ? "The session transcript requires compaction before another model turn." + : "The transcript compactor exceeded its requested message target."); + } + + AgentValidation.ValidateTranscript(fitted, _agentLimits); + var requestMessages = fitted.Concat(additionalMessages).ToArray(); + if (contextWindowTokens > 0) + { + var available = GetAvailableInputTokens(parameters, contextWindowTokens, maximumOutputTokens); + var estimate = EstimateRequestTokens(model, systemPrompt, requestMessages, tools); + if (estimate > available) + { + throw new GameRuntimeLimitException( + nameof(GameAgentRuntimeOptions.ContextWindowTokens), + _transcriptCompactor is null + ? "The estimated model request exceeds the context window and no transcript compactor is configured." + : "The compacted model request still exceeds the configured context window."); + } + } + + return fitted; } + private long? GetTranscriptTokenTarget( + string model, + string systemPrompt, + IReadOnlyList additionalMessages, + IReadOnlyList tools, + ModelParameters parameters, + int contextWindowTokens, + int maximumOutputTokens) + { + if (contextWindowTokens == 0) + { + return null; + } + + var available = GetAvailableInputTokens(parameters, contextWindowTokens, maximumOutputTokens); + var fixedTokens = EstimateRequestTokens(model, systemPrompt, additionalMessages, tools); + if (fixedTokens >= available) + { + throw new GameRuntimeLimitException( + nameof(GameAgentRuntimeOptions.ContextWindowTokens), + "The system prompt, tools, and new input leave no context budget for the session transcript."); + } + + return available - fixedTokens; + } + + private long GetAvailableInputTokens( + ModelParameters parameters, + int contextWindowTokens, + int maximumOutputTokens) + { + var reserve = parameters.MaxOutputTokens is > 0 + ? parameters.MaxOutputTokens.Value + : maximumOutputTokens > 0 + ? maximumOutputTokens + : _contextWindowReserveTokens; + if (reserve >= contextWindowTokens) + { + throw new GameRuntimeLimitException( + nameof(GameAgentRuntimeOptions.ContextWindowReserveTokens), + "The output-token reserve must be smaller than the active model context window."); + } + + return contextWindowTokens - reserve; + } + + private long EstimateRequestTokens( + string model, + string systemPrompt, + IReadOnlyList messages, + IReadOnlyList tools) + { + var estimate = _requestTokenEstimator(model, systemPrompt, messages, tools); + return ValidateTokenEstimate(estimate, "request"); + } + + private long EstimateTranscriptTokens(IReadOnlyList messages) + { + var estimate = _transcriptTokenEstimator(messages); + return ValidateTokenEstimate(estimate, "transcript"); + } + + private static long ValidateTokenEstimate(long estimate, string kind) => + estimate is >= 0 and <= 10_000_000_000 + ? estimate + : throw new InvalidOperationException($"The {kind} token estimator returned an invalid value."); + private static AgentHooks CopyHooks(AgentHooks value) => new() { TransformContextAsync = value.TransformContextAsync, @@ -854,6 +1545,59 @@ private async ValueTask RefreshTurnContextAsync( AfterToolCallAsync = value.AfterToolCallAsync, }; + private ValueTask PublishCompletedAsync( + GameAgentRunResult result, + GameAgentExtensionRunContext extensionContext, + CancellationToken cancellationToken) => + _extensions.PublishAsync( + GameAgentExtensionEvents.RunCompleted, + new GameAgentRunEvent(result), + extensionContext, + cancellationToken); + + public void Dispose() + { + GameAgentAsyncBridge.Run(DisposeAsync); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + try + { + _lifetimeCancellation.Cancel(); + } + catch (AggregateException) + { + // User cancellation callbacks cannot prevent runtime shutdown. + } + + Agent[] active; + lock (_activeAgentsGate) + { + active = _activeAgents.Values.ToArray(); + } + + foreach (var agent in active) + { + agent.TryAbort(); + } + + try + { + await _actors.WaitForIdleAsync().ConfigureAwait(false); + await _extensions.DisposeAsync().ConfigureAwait(false); + } + finally + { + _lifetimeCancellation.Dispose(); + } + } + private sealed class InputPayload { public InputPayload(GameInput input) diff --git a/src/OpenGameAgent/GameAgentValueComparer.cs b/src/OpenGameAgent/GameAgentValueComparer.cs new file mode 100644 index 0000000..9811992 --- /dev/null +++ b/src/OpenGameAgent/GameAgentValueComparer.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent; + +/// +/// Compares durable agent values by contract rather than object identity. Custom persistence +/// implementations can use these helpers when they rehydrate immutable messages and checkpoints. +/// +public static class GameAgentValueComparer +{ + public static bool ContentEquals(AgentContent? left, AgentContent? right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + return (left, right) switch + { + (TextContent first, TextContent second) => + string.Equals(first.Text, second.Text, StringComparison.Ordinal), + (JsonContent first, JsonContent second) => + string.Equals(first.Json, second.Json, StringComparison.Ordinal), + (ReasoningContent first, ReasoningContent second) => + string.Equals(first.Text, second.Text, StringComparison.Ordinal) + && string.Equals(first.Signature, second.Signature, StringComparison.Ordinal), + (ResourceContent first, ResourceContent second) => + string.Equals(first.Uri, second.Uri, StringComparison.Ordinal) + && string.Equals(first.MediaType, second.MediaType, StringComparison.Ordinal) + && string.Equals(first.Name, second.Name, StringComparison.Ordinal), + (ToolCallContent first, ToolCallContent second) => + string.Equals(first.Id, second.Id, StringComparison.Ordinal) + && string.Equals(first.Name, second.Name, StringComparison.Ordinal) + && string.Equals(first.ArgumentsJson, second.ArgumentsJson, StringComparison.Ordinal), + _ => false, + }; + } + + public static bool MessageEquals(AgentMessage? left, AgentMessage? right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + return left is not null + && right is not null + && left.Role == right.Role + && left.Timestamp == right.Timestamp + && string.Equals(left.CustomRole, right.CustomRole, StringComparison.Ordinal) + && string.Equals(left.ToolCallId, right.ToolCallId, StringComparison.Ordinal) + && string.Equals(left.ToolName, right.ToolName, StringComparison.Ordinal) + && left.IsError == right.IsError + && string.Equals(left.DetailsJson, right.DetailsJson, StringComparison.Ordinal) + && string.Equals(left.Model, right.Model, StringComparison.Ordinal) + && left.StopReason == right.StopReason + && string.Equals(left.ErrorMessage, right.ErrorMessage, StringComparison.Ordinal) + && UsageEquals(left.Usage, right.Usage) + && DictionariesEqual(left.Metadata, right.Metadata) + && MessagesContentEqual(left.Content, right.Content); + } + + public static bool MessagesEqual( + IReadOnlyList? left, + IReadOnlyList? right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + return left is not null + && right is not null + && left.Count == right.Count + && left.Zip(right, MessageEquals).All(equal => equal); + } + + public static bool WorkflowInvocationEquals( + GameWorkflowInvocationResult? left, + GameWorkflowInvocationResult? right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + return left is not null + && right is not null + && string.Equals(left.InputId, right.InputId, StringComparison.Ordinal) + && left.Complete == right.Complete + && left.Succeeded == right.Succeeded + && string.Equals(left.Error, right.Error, StringComparison.Ordinal) + && MessagesEqual(left.Messages, right.Messages); + } + + private static bool UsageEquals(ModelUsage? left, ModelUsage? right) => + left is null + ? right is null + : right is not null + && left.InputTokens == right.InputTokens + && left.OutputTokens == right.OutputTokens + && left.CacheReadTokens == right.CacheReadTokens + && left.CacheWriteTokens == right.CacheWriteTokens; + + private static bool DictionariesEqual( + IReadOnlyDictionary left, + IReadOnlyDictionary right) => + left.Count == right.Count + && left.All(pair => right.TryGetValue(pair.Key, out var value) + && string.Equals(pair.Value, value, StringComparison.Ordinal)); + + private static bool MessagesContentEqual( + IReadOnlyList left, + IReadOnlyList right) => + left.Count == right.Count + && left.Zip(right, ContentEquals).All(equal => equal); +} diff --git a/src/OpenGameAgent/GameAgentWire.cs b/src/OpenGameAgent/GameAgentWire.cs index fd87979..cfa65d0 100644 --- a/src/OpenGameAgent/GameAgentWire.cs +++ b/src/OpenGameAgent/GameAgentWire.cs @@ -28,6 +28,12 @@ public static string SerializeInput(GameInput input) ? (JsonElement?)null : ParseElement(input.Moment.CalendarJson), metadata = input.Metadata, + resources = input.Resources.Select(resource => new + { + uri = resource.Uri, + mediaType = resource.MediaType, + name = resource.Name, + }).ToArray(), }, JsonOptions); } @@ -242,6 +248,8 @@ private sealed class InputDocument public Dictionary? Metadata { get; set; } + public List? Resources { get; set; } + public GameInput ToInput() => new( SessionId, ActorId, @@ -254,7 +262,21 @@ private sealed class InputDocument ? calendar.GetRawText() : null), InputId, - Metadata); + Metadata, + (Resources ?? new List()) + .Select(resource => resource.ToResource()) + .ToArray()); + } + + private sealed class InputResourceDocument + { + public string Uri { get; set; } = string.Empty; + + public string MediaType { get; set; } = string.Empty; + + public string? Name { get; set; } + + public ResourceContent ToResource() => new(Uri, MediaType, Name); } private sealed class ContentDocument diff --git a/src/OpenGameAgent/GameData.cs b/src/OpenGameAgent/GameData.cs index 5018ff7..933984a 100644 --- a/src/OpenGameAgent/GameData.cs +++ b/src/OpenGameAgent/GameData.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; +using OpenGameAgent.Kernel; namespace OpenGameAgent; @@ -57,6 +58,14 @@ public override int GetHashCode() public static bool operator !=(GameMoment left, GameMoment right) => !left.Equals(right); + public static bool operator <(GameMoment left, GameMoment right) => left.CompareTo(right) < 0; + + public static bool operator <=(GameMoment left, GameMoment right) => left.CompareTo(right) <= 0; + + public static bool operator >(GameMoment left, GameMoment right) => left.CompareTo(right) > 0; + + public static bool operator >=(GameMoment left, GameMoment right) => left.CompareTo(right) >= 0; + internal GameMoment EnsureValid(string parameterName) { if (string.IsNullOrWhiteSpace(TimelineId)) @@ -87,7 +96,8 @@ public GameInput( string payloadJson, GameMoment moment, string? inputId = null, - IReadOnlyDictionary? metadata = null) + IReadOnlyDictionary? metadata = null, + IReadOnlyList? resources = null) { SessionId = GameJson.RequireId(sessionId, nameof(sessionId)); ActorId = GameJson.RequireId(actorId, nameof(actorId)); @@ -107,6 +117,13 @@ public GameInput( } Metadata = new ReadOnlyDictionary(copiedMetadata); + var copiedResources = (resources ?? Array.Empty()).ToArray(); + if (copiedResources.Any(resource => resource is null)) + { + throw new ArgumentException("Input resources cannot contain null values.", nameof(resources)); + } + + Resources = Array.AsReadOnly(copiedResources); } public string InputId { get; } @@ -122,6 +139,8 @@ public GameInput( public GameMoment Moment { get; } public IReadOnlyDictionary Metadata { get; } + + public IReadOnlyList Resources { get; } } public sealed class GameContextSlice @@ -155,6 +174,8 @@ public sealed class GameRuntimeLimits public int MaxMetadataEntries { get; set; } = 64; + public int MaxInputResources { get; set; } = 16; + public int MaxMetadataKeyCharacters { get; set; } = 256; public int MaxMetadataValueCharacters { get; set; } = 16_384; @@ -163,12 +184,30 @@ public sealed class GameRuntimeLimits public int MaxConcurrentActors { get; set; } = 16; + public int MaxScheduledActors { get; set; } = 4_096; + public int MaxQueuedInputsPerActor { get; set; } = 64; public int MaxSkillsPerRun { get; set; } = 16; public int MaxSkillCharactersPerRun { get; set; } = 1_000_000; + public int MaxExtensionStateEntries { get; set; } = 256; + + public int MaxExtensionStateKeyCharacters { get; set; } = 1_024; + + public int MaxExtensionStateValueCharacters { get; set; } = 1_000_000; + + public int MaxExtensionStateCharacters { get; set; } = 4_000_000; + + public int MaxExtensions { get; set; } = 256; + + public int MaxExtensionResources { get; set; } = 4_096; + + public int MaxExtensionDiagnostics { get; set; } = 1_024; + + public int MaxExtensionDiagnosticCharacters { get; set; } = 64_000; + internal GameRuntimeLimits CopyAndValidate() { var copy = (GameRuntimeLimits)MemberwiseClone(); @@ -177,13 +216,23 @@ internal GameRuntimeLimits CopyAndValidate() RequireRange(copy.MaxContextSlices, 0, 100_000, nameof(MaxContextSlices)); RequireRange(copy.MaxContextJsonCharacters, 2, 100_000_000, nameof(MaxContextJsonCharacters)); RequireRange(copy.MaxMetadataEntries, 0, 100_000, nameof(MaxMetadataEntries)); + RequireRange(copy.MaxInputResources, 0, 10_000, nameof(MaxInputResources)); RequireRange(copy.MaxMetadataKeyCharacters, 1, 100_000, nameof(MaxMetadataKeyCharacters)); RequireRange(copy.MaxMetadataValueCharacters, 0, 100_000_000, nameof(MaxMetadataValueCharacters)); RequireRange(copy.MaxIdentifierCharacters, 1, 16_384, nameof(MaxIdentifierCharacters)); RequireRange(copy.MaxConcurrentActors, 1, 4096, nameof(MaxConcurrentActors)); + RequireRange(copy.MaxScheduledActors, copy.MaxConcurrentActors, 100_000, nameof(MaxScheduledActors)); RequireRange(copy.MaxQueuedInputsPerActor, 1, 100_000, nameof(MaxQueuedInputsPerActor)); RequireRange(copy.MaxSkillsPerRun, 0, 10_000, nameof(MaxSkillsPerRun)); RequireRange(copy.MaxSkillCharactersPerRun, 0, 100_000_000, nameof(MaxSkillCharactersPerRun)); + RequireRange(copy.MaxExtensionStateEntries, 0, 100_000, nameof(MaxExtensionStateEntries)); + RequireRange(copy.MaxExtensionStateKeyCharacters, 1, 100_000, nameof(MaxExtensionStateKeyCharacters)); + RequireRange(copy.MaxExtensionStateValueCharacters, 2, 100_000_000, nameof(MaxExtensionStateValueCharacters)); + RequireRange(copy.MaxExtensionStateCharacters, 2, 100_000_000, nameof(MaxExtensionStateCharacters)); + RequireRange(copy.MaxExtensions, 0, 100_000, nameof(MaxExtensions)); + RequireRange(copy.MaxExtensionResources, 0, 1_000_000, nameof(MaxExtensionResources)); + RequireRange(copy.MaxExtensionDiagnostics, 0, 1_000_000, nameof(MaxExtensionDiagnostics)); + RequireRange(copy.MaxExtensionDiagnosticCharacters, 1, 10_000_000, nameof(MaxExtensionDiagnosticCharacters)); return copy; } @@ -204,6 +253,11 @@ internal void Validate(GameInput input) throw new GameRuntimeLimitException(nameof(MaxMetadataEntries), "The input has too many metadata entries."); } + if (input.Resources.Count > MaxInputResources) + { + throw new GameRuntimeLimitException(nameof(MaxInputResources), "The input has too many attached resources."); + } + foreach (var value in new[] { input.InputId, input.SessionId, input.ActorId, input.Type, input.Moment.TimelineId }) { if (value.Length > MaxIdentifierCharacters) diff --git a/src/OpenGameAgent/Generation.cs b/src/OpenGameAgent/Generation.cs index 4c5664f..12fc748 100644 --- a/src/OpenGameAgent/Generation.cs +++ b/src/OpenGameAgent/Generation.cs @@ -183,7 +183,7 @@ await execution.ReportProgressAsync( }))); return new ToolResult(content, detailsJson: result.MetadataJson); }, - ToolRisk.IdempotentWrite, + ToolRisk.NonIdempotentWrite, ToolExecutionMode.SafeParallel, conflictKey: _ => input.ActorId + ":media"); } diff --git a/src/OpenGameAgent/Memory.cs b/src/OpenGameAgent/Memory.cs index 5b2d80d..45aa515 100644 --- a/src/OpenGameAgent/Memory.cs +++ b/src/OpenGameAgent/Memory.cs @@ -40,27 +40,42 @@ public GameMemory( throw new ArgumentOutOfRangeException(nameof(importance), "Importance must be between 0 and 1."); } - MemoryId = GameJson.RequireId(memoryId, nameof(memoryId)); - SessionId = GameJson.RequireId(sessionId, nameof(sessionId)); - OwnerId = GameJson.RequireId(ownerId, nameof(ownerId)); - Scope = GameJson.RequireId(scope, nameof(scope)); + MemoryId = RequireBoundedId(memoryId, nameof(memoryId)); + SessionId = RequireBoundedId(sessionId, nameof(sessionId)); + OwnerId = RequireBoundedId(ownerId, nameof(ownerId)); + Scope = RequireBoundedId(scope, nameof(scope)); if (!Enum.IsDefined(typeof(GameMemoryKind), kind)) { throw new ArgumentOutOfRangeException(nameof(kind)); } Kind = kind; + if (payloadJson is null || payloadJson.Length > 10_000_000) + { + throw new ArgumentException("A memory payload cannot exceed 10000000 characters.", nameof(payloadJson)); + } + PayloadJson = GameJson.RequireValid(payloadJson, nameof(payloadJson)); Moment = moment.EnsureValid(nameof(moment)); Importance = importance; + if (searchableText?.Length > 1_000_000) + { + throw new ArgumentException("Memory searchable text cannot exceed 1000000 characters.", nameof(searchableText)); + } + SearchableText = searchableText; - Tags = Array.AsReadOnly( - (tags ?? Array.Empty()) - .Select(tag => GameJson.RequireId(tag, nameof(tags))) + var copiedTags = (tags ?? Array.Empty()) + .Select(tag => RequireBoundedId(tag, nameof(tags))) .Distinct(StringComparer.Ordinal) .OrderBy(tag => tag, StringComparer.Ordinal) - .ToArray()); - SourceInputId = sourceInputId is null ? null : GameJson.RequireId(sourceInputId, nameof(sourceInputId)); + .ToArray(); + if (copiedTags.Length > 256) + { + throw new ArgumentException("A memory can contain at most 256 tags.", nameof(tags)); + } + + Tags = Array.AsReadOnly(copiedTags); + SourceInputId = sourceInputId is null ? null : RequireBoundedId(sourceInputId, nameof(sourceInputId)); if (expiresAt is { } expiry && (expiry.EnsureValid(nameof(expiresAt)).TimelineId != moment.TimelineId || expiry.Tick < moment.Tick)) @@ -70,7 +85,11 @@ public GameMemory( ExpiresAt = expiresAt; var copiedMetadata = new Dictionary(metadata ?? new Dictionary(), StringComparer.Ordinal); - if (copiedMetadata.Any(pair => string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)) + if (copiedMetadata.Count > 256 + || copiedMetadata.Any(pair => string.IsNullOrWhiteSpace(pair.Key) + || pair.Key.Length > 256 + || pair.Value is null + || pair.Value.Length > 65_536)) { throw new ArgumentException("Memory metadata requires non-empty keys and non-null values.", nameof(metadata)); } @@ -78,6 +97,17 @@ public GameMemory( Metadata = new ReadOnlyDictionary(copiedMetadata); } + private static string RequireBoundedId(string value, string name) + { + var required = GameJson.RequireId(value, name); + if (required.Length > 1_024) + { + throw new ArgumentException("A memory identifier cannot exceed 1024 characters.", name); + } + + return required; + } + public string MemoryId { get; } public string SessionId { get; } @@ -118,7 +148,7 @@ public GameMemoryQuery( GameMoment? atOrBefore = null, double minimumImportance = 0) { - if (limit < 0) + if (limit < 0 || limit > 100_000) { throw new ArgumentOutOfRangeException(nameof(limit)); } @@ -131,11 +161,11 @@ public GameMemoryQuery( throw new ArgumentOutOfRangeException(nameof(minimumImportance)); } - SessionId = GameJson.RequireId(sessionId, nameof(sessionId)); + SessionId = RequireBoundedId(sessionId, nameof(sessionId)); Limit = limit; - OwnerId = ownerId is null ? null : GameJson.RequireId(ownerId, nameof(ownerId)); + OwnerId = ownerId is null ? null : RequireBoundedId(ownerId, nameof(ownerId)); Scopes = CopyIds(scopes, nameof(scopes)); - var copiedKinds = (kinds ?? Array.Empty()).ToArray(); + var copiedKinds = (kinds ?? Array.Empty()).Distinct().ToArray(); if (copiedKinds.Any(kind => !Enum.IsDefined(typeof(GameMemoryKind), kind))) { throw new ArgumentOutOfRangeException(nameof(kinds)); @@ -143,6 +173,11 @@ public GameMemoryQuery( Kinds = Array.AsReadOnly(copiedKinds); Tags = CopyIds(tags, nameof(tags)); + if (text?.Length > 1_000_000) + { + throw new ArgumentException("A memory query cannot exceed 1000000 text characters.", nameof(text)); + } + Text = text; AtOrBefore = atOrBefore?.EnsureValid(nameof(atOrBefore)); MinimumImportance = minimumImportance; @@ -166,11 +201,30 @@ public GameMemoryQuery( public double MinimumImportance { get; } - private static IReadOnlyCollection CopyIds(IReadOnlyCollection? values, string parameterName) => - Array.AsReadOnly((values ?? Array.Empty()) - .Select(value => GameJson.RequireId(value, parameterName)) + private static IReadOnlyCollection CopyIds(IReadOnlyCollection? values, string parameterName) + { + var copied = (values ?? Array.Empty()) + .Select(value => RequireBoundedId(value, parameterName)) .Distinct(StringComparer.Ordinal) - .ToArray()); + .ToArray(); + if (copied.Length > 256) + { + throw new ArgumentException("A memory query filter can contain at most 256 values.", parameterName); + } + + return Array.AsReadOnly(copied); + } + + private static string RequireBoundedId(string value, string name) + { + var required = GameJson.RequireId(value, name); + if (required.Length > 1_024) + { + throw new ArgumentException("A memory query identifier cannot exceed 1024 characters.", name); + } + + return required; + } } public interface IGameMemoryStore @@ -258,10 +312,10 @@ public async ValueTask> SearchAsync( throw new InvalidOperationException("The memory store exceeded the requested candidate limit."); } - var candidateById = new Dictionary(StringComparer.Ordinal); + var candidateById = new Dictionary<(string OwnerId, string MemoryId), GameMemory>(); foreach (var memory in candidates) { - if (memory is null || !candidateById.TryAdd(memory.MemoryId, memory)) + if (memory is null || !candidateById.TryAdd((memory.OwnerId, memory.MemoryId), memory)) { throw new InvalidOperationException("The memory store returned a null or duplicate candidate."); } @@ -279,13 +333,13 @@ public async ValueTask> SearchAsync( throw new InvalidOperationException("The memory ranker returned more memories than it received."); } - var returnedIds = new HashSet(StringComparer.Ordinal); + var returnedIds = new HashSet<(string OwnerId, string MemoryId)>(); var canonical = new List(ranked.Count); foreach (var memory in ranked) { if (memory is null - || !candidateById.TryGetValue(memory.MemoryId, out var candidate) - || !returnedIds.Add(memory.MemoryId)) + || !candidateById.TryGetValue((memory.OwnerId, memory.MemoryId), out var candidate) + || !returnedIds.Add((memory.OwnerId, memory.MemoryId))) { throw new InvalidOperationException("The memory ranker returned an unknown, duplicate, or null memory."); } @@ -293,7 +347,7 @@ public async ValueTask> SearchAsync( canonical.Add(candidate); } - return canonical.Take(query.Limit).ToArray(); + return Array.AsReadOnly(canonical.Take(query.Limit).ToArray()); } private static bool MatchesQuery(GameMemory memory, GameMemoryQuery query) @@ -323,7 +377,7 @@ private static bool MatchesQuery(GameMemory memory, GameMemoryQuery query) public sealed class InMemoryGameMemoryStore : IGameMemoryStore { private readonly object _gate = new(); - private readonly Dictionary _memories = new(StringComparer.Ordinal); + private readonly Dictionary<(string SessionId, string OwnerId, string MemoryId), GameMemory> _memories = new(); private readonly int _capacity; public InMemoryGameMemoryStore(int capacity = 100_000) @@ -346,7 +400,8 @@ public ValueTask AppendAsync(GameMemory memory, CancellationToken cancellationTo lock (_gate) { - if (_memories.TryGetValue(memory.MemoryId, out var existing)) + var key = (memory.SessionId, memory.OwnerId, memory.MemoryId); + if (_memories.TryGetValue(key, out var existing)) { if (!Equivalent(existing, memory)) { @@ -361,7 +416,7 @@ public ValueTask AppendAsync(GameMemory memory, CancellationToken cancellationTo throw new GameRuntimeLimitException(nameof(_capacity), "The memory store reached its capacity."); } - _memories.Add(memory.MemoryId, memory); + _memories.Add(key, memory); } return default; @@ -403,7 +458,7 @@ public ValueTask> SearchAsync( .Select(candidate => candidate.Memory) .ToArray(); - return new ValueTask>(candidates); + return new ValueTask>(Array.AsReadOnly(candidates)); } private static IReadOnlyList ScoreCandidates( @@ -414,8 +469,8 @@ private static IReadOnlyList ScoreCandidates( if (query.Text is not null && queryTerms.Length == 0) { memories = memories.Where(memory => - (memory.SearchableText?.IndexOf(query.Text, StringComparison.OrdinalIgnoreCase) ?? -1) >= 0 - || memory.PayloadJson.IndexOf(query.Text, StringComparison.OrdinalIgnoreCase) >= 0) + (memory.SearchableText?.Contains(query.Text, StringComparison.OrdinalIgnoreCase) ?? false) + || memory.PayloadJson.Contains(query.Text, StringComparison.OrdinalIgnoreCase)) .ToArray(); } diff --git a/src/OpenGameAgent/ModelProviders.cs b/src/OpenGameAgent/ModelProviders.cs index e7207f2..d3b4b44 100644 --- a/src/OpenGameAgent/ModelProviders.cs +++ b/src/OpenGameAgent/ModelProviders.cs @@ -14,12 +14,14 @@ public sealed class RetryingModelProvider : IModelProvider private readonly int _maximumAttempts; private readonly Func _delay; private readonly Func _isTransient; + private readonly TimeSpan _maximumDelay; public RetryingModelProvider( IModelProvider inner, int maximumAttempts = 3, Func? delay = null, - Func? isTransient = null) + Func? isTransient = null, + TimeSpan? maximumDelay = null) { _inner = inner ?? throw new ArgumentNullException(nameof(inner)); if (maximumAttempts < 1 || maximumAttempts > 32) @@ -29,7 +31,13 @@ public RetryingModelProvider( _maximumAttempts = maximumAttempts; _delay = delay ?? (attempt => TimeSpan.FromMilliseconds(Math.Min(5_000, 200 * Math.Pow(2, attempt - 1)))); - _isTransient = isTransient ?? (_ => true); + _isTransient = isTransient ?? (exception => + exception is not ModelProviderException providerFailure || providerFailure.IsTransient); + _maximumDelay = maximumDelay ?? TimeSpan.FromSeconds(30); + if (_maximumDelay < TimeSpan.Zero || _maximumDelay > TimeSpan.FromMinutes(5)) + { + throw new ArgumentOutOfRangeException(nameof(maximumDelay)); + } } public async IAsyncEnumerable StreamAsync( @@ -40,6 +48,10 @@ public async IAsyncEnumerable StreamAsync( { var enumerator = _inner.StreamAsync(request, cancellationToken).GetAsyncEnumerator(cancellationToken); var emittedMeaningfulEvent = false; + ModelStreamEvent? pendingStart = null; + Exception? retryFailure = null; + Exception? primaryFailure = null; + var terminalSeen = false; try { while (true) @@ -51,11 +63,13 @@ public async IAsyncEnumerable StreamAsync( } catch (Exception exception) when (!cancellationToken.IsCancellationRequested) { + primaryFailure = exception; if (attempt >= _maximumAttempts || emittedMeaningfulEvent || !_isTransient(exception)) { throw; } + retryFailure = exception; break; } @@ -68,7 +82,8 @@ public async IAsyncEnumerable StreamAsync( if (attempt >= _maximumAttempts) { - throw new InvalidOperationException("The model provider completed without emitting a terminal event."); + primaryFailure = new InvalidOperationException("The model provider completed without emitting a terminal event."); + throw primaryFailure; } break; @@ -77,28 +92,67 @@ public async IAsyncEnumerable StreamAsync( var current = enumerator.Current; if (current is null) { - throw new InvalidOperationException("The model provider emitted a null stream event."); + primaryFailure = new InvalidOperationException("The model provider emitted a null stream event."); + throw primaryFailure; + } + + if (current.Kind == ModelStreamEventKind.Started) + { + if (pendingStart is not null) + { + primaryFailure = new InvalidOperationException("The model provider emitted more than one stream start event."); + throw primaryFailure; + } + + pendingStart = current; + if (ModelProviderRetrySafety.HasMeaningfulStart(current)) + { + emittedMeaningfulEvent = true; + yield return pendingStart; + pendingStart = null; + } + + continue; + } + + emittedMeaningfulEvent = true; + if (pendingStart is not null) + { + yield return pendingStart; + pendingStart = null; } - emittedMeaningfulEvent |= current.Kind != ModelStreamEventKind.Started; - yield return current; if (current.IsTerminal) { + terminalSeen = true; + yield return current; yield break; } + + yield return current; } } finally { - await enumerator.DisposeAsync().ConfigureAwait(false); + try + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + catch when (primaryFailure is not null || retryFailure is not null || terminalSeen || cancellationToken.IsCancellationRequested) + { + // Cleanup cannot replace the primary stream outcome. + } } - var wait = _delay(attempt); - if (wait < TimeSpan.Zero || wait > TimeSpan.FromMinutes(5)) + var wait = retryFailure is ModelProviderException { RetryAfter: { } serverDelay } + ? serverDelay + : _delay(attempt); + if (wait < TimeSpan.Zero) { - throw new InvalidOperationException("The retry delay must be between zero and five minutes."); + throw new InvalidOperationException("The retry delay cannot be negative."); } + wait = wait > _maximumDelay ? _maximumDelay : wait; await Task.Delay(wait, cancellationToken).ConfigureAwait(false); } } @@ -125,7 +179,8 @@ public FallbackModelProvider( } _providers = new ReadOnlyCollection(copied); - _canFallback = canFallback ?? (_ => true); + _canFallback = canFallback ?? (exception => + exception is not ModelProviderException providerFailure || providerFailure.IsTransient); } public async IAsyncEnumerable StreamAsync( @@ -136,6 +191,10 @@ public async IAsyncEnumerable StreamAsync( { var enumerator = _providers[index].StreamAsync(request, cancellationToken).GetAsyncEnumerator(cancellationToken); var emittedMeaningfulEvent = false; + ModelStreamEvent? pendingStart = null; + Exception? primaryFailure = null; + Exception? fallbackFailure = null; + var terminalSeen = false; try { while (true) @@ -147,11 +206,13 @@ public async IAsyncEnumerable StreamAsync( } catch (Exception exception) when (!cancellationToken.IsCancellationRequested) { + primaryFailure = exception; if (index >= _providers.Count - 1 || emittedMeaningfulEvent || !_canFallback(exception)) { throw; } + fallbackFailure = exception; break; } @@ -164,7 +225,8 @@ public async IAsyncEnumerable StreamAsync( if (index >= _providers.Count - 1) { - throw new InvalidOperationException("Every fallback model provider completed without output."); + primaryFailure = new InvalidOperationException("Every fallback model provider completed without output."); + throw primaryFailure; } break; @@ -173,21 +235,64 @@ public async IAsyncEnumerable StreamAsync( var current = enumerator.Current; if (current is null) { - throw new InvalidOperationException("A fallback model provider emitted a null stream event."); + primaryFailure = new InvalidOperationException("A fallback model provider emitted a null stream event."); + throw primaryFailure; + } + + if (current.Kind == ModelStreamEventKind.Started) + { + if (pendingStart is not null) + { + primaryFailure = new InvalidOperationException("A fallback model provider emitted more than one stream start event."); + throw primaryFailure; + } + + pendingStart = current; + if (ModelProviderRetrySafety.HasMeaningfulStart(current)) + { + emittedMeaningfulEvent = true; + yield return pendingStart; + pendingStart = null; + } + + continue; + } + + emittedMeaningfulEvent = true; + if (pendingStart is not null) + { + yield return pendingStart; + pendingStart = null; } - emittedMeaningfulEvent |= current.Kind != ModelStreamEventKind.Started; - yield return current; if (current.IsTerminal) { + terminalSeen = true; + yield return current; yield break; } + + yield return current; } } finally { - await enumerator.DisposeAsync().ConfigureAwait(false); + try + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + catch when (primaryFailure is not null || fallbackFailure is not null || terminalSeen || cancellationToken.IsCancellationRequested) + { + // Cleanup cannot replace the primary stream outcome. + } } } } } + +internal static class ModelProviderRetrySafety +{ + public static bool HasMeaningfulStart(ModelStreamEvent streamEvent) => + streamEvent.Partial is { } partial + && (partial.Content.Count > 0 || partial.Usage.TotalTokens > 0); +} diff --git a/src/OpenGameAgent/OpenGameAgent.csproj b/src/OpenGameAgent/OpenGameAgent.csproj index 9ccbdb5..963dd41 100644 --- a/src/OpenGameAgent/OpenGameAgent.csproj +++ b/src/OpenGameAgent/OpenGameAgent.csproj @@ -2,7 +2,7 @@ netstandard2.1 OpenGameAgent - Game-native agent runtime with durable actions, game time, memory, skills, routing, and multi-actor coordination. + Extensible game-native agent runtime with durable actions, game time, skills, routing, workflows, and multi-actor coordination. diff --git a/src/OpenGameAgent/Routing.cs b/src/OpenGameAgent/Routing.cs index d645a40..40013fa 100644 --- a/src/OpenGameAgent/Routing.cs +++ b/src/OpenGameAgent/Routing.cs @@ -217,6 +217,7 @@ public ModelGameRouteClassifier( { MaxTurns = 1, MaxTotalTokens = 16_384, + ModelTimeoutMilliseconds = 15_000, MaxTextCharactersPerPart = 16_384, MaxJsonCharactersPerPart = 1_000_000, }, diff --git a/src/OpenGameAgent/Scheduling.cs b/src/OpenGameAgent/Scheduling.cs index ca947c6..3c71736 100644 --- a/src/OpenGameAgent/Scheduling.cs +++ b/src/OpenGameAgent/Scheduling.cs @@ -241,14 +241,14 @@ public IReadOnlyList CaptureState() { lock (_gate) { - return _triggers.Values + return Array.AsReadOnly(_triggers.Values .OrderBy(state => state.NextDue.Tick) .ThenBy(state => state.Trigger.TriggerId, StringComparer.Ordinal) .Select(state => new ScheduledGameTriggerState( state.Trigger, state.NextDue, state.Occurrences)) - .ToArray(); + .ToArray()); } } @@ -365,7 +365,7 @@ public IReadOnlyList Advance( _triggers.Remove(completed); } - return due; + return Array.AsReadOnly(due.ToArray()); } } @@ -417,6 +417,7 @@ public sealed class MultiActorScheduler private readonly object _gate = new(); private readonly Dictionary _lanes = new(StringComparer.Ordinal); private readonly SemaphoreSlim _concurrency; + private TaskCompletionSource? _idleWaiter; private readonly int _maximumActors; private readonly int _maximumQueuedPerActor; @@ -484,17 +485,32 @@ public Task EnqueueAsync( if (startRunner) { - _ = Task.Run(() => RunLaneAsync(actorId, lane)); + _ = Task.Run(() => RunLaneAsync(actorId, lane), CancellationToken.None); } return item.Task; } + public Task WaitForIdleAsync() + { + lock (_gate) + { + if (_lanes.Count == 0) + { + return Task.CompletedTask; + } + + _idleWaiter ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + return _idleWaiter.Task; + } + } + private async Task RunLaneAsync(string actorId, ActorLane lane) { while (true) { ActorWorkItem item; + TaskCompletionSource? idleWaiter = null; lock (_gate) { if (lane.Queue.Count == 0) @@ -505,10 +521,25 @@ private async Task RunLaneAsync(string actorId, ActorLane lane) _lanes.Remove(actorId); } - return; + if (_lanes.Count == 0) + { + idleWaiter = _idleWaiter; + _idleWaiter = null; + } + + item = null!; + } + else + { + item = lane.Queue.Dequeue(); } + } + + if (idleWaiter is not null || item is null) + { + idleWaiter?.TrySetResult(null); - item = lane.Queue.Dequeue(); + return; } if (item.IsCanceled) @@ -529,7 +560,7 @@ private async Task RunLaneAsync(string actorId, ActorLane lane) try { - if (item.IsCanceled) + if (!item.TryStart()) { item.Cancel(); } @@ -565,6 +596,8 @@ protected ActorWorkItem(CancellationToken cancellationToken) public abstract Task ExecuteAsync(); + public abstract bool TryStart(); + public abstract void Cancel(); } @@ -574,18 +607,35 @@ private sealed class ActorWorkItem : ActorWorkItem private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly CancellationTokenRegistration _cancellationRegistration; + private int _started; public ActorWorkItem(Func> work, CancellationToken cancellationToken) : base(cancellationToken) { _work = work; _cancellationRegistration = cancellationToken.CanBeCanceled - ? cancellationToken.Register(() => _completion.TrySetCanceled(cancellationToken)) + ? cancellationToken.Register(() => + { + if (Volatile.Read(ref _started) == 0) + { + _completion.TrySetCanceled(cancellationToken); + } + }) : default; } public Task Task => _completion.Task; + public override bool TryStart() + { + if (Interlocked.CompareExchange(ref _started, 1, 0) != 0) + { + return false; + } + + return !_completion.Task.IsCompleted; + } + public override async Task ExecuteAsync() { try diff --git a/src/OpenGameAgent/Sessions.cs b/src/OpenGameAgent/Sessions.cs index bf3538f..42c5544 100644 --- a/src/OpenGameAgent/Sessions.cs +++ b/src/OpenGameAgent/Sessions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -36,6 +37,10 @@ public override int GetHashCode() public override string ToString() => (SessionId ?? string.Empty) + ":" + (ActorId ?? string.Empty); + public static bool operator ==(GameSessionKey left, GameSessionKey right) => left.Equals(right); + + public static bool operator !=(GameSessionKey left, GameSessionKey right) => !left.Equals(right); + internal GameSessionKey EnsureValid(string parameterName) { if (string.IsNullOrWhiteSpace(SessionId) || string.IsNullOrWhiteSpace(ActorId)) @@ -54,7 +59,9 @@ public GameSessionSnapshot( long revision, IReadOnlyList? messages = null, IReadOnlyCollection? processedInputIds = null, - GameMoment? lastMoment = null) + GameMoment? lastMoment = null, + IReadOnlyDictionary? extensionState = null, + string? pendingInputId = null) { if (revision < 0) { @@ -75,7 +82,27 @@ public GameSessionSnapshot( .ToArray(); Messages = Array.AsReadOnly(copiedMessages); ProcessedInputIds = Array.AsReadOnly(copiedInputIds); + PendingInputId = pendingInputId is null + ? null + : GameJson.RequireId(pendingInputId, nameof(pendingInputId)); + if (PendingInputId is not null && ProcessedInputIds.Contains(PendingInputId, StringComparer.Ordinal)) + { + throw new ArgumentException("A pending input cannot already be marked as processed.", nameof(pendingInputId)); + } + LastMoment = lastMoment?.EnsureValid(nameof(lastMoment)); + var copiedExtensionState = new Dictionary(StringComparer.Ordinal); + foreach (var pair in extensionState ?? new Dictionary()) + { + var stateKey = GameJson.RequireId(pair.Key, nameof(extensionState)); + var value = GameJson.RequireValid(pair.Value, nameof(extensionState)); + if (!copiedExtensionState.TryAdd(stateKey, value)) + { + throw new ArgumentException($"Duplicate extension state key '{stateKey}'.", nameof(extensionState)); + } + } + + ExtensionState = new ReadOnlyDictionary(copiedExtensionState); } public GameSessionKey Key { get; } @@ -86,7 +113,19 @@ public GameSessionSnapshot( public IReadOnlyCollection ProcessedInputIds { get; } + /// + /// Input whose completed tool turns were durably checkpointed but whose agent run has not reached + /// a terminal commit. Resubmitting the same input resumes after the checkpoint; a different input + /// is rejected until this one is settled or explicitly repaired by the host. + /// + public string? PendingInputId { get; } + public GameMoment? LastMoment { get; } + + /// + /// Namespaced extension-owned JSON state. It is persisted but never added to model context automatically. + /// + public IReadOnlyDictionary ExtensionState { get; } } public sealed class GameSessionSaveResult @@ -189,5 +228,12 @@ public ValueTask SaveAsync( } private static GameSessionSnapshot Copy(GameSessionSnapshot snapshot) => - new(snapshot.Key, snapshot.Revision, snapshot.Messages, snapshot.ProcessedInputIds, snapshot.LastMoment); + new( + snapshot.Key, + snapshot.Revision, + snapshot.Messages, + snapshot.ProcessedInputIds, + snapshot.LastMoment, + snapshot.ExtensionState, + snapshot.PendingInputId); } diff --git a/src/OpenGameAgent/Transcripts.cs b/src/OpenGameAgent/Transcripts.cs index 966ff66..3161cbc 100644 --- a/src/OpenGameAgent/Transcripts.cs +++ b/src/OpenGameAgent/Transcripts.cs @@ -12,13 +12,27 @@ public sealed class GameTranscriptCompactionContext public GameTranscriptCompactionContext( GameSessionKey session, IReadOnlyList messages, - int targetMessageCount) + int targetMessageCount, + long? targetEstimatedTokens = null, + GameTranscriptTokenEstimator? tokenEstimator = null) { if (targetMessageCount < 1) { throw new ArgumentOutOfRangeException(nameof(targetMessageCount)); } + if (targetEstimatedTokens is <= 0) + { + throw new ArgumentOutOfRangeException(nameof(targetEstimatedTokens)); + } + + if (targetEstimatedTokens is not null && tokenEstimator is null) + { + throw new ArgumentException( + "A token estimator is required when a token target is configured.", + nameof(tokenEstimator)); + } + Session = session.EnsureValid(nameof(session)); var copiedMessages = (messages ?? throw new ArgumentNullException(nameof(messages))).ToArray(); if (copiedMessages.Any(message => message is null)) @@ -28,6 +42,8 @@ public GameTranscriptCompactionContext( Messages = Array.AsReadOnly(copiedMessages); TargetMessageCount = targetMessageCount; + TargetEstimatedTokens = targetEstimatedTokens; + TokenEstimator = tokenEstimator; } public GameSessionKey Session { get; } @@ -35,6 +51,133 @@ public GameTranscriptCompactionContext( public IReadOnlyList Messages { get; } public int TargetMessageCount { get; } + + public long? TargetEstimatedTokens { get; } + + public GameTranscriptTokenEstimator? TokenEstimator { get; } +} + +public delegate long GameTranscriptTokenEstimator(IReadOnlyList messages); + +public delegate long GameModelRequestTokenEstimator( + string model, + string systemPrompt, + IReadOnlyList messages, + IReadOnlyList tools); + +public static class ApproximateGameTokenEstimator +{ + private const long ResourceTokenEstimate = 1_200; + + public static long EstimateRequest( + string model, + string systemPrompt, + IReadOnlyList messages, + IReadOnlyList tools) + { + if (string.IsNullOrWhiteSpace(model)) + { + throw new ArgumentException("A model name is required.", nameof(model)); + } + + if (systemPrompt is null) + { + throw new ArgumentNullException(nameof(systemPrompt)); + } + + if (messages is null) + { + throw new ArgumentNullException(nameof(messages)); + } + + if (tools is null) + { + throw new ArgumentNullException(nameof(tools)); + } + + var characters = (long)systemPrompt.Length; + foreach (var tool in tools) + { + if (tool is null) + { + throw new ArgumentException("Tool collections cannot contain null values.", nameof(tools)); + } + + characters = checked(characters + + tool.Name.Length + + tool.Description.Length + + tool.InputSchemaJson.Length + + 128); + } + + return checked(EstimateMessages(messages) + DivideRoundUp(characters, 4)); + } + + public static long EstimateMessages(IReadOnlyList messages) + { + if (messages is null) + { + throw new ArgumentNullException(nameof(messages)); + } + + var tokens = 0L; + foreach (var message in messages) + { + if (message is null) + { + throw new ArgumentException("Message collections cannot contain null values.", nameof(messages)); + } + + var characters = 64L; + characters = checked(characters + + (message.CustomRole?.Length ?? 0) + + (message.ToolCallId?.Length ?? 0) + + (message.ToolName?.Length ?? 0) + + (message.DetailsJson?.Length ?? 0) + + (message.Model?.Length ?? 0) + + (message.ErrorMessage?.Length ?? 0)); + foreach (var pair in message.Metadata) + { + characters = checked(characters + pair.Key.Length + pair.Value.Length); + } + + foreach (var content in message.Content) + { + switch (content) + { + case TextContent text: + characters = checked(characters + text.Text.Length); + break; + case JsonContent json: + characters = checked(characters + json.Json.Length); + break; + case ReasoningContent reasoning: + characters = checked(characters + reasoning.Text.Length + (reasoning.Signature?.Length ?? 0)); + break; + case ToolCallContent call: + characters = checked(characters + call.Id.Length + call.Name.Length + call.ArgumentsJson.Length); + break; + case ResourceContent resource: + characters = checked(characters + + resource.Uri.Length + + resource.MediaType.Length + + (resource.Name?.Length ?? 0)); + tokens = checked(tokens + ResourceTokenEstimate); + break; + default: + throw new InvalidOperationException( + $"Unsupported agent content type '{content.GetType().FullName}'."); + } + } + + tokens = checked(tokens + DivideRoundUp(characters, 4)); + } + + return tokens; + } + + private static long DivideRoundUp(long value, long divisor) => + checked((value + divisor - 1) / divisor); } public interface IGameTranscriptCompactor @@ -67,13 +210,13 @@ public async ValueTask> CompactAsync( throw new ArgumentNullException(nameof(context)); } - if (context.Messages.Count <= context.TargetMessageCount) + if (Fits(context, context.Messages)) { return context.Messages; } var keepCount = Math.Max(1, context.TargetMessageCount - 1); - var start = FindSafeSuffixStart(context.Messages, keepCount); + var start = FindSafeSuffixStart(context, keepCount); if (start == 0) { throw new InvalidOperationException("The transcript cannot be compacted without splitting a tool exchange."); @@ -108,17 +251,41 @@ public async ValueTask> CompactAsync( throw new InvalidOperationException("The transcript compactor exceeded its requested target."); } + if (context.TargetEstimatedTokens is { } tokenTarget + && Estimate(context, result) > tokenTarget) + { + throw new InvalidOperationException("The transcript compactor exceeded its requested token target."); + } + ValidateToolExchanges(result); return result; } - private static int FindSafeSuffixStart(IReadOnlyList messages, int keepCount) + private static int FindSafeSuffixStart(GameTranscriptCompactionContext context, int keepCount) { + var messages = context.Messages; var desired = Math.Max(1, messages.Count - keepCount); for (var index = desired; index < messages.Count; index++) { if (messages[index].Role is AgentRole.User or AgentRole.Custom) { + var projectedCount = checked(messages.Count - index + 1); + if (projectedCount > context.TargetMessageCount) + { + continue; + } + + if (context.TargetEstimatedTokens is { } tokenTarget) + { + // Leave half of the transcript budget available to the summary. This is + // conservative and the completed summary is checked again below. + var suffixTarget = Math.Max(1, tokenTarget / 2); + if (Estimate(context, messages.Skip(index).ToArray()) > suffixTarget) + { + continue; + } + } + return index; } } @@ -126,6 +293,18 @@ private static int FindSafeSuffixStart(IReadOnlyList messages, int return -1; } + private static bool Fits(GameTranscriptCompactionContext context, IReadOnlyList messages) => + messages.Count <= context.TargetMessageCount + && (context.TargetEstimatedTokens is not { } target || Estimate(context, messages) <= target); + + private static long Estimate(GameTranscriptCompactionContext context, IReadOnlyList messages) + { + var estimate = context.TokenEstimator!(messages); + return estimate >= 0 + ? estimate + : throw new InvalidOperationException("The transcript token estimator returned a negative value."); + } + private static void ValidateToolExchanges(IReadOnlyList messages) { var openCalls = new HashSet(StringComparer.Ordinal); diff --git a/tests/OpenGameAgent.Connectors.Mcp.Tests/McpConnectorTests.cs b/tests/OpenGameAgent.Connectors.Mcp.Tests/McpConnectorTests.cs new file mode 100644 index 0000000..77ae7f3 --- /dev/null +++ b/tests/OpenGameAgent.Connectors.Mcp.Tests/McpConnectorTests.cs @@ -0,0 +1,328 @@ +using System.Collections.Concurrent; +using System.IO.Pipelines; +using System.Runtime.CompilerServices; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using OpenGameAgent.Extensions; +using OpenGameAgent.Kernel; +using Xunit; + +namespace OpenGameAgent.Connectors.Mcp.Tests; + +public sealed class McpConnectorTests +{ + [Fact] + public async Task DiscoversAndCallsAStandardExternalTool() + { + 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((string value) => $"echo:{value}", new() { Name = "echo" }), + ], + }); + var serverTask = server.RunAsync(TestContext.Current.CancellationToken); + var provider = new ScriptedProvider(call => call == 1 + ? new ModelResponse( + new AgentContent[] { new ToolCallContent("external", "test__echo", "{\"value\":\"hello\"}") }, + 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 }, + exposure: GameMcpToolExposure.Direct)) + .Build(); + + var result = await runtime.RunAsync( + new GameInput("session", "actor", "request", "{}", new GameMoment("world", 1), "input"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Contains(provider.Requests.First().Tools, tool => tool.Name == "test__echo"); + var toolMessage = provider.Requests.ElementAt(1).Messages.Last(message => message.Role == AgentRole.Tool); + var json = Assert.IsType(Assert.Single(toolMessage.Content)).Json; + Assert.Contains("echo:hello", json); + await runtime.DisposeAsync(); + await server.DisposeAsync(); + await serverTask; + } + + [Fact] + public async Task DefaultExposureConnectsLazilyAndDiscoversBeforeCalling() + { + 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((string value) => $"echo:{value}", new() { Name = "echo" }), + ], + }); + var serverTask = server.RunAsync(TestContext.Current.CancellationToken); + var provider = new ScriptedProvider((call, request) => + { + if (call == 1) + { + return new ModelResponse( + new AgentContent[] + { + new ToolCallContent( + "search", + "external_tools", + "{\"action\":\"search\",\"query\":\"echo\"}"), + }, + ModelStopReason.ToolUse); + } + + if (call == 2) + { + var search = Assert.IsType(Assert.Single( + request.Messages.Last(message => message.Role == AgentRole.Tool).Content)); + using var document = System.Text.Json.JsonDocument.Parse(search.Json); + var path = Assert.Single(document.RootElement.GetProperty("matches").EnumerateArray()) + .GetProperty("path") + .GetString(); + return new ModelResponse( + new AgentContent[] + { + new ToolCallContent( + "call", + "external_tools", + System.Text.Json.JsonSerializer.Serialize(new + { + action = "call", + path, + arguments = new { value = "hello" }, + })), + }, + ModelStopReason.ToolUse); + } + + return new ModelResponse(new AgentContent[] { new TextContent("done") }, ModelStopReason.Stop); + }); + var connectCount = 0; + var connection = new GameMcpServer( + "test", + async cancellationToken => + { + Interlocked.Increment(ref connectCount); + return 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 })) + .Build(); + + var result = await runtime.RunAsync( + new GameInput("session", "actor", "request", "{}", new GameMoment("world", 1), "input"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(1, connectCount); + Assert.Equal(new[] { "external_tools" }, provider.Requests.First().Tools.Select(tool => tool.Name)); + var toolMessage = provider.Requests.ElementAt(2).Messages.Last(message => message.Role == AgentRole.Tool); + var json = Assert.IsType(Assert.Single(toolMessage.Content)).Json; + Assert.Contains("echo:hello", json); + await runtime.DisposeAsync(); + await server.DisposeAsync(); + await serverTask; + } + + [Fact] + public async Task OnDemandCallRejectsInvalidRemoteArgumentsBeforeExecution() + { + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + var executions = 0; + await using var server = McpServer.Create( + new StreamServerTransport(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream()), + new McpServerOptions + { + ToolCollection = + [ + McpServerTool.Create( + (string value) => + { + Interlocked.Increment(ref executions); + return $"echo:{value}"; + }, + new() { Name = "echo" }), + ], + }); + var serverTask = server.RunAsync(TestContext.Current.CancellationToken); + var provider = new ScriptedProvider((call, _) => call == 1 + ? new ModelResponse( + new AgentContent[] + { + new ToolCallContent( + "invalid", + "external_tools", + "{\"action\":\"call\",\"path\":\"test__echo\",\"arguments\":{}}"), + }, + 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 })) + .Build(); + + var result = await runtime.RunAsync( + new GameInput("session", "actor", "request", "{}", new GameMoment("world", 1), "input"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(0, executions); + var toolMessage = provider.Requests.ElementAt(1).Messages.Last(message => message.Role == AgentRole.Tool); + var error = Assert.IsType(Assert.Single(toolMessage.Content)).Text; + Assert.Contains("Invalid external tool arguments", error); + await runtime.DisposeAsync(); + await server.DisposeAsync(); + await serverTask; + } + + [Fact] + public async Task LargeResultsUseBoundedArtifactIdsForLongGameIdentities() + { + 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)); + var artifacts = new InMemoryGameAgentArtifactStore(); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension(new McpToolConnectorExtension( + new[] { connection }, + maximumInlineResultCharacters: 1_024, + artifactStore: artifacts, + exposure: GameMcpToolExposure.Direct)) + .Build(); + var sessionId = new string('s', 400); + var actorId = new string('a', 400); + + var result = await runtime.RunAsync( + new GameInput(sessionId, actorId, "request", "{}", new GameMoment("world", 1), "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()); + Assert.StartsWith("mcp-", artifactId, StringComparison.Ordinal); + Assert.True(artifactId.Length <= 512); + var artifact = await artifacts.GetAsync( + sessionId, + actorId, + artifactId, + TestContext.Current.CancellationToken); + Assert.NotNull(artifact); + Assert.True(artifact.Content.Length > 1_024); + await runtime.DisposeAsync(); + await server.DisposeAsync(); + await serverTask; + } + + [Fact] + public void StdioRejectsEmbeddedNullCharactersBeforeStartingAProcess() + { + Assert.Throws(() => GameMcpServer.Stdio("test", "tool\0name")); + Assert.Throws(() => GameMcpServer.Stdio("test", "tool", new[] { "value\0suffix" })); + Assert.Throws(() => GameMcpServer.Stdio("test", "tool", workingDirectory: "path\0suffix")); + } + + [Fact] + public async Task ThrowingConnectorCancellationCallbacksCannotBlockCleanup() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var connection = new GameMcpServer( + "test", + async cancellationToken => + { + using var registration = cancellationToken.Register( + () => throw new InvalidOperationException("callback failed")); + entered.TrySetResult(true); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + throw new InvalidOperationException("unreachable"); + }); + var extension = new McpToolConnectorExtension( + new[] { connection }, + exposure: GameMcpToolExposure.Direct); + await using var runtime = new GameAgentBuilder(new ScriptedProvider(_ => + new ModelResponse(new AgentContent[] { new TextContent("done") }, ModelStopReason.Stop)), "model") + .UseExtension(extension) + .Build(); + var run = runtime.RunAsync( + new GameInput("session", "actor", "request", "{}", new GameMoment("world", 1), "input"), + TestContext.Current.CancellationToken); + await entered.Task.WaitAsync(TestContext.Current.CancellationToken); + + var exception = await Record.ExceptionAsync(async () => await extension.DisposeAsync()); + + Assert.Null(exception); + await Assert.ThrowsAnyAsync(() => run); + } + + private sealed class ScriptedProvider : IModelProvider + { + private readonly Func _response; + private int _calls; + + public ScriptedProvider(Func response) + { + ArgumentNullException.ThrowIfNull(response); + _response = (call, _) => response(call); + } + + public ScriptedProvider(Func response) + { + _response = response ?? throw new ArgumentNullException(nameof(response)); + } + + public ConcurrentQueue Requests { get; } = new(); + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Requests.Enqueue(request); + yield return ModelStreamEvent.Terminal(_response(Interlocked.Increment(ref _calls), request)); + await Task.CompletedTask; + } + } +} diff --git a/tests/OpenGameAgent.Connectors.Mcp.Tests/OpenGameAgent.Connectors.Mcp.Tests.csproj b/tests/OpenGameAgent.Connectors.Mcp.Tests/OpenGameAgent.Connectors.Mcp.Tests.csproj new file mode 100644 index 0000000..f4623e0 --- /dev/null +++ b/tests/OpenGameAgent.Connectors.Mcp.Tests/OpenGameAgent.Connectors.Mcp.Tests.csproj @@ -0,0 +1,18 @@ + + + net8.0 + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/tests/OpenGameAgent.Connectors.Mcp.Tests/packages.lock.json b/tests/OpenGameAgent.Connectors.Mcp.Tests/packages.lock.json new file mode 100644 index 0000000..65accab --- /dev/null +++ b/tests/OpenGameAgent.Connectors.Mcp.Tests/packages.lock.json @@ -0,0 +1,242 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "Microsoft.NET.Test.Sdk": { + "type": "Direct", + "requested": "[17.11.1, )", + "resolved": "17.11.1", + "contentHash": "U3Ty4BaGoEu+T2bwSko9tWqWUOU16WzSFkq6U8zve75oRBMSLTBdMAZrVNNz1Tq12aCdDom9fcOcM9QZaFHqFg==", + "dependencies": { + "Microsoft.CodeCoverage": "17.11.1", + "Microsoft.TestPlatform.TestHost": "17.11.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.0.0, )", + "resolved": "3.0.0", + "contentHash": "HggUqjQJe8PtDxcP25Q+CnR6Lz4oX3GElhD9V4oU2+75x9HI6A6sxbfKGS4UwU4t4yJaS9fBmAuriz8bQApNjw==" + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.0.0, )", + "resolved": "3.0.0", + "contentHash": "IzPThK1+JjkBJzMWrWtMZY7FtYagnpg3cBwR/8XJE5mK6JPwz5nz/auQ9Ln02DZCK3O3E1kHyMC1f490/6g6dQ==", + "dependencies": { + "xunit.analyzers": "1.23.0", + "xunit.v3.assert": "[3.0.0]", + "xunit.v3.core": "[3.0.0]" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeCoverage": { + "type": "Transitive", + "resolved": "17.11.1", + "contentHash": "nPJqrcA5iX+Y0kqoT3a+pD/8lrW/V7ayqnEJQsTonSoPz59J8bmoQhcSN4G8+UJ64Hkuf0zuxnfuj2lkHOq4cA==" + }, + "Microsoft.Extensions.AI.Abstractions": { + "type": "Transitive", + "resolved": "10.8.3", + "contentHash": "K0B05oApxmviWalNHPMBBcRC7erKiDATz3ENNR/jqTR9JwIwLRefgDhj2jCRwL1aca99pXUe0qyQC73/xIuZig==", + "dependencies": { + "System.Text.Json": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "System.Diagnostics.DiagnosticSource": "10.0.10" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.7.3", + "contentHash": "dDEETHbX5JQBMIFgBPkX/FmCU4DRQSG+k58QUtz7zTdnEZDafPKH6YHSMoHY3blDiZV/vcSol35Ux3WC7wU+9Q==", + "dependencies": { + "Microsoft.Testing.Platform": "1.7.3" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.7.3", + "contentHash": "cI6u+CPxv3+07cbSwJVKOfxFrecbjfZnid1fe8EMhyPY4qmsSNnm+hN+GIy8u4JIlrADfrskDiwnScjRzVzJNw==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.7.3", + "contentHash": "tSIKXv7tLLYDjfodqLuTigsmOVcYj0CDC1rYeac5MTgHjbYV8IfmYh4FprBt/xE1zW8phkCYP766F9ayo560jA==", + "dependencies": { + "Microsoft.Testing.Platform": "1.7.3" + } + }, + "Microsoft.TestPlatform.ObjectModel": { + "type": "Transitive", + "resolved": "17.11.1", + "contentHash": "E2jZqAU6JeWEVsyOEOrSW1o1bpHLgb25ypvKNB/moBXPVsFYBPd/Jwi7OrYahG50J83LfHzezYI+GaEkpAotiA==", + "dependencies": { + "System.Reflection.Metadata": "1.6.0" + } + }, + "Microsoft.TestPlatform.TestHost": { + "type": "Transitive", + "resolved": "17.11.1", + "contentHash": "DnG+GOqJXO/CkoqlJWeDFTgPhqD/V6VqUIL3vINizCWZ3X+HshCtbbyDdSHQQEjrc2Sl/K3yaxX6s+5LFEdYuw==", + "dependencies": { + "Microsoft.TestPlatform.ObjectModel": "17.11.1", + "Newtonsoft.Json": "13.0.1" + } + }, + "ModelContextProtocol.Core": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "cU/urrhRxE4/iSyBIJI7QOaFqSP1FOEnwEHsct9n6t6/XluCAFD9iqnrPkBAsEYr+f/G4tVQ21U+6wN/6fQvOg==", + "dependencies": { + "Microsoft.Extensions.AI.Abstractions": "10.8.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "System.IO.Pipelines": "10.0.10", + "System.Net.ServerSentEvents": "10.0.10" + } + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.1", + "contentHash": "ppPFpBcvxdsfUonNcvITKqLl3bqxWbDCZIzDWHzjpdAHRFfZe0Dw9HmA0+za13IdyrgJwpkDTDA9fHaxOrt20A==" + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "7WX0W96y3dpQdYG4sEGdh38g3/0lOD4/dKbn2rRVOVzKhzoZUn2gKNIKaFeKWs8RCbpFfmmEWsRhSy95hMpvqA==" + }, + "System.Net.ServerSentEvents": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "1m3dGOl5YI9VhOE+MPCSII+WXZcyYVr5D/UbBifOUxkrx2npczhWjdl0PYZ1tMGygVce1mIfUDhdM1LBiEQFNw==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "1.6.0", + "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "o16m2YpDN/pjHsnxf9pTGwkpcuvjW8v1/wGUwJtM1c3QZUKm7ZEO/eYRJg7iIx6GxS2Zv9lAMHpiQwHDdgqauA==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "bmsO6UdYtBdtn32zYXfsh7KlyTIzV/3V9hdT9RIb4pXKgYOsNxXR+VbWigNwBtNFVGYGm6Hwmqw5a+/IWFd36Q==", + "dependencies": { + "System.IO.Pipelines": "10.0.10", + "System.Text.Encodings.Web": "10.0.10" + } + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.23.0", + "contentHash": "WCkO1FPTWoESLhghoXA881CulRYpve0UrXLsL5aYcLQd9SlD+oADb16NAP+SE5o3w0FM2MzGTklBwY8yUfj0ng==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "tlEmAE8uQ9fOhbNyg7sLJiYhekeD4LFRDJRZrv3mdnvJv7kfb2Z1OmJcsMHF+/N/QE/vTtwqRItiPJZGpOw/EQ==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "8bPJb2N2kYWSa9c9pkUtQmzWc2+0wKxWo9NIgUev6DX+TkXjdBUh6zY0LPVIdSGxsl3swjn4+gEpj5QjrzZiQw==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "c71CPzXXTbnmr3iz2dmo4OniOWNM+30DtWQxpepKVjLIFRYimP+ahn/pjPHSmDqL2xl2tewosLNFodyijcnBLQ==", + "dependencies": { + "Microsoft.Testing.Platform.MSBuild": "1.7.3", + "xunit.v3.extensibility.core": "[3.0.0]", + "xunit.v3.runner.inproc.console": "[3.0.0]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "bzYmxPXtuLDwueg5gqB4/JlCShqHO/weZVgsOxgvMSw4ulyKFqv78JpUE1+aluJWitrfeaL6P8mfPV6zLQS49w==", + "dependencies": { + "xunit.v3.common": "[3.0.0]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "GIk0y3T/siVtU2rwbPPC/PFANHlAbknwWmxNOPqyz+NviFTKY3sILeHeotQff4MT+Rk0tjPGNAwCtzp9fRPLpQ==", + "dependencies": { + "xunit.v3.common": "[3.0.0]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "V0ZxjXFRVDoscU4SfVFvHVzqhGbevBitl7Yn3Vn6s+NqFmwP4tY6/o3ZU9skAQ1bgtfNUEYfrwYTzzoeQFbNQw==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.7.3", + "Microsoft.Testing.Platform": "1.7.3", + "xunit.v3.extensibility.core": "[3.0.0]", + "xunit.v3.runner.common": "[3.0.0]" + } + }, + "opengameagent": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.connectors.mcp": { + "type": "Project", + "dependencies": { + "ModelContextProtocol.Core": "[2.1.0, )", + "OpenGameAgent.Extensions": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.extensions": { + "type": "Project", + "dependencies": { + "OpenGameAgent": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs b/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs new file mode 100644 index 0000000..5f3f1c6 --- /dev/null +++ b/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs @@ -0,0 +1,1368 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text; +using OpenGameAgent.Kernel; +using Xunit; + +namespace OpenGameAgent.Extensions.Tests; + +public sealed class OfficialExtensionTests +{ + [Fact] + public async Task StructuredInteractionUsesOneEngineNeutralBrokerContract() + { + var provider = new ScriptedProvider(call => call == 1 + ? new ModelResponse( + new AgentContent[] + { + new ToolCallContent( + "ask-1", + "ask_player", + """ + {"questions":[{"id":"approach","prompt":"Choose an approach","options":[{"id":"safe","label":"Safe","description":"Validate first","recommended":true},{"id":"fast","label":"Fast","description":"Skip optional checks"}]}]} + """), + }, + ModelStopReason.ToolUse) + : new ModelResponse(new AgentContent[] { new TextContent("done") }, ModelStopReason.Stop)); + var broker = new RecordingBroker(); + var lifecycle = new ConcurrentQueue(); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension(new StructuredInteractionExtension(broker)) + .UseExtension( + "interaction.listener", + "1", + api => + { + api.Subscribe(StructuredInteractionExtension.InteractionStarted, (_, _) => + { + lifecycle.Enqueue("started"); + return ValueTask.CompletedTask; + }); + api.Subscribe(StructuredInteractionExtension.InteractionCompleted, (_, _) => + { + lifecycle.Enqueue("completed"); + return ValueTask.CompletedTask; + }); + }) + .Build(); + + var result = await runtime.RunAsync(Input(), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + var request = Assert.Single(broker.Requests); + var question = Assert.Single(request.Questions); + Assert.Equal("safe", Assert.Single(question.Options, option => option.Recommended).Id); + Assert.Equal(new[] { "started", "completed" }, lifecycle.ToArray()); + var secondRequest = provider.Requests.ElementAt(1); + var toolResult = secondRequest.Messages.Last(message => message.Role == AgentRole.Tool); + var json = Assert.IsType(Assert.Single(toolResult.Content)).Json; + Assert.Contains("safe", json); + } + + [Fact] + public void StructuredInteractionContractsRejectAmbiguousCancelledOrUnboundedAnswers() + { + Assert.Throws(() => new GameInteractionResponse( + true, + new[] { new GameInteractionAnswer("question", new[] { "choice" }) })); + Assert.Throws(() => new GameInteractionAnswer( + "question", + Enumerable.Range(0, 9).Select(index => "choice-" + index))); + Assert.Throws(() => new GameInteractionAnswer( + new string('q', 129), + new[] { "choice" })); + } + + [Fact] + public async Task ToolPolicyDenialPreventsBusinessHandlerExecution() + { + var provider = new ScriptedProvider(call => call == 1 + ? new ModelResponse( + new AgentContent[] { new ToolCallContent("delete-1", "delete_world", "{}") }, + ModelStopReason.ToolUse) + : new ModelResponse(new AgentContent[] { new TextContent("denied") }, ModelStopReason.Stop)); + var executed = 0; + var audits = new ConcurrentQueue(); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension( + "game.tools", + "1", + api => api.RegisterTool(new AgentTool( + new ToolDefinition("delete_world", "Delete the world.", "{\"type\":\"object\",\"additionalProperties\":false}"), + (_, _, _) => + { + Interlocked.Increment(ref executed); + return new ValueTask(new ToolResult(new AgentContent[] { new TextContent("deleted") })); + }, + ToolRisk.NonIdempotentWrite))) + .UseExtension(new ToolPolicyExtension(new[] { new DenyDeletePolicy() })) + .UseExtension( + "policy.listener", + "1", + api => api.Subscribe(ToolPolicyExtension.DecisionRecorded, (audit, _) => + { + audits.Enqueue(audit); + return ValueTask.CompletedTask; + })) + .Build(); + + var result = await runtime.RunAsync(Input(), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(0, Volatile.Read(ref executed)); + var audit = Assert.Single(audits); + Assert.Equal(GameToolPolicyOutcome.Deny, audit.Outcome); + Assert.Equal("delete_world", audit.ToolName); + var toolMessage = provider.Requests.ElementAt(1).Messages.Last(message => message.Role == AgentRole.Tool); + Assert.True(toolMessage.IsError); + } + + [Fact] + public async Task PolicyExceptionsFailClosedByDefault() + { + var provider = new ScriptedProvider(call => call == 1 + ? new ModelResponse( + new AgentContent[] { new ToolCallContent("call", "write", "{}") }, + ModelStopReason.ToolUse) + : new ModelResponse(new AgentContent[] { new TextContent("handled") }, ModelStopReason.Stop)); + var executed = false; + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension( + "game.tools", + "1", + api => api.RegisterTool(new AgentTool( + new ToolDefinition("write", "Write game state.", "{\"type\":\"object\",\"additionalProperties\":false}"), + (_, _, _) => + { + executed = true; + return new ValueTask(new ToolResult(new AgentContent[] { new TextContent("written") })); + }, + ToolRisk.IdempotentWrite))) + .UseExtension(new ToolPolicyExtension(new[] { new ThrowingPolicy() })) + .Build(); + + var result = await runtime.RunAsync(Input(), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.False(executed); + var toolMessage = provider.Requests.ElementAt(1).Messages.Last(message => message.Role == AgentRole.Tool); + Assert.Contains("failed closed", Assert.IsType(Assert.Single(toolMessage.Content)).Text); + } + + [Fact] + public async Task ToolCatalogActivatesOnlySelectedSchemaOnNextTurn() + { + var provider = new ScriptedProvider(call => call switch + { + 1 => new ModelResponse( + new AgentContent[] + { + new ToolCallContent("activate", "set_active_game_tools", "{\"names\":[\"build_house\"]}"), + }, + ModelStopReason.ToolUse), + 2 => new ModelResponse( + new AgentContent[] { new ToolCallContent("build", "build_house", "{}") }, + ModelStopReason.ToolUse), + _ => new ModelResponse(new AgentContent[] { new TextContent("built") }, ModelStopReason.Stop), + }); + var executions = 0; + var catalog = new InMemoryGameToolCatalog(new[] + { + new GameToolCatalogEntry( + "build_house", + "Build a house in the current settlement.", + (_, _) => new ValueTask(new AgentTool( + new ToolDefinition("build_house", "Build a house.", "{\"type\":\"object\",\"additionalProperties\":false}"), + (_, _, _) => + { + Interlocked.Increment(ref executions); + return new ValueTask(new ToolResult(new AgentContent[] { new TextContent("receipt") })); + }, + ToolRisk.IdempotentWrite)), + tags: new[] { "building", "settlement" }), + }); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension(new ToolCatalogExtension(catalog)) + .Build(); + + var result = await runtime.RunAsync(Input(), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(1, executions); + Assert.Equal( + new[] { "search_game_tools", "set_active_game_tools" }, + provider.Requests.First().Tools.Select(tool => tool.Name).OrderBy(value => value, StringComparer.Ordinal).ToArray()); + Assert.Contains(provider.Requests.ElementAt(1).Tools, tool => tool.Name == "build_house"); + } + + [Fact] + public async Task GoalLoopWaitsOnGameTimeAndEventThenResumesDurably() + { + var provider = new ScriptedProvider(call => call switch + { + 1 => ToolCall("create", "manage_goal", "{\"action\":\"create\",\"goalId\":\"monthly-plan\",\"objective\":{\"kind\":\"advance_month\"}}"), + 2 => ToolCall("wait", "manage_goal", "{\"action\":\"wait\",\"goalId\":\"monthly-plan\",\"expectedRevision\":1,\"notBeforeTick\":10,\"eventTypes\":[\"month_advanced\"]}"), + 3 => TextResponse("waiting"), + 4 => TextResponse("still waiting"), + 5 => ToolCall("complete", "manage_goal", "{\"action\":\"complete\",\"goalId\":\"monthly-plan\",\"expectedRevision\":3}"), + _ => TextResponse("complete"), + }); + var store = new InMemoryGameSessionStore(); + var changes = new ConcurrentQueue(); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseSessionStore(store) + .UseExtension(new GoalLoopExtension()) + .UseExtension( + "goal.listener", + "1", + api => api.Subscribe(GoalLoopExtension.GoalChanged, (change, _) => + { + changes.Enqueue(change.Reason); + return ValueTask.CompletedTask; + })) + .Build(); + + await runtime.RunAsync( + new GameInput("session", "actor", "request", "{}", new GameMoment("world", 1), "one"), + TestContext.Current.CancellationToken); + await runtime.RunAsync( + new GameInput("session", "actor", "unrelated", "{}", new GameMoment("world", 10), "two"), + TestContext.Current.CancellationToken); + await runtime.RunAsync( + new GameInput("session", "actor", "month_advanced", "{}", new GameMoment("world", 10), "three"), + TestContext.Current.CancellationToken); + + var snapshot = await store.LoadAsync( + new GameSessionKey("session", "actor"), + TestContext.Current.CancellationToken); + var stateJson = Assert.Single(snapshot!.ExtensionState).Value; + using var document = System.Text.Json.JsonDocument.Parse(stateJson); + Assert.Equal("Completed", document.RootElement.GetProperty("Status").GetString()); + Assert.Equal(4, document.RootElement.GetProperty("Revision").GetInt64()); + Assert.Contains("resumed", changes); + } + + [Fact] + public async Task WorkflowGraphRunsIndependentNodesConcurrentlyAndJoinsInDeclarationOrder() + { + var started = 0; + var bothStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + async ValueTask ParallelNode( + GameWorkflowNodeContext context, + CancellationToken cancellationToken) + { + if (Interlocked.Increment(ref started) == 2) + { + bothStarted.TrySetResult(); + } + + await bothStarted.Task.WaitAsync(cancellationToken); + if (context.NodeId == "first") + { + await Task.Delay(20, cancellationToken); + } + + return GameWorkflowNodeResult.Complete( + System.Text.Json.JsonSerializer.Serialize(new { node = context.NodeId }), + Assistant(context.NodeId)); + } + + var checkpoints = new InMemoryGameWorkflowCheckpointStore(); + var graph = new DurableGameWorkflowGraph( + "evolve", + new[] + { + new GameWorkflowNode("first", ParallelNode), + new GameWorkflowNode("second", ParallelNode), + new GameWorkflowNode( + "join", + (context, _) => + { + Assert.Contains("first", context.DependencyOutputs["first"], StringComparison.Ordinal); + Assert.Contains("second", context.DependencyOutputs["second"], StringComparison.Ordinal); + return new ValueTask( + GameWorkflowNodeResult.Complete("{\"joined\":true}", Assistant("join"))); + }, + new[] { "first", "second" }), + }, + checkpoints, + maximumConcurrentNodes: 2); + var sessions = new InMemoryGameSessionStore(); + var provider = new ScriptedProvider(_ => throw new InvalidOperationException("Workflow must not call the model.")); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseSessionStore(sessions) + .UseExtension("game.workflows", "1", api => api.RegisterWorkflow(graph)) + .Build(); + + var result = await runtime.RunAsync( + WorkflowInput("one", "shared"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Empty(provider.Requests); + var session = await sessions.LoadAsync( + new GameSessionKey("session", "actor"), + TestContext.Current.CancellationToken); + Assert.Equal( + new[] { "first", "second", "join" }, + session!.Messages + .Where(message => message.Role == AgentRole.Assistant) + .Select(message => Assert.IsType(Assert.Single(message.Content)).Text)); + var instanceId = string.Join(":", new[] { "session", "actor", "evolve", "shared" }.Select(Uri.EscapeDataString)); + Assert.True((await checkpoints.LoadAsync(instanceId, TestContext.Current.CancellationToken))!.Completed); + } + + [Fact] + public async Task WorkflowGraphWaitsAndResumesOnlyTheBlockedBranch() + { + var attempts = 0; + var checkpoints = new InMemoryGameWorkflowCheckpointStore(); + var graph = new DurableGameWorkflowGraph( + "evolve", + new[] + { + new GameWorkflowNode("always", (_, _) => new ValueTask( + GameWorkflowNodeResult.Complete("{\"stable\":true}", Assistant("always")))), + new GameWorkflowNode("wait", (context, _) => + { + var attempt = Interlocked.Increment(ref attempts); + Assert.Equal(attempt == 1 ? "{}" : "{\"waiting\":true}", context.PreviousOutputJson); + return new ValueTask(attempt == 1 + ? GameWorkflowNodeResult.Wait("{\"waiting\":true}", Assistant("waiting")) + : GameWorkflowNodeResult.Complete("{\"ready\":true}", Assistant("ready"))); + }), + new GameWorkflowNode( + "after", + (_, _) => new ValueTask( + GameWorkflowNodeResult.Complete("{\"done\":true}", Assistant("after"))), + new[] { "wait" }), + }, + checkpoints, + maximumConcurrentNodes: 2); + var sessions = new InMemoryGameSessionStore(); + var provider = new ScriptedProvider(_ => throw new InvalidOperationException("Workflow must not call the model.")); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseSessionStore(sessions) + .UseExtension("game.workflows", "1", api => api.RegisterWorkflow(graph)) + .Build(); + + Assert.True((await runtime.RunAsync( + WorkflowInput("one", "shared"), + TestContext.Current.CancellationToken)).Succeeded); + Assert.True((await runtime.RunAsync( + WorkflowInput("two", "shared"), + TestContext.Current.CancellationToken)).Succeeded); + + Assert.Equal(2, attempts); + var session = await sessions.LoadAsync( + new GameSessionKey("session", "actor"), + TestContext.Current.CancellationToken); + var output = session!.Messages + .Where(message => message.Role == AgentRole.Assistant) + .Select(message => Assert.IsType(Assert.Single(message.Content)).Text) + .ToArray(); + Assert.Equal(new[] { "always", "waiting", "ready", "after" }, output); + } + + [Fact] + public async Task WorkflowGraphReplaysTheSameInputAfterSessionCommitFailure() + { + var executions = 0; + var checkpoints = new InMemoryGameWorkflowCheckpointStore(); + var graph = new DurableGameWorkflowGraph( + "evolve", + new[] + { + new GameWorkflowNode("once", (_, _) => + { + Interlocked.Increment(ref executions); + return new ValueTask( + GameWorkflowNodeResult.Complete("{\"done\":true}", Assistant("durable output"))); + }), + }, + checkpoints); + var sessions = new FailOnceGameSessionStore(); + var provider = new ScriptedProvider(_ => throw new InvalidOperationException("Workflow must not call the model.")); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseSessionStore(sessions) + .UseExtension("game.workflows", "1", api => api.RegisterWorkflow(graph)) + .Build(); + var input = WorkflowInput("replay-input", "replay-instance"); + + await Assert.ThrowsAsync(async () => + await runtime.RunAsync(input, TestContext.Current.CancellationToken)); + var replayed = await runtime.RunAsync(input, TestContext.Current.CancellationToken); + + Assert.True(replayed.Succeeded); + Assert.Equal(1, executions); + var session = await sessions.LoadAsync( + new GameSessionKey("session", "actor"), + TestContext.Current.CancellationToken); + Assert.Contains(session!.Messages, message => + message.Role == AgentRole.Assistant + && Assert.IsType(Assert.Single(message.Content)).Text == "durable output"); + } + + [Fact] + public void WorkflowGraphRejectsCyclesAndMissingDependencies() + { + static ValueTask Complete(GameWorkflowNodeContext _, CancellationToken __) => + new(GameWorkflowNodeResult.Complete("{}")); + + Assert.Throws(() => new DurableGameWorkflowGraph( + "cycle", + new[] + { + new GameWorkflowNode("a", Complete, new[] { "b" }), + new GameWorkflowNode("b", Complete, new[] { "a" }), + }, + new InMemoryGameWorkflowCheckpointStore())); + Assert.Throws(() => new DurableGameWorkflowGraph( + "missing", + new[] { new GameWorkflowNode("a", Complete, new[] { "missing" }) }, + new InMemoryGameWorkflowCheckpointStore())); + } + + [Fact] + public async Task WorkflowGraphValidatesCumulativeOutputBeforeCheckpointing() + { + var checkpoints = new InMemoryGameWorkflowCheckpointStore(); + var graph = new DurableGameWorkflowGraph( + "evolve", + new[] + { + new GameWorkflowNode( + "oversized", + (_, _) => new ValueTask(GameWorkflowNodeResult.Complete( + "{}", + Assistant(new string('x', 65))))), + }, + checkpoints); + var provider = new ScriptedProvider(_ => throw new InvalidOperationException("Workflow must not call the model.")); + await using var runtime = new GameAgentBuilder(provider, "model") + .Configure(options => options.AgentLimits.MaxTextCharactersPerPart = 64) + .UseExtension("game.workflows", "1", api => api.RegisterWorkflow(graph)) + .Build(); + + await Assert.ThrowsAsync(async () => await runtime.RunAsync( + WorkflowInput("oversized-input", "oversized-instance"), + TestContext.Current.CancellationToken)); + var instanceId = string.Join(":", new[] + { + "session", + "actor", + "evolve", + "oversized-instance", + }.Select(Uri.EscapeDataString)); + Assert.Null(await checkpoints.LoadAsync(instanceId, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DelegatedAgentRunsWithAnIsolatedContextAndDurableResult() + { + var provider = new ScriptedProvider(call => call == 1 + ? ToolCall( + "delegate", + "delegate_agent", + "{\"delegationId\":\"research-1\",\"task\":{\"kind\":\"inspect_region\"},\"inheritContext\":false}") + : TextResponse("delegated")); + var executor = new ImmediateDelegateExecutor(new GameAgentDelegateOutcome( + true, + new[] + { + new AgentMessage( + AgentRole.Assistant, + new AgentContent[] { new TextContent("region inspected") }, + DateTimeOffset.UtcNow, + model: "delegate-model", + stopReason: ModelStopReason.Stop), + })); + var store = new InMemoryGameAgentDelegationStore(); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension(new AgentDelegationExtension(executor, store)) + .Build(); + + var result = await runtime.RunAsync(Input(), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + var request = Assert.Single(executor.Requests); + Assert.False(request.InheritContext); + Assert.Equal(1, request.Depth); + Assert.Contains("inspect_region", request.TaskJson); + var record = await store.LoadAsync("session", "actor", "research-1", TestContext.Current.CancellationToken); + Assert.NotNull(record); + Assert.Equal(GameAgentDelegationStatus.Completed, record.Status); + Assert.Equal(3, record.Revision); + Assert.Contains("region inspected", record.ResultJson); + } + + [Fact] + public async Task BackgroundDelegationCanBeCancelledFromANewGameInput() + { + var provider = new ScriptedProvider(call => call switch + { + 1 => ToolCall( + "delegate", + "delegate_agent", + "{\"delegationId\":\"background-1\",\"task\":{\"kind\":\"long_task\"},\"background\":true}"), + 2 => TextResponse("started"), + 3 => ToolCall("cancel", "cancel_delegate", "{\"delegationId\":\"background-1\"}"), + _ => TextResponse("cancel requested"), + }); + var executor = new ControllableDelegateExecutor(); + var store = new InMemoryGameAgentDelegationStore(); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension(new AgentDelegationExtension(executor, store)) + .Build(); + + var started = await runtime.RunAsync(Input(), TestContext.Current.CancellationToken); + Assert.True(started.Succeeded); + await WaitUntilAsync(() => executor.Handle is not null, TestContext.Current.CancellationToken); + + var cancelled = await runtime.RunAsync( + new GameInput("session", "actor", "cancel", "{}", new GameMoment("world", 6), "cancel-input"), + TestContext.Current.CancellationToken); + + Assert.True(cancelled.Succeeded); + await WaitUntilAsync( + () => store.LoadAsync("session", "actor", "background-1", CancellationToken.None).AsTask().GetAwaiter().GetResult()?.Status + == GameAgentDelegationStatus.Cancelled, + TestContext.Current.CancellationToken); + Assert.True(executor.Handle!.CancelCalled); + var record = await store.LoadAsync("session", "actor", "background-1", TestContext.Current.CancellationToken); + Assert.Equal(GameAgentDelegationStatus.Cancelled, record!.Status); + } + + [Fact] + public async Task RuntimeShutdownIsBoundedWhenADelegateIgnoresCancellation() + { + var provider = new ScriptedProvider(call => call == 1 + ? ToolCall( + "delegate", + "delegate_agent", + "{\"delegationId\":\"stubborn\",\"task\":{},\"background\":true}") + : TextResponse("started")); + var executor = new UncooperativeDelegateExecutor(); + var runtime = new GameAgentBuilder(provider, "model") + .UseExtension(new AgentDelegationExtension( + executor, + settlementTimeoutMilliseconds: 100)) + .Build(); + Assert.True((await runtime.RunAsync(Input(), TestContext.Current.CancellationToken)).Succeeded); + await WaitUntilAsync(() => executor.Handle is not null, TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync( + () => runtime.DisposeAsync().AsTask()); + + Assert.Contains("shutdown", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.True(executor.Handle!.CancelCalled); + executor.Handle.Release(); + await WaitUntilAsync(() => executor.Handle.Disposed, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task DelegateExecutorFailureBecomesATerminalRecord() + { + var provider = new ScriptedProvider(call => call == 1 + ? ToolCall( + "delegate", + "delegate_agent", + "{\"delegationId\":\"failed-1\",\"task\":{\"kind\":\"fail\"}}") + : TextResponse("handled")); + var store = new InMemoryGameAgentDelegationStore(); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension(new AgentDelegationExtension(new ThrowingDelegateExecutor(), store)) + .Build(); + + var result = await runtime.RunAsync(Input(), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + var record = await store.LoadAsync("session", "actor", "failed-1", TestContext.Current.CancellationToken); + Assert.NotNull(record); + Assert.Equal(GameAgentDelegationStatus.Failed, record.Status); + Assert.Contains("executor failed", record.Error); + } + + [Fact] + public async Task DelegationIdsAreScopedAndSerializedResultsAreBounded() + { + var store = new InMemoryGameAgentDelegationStore(); + var first = new GameAgentDelegationRecord( + "shared", + "session-a", + "actor", + 1, + GameAgentDelegationStatus.Pending, + "{}", + 1, + new GameMoment("world", 1)); + var second = new GameAgentDelegationRecord( + "shared", + "session-b", + "actor", + 1, + GameAgentDelegationStatus.Pending, + "{}", + 1, + new GameMoment("world", 1)); + Assert.True((await store.SaveAsync(first, 0, TestContext.Current.CancellationToken)).Saved); + Assert.True((await store.SaveAsync(second, 0, TestContext.Current.CancellationToken)).Saved); + Assert.Same(first, await store.LoadAsync("session-a", "actor", "shared", TestContext.Current.CancellationToken)); + Assert.Same(second, await store.LoadAsync("session-b", "actor", "shared", TestContext.Current.CancellationToken)); + + var provider = new ScriptedProvider(call => call == 1 + ? ToolCall("delegate", "delegate_agent", "{\"delegationId\":\"bounded\",\"task\":{}}") + : TextResponse("done")); + var executor = new ImmediateDelegateExecutor(new GameAgentDelegateOutcome( + true, + new[] + { + new AgentMessage( + AgentRole.Assistant, + new AgentContent[] { new TextContent(new string('x', 50_000)) }, + DateTimeOffset.UtcNow, + model: "delegate-model", + stopReason: ModelStopReason.Stop), + })); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension(new AgentDelegationExtension(executor, store, maximumResultCharacters: 1_024)) + .Build(); + + Assert.True((await runtime.RunAsync(Input(), TestContext.Current.CancellationToken)).Succeeded); + var bounded = await store.LoadAsync("session", "actor", "bounded", TestContext.Current.CancellationToken); + Assert.NotNull(bounded); + Assert.True(bounded.ResultJson!.Length <= 1_024); + } + + [Fact] + public async Task DelegationIdCannotBeReusedForDifferentTaskContent() + { + var store = new InMemoryGameAgentDelegationStore(); + var original = new GameAgentDelegationRecord( + "stable-id", + "session", + "actor", + 1, + GameAgentDelegationStatus.Pending, + "{\"task\":1}", + 1, + new GameMoment("world", 1)); + Assert.True((await store.SaveAsync(original, 0, TestContext.Current.CancellationToken)).Saved); + var conflicting = new GameAgentDelegationRecord( + "stable-id", + "session", + "actor", + 1, + GameAgentDelegationStatus.Pending, + "{\"task\":2}", + 1, + new GameMoment("world", 1)); + + await Assert.ThrowsAsync(async () => + await store.SaveAsync(conflicting, 0, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ArtifactIdsAreScopedToActorSessions() + { + var store = new InMemoryGameAgentArtifactStore(); + var first = new GameAgentArtifact( + "shared", + "session-a", + "actor", + "text/plain", + "first", + new GameMoment("world", 1)); + var second = new GameAgentArtifact( + "shared", + "session-b", + "actor", + "text/plain", + "second", + new GameMoment("world", 1)); + + await store.PutAsync(first, TestContext.Current.CancellationToken); + await store.PutAsync(second, TestContext.Current.CancellationToken); + + Assert.Same(first, await store.GetAsync("session-a", "actor", "shared", TestContext.Current.CancellationToken)); + Assert.Same(second, await store.GetAsync("session-b", "actor", "shared", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task LargeToolResultsSpillToArtifactsAndRemainReadableInTheSameRun() + { + var largeValue = new string('x', 2_048); + var store = new InMemoryGameAgentArtifactStore(); + var provider = new ScriptedProvider((call, request) => + { + if (call == 1) + { + return ToolCall("large", "large_result", "{}"); + } + + if (call == 2) + { + var message = request.Messages.Last(value => value.Role == AgentRole.Tool); + var handle = Assert.IsType(Assert.Single(message.Content)); + using var document = System.Text.Json.JsonDocument.Parse(handle.Json); + var artifactId = document.RootElement.GetProperty("artifactId").GetString(); + Assert.NotNull(artifactId); + Assert.DoesNotContain(largeValue, handle.Json, StringComparison.Ordinal); + return ToolCall( + "read", + "read_agent_artifact", + System.Text.Json.JsonSerializer.Serialize(new + { + artifactId, + maximumCharacters = 4_096, + })); + } + + return TextResponse("read"); + }); + 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(largeValue) }))))) + .UseExtension(new GameAgentArtifactExtension( + store, + spillToolResultsAboveCharacters: 1_024, + maximumInlinePreviewCharacters: 64)) + .Build(); + + var result = await runtime.RunAsync(Input(), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + var readMessage = provider.Requests.ElementAt(2).Messages.Last(message => message.Role == AgentRole.Tool); + var readJson = Assert.IsType(Assert.Single(readMessage.Content)).Json; + Assert.Contains(largeValue, readJson, StringComparison.Ordinal); + } + + [Fact] + public async Task LargeToolResultSpillPreservesResourcesAndExecutionMetadata() + { + var provider = new ScriptedProvider(call => call == 1 + ? ToolCall("large", "large_result", "{}") + : TextResponse("handled")); + var observed = new ConcurrentQueue(); + 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)), + new ResourceContent("game://scene/castle", "application/json", "castle"), + }, + isError: true, + detailsJson: "{\"code\":\"partial\"}", + usage: new ModelUsage(7, 3), + outcomeUncertain: true)), + ToolRisk.IdempotentWrite))) + .UseExtension(new GameAgentArtifactExtension( + new InMemoryGameAgentArtifactStore(), + spillToolResultsAboveCharacters: 1_024, + maximumInlinePreviewCharacters: 64)) + .UseExtension( + "game.observer", + "1", + api => api.On(GameAgentExtensionEvents.KernelEvent, (value, _, _) => + { + if (value.Value.Kind == AgentEventKind.ToolEnded && value.Value.ToolResult is not null) + { + observed.Enqueue(value.Value.ToolResult); + } + + return ValueTask.CompletedTask; + })) + .Build(); + + var result = await runtime.RunAsync(Input(), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + var spilled = Assert.Single(observed); + Assert.True(spilled.IsError); + Assert.True(spilled.OutcomeUncertain); + Assert.Equal("{\"code\":\"partial\"}", spilled.DetailsJson); + Assert.Equal(10, spilled.Usage!.TotalTokens); + var resource = Assert.Single(spilled.Content.OfType()); + Assert.Equal("game://scene/castle", resource.Uri); + Assert.Single(spilled.Content.OfType()); + } + + [Fact] + public async Task ArtifactStoreFailureLeavesTheAuthoritativeToolResultUntouched() + { + var largeValue = new string('x', 2_048); + var provider = new ScriptedProvider(call => call == 1 + ? ToolCall("large", "large_result", "{}") + : TextResponse("handled")); + 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(largeValue) }))))) + .UseExtension(new GameAgentArtifactExtension( + new FailingArtifactStore(), + spillToolResultsAboveCharacters: 1_024, + maximumInlinePreviewCharacters: 64)) + .Build(); + + var result = await runtime.RunAsync(Input(), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + var message = provider.Requests.ElementAt(1).Messages.Last(value => value.Role == AgentRole.Tool); + Assert.Equal(largeValue, Assert.IsType(Assert.Single(message.Content)).Text); + } + + [Fact] + public async Task MemoryToolsPreserveGameTimeAndFloatingPointPayloads() + { + var provider = new ScriptedProvider(call => call switch + { + 1 => ToolCall( + "remember", + "remember_game_memory", + "{\"memoryId\":\"memory-1\",\"scope\":\"relationship\",\"kind\":\"relationship\",\"payload\":{\"affinity\":0.75},\"importance\":0.8}"), + 2 => ToolCall( + "search", + "search_game_memory", + "{\"scopes\":[\"relationship\"],\"atOrBeforeTick\":5}"), + _ => TextResponse("remembered"), + }); + var store = new InMemoryGameMemoryStore(); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension(new GameMemoryExtension(store)) + .Build(); + + var result = await runtime.RunAsync(Input(), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + var searchResult = provider.Requests.ElementAt(2).Messages.Last(message => message.Role == AgentRole.Tool); + var json = Assert.IsType(Assert.Single(searchResult.Content)).Json; + using var document = System.Text.Json.JsonDocument.Parse(json); + var memory = Assert.Single(document.RootElement.GetProperty("memories").EnumerateArray()); + Assert.Equal(0.75, memory.GetProperty("payload").GetProperty("affinity").GetDouble()); + Assert.Equal(5, memory.GetProperty("tick").GetInt64()); + } + + [Fact] + public async Task AutomaticMemoryRecallDefaultsToCurrentActorAndCurrentGameMoment() + { + var store = new InMemoryGameMemoryStore(); + await store.AppendAsync( + new GameMemory( + "past", + "session", + "actor", + "facts", + GameMemoryKind.Fact, + "{\"value\":\"known\"}", + new GameMoment("world", 5)), + TestContext.Current.CancellationToken); + await store.AppendAsync( + new GameMemory( + "other-actor", + "session", + "other", + "facts", + GameMemoryKind.Fact, + "{\"value\":\"private\"}", + new GameMoment("world", 4)), + TestContext.Current.CancellationToken); + await store.AppendAsync( + new GameMemory( + "future", + "session", + "actor", + "facts", + GameMemoryKind.Fact, + "{\"value\":\"spoiler\"}", + new GameMoment("world", 10)), + TestContext.Current.CancellationToken); + var provider = new ScriptedProvider(_ => TextResponse("done")); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension(new GameMemoryExtension( + store, + (context, _) => new ValueTask(new GameMemoryQuery( + context.Input.SessionId, + 8)))) + .Build(); + + var result = await runtime.RunAsync( + new GameInput("session", "actor", "request", "{}", new GameMoment("world", 7), "memory-input"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + var prompt = Assert.Single(provider.Requests).SystemPrompt; + Assert.Contains("known", prompt); + Assert.DoesNotContain("spoiler", prompt); + Assert.DoesNotContain("private", prompt); + } + + [Fact] + public async Task MemorySearchToolRejectsFutureGameTime() + { + var provider = new ScriptedProvider(call => call == 1 + ? ToolCall("search", "search_game_memory", "{\"atOrBeforeTick\":6}") + : TextResponse("done")); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension(new GameMemoryExtension(new InMemoryGameMemoryStore())) + .Build(); + + var result = await runtime.RunAsync(Input(), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + var tool = provider.Requests.ElementAt(1).Messages.Last(message => message.Role == AgentRole.Tool); + Assert.True(tool.IsError); + Assert.Contains("future", Assert.IsType(Assert.Single(tool.Content)).Text, StringComparison.Ordinal); + } + + [Fact] + public async Task LargeKnowledgeResultsAreStoredOutsideModelContext() + { + var provider = new ScriptedProvider(call => call == 1 + ? ToolCall( + "knowledge", + "query_external_knowledge", + "{\"source\":\"local\",\"query\":{\"topic\":\"world\"},\"limit\":1}") + : TextResponse("stored")); + var artifacts = new InMemoryGameAgentArtifactStore(); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension(new GameAgentArtifactExtension(artifacts)) + .UseExtension(new ExternalKnowledgeExtension( + new[] { new LargeKnowledgeSource() }, + maximumInlineResultCharacters: 1_024, + artifactStore: artifacts)) + .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 json = Assert.IsType(Assert.Single(toolMessage.Content)).Json; + using var document = System.Text.Json.JsonDocument.Parse(json); + var artifactId = document.RootElement.GetProperty("artifactId").GetString(); + Assert.NotNull(artifactId); + var artifact = await artifacts.GetAsync("session", "actor", artifactId, TestContext.Current.CancellationToken); + Assert.NotNull(artifact); + Assert.Contains(new string('x', 512), artifact.Content); + Assert.DoesNotContain(new string('x', 512), json); + } + + [Fact] + public async Task KnowledgeHttpSourceRejectsInjectedHeadersBeforeTransport() + { + var handler = new KnowledgeHandler(_ => throw new InvalidOperationException("transport must not run")); + var source = new JsonHttpGameKnowledgeSource( + "remote", + new HttpClient(handler), + new Uri("https://knowledge.test/query"), + (_, _) => new ValueTask>( + new Dictionary { ["X-Session"] = "value\r\ninjected" })); + + await Assert.ThrowsAsync(async () => + await source.QueryAsync( + new GameExternalKnowledgeRequest(Input(), "{}", 1), + TestContext.Current.CancellationToken)); + + Assert.Equal(0, handler.Calls); + } + + [Fact] + public async Task KnowledgeHttpSourceRejectsAmbiguousResponseObjects() + { + var handler = new KnowledgeHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + "{\"items\":[],\"items\":[]}", + Encoding.UTF8, + "application/json"), + }); + var source = new JsonHttpGameKnowledgeSource( + "remote", + new HttpClient(handler), + new Uri("https://knowledge.test/query")); + + var exception = await Assert.ThrowsAsync(async () => + await source.QueryAsync( + new GameExternalKnowledgeRequest(Input(), "{}", 1), + TestContext.Current.CancellationToken)); + + Assert.Contains("duplicate", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task TracingRecordsLifecycleWithoutInputPayloadByDefault() + { + var provider = new ScriptedProvider(_ => TextResponse("done")); + var sink = new InMemoryGameAgentTraceSink(); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension(new GameAgentTracingExtension(sink)) + .Build(); + + var result = await runtime.RunAsync( + new GameInput( + "session", + "actor", + "secret_input", + "{\"secret\":\"not-for-traces\"}", + new GameMoment("world", 12), + "trace-input"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + var traces = sink.Snapshot(); + Assert.Contains(traces, trace => trace.Kind == "input.received"); + Assert.Contains(traces, trace => trace.Kind == "kernel.runstarted"); + Assert.Contains(traces, trace => trace.Kind == "run.completed"); + Assert.All(traces, trace => Assert.DoesNotContain("not-for-traces", trace.DetailsJson)); + Assert.All(traces, trace => Assert.Equal(12, trace.Moment.Tick)); + } + + private static GameInput Input() => + new("session", "actor", "request", "{}", new GameMoment("world", 5), "input"); + + private static GameInput WorkflowInput(string inputId, string instanceId) => + new( + "session", + "actor", + "evolve", + "{}", + new GameMoment("world", 5), + inputId, + new Dictionary + { + ["agent.route"] = "workflow:evolve", + ["agent.workflow_instance"] = instanceId, + }); + + private static AgentMessage Assistant(string text) => + new( + AgentRole.Assistant, + new AgentContent[] { new TextContent(text) }, + DateTimeOffset.UnixEpoch, + model: "workflow", + stopReason: ModelStopReason.Stop); + + private static ModelResponse ToolCall(string id, string name, string arguments) => + new(new AgentContent[] { new ToolCallContent(id, name, arguments) }, ModelStopReason.ToolUse); + + private static ModelResponse TextResponse(string text) => + new(new AgentContent[] { new TextContent(text) }, ModelStopReason.Stop); + + private static async Task WaitUntilAsync(Func predicate, CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 200; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + if (predicate()) + { + return; + } + + await Task.Delay(10, cancellationToken); + } + + throw new TimeoutException("The expected asynchronous state was not reached."); + } + + private sealed class RecordingBroker : IGameInteractionBroker + { + public ConcurrentQueue Requests { get; } = new(); + + public ValueTask PromptAsync( + GameInteractionRequest request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Requests.Enqueue(request); + return new ValueTask(new GameInteractionResponse( + false, + new[] { new GameInteractionAnswer("approach", new[] { "safe" }) })); + } + } + + private sealed class DenyDeletePolicy : IGameToolPolicy + { + public string Id => "deny-delete"; + + public ValueTask EvaluateAsync( + GameToolPolicyContext context, + CancellationToken cancellationToken) => + new(context.Call.Name == "delete_world" + ? GameToolPolicyDecision.Deny("Deletion is disabled.") + : GameToolPolicyDecision.NotApplicable()); + } + + private sealed class ThrowingPolicy : IGameToolPolicy + { + public string Id => "broken"; + + public ValueTask EvaluateAsync( + GameToolPolicyContext context, + CancellationToken cancellationToken) => + throw new InvalidOperationException("policy unavailable"); + } + + private sealed class ImmediateDelegateExecutor : IGameAgentDelegateExecutor + { + private readonly GameAgentDelegateOutcome _outcome; + + public ImmediateDelegateExecutor(GameAgentDelegateOutcome outcome) + { + _outcome = outcome; + } + + public ConcurrentQueue Requests { get; } = new(); + + public IGameAgentDelegateHandle Start(GameAgentDelegateRequest request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Requests.Enqueue(request); + return new CompletedDelegateHandle(_outcome); + } + } + + private sealed class FailOnceGameSessionStore : IGameSessionStore + { + private readonly InMemoryGameSessionStore _inner = new(); + private int _failuresRemaining = 1; + + public ValueTask LoadAsync( + GameSessionKey key, + CancellationToken cancellationToken) => + _inner.LoadAsync(key, cancellationToken); + + public ValueTask SaveAsync( + GameSessionSnapshot snapshot, + long expectedRevision, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (Interlocked.Exchange(ref _failuresRemaining, 0) != 0) + { + throw new InvalidOperationException("simulated session commit failure"); + } + + return _inner.SaveAsync(snapshot, expectedRevision, cancellationToken); + } + } + + private sealed class CompletedDelegateHandle : IGameAgentDelegateHandle + { + public CompletedDelegateHandle(GameAgentDelegateOutcome outcome) + { + Completion = Task.FromResult(outcome); + } + + public Task Completion { get; } + + public bool TrySteer(AgentMessage message) => false; + + public bool TryCancel() => false; + + public void Dispose() + { + } + } + + private sealed class ControllableDelegateExecutor : IGameAgentDelegateExecutor + { + public ControllableDelegateHandle? Handle { get; private set; } + + public IGameAgentDelegateHandle Start(GameAgentDelegateRequest request, CancellationToken cancellationToken) + { + Handle = new ControllableDelegateHandle(cancellationToken); + return Handle; + } + } + + private sealed class UncooperativeDelegateExecutor : IGameAgentDelegateExecutor + { + public UncooperativeDelegateHandle? Handle { get; private set; } + + public IGameAgentDelegateHandle Start(GameAgentDelegateRequest request, CancellationToken cancellationToken) + { + Handle = new UncooperativeDelegateHandle(); + return Handle; + } + } + + private sealed class UncooperativeDelegateHandle : IGameAgentDelegateHandle + { + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _disposed; + + public Task Completion => _completion.Task; + + public bool CancelCalled { get; private set; } + + public bool Disposed => Volatile.Read(ref _disposed) != 0; + + public bool TrySteer(AgentMessage message) => false; + + public bool TryCancel() + { + CancelCalled = true; + return true; + } + + public void Release() => _completion.TrySetResult(new GameAgentDelegateOutcome( + false, + Array.Empty(), + "released after shutdown")); + + public void Dispose() => Interlocked.Exchange(ref _disposed, 1); + } + + private sealed class ControllableDelegateHandle : IGameAgentDelegateHandle + { + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly CancellationTokenRegistration _registration; + + public ControllableDelegateHandle(CancellationToken cancellationToken) + { + _registration = cancellationToken.Register(Cancel); + } + + public bool CancelCalled { get; private set; } + + public Task Completion => _completion.Task; + + public bool TrySteer(AgentMessage message) => true; + + public bool TryCancel() + { + if (_completion.Task.IsCompleted) + { + return false; + } + + CancelCalled = true; + Cancel(); + return true; + } + + public void Dispose() => _registration.Dispose(); + + private void Cancel() => _completion.TrySetResult(new GameAgentDelegateOutcome( + false, + Array.Empty(), + "cancelled", + cancelled: true)); + } + + private sealed class ThrowingDelegateExecutor : IGameAgentDelegateExecutor + { + public IGameAgentDelegateHandle Start(GameAgentDelegateRequest request, CancellationToken cancellationToken) => + throw new InvalidOperationException("executor failed"); + } + + private sealed class LargeKnowledgeSource : IGameExternalKnowledgeSource + { + public string Id => "local"; + + public ValueTask> QueryAsync( + GameExternalKnowledgeRequest request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask>(new[] + { + new GameExternalKnowledgeItem( + "item", + "Large local result", + System.Text.Json.JsonSerializer.Serialize(new { content = new string('x', 2_048) })), + }); + } + } + + private sealed class KnowledgeHandler : HttpMessageHandler + { + private readonly Func _response; + private int _calls; + + public KnowledgeHandler(Func response) + { + _response = response; + } + + public int Calls => Volatile.Read(ref _calls); + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Interlocked.Increment(ref _calls); + return Task.FromResult(_response(request)); + } + } + + private sealed class ScriptedProvider : IModelProvider + { + private readonly Func _response; + private int _calls; + + public ScriptedProvider(Func response) + { + ArgumentNullException.ThrowIfNull(response); + _response = (call, _) => response(call); + } + + public ScriptedProvider(Func response) + { + _response = response ?? throw new ArgumentNullException(nameof(response)); + } + + public ConcurrentQueue Requests { get; } = new(); + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Requests.Enqueue(request); + var call = Interlocked.Increment(ref _calls); + yield return ModelStreamEvent.Terminal(_response(call, request)); + await Task.CompletedTask; + } + } + + private sealed class FailingArtifactStore : IGameAgentArtifactStore + { + public ValueTask PutAsync(GameAgentArtifact artifact, CancellationToken cancellationToken) => + ValueTask.FromException(new InvalidOperationException("store unavailable")); + + public ValueTask GetAsync( + string sessionId, + string actorId, + string artifactId, + CancellationToken cancellationToken) => + new((GameAgentArtifact?)null); + } +} diff --git a/tests/OpenGameAgent.Extensions.Tests/OpenGameAgent.Extensions.Tests.csproj b/tests/OpenGameAgent.Extensions.Tests/OpenGameAgent.Extensions.Tests.csproj new file mode 100644 index 0000000..5b64af9 --- /dev/null +++ b/tests/OpenGameAgent.Extensions.Tests/OpenGameAgent.Extensions.Tests.csproj @@ -0,0 +1,20 @@ + + + Exe + net8.0 + false + true + OpenGameAgent.Extensions.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/tests/OpenGameAgent.Extensions.Tests/packages.lock.json b/tests/OpenGameAgent.Extensions.Tests/packages.lock.json new file mode 100644 index 0000000..2eee7f4 --- /dev/null +++ b/tests/OpenGameAgent.Extensions.Tests/packages.lock.json @@ -0,0 +1,226 @@ +{ + "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.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.extensions": { + "type": "Project", + "dependencies": { + "OpenGameAgent": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/OpenGameAgent.Kernel.Tests/AgentLoopTests.cs b/tests/OpenGameAgent.Kernel.Tests/AgentLoopTests.cs index 8270f6d..743a6aa 100644 --- a/tests/OpenGameAgent.Kernel.Tests/AgentLoopTests.cs +++ b/tests/OpenGameAgent.Kernel.Tests/AgentLoopTests.cs @@ -36,6 +36,10 @@ public void CanonicalTranscriptRejectsUnresolvedOrMismatchedToolExchanges() AgentRole.User, new AgentContent[] { new ToolCallContent("forged", "inspect", "{}") }, DateTimeOffset.UnixEpoch)); + Assert.Throws(() => new ToolResult( + new AgentContent[] { new ReasoningContent("assistant-only") })); + Assert.Throws(() => new ToolResult( + new AgentContent[] { new ToolCallContent("nested", "inspect", "{}") })); } [Fact] @@ -84,9 +88,9 @@ public async Task SuccessfulRunEmitsCompleteOrderedLifecycle() new[] { AgentEventKind.RunStarted, + AgentEventKind.TurnStarted, AgentEventKind.MessageStarted, AgentEventKind.MessageEnded, - AgentEventKind.TurnStarted, AgentEventKind.MessageStarted, AgentEventKind.MessageEnded, AgentEventKind.TurnEnded, @@ -95,6 +99,37 @@ public async Task SuccessfulRunEmitsCompleteOrderedLifecycle() events); } + [Fact] + public async Task InitialSteeringPollOccursInsideTheFirstTurnAfterPromptEvents() + { + var provider = ScriptedProvider.FromResponses(Responses.Text("hello")); + var order = new List(); + var options = new AgentLoopOptions(provider, "test") + { + GetSteeringMessagesAsync = _ => + { + order.Add("poll"); + return new ValueTask>(Array.Empty()); + }, + }; + + var result = await AgentLoop.RunAsync( + new[] { AgentMessage.User("hi", DateTimeOffset.UnixEpoch) }, + new AgentContext(string.Empty), + options, + (value, _) => + { + order.Add(value.Kind.ToString()); + return ValueTask.CompletedTask; + }, + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.True(order.IndexOf(AgentEventKind.TurnStarted.ToString()) < order.IndexOf("poll")); + Assert.True(order.IndexOf(AgentEventKind.MessageEnded.ToString()) < order.IndexOf("poll")); + Assert.True(order.IndexOf("poll") < order.LastIndexOf(AgentEventKind.MessageStarted.ToString())); + } + [Fact] public async Task ToolLoopPersistsAssistantCallResultAndFinalReply() { @@ -556,6 +591,53 @@ public async Task ToolProgressAfterSettlementIsIgnored() Assert.Equal(0, progress); } + [Fact] + public async Task AcceptedToolProgressSettlesBeforeToolEndEvenWhenToolDoesNotAwaitIt() + { + var provider = ScriptedProvider.FromResponses( + Responses.Tools(ModelStopReason.ToolUse, new ToolCallContent("1", "work", "{}")), + Responses.Text("done")); + var progressStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseProgress = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var events = new List(); + var options = new AgentOptions(provider, "test"); + options.Tools.Add(Responses.Tool("work", (_, context, _) => + { + _ = context.ReportProgressAsync(new ToolProgress("working")); + return new ValueTask(Responses.Result("done")); + })); + var agent = new Agent(options); + agent.Subscribe(async (value, _) => + { + lock (events) + { + events.Add(value.Kind); + } + + if (value.Kind == AgentEventKind.ToolProgressed) + { + progressStarted.TrySetResult(); + await releaseProgress.Task; + } + }); + + var run = agent.RunAsync("go", TestContext.Current.CancellationToken); + await progressStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + + lock (events) + { + Assert.DoesNotContain(AgentEventKind.ToolEnded, events); + } + + releaseProgress.TrySetResult(); + await run; + + lock (events) + { + Assert.True(events.IndexOf(AgentEventKind.ToolProgressed) < events.IndexOf(AgentEventKind.ToolEnded)); + } + } + [Fact] public async Task SteeringIsInjectedAfterCurrentToolBatch() { @@ -585,6 +667,37 @@ public async Task SteeringIsInjectedAfterCurrentToolBatch() second.Messages.Select(message => message.Role)); } + [Fact] + public async Task QueuedInputEventsBelongToTheTurnThatConsumesThem() + { + var first = Responses.Tools(ModelStopReason.ToolUse, new ToolCallContent("one", "read", "{}")); + var second = Responses.Text("done"); + var provider = ScriptedProvider.FromResponses(first, second); + var tool = Responses.Tool("read", (_, _, _) => + new ValueTask(Responses.Result("ok"))); + var agent = new Agent(new AgentOptions(provider, "model") + { + Tools = { tool }, + }); + agent.Steer("new direction"); + var observed = new List(); + using var subscription = agent.Subscribe((agentEvent, _) => + { + observed.Add(agentEvent); + return ValueTask.CompletedTask; + }); + + var result = await agent.RunAsync("start", TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + var queuedMessageEvents = observed.Where(agentEvent => + (agentEvent.Kind is AgentEventKind.MessageStarted or AgentEventKind.MessageEnded) + && agentEvent.Message?.Content.OfType().Any(content => content.Text == "new direction") == true) + .ToArray(); + Assert.Equal(2, queuedMessageEvents.Length); + Assert.All(queuedMessageEvents, agentEvent => Assert.Equal(1, agentEvent.Turn)); + } + [Fact] public async Task FollowUpRunsAfterAgentWouldOtherwiseStop() { @@ -737,18 +850,19 @@ public async Task OneTerminatingBlockedToolDoesNotStopAMixedBatch() } [Fact] - public async Task NextTurnHookCanReplaceModelAndContext() + public async Task NextTurnHookCanReplaceProviderModelAndContext() { - var provider = ScriptedProvider.FromResponses( - Responses.Tools(ModelStopReason.ToolUse, new ToolCallContent("1", "next", "{}")), - Responses.Text("done")); + var firstProvider = ScriptedProvider.FromResponses( + Responses.Tools(ModelStopReason.ToolUse, new ToolCallContent("1", "next", "{}"))); + var secondProvider = ScriptedProvider.FromResponses(Responses.Text("done")); var replacement = AgentMessage.UserJson("{\"phase\":2}", DateTimeOffset.UnixEpoch); - var options = new AgentOptions(provider, "first") + var options = new AgentOptions(firstProvider, "first") { Hooks = new AgentHooks { PrepareNextTurnAsync = (_, _) => new ValueTask(new NextTurnUpdate { + Provider = secondProvider, Model = "second", Context = new AgentContext("replacement", new[] { replacement }), }), @@ -759,7 +873,9 @@ public async Task NextTurnHookCanReplaceModelAndContext() await agent.RunAsync("go", TestContext.Current.CancellationToken); - var second = provider.Requests.ToArray()[1]; + Assert.Equal(1, firstProvider.CallCount); + Assert.Equal(1, secondProvider.CallCount); + var second = Assert.Single(secondProvider.Requests); Assert.Equal("second", second.Model); Assert.Equal("replacement", second.SystemPrompt); Assert.Single(second.Messages); @@ -794,6 +910,32 @@ public async Task StopHookSeesContextPreparedForTheNextTurn() Assert.True(sawReplacement); } + [Fact] + public async Task NextTurnProviderReplacementRequiresAnAtomicModelTarget() + { + var firstProvider = ScriptedProvider.FromResponses( + Responses.Tools(ModelStopReason.ToolUse, new ToolCallContent("1", "next", "{}"))); + var secondProvider = ScriptedProvider.FromResponses(Responses.Text("unused")); + var options = new AgentOptions(firstProvider, "first") + { + Hooks = new AgentHooks + { + PrepareNextTurnAsync = (_, _) => new ValueTask(new NextTurnUpdate + { + Provider = secondProvider, + }), + }, + }; + options.Tools.Add(Responses.Tool("next", (_, _, _) => new ValueTask(Responses.Result("ok")))); + + var result = await new Agent(options).RunAsync("go", TestContext.Current.CancellationToken); + + Assert.Equal(AgentRunStatus.KernelError, result.Status); + Assert.Equal(1, firstProvider.CallCount); + Assert.Equal(0, secondProvider.CallCount); + Assert.Contains("provider replacement", result.Error, StringComparison.Ordinal); + } + [Fact] public async Task CancellationDuringAfterToolHookDoesNotEraseCompletedToolResult() { @@ -860,13 +1002,21 @@ public async Task CancellationDuringToolPreparationSettlesEveryUndispatchedCall( return new ValueTask(Responses.Result("unexpected")); }, ToolRisk.NonIdempotentWrite)); + var events = new List(); var agent = new Agent(options); + agent.Subscribe((value, _) => + { + events.Add(value.Kind); + return ValueTask.CompletedTask; + }); var result = await agent.RunAsync("go", cancellation.Token); Assert.Equal(AgentRunStatus.Aborted, result.Status); Assert.Equal(0, executed); Assert.Equal(2, agent.State.Messages.Count(message => message.Role == AgentRole.Tool && message.IsError)); + Assert.Contains(AgentEventKind.TurnEnded, events); + Assert.True(events.IndexOf(AgentEventKind.TurnEnded) < events.IndexOf(AgentEventKind.RunFaulted)); AgentValidation.ValidateTranscript(agent.State.Messages, options.Limits); } @@ -935,6 +1085,21 @@ public async Task ProviderExceptionBecomesNormalFailureLifecycle() Assert.Equal(ModelStopReason.Error, agent.State.Messages[^1].StopReason); } + [Fact] + public async Task SubscriberFailureDoesNotHideThePrimaryRunFailure() + { + var provider = new ScriptedProvider((_, _, _) => throw new InvalidOperationException("provider failed")); + var agent = new Agent(new AgentOptions(provider, "test")); + agent.Subscribe((_, _) => throw new InvalidOperationException("subscriber failed")); + + var result = await agent.RunAsync("go", TestContext.Current.CancellationToken); + + Assert.Equal(AgentRunStatus.ProviderError, result.Status); + Assert.Contains("provider failed", result.Error, StringComparison.Ordinal); + Assert.Contains("provider failed", agent.State.Error, StringComparison.Ordinal); + Assert.Contains("subscriber failed", result.SubscriberErrors); + } + [Fact] public async Task ProviderFailureTextIsBoundedAndRetainsModelIdentity() { @@ -953,6 +1118,27 @@ public async Task ProviderFailureTextIsBoundedAndRetainsModelIdentity() Assert.Equal(32, agent.State.Messages[^1].ErrorMessage!.Length); } + [Fact] + public async Task HookAndSubscriberFailureTextIsBounded() + { + var options = new AgentOptions(ScriptedProvider.FromResponses(Responses.Text("unused")), "test") + { + Limits = new AgentLimits { MaxTextCharactersPerPart = 32 }, + Hooks = new AgentHooks + { + TransformContextAsync = (_, _) => throw new InvalidOperationException(new string('h', 256)), + }, + }; + var agent = new Agent(options); + agent.Subscribe((_, _) => throw new InvalidOperationException(new string('s', 256))); + + var result = await agent.RunAsync("go", TestContext.Current.CancellationToken); + + Assert.Equal(AgentRunStatus.KernelError, result.Status); + Assert.Equal(32, result.Error!.Length); + Assert.Equal(32, Assert.Single(result.SubscriberErrors).Length); + } + [Fact] public async Task AbortCancelsProviderAndSettles() { @@ -975,6 +1161,64 @@ public async Task AbortCancelsProviderAndSettles() Assert.False(agent.State.IsRunning); } + [Fact] + public async Task AbortCannotBeBlockedByAThrowingCancellationCallback() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var provider = new ScriptedProvider(async (_, _, cancellationToken) => + { + using var registration = cancellationToken.Register( + () => throw new InvalidOperationException("callback failed")); + entered.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return Responses.Text("unreachable"); + }); + var agent = new Agent(new AgentOptions(provider, "test")); + + var run = agent.RunAsync("go", TestContext.Current.CancellationToken); + await entered.Task.WaitAsync(TestContext.Current.CancellationToken); + + Assert.True(agent.TryAbort()); + var result = await run.WaitAsync(TestContext.Current.CancellationToken); + Assert.Equal(AgentRunStatus.Aborted, result.Status); + Assert.False(agent.State.IsRunning); + } + + [Fact] + public async Task ModelTimeoutSettlesEvenWhenTheProviderIgnoresCancellation() + { + var provider = new NonCooperativeProvider(); + var agent = new Agent(new AgentOptions(provider, "test") + { + Limits = new AgentLimits { ModelTimeoutMilliseconds = 25 }, + }); + + var run = agent.RunAsync("go", TestContext.Current.CancellationToken); + await provider.Started.Task.WaitAsync(TestContext.Current.CancellationToken); + var result = await run.WaitAsync(TestContext.Current.CancellationToken); + provider.Release.TrySetResult(); + + Assert.Equal(AgentRunStatus.ProviderError, result.Status); + Assert.Contains("exceeded 25 ms", result.Error, StringComparison.Ordinal); + Assert.False(agent.State.IsRunning); + } + + [Fact] + public async Task AbortSettlesEvenWhenTheProviderIgnoresCancellation() + { + var provider = new NonCooperativeProvider(); + var agent = new Agent(new AgentOptions(provider, "test")); + + var run = agent.RunAsync("go", TestContext.Current.CancellationToken); + await provider.Started.Task.WaitAsync(TestContext.Current.CancellationToken); + agent.Abort(); + var result = await run.WaitAsync(TestContext.Current.CancellationToken); + provider.Release.TrySetResult(); + + Assert.Equal(AgentRunStatus.Aborted, result.Status); + Assert.False(agent.State.IsRunning); + } + [Fact] public async Task LowLevelCancellationStillDeliversTerminalEvents() { @@ -1109,6 +1353,7 @@ static async IAsyncEnumerable Stream( var partial = new ModelResponse( new AgentContent[] { new TextContent("hel") }, ModelStopReason.Pending); + yield return ModelStreamEvent.Update(ModelStreamEventKind.Started, partial); yield return ModelStreamEvent.Update(ModelStreamEventKind.TextDelta, partial, "hel"); await Task.Yield(); yield return ModelStreamEvent.Terminal(Responses.Text("hello")); @@ -1116,8 +1361,15 @@ static async IAsyncEnumerable Stream( var agent = new Agent(new AgentOptions(new StreamingProvider((_, token) => Stream(token)), "test")); ModelStreamEvent? observed = null; + var assistantEvents = new List(); agent.Subscribe((value, _) => { + if (value.Message?.Role == AgentRole.Assistant + && value.Kind is AgentEventKind.MessageStarted or AgentEventKind.MessageUpdated or AgentEventKind.MessageEnded) + { + assistantEvents.Add(value.Kind); + } + if (value.Kind == AgentEventKind.MessageUpdated) { observed = agent.State.StreamingEvent; @@ -1135,6 +1387,9 @@ static async IAsyncEnumerable Stream( Assert.NotNull(observed); Assert.Equal(ModelStreamEventKind.TextDelta, observed.Kind); Assert.Equal("hel", observed.Delta); + Assert.Equal( + new[] { AgentEventKind.MessageStarted, AgentEventKind.MessageUpdated, AgentEventKind.MessageEnded }, + assistantEvents); Assert.Null(agent.State.StreamingMessage); Assert.Null(agent.State.StreamingEvent); } @@ -1197,6 +1452,7 @@ public async Task ProviderErrorClosesEveryCompleteToolCall() var toolResult = Assert.Single(agent.State.Messages, message => message.Role == AgentRole.Tool); Assert.Equal("call", toolResult.ToolCallId); Assert.True(toolResult.IsError); + AgentValidation.ValidateTranscript(agent.State.Messages, options.Limits); } [Fact] @@ -1508,6 +1764,43 @@ await Assert.ThrowsAsync(() => agent.State.Messages.Skip(1).Select(message => message.Role)); } + [Fact] + public async Task ContinueFromFollowUpStillPollsNewSteeringBeforeTheFirstModelRequest() + { + var provider = ScriptedProvider.FromResponses(Responses.Text("done")); + var options = new AgentOptions(provider, "test"); + options.InitialMessages.Add(new AgentMessage( + AgentRole.Assistant, + new AgentContent[] { new TextContent("waiting") }, + DateTimeOffset.UnixEpoch, + model: "test", + stopReason: ModelStopReason.Stop)); + var agent = new Agent(options); + var runStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseRun = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var subscription = agent.Subscribe(async (agentEvent, cancellationToken) => + { + if (agentEvent.Kind == AgentEventKind.RunStarted) + { + runStarted.TrySetResult(); + await releaseRun.Task.WaitAsync(cancellationToken); + } + }); + agent.FollowUp("queued follow-up"); + + var run = agent.ContinueAsync(TestContext.Current.CancellationToken); + await runStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + var steeringAccepted = agent.TrySteer("urgent steering"); + releaseRun.TrySetResult(); + Assert.True(steeringAccepted); + await run; + + var request = Assert.Single(provider.Requests); + Assert.Equal( + new[] { "waiting", "queued follow-up", "urgent steering" }, + request.Messages.Select(message => Assert.IsType(Assert.Single(message.Content)).Text)); + } + [Fact] public async Task ExplicitSequentialToolOverridesParallelBatch() { @@ -1798,6 +2091,38 @@ public async Task UncooperativeToolTimesOutWithUncertainOutcome() Assert.Equal("{\"outcome\":\"uncertain\"}", tool.DetailsJson); } + [Fact] + public async Task AbortSettlesBeforeAnUncooperativeWriteFinishesLate() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var provider = ScriptedProvider.FromResponses( + Responses.Tools(ModelStopReason.ToolUse, new ToolCallContent("slow", "write", "{}"))); + var options = new AgentOptions(provider, "test") + { + Limits = new AgentLimits { ToolTimeoutMilliseconds = 5_000 }, + }; + options.Tools.Add(Responses.Tool("write", async (_, _, cancellationToken) => + { + using var registration = cancellationToken.Register( + () => throw new InvalidOperationException("cancellation callback failed")); + entered.TrySetResult(); + return await release.Task.ConfigureAwait(false); + }, ToolRisk.NonIdempotentWrite)); + var agent = new Agent(options); + + var pending = agent.RunAsync("go", TestContext.Current.CancellationToken); + await entered.Task.WaitAsync(TestContext.Current.CancellationToken); + agent.Abort(); + var result = await pending.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + release.TrySetException(new InvalidOperationException("late failure")); + + Assert.Equal(AgentRunStatus.Aborted, result.Status); + var tool = Assert.Single(result.NewMessages, message => message.Role == AgentRole.Tool); + Assert.True(tool.IsError); + Assert.Equal("{\"outcome\":\"uncertain\"}", tool.DetailsJson); + } + [Fact] public async Task UncertainSequentialWritePreventsLaterWritesInTheBatch() { @@ -1903,6 +2228,82 @@ public async Task BeforeModelHookCannotBypassRequestLimits() Assert.Contains("system prompt", result.Error, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task BeforeModelHookReplacementModelIsUsedForDispatchAndMessageIdentity() + { + var provider = ScriptedProvider.FromResponses(Responses.Text("done")); + var options = new AgentOptions(provider, "original") + { + Hooks = new AgentHooks + { + BeforeModelRequestAsync = (request, _) => new ValueTask(new ModelRequest( + "replacement", + request.SystemPrompt, + request.Messages, + request.Tools, + request.Parameters, + request.SessionId, + request.RunId, + request.Turn)), + }, + }; + var agent = new Agent(options); + + var result = await agent.RunAsync("go", TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal("replacement", Assert.Single(provider.Requests).Model); + Assert.Equal("replacement", agent.State.Messages.Last().Model); + Assert.Equal("replacement", result.NewMessages.Last().Model); + } + + [Fact] + public async Task ProviderDisposalFailureAfterTerminalCannotReplaceACompletedResponse() + { + var events = new ConcurrentQueue(); + var agent = new Agent(new AgentOptions(new TerminalThenDisposalFailureProvider(), "model")); + using var subscription = agent.Subscribe((agentEvent, _) => + { + events.Enqueue(agentEvent); + return default; + }); + + var result = await agent.RunAsync("go", TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(1, events.Count(value => value.Kind == AgentEventKind.MessageEnded + && value.Message?.Role == AgentRole.Assistant)); + Assert.Equal(1, events.Count(value => value.Kind == AgentEventKind.RunEnded)); + Assert.DoesNotContain(events, value => value.Kind == AgentEventKind.RunFaulted); + } + + [Fact] + public async Task BeforeModelHookCannotChangeActiveRunCoordinates() + { + var provider = ScriptedProvider.FromResponses(Responses.Text("unused")); + var options = new AgentOptions(provider, "test") + { + Hooks = new AgentHooks + { + BeforeModelRequestAsync = (request, _) => new ValueTask(new ModelRequest( + request.Model, + request.SystemPrompt, + request.Messages, + request.Tools, + request.Parameters, + request.SessionId, + "different-run", + request.Turn + 1)), + }, + }; + + var result = await new Agent(options).RunAsync("go", TestContext.Current.CancellationToken); + + Assert.Equal(AgentRunStatus.KernelError, result.Status); + Assert.Equal(0, provider.CallCount); + Assert.Contains("run ID or turn", result.Error, StringComparison.Ordinal); + } + [Fact] public async Task IdleModelParametersCanBeReplacedWithoutSharingMutableState() { @@ -1923,6 +2324,73 @@ public async Task IdleModelParametersCanBeReplacedWithoutSharingMutableState() Assert.Equal("\"game\"", agent.State.Parameters.Extensions["mode"]); } + [Fact] + public async Task IdleAgentCanAtomicallySwitchProviderAndModelBetweenRuns() + { + var firstProvider = ScriptedProvider.FromResponses(Responses.Text("first")); + var secondProvider = ScriptedProvider.FromResponses(Responses.Text("second")); + var agent = new Agent(new AgentOptions(firstProvider, "first-model")); + + await agent.RunAsync("one", TestContext.Current.CancellationToken); + agent.SetModel(secondProvider, "second-model"); + await agent.RunAsync("two", TestContext.Current.CancellationToken); + + Assert.Same(secondProvider, agent.State.Provider); + Assert.Equal("second-model", agent.State.Model); + Assert.Equal(1, firstProvider.CallCount); + Assert.Equal(1, secondProvider.CallCount); + Assert.Equal("second-model", secondProvider.Requests.Single().Model); + } + + [Fact] + public async Task IdleAgentCanReplaceHooksBetweenRunsWithoutSharingMutableState() + { + var provider = ScriptedProvider.FromResponses(Responses.Text("first"), Responses.Text("second")); + var agent = new Agent(new AgentOptions(provider, "test")); + + await agent.RunAsync("one", TestContext.Current.CancellationToken); + var hooks = new AgentHooks + { + BeforeModelRequestAsync = (request, _) => new ValueTask(new ModelRequest( + "replacement", + request.SystemPrompt, + request.Messages, + request.Tools, + request.Parameters, + request.SessionId, + request.RunId, + request.Turn)), + }; + agent.SetHooks(hooks); + hooks.BeforeModelRequestAsync = null; + + await agent.RunAsync("two", TestContext.Current.CancellationToken); + + Assert.Equal("replacement", provider.Requests.Last().Model); + } + + [Fact] + public async Task MutableRuntimeConfigurationCannotChangeDuringAnActiveRun() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var provider = new ScriptedProvider(async (_, _, cancellationToken) => + { + started.TrySetResult(); + await release.Task.WaitAsync(cancellationToken); + return Responses.Text("done"); + }); + var agent = new Agent(new AgentOptions(provider, "test")); + var run = agent.RunAsync("go", TestContext.Current.CancellationToken); + await started.Task.WaitAsync(TestContext.Current.CancellationToken); + + Assert.Throws(() => agent.SetHooks(new AgentHooks())); + Assert.Throws(() => agent.SetToolExecution(ToolExecutionMode.Sequential)); + + release.TrySetResult(); + Assert.True((await run).Succeeded); + } + [Fact] public async Task OversizedAfterToolHookResultBecomesBoundedToolError() { @@ -1964,4 +2432,54 @@ public void FloatingPointConfigurationRejectsNonFiniteValues() Parameters = new ModelParameters { Temperature = double.NaN }, })); } + + [Fact] + public async Task PublicSnapshotsAndLifecycleCollectionsAreImmutableDefensiveCopies() + { + var provider = ScriptedProvider.FromResponses(Responses.Text("done")); + AfterTurnContext? afterTurn = null; + AgentEvent? ended = null; + var options = new AgentOptions(provider, "test") + { + Hooks = new AgentHooks + { + ShouldStopAfterTurnAsync = (value, _) => + { + afterTurn = value; + return new ValueTask(false); + }, + }, + }; + options.Tools.Add(Responses.Tool("look", (_, _, _) => + new ValueTask(Responses.Result("ok")))); + var agent = new Agent(options); + using var subscription = agent.Subscribe((value, _) => + { + if (value.Kind == AgentEventKind.RunEnded) + { + ended = value; + } + + return ValueTask.CompletedTask; + }); + + var result = await agent.RunAsync("go", TestContext.Current.CancellationToken); + var state = agent.State; + + AssertImmutable(result.NewMessages); + AssertImmutable(result.SubscriberErrors); + AssertImmutable(state.Messages); + AssertImmutable(state.Tools); + AssertImmutable(state.PendingToolCallIds); + AssertImmutable(Assert.IsType(afterTurn).NewMessages); + AssertImmutable(afterTurn!.ToolResults); + AssertImmutable(Assert.IsType(ended).Messages); + } + + private static void AssertImmutable(IReadOnlyCollection values) + { + var list = Assert.IsAssignableFrom>(values); + Assert.True(list.IsReadOnly); + Assert.Throws(() => list.Add(default!)); + } } diff --git a/tests/OpenGameAgent.Kernel.Tests/TestDoubles.cs b/tests/OpenGameAgent.Kernel.Tests/TestDoubles.cs index fe5fc82..2a985a0 100644 --- a/tests/OpenGameAgent.Kernel.Tests/TestDoubles.cs +++ b/tests/OpenGameAgent.Kernel.Tests/TestDoubles.cs @@ -55,6 +55,55 @@ public IAsyncEnumerable StreamAsync(ModelRequest request, Canc _stream(request, cancellationToken); } +internal sealed class TerminalThenDisposalFailureProvider : IModelProvider +{ + public IAsyncEnumerable StreamAsync(ModelRequest request, CancellationToken cancellationToken) => + new Stream(); + + private sealed class Stream : IAsyncEnumerable, IAsyncEnumerator + { + private bool _emitted; + + public ModelStreamEvent Current { get; private set; } = null!; + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => this; + + public ValueTask MoveNextAsync() + { + if (_emitted) + { + return new ValueTask(false); + } + + _emitted = true; + Current = ModelStreamEvent.Terminal(Responses.Text("done")); + return new ValueTask(true); + } + + public ValueTask DisposeAsync() => ValueTask.FromException(new InvalidOperationException("dispose failed")); + } +} + +internal sealed class NonCooperativeProvider : IModelProvider +{ + public TaskCompletionSource Started { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource Release { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + _ = request; + _ = cancellationToken; + Started.TrySetResult(); + await Release.Task.ConfigureAwait(false); + yield return ModelStreamEvent.Terminal(Responses.Text("late")); + } +} + internal static class Responses { public static ModelResponse Text(string text) => diff --git a/tests/OpenGameAgent.Models.Tests/ModelCatalogTests.cs b/tests/OpenGameAgent.Models.Tests/ModelCatalogTests.cs new file mode 100644 index 0000000..33cee15 --- /dev/null +++ b/tests/OpenGameAgent.Models.Tests/ModelCatalogTests.cs @@ -0,0 +1,643 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using OpenGameAgent.Kernel; +using Xunit; + +namespace OpenGameAgent.Models.Tests; + +public sealed class ModelCatalogTests +{ + [Fact] + public void CredentialKeysExposeConsistentValueOperators() + { + var key = new GameCredentialKey("provider", "profile"); + + Assert.True(key == new GameCredentialKey("provider", "profile")); + Assert.True(key != new GameCredentialKey("provider", "other")); + } + + [Fact] + public void DescriptorClampsReasoningAndResolutionBoundsParametersAndCost() + { + var provider = new ScriptedProvider(); + var model = Model( + "provider", + "model", + maximumOutputTokens: 2_000, + reasoningLevels: new[] { GameReasoningLevel.Low, GameReasoningLevel.High }, + cost: new GameModelCost(1, 2, 0.25m, 1.25m), + reasoningLevelValues: new Dictionary + { + [GameReasoningLevel.Low] = "provider-low", + }); + var catalog = Catalog(Registration("provider", provider, model)); + + var resolution = catalog.Resolve( + "provider", + "model", + GameReasoningLevel.Minimal, + requiredInput: GameModelInputCapabilities.StructuredData, + requiredOutput: GameModelOutputCapabilities.ToolCalls); + var parameters = resolution.CreateParameters(new ModelParameters { MaxOutputTokens = 10_000 }); + + Assert.Equal(GameReasoningLevel.Low, resolution.Reasoning); + Assert.Equal("provider-low", parameters.ReasoningLevel); + Assert.Equal(2_000, parameters.MaxOutputTokens); + Assert.Equal(4.5m, resolution.EstimateCost(new ModelUsage(1_000_000, 1_000_000, 1_000_000, 1_000_000))); + Assert.Throws(() => catalog.Resolve( + "provider", + "model", + requiredInput: GameModelInputCapabilities.Video)); + } + + [Fact] + public async Task RefreshOverlaysBaselineAndDetectsEveryDescriptorChange() + { + var provider = new ScriptedProvider(); + var dynamic = Model("provider", "shared", displayName: "dynamic", cost: new GameModelCost(1)); + var registration = Registration( + "provider", + provider, + new[] { Model("provider", "shared", displayName: "baseline"), Model("provider", "static") }, + refresh: (_, _) => new ValueTask>(new[] { dynamic })); + var catalog = Catalog(registration); + + var first = await catalog.RefreshAsync("provider", cancellationToken: TestContext.Current.CancellationToken); + var second = await catalog.RefreshAsync("provider", cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(GameModelRefreshStatus.Updated, first.Status); + Assert.Equal(GameModelRefreshStatus.Unchanged, second.Status); + Assert.Equal(new[] { "dynamic", "static" }, catalog.GetModels("provider").Select(model => model.DisplayName)); + + dynamic = Model("provider", "shared", displayName: "dynamic", cost: new GameModelCost(2)); + var costChange = await catalog.RefreshAsync("provider", cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(GameModelRefreshStatus.Updated, costChange.Status); + } + + [Fact] + public async Task ReplacingProviderSupersedesAnInFlightRefreshEvenWhenItsSourceIgnoresCancellation() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var old = Registration( + "provider", + new ScriptedProvider(), + new[] { Model("provider", "old") }, + async (_, _) => + { + entered.TrySetResult(true); + await release.Task.ConfigureAwait(false); + return new[] { Model("provider", "stale") }; + }); + var catalog = Catalog(old); + + var refresh = catalog.RefreshAsync("provider", cancellationToken: TestContext.Current.CancellationToken).AsTask(); + await entered.Task.WaitAsync(TestContext.Current.CancellationToken); + catalog.Register(Registration("provider", new ScriptedProvider(), Model("provider", "new")), replace: true); + release.TrySetResult(true); + + var result = await refresh; + Assert.Equal(GameModelRefreshStatus.StaleRegistration, result.Status); + Assert.Equal("new", Assert.Single(catalog.GetModels("provider")).ModelId); + } + + [Fact] + public async Task ThrowingRefreshCancellationCallbacksCannotBlockProviderReplacement() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var old = Registration( + "provider", + new ScriptedProvider(), + new[] { Model("provider", "old") }, + async (_, cancellationToken) => + { + using var registration = cancellationToken.Register( + () => throw new InvalidOperationException("callback failed")); + entered.TrySetResult(true); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + return Array.Empty(); + }); + var catalog = Catalog(old); + + var refresh = catalog.RefreshAsync("provider", cancellationToken: TestContext.Current.CancellationToken).AsTask(); + await entered.Task.WaitAsync(TestContext.Current.CancellationToken); + + var exception = Record.Exception( + () => catalog.Register( + Registration("provider", new ScriptedProvider(), Model("provider", "new")), + replace: true)); + + Assert.Null(exception); + Assert.Equal(GameModelRefreshStatus.StaleRegistration, (await refresh).Status); + Assert.Equal("new", Assert.Single(catalog.GetModels("provider")).ModelId); + } + + [Fact] + public async Task SupersededRefreshCannotCommitStaleModelsWhenStorageIgnoresCancellation() + { + var firstEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseSecond = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + var store = new IgnoringCancellationCatalogStore(); + var catalog = new GameModelCatalog(store: store); + catalog.Register(Registration( + "provider", + new ScriptedProvider(), + Array.Empty(), + async (_, _) => + { + if (Interlocked.Increment(ref calls) == 1) + { + firstEntered.TrySetResult(true); + await releaseFirst.Task.ConfigureAwait(false); + return new[] { Model("provider", "stale") }; + } + + secondEntered.TrySetResult(true); + await releaseSecond.Task.ConfigureAwait(false); + return new[] { Model("provider", "newest") }; + })); + + var first = catalog.RefreshAsync("provider", cancellationToken: TestContext.Current.CancellationToken).AsTask(); + await firstEntered.Task.WaitAsync(TestContext.Current.CancellationToken); + var second = catalog.RefreshAsync("provider", cancellationToken: TestContext.Current.CancellationToken).AsTask(); + releaseFirst.TrySetResult(true); + Assert.Equal(GameModelRefreshStatus.StaleRegistration, (await first).Status); + await secondEntered.Task.WaitAsync(TestContext.Current.CancellationToken); + releaseSecond.TrySetResult(true); + + Assert.Equal(GameModelRefreshStatus.Updated, (await second).Status); + Assert.Equal("newest", Assert.Single(catalog.GetModels("provider")).ModelId); + Assert.Equal("newest", Assert.Single((await store.LoadAsync("provider", TestContext.Current.CancellationToken))!.Models).ModelId); + } + + [Fact] + public async Task CachedDynamicModelsRestoreBeforeAuthenticationOrNetworkAccess() + { + var store = new InMemoryGameModelCatalogStore(); + var writer = new GameModelCatalog(store: store, clock: () => DateTimeOffset.UnixEpoch); + writer.Register(Registration( + "provider", + new ScriptedProvider(), + Array.Empty(), + (_, _) => new ValueTask>(new[] { Model("provider", "cached") }))); + Assert.Equal( + GameModelRefreshStatus.Updated, + (await writer.RefreshAsync("provider", cancellationToken: TestContext.Current.CancellationToken)).Status); + + var fetches = 0; + var reader = new GameModelCatalog(store: store); + reader.Register(new GameModelProviderRegistration( + new GameProviderDescriptor("provider", supportsDynamicModels: true), + new ScriptedProvider(), + new StaticGameProviderAuthentication(configured: false), + refreshModels: (_, _) => + { + Interlocked.Increment(ref fetches); + return new ValueTask>(Array.Empty()); + })); + + var result = await reader.RefreshAsync( + "provider", + allowNetwork: false, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(GameModelRefreshStatus.SkippedUnconfigured, result.Status); + Assert.Equal("cached", Assert.Single(reader.GetModels("provider")).ModelId); + Assert.Equal(0, fetches); + } + + [Fact] + public async Task ConcurrentCatalogInstancesCannotOverwriteTheSameStoredRevision() + { + var store = new InMemoryGameModelCatalogStore(); + var bothEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var entered = 0; + GameModelRefresh Refresh(string modelId) => async (_, _) => + { + if (Interlocked.Increment(ref entered) == 2) + { + bothEntered.TrySetResult(true); + } + + await release.Task.ConfigureAwait(false); + return new[] { Model("provider", modelId) }; + }; + + var first = new GameModelCatalog(store: store); + var second = new GameModelCatalog(store: store); + first.Register(Registration( + "provider", + new ScriptedProvider(), + Array.Empty(), + Refresh("first"))); + second.Register(Registration( + "provider", + new ScriptedProvider(), + Array.Empty(), + Refresh("second"))); + + var firstRefresh = first.RefreshAsync("provider", cancellationToken: TestContext.Current.CancellationToken).AsTask(); + var secondRefresh = second.RefreshAsync("provider", cancellationToken: TestContext.Current.CancellationToken).AsTask(); + await bothEntered.Task.WaitAsync(TestContext.Current.CancellationToken); + release.TrySetResult(true); + var results = await Task.WhenAll(firstRefresh, secondRefresh); + + Assert.Contains(results, result => result.Status == GameModelRefreshStatus.Updated); + Assert.Contains(results, result => result.Status == GameModelRefreshStatus.StoreConflict); + Assert.Equal(1, (await store.LoadAsync("provider", TestContext.Current.CancellationToken))!.Revision); + } + + [Fact] + public async Task StoredAuthenticationSerializesRefreshAndDurablyCommitsLogin() + { + var store = new InMemoryGameCredentialStore(); + var now = DateTimeOffset.UnixEpoch.AddHours(1); + var key = new GameCredentialKey("provider"); + await store.SetAsync( + key, + new GameCredential(GameCredentialKind.OAuth, "expired", now.AddMinutes(-1)), + TestContext.Current.CancellationToken); + var refreshes = 0; + var authentication = new StoredGameProviderAuthentication( + "provider", + store, + schemes: new[] { "oauth" }, + login: (_, _, _) => new ValueTask( + new GameCredential(GameCredentialKind.OAuth, "logged-in", now.AddHours(1))), + refresh: (_, _) => + { + Interlocked.Increment(ref refreshes); + return new ValueTask( + new GameCredential(GameCredentialKind.OAuth, "refreshed", now.AddHours(1))); + }, + clock: () => now, + refreshSkew: TimeSpan.Zero); + + var statusBeforeRefresh = await authentication.CheckAsync(TestContext.Current.CancellationToken); + Assert.True(statusBeforeRefresh.Configured); + + var resolved = await Task.WhenAll( + authentication.ResolveAsync(TestContext.Current.CancellationToken).AsTask(), + authentication.ResolveAsync(TestContext.Current.CancellationToken).AsTask()); + + Assert.Equal(1, refreshes); + Assert.All(resolved, value => Assert.Equal("refreshed", value!.Credential.Secret)); + var login = await authentication.LoginAsync( + "oauth", + new GameAuthInteraction(), + TestContext.Current.CancellationToken); + Assert.Equal("logged-in", login.Secret); + Assert.Equal("logged-in", (await store.GetAsync(key, TestContext.Current.CancellationToken))!.Secret); + Assert.DoesNotContain("logged-in", login.ToString(), StringComparison.Ordinal); + Assert.Throws(() => new GameCredential(GameCredentialKind.ApiKey, "unsafe\r\nvalue")); + } + + [Fact] + public void CredentialExpiryHandlesBoundaryClocksWithoutOverflow() + { + var credential = new GameCredential( + GameCredentialKind.DeveloperHostedToken, + "short-lived", + DateTimeOffset.MaxValue); + + Assert.True(credential.IsExpired(DateTimeOffset.MaxValue.AddMinutes(-30), TimeSpan.FromHours(1))); + Assert.True(credential.IsExpired(DateTimeOffset.MaxValue, TimeSpan.FromHours(1))); + Assert.Throws(() => credential.IsExpired( + DateTimeOffset.UtcNow, + TimeSpan.FromSeconds(-1))); + } + + [Fact] + public async Task StoredAuthenticationNeverCommitsExpiredLoginOrRefreshResults() + { + var store = new InMemoryGameCredentialStore(); + var now = DateTimeOffset.UnixEpoch.AddHours(4); + var key = new GameCredentialKey("provider"); + var original = new GameCredential(GameCredentialKind.OAuth, "original", now.AddMinutes(-1)); + await store.SetAsync(key, original, TestContext.Current.CancellationToken); + var authentication = new StoredGameProviderAuthentication( + "provider", + store, + schemes: new[] { "oauth" }, + login: (_, _, _) => new ValueTask( + new GameCredential(GameCredentialKind.OAuth, "expired-login", now)), + refresh: (_, _) => new ValueTask( + new GameCredential(GameCredentialKind.OAuth, "expired-refresh", now)), + clock: () => now, + refreshSkew: TimeSpan.Zero); + + await Assert.ThrowsAsync(async () => + await authentication.ResolveAsync(TestContext.Current.CancellationToken)); + Assert.Same(original, await store.GetAsync(key, TestContext.Current.CancellationToken)); + + await Assert.ThrowsAsync(async () => + await authentication.LoginAsync("oauth", new GameAuthInteraction(), TestContext.Current.CancellationToken)); + Assert.Same(original, await store.GetAsync(key, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task EnvironmentAuthenticationResolvesPerRequestWithoutExposingSecretsInStatus() + { + var value = "first"; + var authentication = new EnvironmentGameProviderAuthentication( + "GAME_MODEL_KEY", + read: _ => value); + + var status = await authentication.CheckAsync(TestContext.Current.CancellationToken); + var first = await authentication.ResolveAsync(TestContext.Current.CancellationToken); + value = "second"; + var second = await authentication.ResolveAsync(TestContext.Current.CancellationToken); + + Assert.True(status.Configured); + Assert.DoesNotContain("first", status.Source, StringComparison.Ordinal); + Assert.Equal("first", first!.Credential.Secret); + Assert.Equal("second", second!.Credential.Secret); + Assert.DoesNotContain("second", second.Credential.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task InvalidEnvironmentCredentialFailsAvailabilityCheckWithoutLeakingItsValue() + { + const string malformed = "secret\r\nheader"; + var authentication = new EnvironmentGameProviderAuthentication( + "GAME_MODEL_KEY", + read: _ => malformed); + + var status = await authentication.CheckAsync(TestContext.Current.CancellationToken); + + Assert.False(status.Configured); + Assert.Contains("invalid credential", status.Error, StringComparison.Ordinal); + Assert.DoesNotContain(malformed, status.Error, StringComparison.Ordinal); + await Assert.ThrowsAsync(async () => + await authentication.ResolveAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CatalogDispatchRefreshesAnExpiredCredentialBeforeStreaming() + { + var store = new InMemoryGameCredentialStore(); + var now = DateTimeOffset.UnixEpoch.AddHours(2); + await store.SetAsync( + new GameCredentialKey("provider"), + new GameCredential(GameCredentialKind.OAuth, "expired", now.AddMinutes(-1)), + TestContext.Current.CancellationToken); + var refreshes = 0; + var authentication = new StoredGameProviderAuthentication( + "provider", + store, + refresh: (_, _) => + { + Interlocked.Increment(ref refreshes); + return new ValueTask( + new GameCredential(GameCredentialKind.OAuth, "fresh", now.AddHours(1))); + }, + clock: () => now, + refreshSkew: TimeSpan.Zero); + var provider = new ScriptedProvider(); + string? streamedSecret = null; + var catalog = Catalog(new GameModelProviderRegistration( + new GameProviderDescriptor("provider"), + provider, + authentication, + new[] { Model("provider", "model") }, + stream: (request, resolved, cancellationToken) => CaptureSecret( + provider, + request, + resolved, + value => streamedSecret = value, + cancellationToken))); + var extension = new GameModelCatalogExtension(catalog); + await using var runtime = new GameAgentBuilder(new ScriptedProvider(), "fallback") + .UseModelSelector((_, _) => new ValueTask(extension.Select("provider", "model"))) + .UseExtension(extension) + .Build(); + + var result = await runtime.RunAsync( + new GameInput("session", "actor", "event", "{}", new GameMoment("world", 1), inputId: "refresh-input"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(1, refreshes); + Assert.Equal("fresh", streamedSecret); + } + + [Fact] + public async Task CatalogExtensionResolvesAuthPerTurnAndAppliesSelectedModelParameters() + { + var fallback = new ScriptedProvider(); + var underlying = new ScriptedProvider(); + var seenSecrets = new ConcurrentQueue(); + var catalog = Catalog(new GameModelProviderRegistration( + new GameProviderDescriptor("catalog-provider"), + underlying, + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.DeveloperHostedToken, "short-lived")), + new[] + { + Model( + "catalog-provider", + "capable", + maximumOutputTokens: 512, + reasoningLevels: new[] { GameReasoningLevel.High }), + }, + stream: (request, authentication, cancellationToken) => Capture( + underlying, + request, + authentication, + seenSecrets, + cancellationToken))); + var extension = new GameModelCatalogExtension(catalog); + var selectedModel = extension.Select( + "catalog-provider", + "capable", + GameReasoningLevel.Medium, + new ModelParameters { MaxOutputTokens = 4_096 }); + Assert.Equal(2_048, selectedModel.ContextWindowTokens); + Assert.Equal(512, selectedModel.MaximumOutputTokens); + await using var runtime = new GameAgentBuilder(fallback, "fallback") + .UseModelSelector((_, _) => new ValueTask(selectedModel)) + .UseExtension(extension) + .Build(); + + var result = await runtime.RunAsync( + new GameInput("session", "actor", "event", "{}", new GameMoment("world", 1), inputId: "input"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Empty(fallback.Requests); + var request = Assert.Single(underlying.Requests); + Assert.Equal("capable", request.Model); + Assert.Equal("high", request.Parameters.ReasoningLevel); + Assert.Equal(512, request.Parameters.MaxOutputTokens); + Assert.Equal("short-lived", Assert.Single(seenSecrets)); + } + + [Fact] + public async Task CatalogExtensionCanSelectAProviderRegisteredAfterRuntimeConstruction() + { + var fallback = new ScriptedProvider(); + var selected = new ScriptedProvider(); + var catalog = new GameModelCatalog(); + var extension = new GameModelCatalogExtension(catalog); + await using var runtime = new GameAgentBuilder(fallback, "fallback") + .UseModelSelector((_, _) => new ValueTask(extension.Select("late", "model"))) + .UseExtension(extension) + .Build(); + catalog.Register(Registration("late", selected, Model("late", "model"))); + + var result = await runtime.RunAsync( + new GameInput("session", "actor", "event", "{}", new GameMoment("world", 1), inputId: "late-input"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Empty(fallback.Requests); + Assert.Single(selected.Requests); + } + + private static async IAsyncEnumerable Capture( + ScriptedProvider provider, + ModelRequest request, + GameProviderAuthResolution? authentication, + ConcurrentQueue secrets, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + secrets.Enqueue(authentication?.Credential.Secret); + await foreach (var streamEvent in provider.StreamAsync(request, cancellationToken).WithCancellation(cancellationToken)) + { + yield return streamEvent; + } + } + + private static async IAsyncEnumerable CaptureSecret( + ScriptedProvider provider, + ModelRequest request, + GameProviderAuthResolution? authentication, + Action capture, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + capture(authentication?.Credential.Secret); + await foreach (var streamEvent in provider.StreamAsync(request, cancellationToken).WithCancellation(cancellationToken)) + { + yield return streamEvent; + } + } + + private static GameModelCatalog Catalog(params GameModelProviderRegistration[] registrations) + { + var catalog = new GameModelCatalog(); + foreach (var registration in registrations) + { + catalog.Register(registration); + } + + return catalog; + } + + private static GameModelProviderRegistration Registration( + string providerId, + IModelProvider provider, + GameModelDescriptor model) => + Registration(providerId, provider, new[] { model }); + + private static GameModelProviderRegistration Registration( + string providerId, + IModelProvider provider, + IReadOnlyList models, + GameModelRefresh? refresh = null) => + new( + new GameProviderDescriptor(providerId, supportsDynamicModels: refresh is not null), + provider, + new StaticGameProviderAuthentication(), + models, + refresh); + + private static GameModelDescriptor Model( + string providerId, + string modelId, + string? displayName = null, + int maximumOutputTokens = 0, + IReadOnlyCollection? reasoningLevels = null, + GameModelCost? cost = null, + IReadOnlyDictionary? reasoningLevelValues = null) => + new( + providerId, + modelId, + displayName, + contextWindowTokens: maximumOutputTokens == 0 ? 0 : maximumOutputTokens * 4, + maximumOutputTokens, + outputCapabilities: GameModelOutputCapabilities.Text + | GameModelOutputCapabilities.ToolCalls + | GameModelOutputCapabilities.Reasoning, + reasoningLevels: reasoningLevels, + cost: cost, + reasoningLevelValues: reasoningLevelValues); + + private sealed class ScriptedProvider : IModelProvider + { + public ConcurrentQueue Requests { get; } = new(); + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Requests.Enqueue(request); + var partial = new ModelResponse(Array.Empty(), ModelStopReason.Pending); + yield return ModelStreamEvent.Update(ModelStreamEventKind.Started, partial); + await Task.Yield(); + yield return ModelStreamEvent.Terminal(new ModelResponse( + new AgentContent[] { new TextContent("ok") }, + ModelStopReason.Stop, + new ModelUsage(1, 1))); + } + } + + private sealed class IgnoringCancellationCatalogStore : IGameModelCatalogStore + { + private readonly object _gate = new(); + private GameStoredModelCatalog? _catalog; + + public ValueTask LoadAsync( + string providerId, + CancellationToken cancellationToken) + { + _ = providerId; + _ = cancellationToken; + lock (_gate) + { + return new ValueTask(_catalog); + } + } + + public ValueTask SaveAsync( + GameStoredModelCatalog catalog, + long expectedRevision, + CancellationToken cancellationToken) + { + _ = cancellationToken; + lock (_gate) + { + var revision = _catalog?.Revision ?? 0; + if (revision != expectedRevision) + { + return new ValueTask( + new GameModelCatalogSaveResult(GameModelCatalogSaveStatus.Conflict, revision)); + } + + var nextRevision = checked(revision + 1); + _catalog = new GameStoredModelCatalog( + catalog.ProviderId, + catalog.CatalogVersion, + catalog.Models, + catalog.CheckedAt, + nextRevision); + return new ValueTask( + new GameModelCatalogSaveResult(GameModelCatalogSaveStatus.Saved, nextRevision)); + } + } + } +} diff --git a/tests/OpenGameAgent.Models.Tests/OpenGameAgent.Models.Tests.csproj b/tests/OpenGameAgent.Models.Tests/OpenGameAgent.Models.Tests.csproj new file mode 100644 index 0000000..b190f33 --- /dev/null +++ b/tests/OpenGameAgent.Models.Tests/OpenGameAgent.Models.Tests.csproj @@ -0,0 +1,20 @@ + + + Exe + net8.0 + false + true + OpenGameAgent.Models.Tests + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/tests/OpenGameAgent.Models.Tests/packages.lock.json b/tests/OpenGameAgent.Models.Tests/packages.lock.json new file mode 100644 index 0000000..2b6fc63 --- /dev/null +++ b/tests/OpenGameAgent.Models.Tests/packages.lock.json @@ -0,0 +1,226 @@ +{ + "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.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent": "[0.3.0-alpha.1, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/OpenGameAgent.Persistence.Tests/PersistenceTests.cs b/tests/OpenGameAgent.Persistence.Tests/PersistenceTests.cs index b1d97f0..ae12731 100644 --- a/tests/OpenGameAgent.Persistence.Tests/PersistenceTests.cs +++ b/tests/OpenGameAgent.Persistence.Tests/PersistenceTests.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using OpenGameAgent.Extensions; using OpenGameAgent.Kernel; using Xunit; @@ -77,6 +78,38 @@ public async Task SessionSaveUsesOptimisticRevisionAfterRestart() Assert.False(stale.Saved); Assert.Equal(1, stale.Current.Revision); + + } + + [Fact] + public async Task IndependentSessionStoresPreserveCompareAndSwapUnderConcurrentWriters() + { + using var directory = new TemporaryDirectory(); + var key = new GameSessionKey("session", "actor"); + var first = new FileGameSessionStore(directory.Path); + var second = new FileGameSessionStore(directory.Path); + var start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + async Task SaveAsync(FileGameSessionStore store, string inputId) + { + await start.Task.WaitAsync(TestContext.Current.CancellationToken); + return await store.SaveAsync( + new GameSessionSnapshot(key, 1, processedInputIds: new[] { inputId }), + 0, + TestContext.Current.CancellationToken); + } + + var writes = new[] { SaveAsync(first, "one"), SaveAsync(second, "two") }; + start.SetResult(); + var results = await Task.WhenAll(writes); + + Assert.Single(results, result => result.Saved); + Assert.Single(results, result => !result.Saved); + Assert.All(results, result => Assert.Equal(1, result.Current.Revision)); + var loaded = await new FileGameSessionStore(directory.Path) + .LoadAsync(key, TestContext.Current.CancellationToken); + Assert.NotNull(loaded); + Assert.Single(loaded.ProcessedInputIds); } [Fact] @@ -118,6 +151,47 @@ await restarted.SaveReceiptAsync( Assert.Empty(await finalRestart.ListPendingAsync(10, TestContext.Current.CancellationToken)); } + [Fact] + public async Task IndependentFileDispatchersNeverExecuteTheSameOperationTwice() + { + using var directory = new TemporaryDirectory(); + var intent = Intent("concurrent-operation"); + var executeEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var executeCount = 0; + var recoverCount = 0; + var handler = new CallbackActionHandler( + async (candidate, cancellationToken) => + { + Interlocked.Increment(ref executeCount); + executeEntered.TrySetResult(); + await release.Task.WaitAsync(cancellationToken); + return GameActionReceipt.Committed(candidate, "{\"executed\":true}"); + }, + async (candidate, cancellationToken) => + { + Interlocked.Increment(ref recoverCount); + await release.Task.WaitAsync(cancellationToken); + return GameActionReceipt.Committed(candidate, "{\"executed\":true}"); + }); + var first = new DurableGameActionDispatcher(new FileGameActionJournal(directory.Path), handler); + var second = new DurableGameActionDispatcher(new FileGameActionJournal(directory.Path), handler); + + var executions = new[] + { + first.ExecuteAsync(intent, TestContext.Current.CancellationToken).AsTask(), + second.ExecuteAsync(intent, TestContext.Current.CancellationToken).AsTask(), + }; + await executeEntered.Task.WaitAsync(TestContext.Current.CancellationToken); + await Task.Delay(50, TestContext.Current.CancellationToken); + release.SetResult(); + var receipts = await Task.WhenAll(executions); + + Assert.All(receipts, receipt => Assert.Equal(GameActionStatus.Committed, receipt.Status)); + Assert.Equal(1, executeCount); + Assert.InRange(recoverCount, 0, 1); + } + [Fact] public async Task ZeroPendingActionLimitReturnsNoEntries() { @@ -316,8 +390,21 @@ public async Task WorkflowCheckpointSurvivesRestart() { using var directory = new TemporaryDirectory(); var store = new FileGameWorkflowCheckpointStore(directory.Path); + var invocation = new GameWorkflowInvocationResult( + "input", + new[] + { + new AgentMessage( + AgentRole.Assistant, + new AgentContent[] { new TextContent("durable output") }, + DateTimeOffset.UnixEpoch, + model: "model", + stopReason: ModelStopReason.Stop), + }, + complete: true, + succeeded: true); await store.SaveAsync( - new GameWorkflowCheckpoint("instance", "evolve", 1, 2, "{\"month\":4}"), + new GameWorkflowCheckpoint("instance", "evolve", 1, 2, "{\"month\":4}", invocation: invocation), 0, TestContext.Current.CancellationToken); @@ -327,6 +414,10 @@ await store.SaveAsync( Assert.NotNull(checkpoint); Assert.Equal(2, checkpoint.NextStep); Assert.Contains("\"month\":4", checkpoint.StateJson, StringComparison.Ordinal); + Assert.Equal("input", checkpoint.Invocation!.InputId); + Assert.Equal( + "durable output", + Assert.IsType(Assert.Single(Assert.Single(checkpoint.Invocation.Messages).Content)).Text); } [Fact] @@ -379,6 +470,38 @@ await store.AppendAsync( Assert.Equal("npc-v2", memory.Metadata["incarnation"]); } + [Fact] + public async Task FileMemoryIdentifiersAreScopedToTheirGameSessionAndOwner() + { + using var directory = new TemporaryDirectory(); + var store = new FileGameMemoryStore(directory.Path); + await store.AppendAsync( + new GameMemory("shared-id", "session-a", "npc", "personal", GameMemoryKind.Fact, "{\"value\":1}", new GameMoment("world", 1)), + TestContext.Current.CancellationToken); + await store.AppendAsync( + new GameMemory("shared-id", "session-b", "npc", "personal", GameMemoryKind.Fact, "{\"value\":2}", new GameMoment("world", 1)), + TestContext.Current.CancellationToken); + await store.AppendAsync( + new GameMemory("shared-id", "session-a", "other-npc", "personal", GameMemoryKind.Fact, "{\"value\":3}", new GameMoment("world", 1)), + TestContext.Current.CancellationToken); + + var restarted = new FileGameMemoryStore(directory.Path); + var first = await restarted.SearchAsync( + new GameMemoryQuery("session-a", 1, ownerId: "npc", atOrBefore: new GameMoment("world", 1)), + TestContext.Current.CancellationToken); + var second = await restarted.SearchAsync( + new GameMemoryQuery("session-b", 1, atOrBefore: new GameMoment("world", 1)), + TestContext.Current.CancellationToken); + + Assert.Contains("1", Assert.Single(first).PayloadJson, StringComparison.Ordinal); + Assert.Contains("2", Assert.Single(second).PayloadJson, StringComparison.Ordinal); + Assert.Equal( + 2, + (await restarted.SearchAsync( + new GameMemoryQuery("session-a", 2, atOrBefore: new GameMoment("world", 1)), + TestContext.Current.CancellationToken)).Count); + } + [Fact] public async Task DirectorySkillSourceLoadsDeclarativeInstructionsOnly() { @@ -428,6 +551,61 @@ await File.WriteAllTextAsync( Assert.Equal("Inspect the region before placing anything.", skill.Instructions); } + [Fact] + public async Task DirectorySkillSourceDiscoversNestedSkillRootsAndStopsBelowEachRoot() + { + using var directory = new TemporaryDirectory(); + var skillDirectory = System.IO.Path.Combine(directory.Path, "packs", "building"); + var ignoredNested = System.IO.Path.Combine(skillDirectory, "nested"); + Directory.CreateDirectory(ignoredNested); + await File.WriteAllTextAsync( + System.IO.Path.Combine(skillDirectory, "SKILL.md"), + "---\nname: building\ndescription: Build from a validated plan.\n---\nUse the construction tools.", + TestContext.Current.CancellationToken); + await File.WriteAllTextAsync( + System.IO.Path.Combine(ignoredNested, "SKILL.md"), + "---\nname: hidden-child\ndescription: Must not be discovered below another skill root.\n---\nIgnored.", + TestContext.Current.CancellationToken); + var source = new DirectoryGameSkillSource(directory.Path); + + var selected = await source.SelectAsync( + new GameSkillQuery( + new GameInput("session", "actor", "build", "{}", new GameMoment("world", 1)), + Array.Empty(), + 10), + TestContext.Current.CancellationToken); + + Assert.Equal("building", Assert.Single(selected).SkillId); + } + + [Fact] + public async Task DirectorySkillSourceRejectsNonPortableMarkdownSkillNames() + { + using var directory = new TemporaryDirectory(); + var skillDirectory = System.IO.Path.Combine(directory.Path, "invalid"); + Directory.CreateDirectory(skillDirectory); + await File.WriteAllTextAsync( + System.IO.Path.Combine(skillDirectory, "SKILL.md"), + "---\nname: Invalid Name\ndescription: Invalid portable name.\n---\nInstructions.", + TestContext.Current.CancellationToken); + + var exception = Assert.Throws(() => new DirectoryGameSkillSource(directory.Path)); + + Assert.Contains("lowercase name", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void DirectorySkillSourceBoundsDescriptorFreeDirectoryScanning() + { + using var directory = new TemporaryDirectory(); + Directory.CreateDirectory(System.IO.Path.Combine(directory.Path, "one", "two", "three")); + + var exception = Assert.Throws(() => + new DirectoryGameSkillSource(directory.Path, maximumScannedDirectories: 2)); + + Assert.Equal("maximumScannedDirectories", exception.Limit); + } + [Fact] public async Task DirectorySkillSourceRejectsDuplicateFrontMatterKeys() { @@ -589,6 +767,37 @@ await finalRestart.CompleteAsync( TestContext.Current.CancellationToken)); } + [Fact] + public async Task IndependentMailboxWorkersCannotClaimTheSameMessageLease() + { + using var directory = new TemporaryDirectory(); + var writer = new FileGameMailbox(directory.Path); + await writer.EnqueueAsync( + new GameMailboxMessage("message", "session", "npc", "signal", "{}", new GameMoment("world", 1)), + TestContext.Current.CancellationToken); + var first = new FileGameMailbox(directory.Path); + var second = new FileGameMailbox(directory.Path); + var now = DateTimeOffset.Parse("2026-01-01T00:00:00Z"); + + var claims = await Task.WhenAll( + first.ClaimAsync( + "session", + "npc", + 1, + now, + TimeSpan.FromMinutes(1), + TestContext.Current.CancellationToken).AsTask(), + second.ClaimAsync( + "session", + "npc", + 1, + now, + TimeSpan.FromMinutes(1), + TestContext.Current.CancellationToken).AsTask()); + + Assert.Equal(1, claims.Sum(claim => claim.Count)); + } + [Fact] public async Task MailboxDeduplicatesEquivalentMessagesAndRejectsIdentityReuse() { @@ -644,6 +853,91 @@ await mailbox.ClaimAsync( Assert.Equal(1, claimed.Attempt); } + [Fact] + public async Task DelegationStateSurvivesRestartAndRejectsStaleRevision() + { + using var directory = new TemporaryDirectory(); + var pending = new GameAgentDelegationRecord( + "delegation", + "session", + "actor", + 1, + GameAgentDelegationStatus.Pending, + "{\"task\":\"inspect\"}", + 1, + new GameMoment("world", 4)); + var store = new FileGameAgentDelegationStore(directory.Path); + Assert.True((await store.SaveAsync(pending, 0, TestContext.Current.CancellationToken)).Saved); + + var restarted = new FileGameAgentDelegationStore(directory.Path); + var loaded = await restarted.LoadAsync("session", "actor", "delegation", TestContext.Current.CancellationToken); + var stale = await restarted.SaveAsync(pending, 0, TestContext.Current.CancellationToken); + + Assert.NotNull(loaded); + Assert.Equal("{\"task\":\"inspect\"}", loaded.TaskJson); + Assert.Equal(4, loaded.CreatedAt.Tick); + Assert.False(stale.Saved); + Assert.Equal(1, stale.Current.Revision); + + var otherSession = new GameAgentDelegationRecord( + "delegation", + "other-session", + "actor", + 1, + GameAgentDelegationStatus.Pending, + "{\"task\":\"other\"}", + 1, + new GameMoment("world", 4)); + Assert.True((await restarted.SaveAsync(otherSession, 0, TestContext.Current.CancellationToken)).Saved); + Assert.Equal( + "{\"task\":\"other\"}", + (await restarted.LoadAsync("other-session", "actor", "delegation", TestContext.Current.CancellationToken))!.TaskJson); + } + + [Fact] + public async Task LargeArtifactSurvivesRestartWithoutChangingFloatingPointJson() + { + using var directory = new TemporaryDirectory(); + var artifact = new GameAgentArtifact( + "artifact", + "session", + "actor", + "application/json", + "{\"position\":1.75}", + new GameMoment("world", 8)); + var store = new FileGameAgentArtifactStore(directory.Path); + await store.PutAsync(artifact, TestContext.Current.CancellationToken); + + var restarted = new FileGameAgentArtifactStore(directory.Path); + var loaded = await restarted.GetAsync("session", "actor", "artifact", TestContext.Current.CancellationToken); + + Assert.NotNull(loaded); + Assert.Equal("{\"position\":1.75}", loaded.Content); + Assert.Equal(8, loaded.CreatedAt.Tick); + var otherSession = new GameAgentArtifact( + "artifact", + "other-session", + "actor", + "application/json", + "{\"position\":2.5}", + new GameMoment("world", 8)); + await restarted.PutAsync(otherSession, TestContext.Current.CancellationToken); + Assert.Equal( + "{\"position\":2.5}", + (await restarted.GetAsync("other-session", "actor", "artifact", TestContext.Current.CancellationToken))!.Content); + await restarted.PutAsync(artifact, TestContext.Current.CancellationToken); + await Assert.ThrowsAsync(async () => + await restarted.PutAsync( + new GameAgentArtifact( + "artifact", + "session", + "actor", + "application/json", + "{\"position\":9.25}", + new GameMoment("world", 8)), + TestContext.Current.CancellationToken)); + } + private static GameActionIntent Intent(string operationId) => new(operationId, "input", "session", "actor", "move", "{\"x\":1.5}", new GameMoment("world", 4)); diff --git a/tests/OpenGameAgent.Persistence.Tests/packages.lock.json b/tests/OpenGameAgent.Persistence.Tests/packages.lock.json index 769fc93..9cf1db8 100644 --- a/tests/OpenGameAgent.Persistence.Tests/packages.lock.json +++ b/tests/OpenGameAgent.Persistence.Tests/packages.lock.json @@ -209,6 +209,12 @@ "System.Text.Json": "[8.0.6, )" } }, + "opengameagent.extensions": { + "type": "Project", + "dependencies": { + "OpenGameAgent": "[0.3.0-alpha.1, )" + } + }, "opengameagent.kernel": { "type": "Project", "dependencies": { @@ -219,6 +225,7 @@ "type": "Project", "dependencies": { "OpenGameAgent": "[0.3.0-alpha.1, )", + "OpenGameAgent.Extensions": "[0.3.0-alpha.1, )", "System.Text.Json": "[8.0.6, )" } } diff --git a/tests/OpenGameAgent.Providers.MediaHttp.Tests/MediaHttpTests.cs b/tests/OpenGameAgent.Providers.MediaHttp.Tests/MediaHttpTests.cs index bee161d..ef1f429 100644 --- a/tests/OpenGameAgent.Providers.MediaHttp.Tests/MediaHttpTests.cs +++ b/tests/OpenGameAgent.Providers.MediaHttp.Tests/MediaHttpTests.cs @@ -20,6 +20,22 @@ public void InvalidAuthenticationHeaderIsRejectedBeforeTransport() Assert.Throws(() => new HttpMediaGenerator(options)); } + [Fact] + public void RemotePlainHttpRequiresExplicitOptInWhileLoopbackRemainsAvailable() + { + using var client = new HttpClient(new StubHandler(_ => throw new InvalidOperationException("transport must not run"))); + + Assert.Throws(() => new HttpMediaGenerator( + new HttpMediaGeneratorOptions(client, new Uri("http://media.test/generate")))); + _ = new HttpMediaGenerator( + new HttpMediaGeneratorOptions(client, new Uri("http://127.0.0.1:8080/generate"))); + _ = new HttpMediaGenerator( + new HttpMediaGeneratorOptions(client, new Uri("http://media.test/generate")) + { + AllowInsecureHttp = true, + }); + } + [Fact] public async Task SynchronousImageResultPreservesStructuredParameters() { @@ -220,6 +236,25 @@ await generator.GenerateAsync( Assert.Equal(new[] { "Bearer key-1", "Bearer key-2" }, handler.Authorizations); } + [Fact] + public async Task OversizedDynamicCredentialIsRejectedBeforeTransport() + { + var handler = new StubHandler(_ => throw new InvalidOperationException("transport must not run")); + var generator = new HttpMediaGenerator(new HttpMediaGeneratorOptions( + new HttpClient(handler), + new Uri("https://media.test/generate")) + { + GetApiKeyAsync = _ => new ValueTask(new string('x', 65_537)), + }); + + await Assert.ThrowsAsync(async () => + await generator.GenerateAsync( + new GameMediaGenerationRequest("image", GameMediaKind.Image, "{}"), + null, + TestContext.Current.CancellationToken)); + Assert.Empty(handler.Requests); + } + [Fact] public async Task CrossOriginPollingDoesNotForwardCredentialsByDefault() { diff --git a/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/ProviderTests.cs b/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/ProviderTests.cs index d5710d4..fa1d65a 100644 --- a/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/ProviderTests.cs +++ b/tests/OpenGameAgent.Providers.OpenAICompatible.Tests/ProviderTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Net; using System.Text; using System.Text.Json; @@ -36,7 +37,9 @@ public async Task StreamsReasoningTextToolArgumentsAndUsage() var response = events.Last().Response!; Assert.Equal(ModelStopReason.ToolUse, response.StopReason); - Assert.Equal("think", Assert.IsType(response.Content[0]).Text); + var reasoning = Assert.IsType(response.Content[0]); + Assert.Equal("think", reasoning.Text); + Assert.Equal("reasoning_content", reasoning.Signature); Assert.Equal("hello", Assert.IsType(response.Content[1]).Text); var call = Assert.IsType(response.Content[2]); Assert.Equal("move", call.Name); @@ -56,6 +59,33 @@ public async Task StreamsReasoningTextToolArgumentsAndUsage() Assert.Equal("move", toolEnded.ToolName); } + [Fact] + public async Task PreservesAlternateReasoningFieldAndMatchesToolDeltasByIdWhenIndexIsMissing() + { + const string stream = """ + data: {"choices":[{"delta":{"reasoning":"plan"},"finish_reason":null}]} + + data: {"choices":[{"delta":{"tool_calls":[{"id":"call-1","function":{"name":"move","arguments":"{\"x\":"}}]},"finish_reason":null}]} + + data: {"choices":[{"delta":{"tool_calls":[{"id":"call-1","function":{"arguments":"1}"}}]},"finish_reason":null}]} + + data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]} + + data: [DONE] + + """; + var handler = new StubHandler(_ => Response(HttpStatusCode.OK, stream, "text/event-stream")); + var provider = Create(handler); + + var events = await CollectAsync(provider.StreamAsync(Request(), TestContext.Current.CancellationToken)); + + var response = events.Last().Response!; + var reasoning = Assert.IsType(response.Content[0]); + Assert.Equal("reasoning", reasoning.Signature); + var call = Assert.IsType(response.Content[1]); + Assert.Equal("{\"x\":1}", call.ArgumentsJson); + } + [Fact] public async Task SendsToolsExtensionsAndRotatingAuthorizationWithoutLeakingItIntoBody() { @@ -114,6 +144,156 @@ public async Task SendsToolsExtensionsAndRotatingAuthorizationWithoutLeakingItIn Assert.DoesNotContain("private-plan", handler.RequestBody, StringComparison.Ordinal); } + [Fact] + public async Task ProjectsImageAndProviderSpecificMediaPartsWithoutFlatteningThemToText() + { + var handler = new StubHandler(_ => Response( + HttpStatusCode.OK, + "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "text/event-stream")); + var options = new OpenAICompatibleProviderOptions( + new HttpClient(handler), + new Uri("https://example.test/v1/chat/completions")) + { + ProjectResourcePart = resource => resource.MediaType switch + { + "audio/wav" => "{\"type\":\"input_audio\",\"input_audio\":{\"data\":\"audio-data\",\"format\":\"wav\"}}", + "video/mp4" => "{\"type\":\"video_url\",\"video_url\":{\"url\":\"" + resource.Uri + "\"}}", + _ => null, + }, + }; + var provider = new OpenAICompatibleProvider(options); + var request = new ModelRequest( + "model", + "rules", + new[] + { + new AgentMessage( + AgentRole.User, + new AgentContent[] + { + new JsonContent("{\"question\":\"what changed?\"}"), + new ResourceContent("https://assets.example.test/frame.png", "image/png", "frame"), + new ResourceContent("game://capture.wav", "audio/wav", "voice"), + new ResourceContent("https://assets.example.test/clip.mp4", "video/mp4", "clip"), + }, + DateTimeOffset.UnixEpoch), + }, + Array.Empty(), + new ModelParameters(), + null, + "run", + 1); + + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + using var document = JsonDocument.Parse(handler.RequestBody!); + var content = document.RootElement.GetProperty("messages")[1].GetProperty("content"); + Assert.Equal(JsonValueKind.Array, content.ValueKind); + Assert.Equal("text", content[0].GetProperty("type").GetString()); + Assert.Equal("image_url", content[1].GetProperty("type").GetString()); + Assert.Equal("https://assets.example.test/frame.png", content[1].GetProperty("image_url").GetProperty("url").GetString()); + Assert.Equal("input_audio", content[2].GetProperty("type").GetString()); + Assert.Equal("video_url", content[3].GetProperty("type").GetString()); + } + + [Fact] + public async Task ProjectsToolReturnedImagesAfterTheCompleteToolResultBatch() + { + var handler = new StubHandler(_ => Response( + HttpStatusCode.OK, + "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "text/event-stream")); + var provider = new OpenAICompatibleProvider(new OpenAICompatibleProviderOptions( + new HttpClient(handler), + new Uri("https://example.test/v1/chat/completions"))); + var firstCall = new ToolCallContent("capture", "capture_view", "{}"); + var secondCall = new ToolCallContent("inspect", "inspect_state", "{}"); + var request = new ModelRequest( + "model", + "rules", + new AgentMessage[] + { + AgentMessage.User("look"), + new( + AgentRole.Assistant, + new AgentContent[] { firstCall, secondCall }, + DateTimeOffset.UnixEpoch, + model: "model", + stopReason: ModelStopReason.ToolUse), + AgentMessage.ToolResult( + firstCall, + new ToolResult(new AgentContent[] + { + new ResourceContent("https://assets.example.test/capture.png", "image/png", "capture"), + }), + DateTimeOffset.UnixEpoch), + AgentMessage.ToolResult( + secondCall, + new ToolResult(new AgentContent[] { new TextContent("clear") }), + DateTimeOffset.UnixEpoch), + }, + Array.Empty(), + new ModelParameters(), + null, + "run", + 1); + + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + using var document = JsonDocument.Parse(handler.RequestBody!); + var messages = document.RootElement.GetProperty("messages"); + Assert.Equal("tool", messages[3].GetProperty("role").GetString()); + Assert.Equal("tool", messages[4].GetProperty("role").GetString()); + Assert.Equal("user", messages[5].GetProperty("role").GetString()); + var attachments = messages[5].GetProperty("content"); + Assert.Equal("image_url", attachments[1].GetProperty("type").GetString()); + Assert.Equal( + "https://assets.example.test/capture.png", + attachments[1].GetProperty("image_url").GetProperty("url").GetString()); + } + + [Fact] + public async Task ReplaysSignedReasoningForToolContinuationButKeepsUnsignedReasoningPrivate() + { + var handler = new StubHandler(_ => Response( + HttpStatusCode.OK, + "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "text/event-stream")); + var provider = new OpenAICompatibleProvider(new OpenAICompatibleProviderOptions( + new HttpClient(handler), + new Uri("https://example.test/v1/chat/completions"))); + var request = new ModelRequest( + "model", + "rules", + new[] + { + new AgentMessage( + AgentRole.Assistant, + new AgentContent[] + { + new ReasoningContent("provider-state", "reasoning_content"), + new ReasoningContent("private-state"), + new TextContent("answer"), + }, + DateTimeOffset.UnixEpoch, + model: "model", + stopReason: ModelStopReason.Stop), + }, + Array.Empty(), + new ModelParameters(), + null, + "run", + 1); + + await CollectAsync(provider.StreamAsync(request, TestContext.Current.CancellationToken)); + + using var document = JsonDocument.Parse(handler.RequestBody!); + var assistant = document.RootElement.GetProperty("messages")[1]; + Assert.Equal("provider-state", assistant.GetProperty("reasoning_content").GetString()); + Assert.DoesNotContain("private-state", handler.RequestBody, StringComparison.Ordinal); + } + [Fact] public async Task HttpFailureBecomesProviderFailureWithoutIncludingApiKey() { @@ -126,13 +306,35 @@ public async Task HttpFailureBecomesProviderFailureWithoutIncludingApiKey() }; var provider = new OpenAICompatibleProvider(options); - var exception = await Assert.ThrowsAsync(async () => + var exception = await Assert.ThrowsAsync(async () => await CollectAsync(provider.StreamAsync(Request(), TestContext.Current.CancellationToken))); Assert.Contains("429", exception.Message, StringComparison.Ordinal); + Assert.True(exception.IsTransient); + Assert.Equal(429, exception.StatusCode); Assert.DoesNotContain("do-not-expose", exception.ToString(), StringComparison.Ordinal); } + [Fact] + public async Task HttpFailureHonorsRetryDirectivesAndBoundedServerDelay() + { + var handler = new StubHandler(_ => + { + var response = Response(HttpStatusCode.BadRequest, "retry", "text/plain"); + response.Headers.TryAddWithoutValidation("x-should-retry", "true"); + response.Headers.TryAddWithoutValidation("retry-after-ms", "1250"); + return response; + }); + var provider = Create(handler); + + var exception = await Assert.ThrowsAsync(async () => + await CollectAsync(provider.StreamAsync(Request(), TestContext.Current.CancellationToken))); + + Assert.True(exception.IsTransient); + Assert.Equal(TimeSpan.FromMilliseconds(1250), exception.RetryAfter); + Assert.Equal(400, exception.StatusCode); + } + [Fact] public async Task TruncatedStreamWithoutFinishReasonFails() { @@ -148,6 +350,26 @@ public async Task TruncatedStreamWithoutFinishReasonFails() Assert.Contains("ended before", exception.Message, StringComparison.Ordinal); } + [Fact] + public async Task DoneMarkerWithoutFinishReasonIsStrictByDefaultAndCanBeEnabledExplicitly() + { + const string stream = "data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\ndata: [DONE]\n\n"; + var strict = Create(new StubHandler(_ => Response(HttpStatusCode.OK, stream, "text/event-stream"))); + + var exception = await Assert.ThrowsAsync(async () => + await CollectAsync(strict.StreamAsync(Request(), TestContext.Current.CancellationToken))); + Assert.Contains("finish reason", exception.Message, StringComparison.Ordinal); + + var compatible = new OpenAICompatibleProvider(new OpenAICompatibleProviderOptions( + new HttpClient(new StubHandler(_ => Response(HttpStatusCode.OK, stream, "text/event-stream"))), + new Uri("https://example.test/v1/chat/completions")) + { + AllowDoneWithoutFinishReason = true, + }); + var events = await CollectAsync(compatible.StreamAsync(Request(), TestContext.Current.CancellationToken)); + Assert.Equal(ModelStopReason.Stop, events.Last().Response!.StopReason); + } + [Fact] public async Task RequestBodyIsBoundedBeforeTransport() { @@ -379,6 +601,24 @@ public void HeaderLineBreaksAreRejectedBeforeTransport() Assert.Throws(() => new OpenAICompatibleProvider(options)); } + [Fact] + public void RemoteHttpEndpointsRequireAnExplicitDevelopmentOverride() + { + using var client = new HttpClient(new StubHandler(_ => throw new InvalidOperationException("transport must not run"))); + Assert.Throws(() => new OpenAICompatibleProvider(new OpenAICompatibleProviderOptions( + client, + new Uri("http://model.test/v1/chat/completions")))); + + var provider = new OpenAICompatibleProvider(new OpenAICompatibleProviderOptions( + client, + new Uri("http://model.test/v1/chat/completions")) + { + AllowInsecureHttp = true, + }); + + Assert.NotNull(provider); + } + [Fact] public void InvalidOrDuplicateAuthenticationHeadersAreRejectedBeforeTransport() { @@ -396,6 +636,14 @@ public void InvalidOrDuplicateAuthenticationHeadersAreRejectedBeforeTransport() }; duplicate.Headers["Authorization"] = "other"; Assert.Throws(() => new OpenAICompatibleProvider(duplicate)); + + var embeddedNull = new OpenAICompatibleProviderOptions( + new HttpClient(new StubHandler(_ => throw new InvalidOperationException("transport must not run"))), + new Uri("https://example.test/v1/chat/completions")) + { + ApiKey = "secret\0suffix", + }; + Assert.Throws(() => new OpenAICompatibleProvider(embeddedNull)); } [Fact] @@ -433,6 +681,193 @@ public async Task ChoiceAfterFinishReasonIsRejected() Assert.Contains("after its finish reason", exception.Message, StringComparison.Ordinal); } + [Fact] + public async Task DeveloperGatewayCredentialsAreSingleFlightCachedAndRefreshedBeforeExpiry() + { + var now = DateTimeOffset.Parse("2026-08-07T00:00:00Z", System.Globalization.CultureInfo.InvariantCulture); + var source = new GatewayCredentialSource(() => now); + using var cache = new CachedDeveloperGatewayCredentialSource( + source, + TimeSpan.FromMinutes(1), + () => now); + + var first = await Task.WhenAll(Enumerable.Range(0, 16) + .Select(_ => cache.GetAccessTokenAsync(TestContext.Current.CancellationToken).AsTask())); + now = now.AddMinutes(9).AddSeconds(1); + var refreshed = await cache.GetAccessTokenAsync(TestContext.Current.CancellationToken); + + Assert.All(first, token => Assert.Equal("session-token-1", token)); + Assert.Equal("session-token-2", refreshed); + Assert.Equal(2, source.CallCount); + Assert.False(source.ForceRefreshValues.First()); + Assert.True(source.ForceRefreshValues.Last()); + } + + [Fact] + public async Task DeveloperGatewayCredentialBoundariesRejectControlCharactersAndAvoidClockOverflow() + { + Assert.Throws(() => new DeveloperGatewayCredential( + "token\0suffix", + DateTimeOffset.UtcNow.AddMinutes(1))); + var now = DateTimeOffset.MinValue; + var source = new FixedGatewayCredentialSource( + new DeveloperGatewayCredential("short-lived", now.AddMinutes(1))); + using var cache = new CachedDeveloperGatewayCredentialSource( + source, + TimeSpan.FromHours(1), + () => now); + + Assert.Equal("short-lived", await cache.GetAccessTokenAsync(TestContext.Current.CancellationToken)); + Assert.Equal("short-lived", await cache.GetAccessTokenAsync(TestContext.Current.CancellationToken)); + Assert.Equal(2, source.CallCount); + } + + [Fact] + public async Task DeveloperGatewayProviderSendsOnlyShortLivedAccessToken() + { + var now = DateTimeOffset.UtcNow; + var source = new GatewayCredentialSource(() => now); + using var credentials = new CachedDeveloperGatewayCredentialSource(source, clock: () => now); + var handler = new StubHandler(_ => Response( + HttpStatusCode.OK, + "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "text/event-stream")); + var provider = DeveloperGatewayProvider.Create( + new HttpClient(handler), + new Uri("https://gateway.example.test/v1/chat/completions"), + credentials); + + await CollectAsync(provider.StreamAsync(Request(), TestContext.Current.CancellationToken)); + + Assert.Equal("Bearer session-token-1", handler.Authorization); + Assert.DoesNotContain("session-token-1", handler.RequestBody, StringComparison.Ordinal); + } + + [Fact] + public void DeveloperGatewayProviderRejectsStaticProviderKey() + { + using var credentials = new CachedDeveloperGatewayCredentialSource( + new GatewayCredentialSource(() => DateTimeOffset.UtcNow)); + + Assert.Throws(() => DeveloperGatewayProvider.Create( + new HttpClient(new StubHandler(_ => throw new InvalidOperationException("transport must not run"))), + new Uri("https://gateway.example.test/v1/chat/completions"), + credentials, + options => options.ApiKey = "static-key-is-forbid")); + } + + [Fact] + public async Task HttpDeveloperGatewayExchangesPlayerSessionForScopedToken() + { + var expiresAt = DateTimeOffset.UtcNow.AddMinutes(5); + var handler = new StubHandler(_ => Response( + HttpStatusCode.OK, + System.Text.Json.JsonSerializer.Serialize(new + { + accessToken = "scoped-client-token", + expiresAt, + scope = "model:chat tenant:player-1", + }), + "application/json")); + var source = new HttpDeveloperGatewayCredentialSource( + new HttpClient(handler), + new Uri("https://game.example.test/v1/model-token"), + (forceRefresh, _) => new ValueTask>( + new Dictionary + { + ["Authorization"] = "Bearer player-session-token", + ["X-Force-Refresh"] = forceRefresh.ToString(), + })); + + var credential = await source.GetCredentialAsync(true, TestContext.Current.CancellationToken); + + Assert.Equal("scoped-client-token", credential.AccessToken); + Assert.Equal(expiresAt, credential.ExpiresAt); + Assert.Equal("model:chat tenant:player-1", credential.Scope); + Assert.Equal("Bearer player-session-token", handler.Authorization); + Assert.Contains("\"forceRefresh\":true", handler.RequestBody, StringComparison.Ordinal); + } + + [Fact] + public async Task HttpDeveloperGatewayRejectsAmbiguousCredentialResponses() + { + var handler = new StubHandler(_ => Response( + HttpStatusCode.OK, + "{\"accessToken\":\"first\",\"accessToken\":\"second\",\"expiresAt\":\"2030-01-01T00:00:00Z\"}", + "application/json")); + var source = new HttpDeveloperGatewayCredentialSource( + new HttpClient(handler), + new Uri("https://game.example.test/v1/model-token"), + (_, _) => new ValueTask>( + new Dictionary())); + + await Assert.ThrowsAsync(async () => + await source.GetCredentialAsync(false, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AuthenticationFailureInvalidatesGatewayCredentialForNextRequest() + { + var now = DateTimeOffset.UtcNow; + var source = new GatewayCredentialSource(() => now); + using var credentials = new CachedDeveloperGatewayCredentialSource(source, clock: () => now); + var calls = 0; + var handler = new StubHandler(_ => Interlocked.Increment(ref calls) == 1 + ? Response(HttpStatusCode.Unauthorized, "expired", "text/plain") + : Response( + HttpStatusCode.OK, + "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", + "text/event-stream")); + var provider = DeveloperGatewayProvider.Create( + new HttpClient(handler), + new Uri("https://gateway.example.test/v1/chat/completions"), + credentials); + + await Assert.ThrowsAsync(async () => + await CollectAsync(provider.StreamAsync(Request(), TestContext.Current.CancellationToken))); + await CollectAsync(provider.StreamAsync(Request(), TestContext.Current.CancellationToken)); + + Assert.Equal(2, source.CallCount); + Assert.True(source.ForceRefreshValues.Last()); + Assert.Equal("Bearer session-token-2", handler.Authorization); + } + + [Fact] + public async Task AuthenticationFailureCallbackCannotMaskTheProviderError() + { + var handler = new StubHandler(_ => Response(HttpStatusCode.Unauthorized, "expired", "text/plain")); + var options = new OpenAICompatibleProviderOptions( + new HttpClient(handler), + new Uri("https://gateway.example.test/v1/chat/completions")) + { + OnAuthenticationFailure = _ => throw new InvalidOperationException("cache invalidation failed"), + }; + var provider = new OpenAICompatibleProvider(options); + + var exception = await Assert.ThrowsAsync(async () => + await CollectAsync(provider.StreamAsync(Request(), TestContext.Current.CancellationToken))); + + Assert.Equal(401, exception.StatusCode); + Assert.IsType(exception.InnerException); + } + + [Fact] + public async Task InvalidationDuringCredentialRefreshCannotReinstallTheRevokedToken() + { + var now = DateTimeOffset.UtcNow; + var source = new RacingCredentialSource(() => now); + using var credentials = new CachedDeveloperGatewayCredentialSource(source, clock: () => now); + + var pending = credentials.GetAccessTokenAsync(TestContext.Current.CancellationToken).AsTask(); + await source.FirstStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + credentials.Invalidate(); + source.ReleaseFirst.TrySetResult(); + + Assert.Equal("session-token-2", await pending); + Assert.Equal(2, source.CallCount); + Assert.True(source.ForceRefreshValues.Last()); + } + private static OpenAICompatibleProvider Create(HttpMessageHandler handler) => new(new OpenAICompatibleProviderOptions( new HttpClient(handler), @@ -485,4 +920,93 @@ protected override async Task SendAsync( return _response(request); } } + + private sealed class GatewayCredentialSource : IDeveloperGatewayCredentialSource + { + private readonly Func _clock; + private int _calls; + + public GatewayCredentialSource(Func clock) + { + _clock = clock; + } + + public int CallCount => Volatile.Read(ref _calls); + + public ConcurrentQueue ForceRefreshValues { get; } = new(); + + public ValueTask GetCredentialAsync( + bool forceRefresh, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ForceRefreshValues.Enqueue(forceRefresh); + var call = Interlocked.Increment(ref _calls); + return new ValueTask(new DeveloperGatewayCredential( + "session-token-" + call.ToString(System.Globalization.CultureInfo.InvariantCulture), + _clock().AddMinutes(10), + "model:chat")); + } + } + + private sealed class FixedGatewayCredentialSource : IDeveloperGatewayCredentialSource + { + private readonly DeveloperGatewayCredential _credential; + private int _calls; + + public FixedGatewayCredentialSource(DeveloperGatewayCredential credential) + { + _credential = credential; + } + + public int CallCount => Volatile.Read(ref _calls); + + public ValueTask GetCredentialAsync( + bool forceRefresh, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Interlocked.Increment(ref _calls); + return new ValueTask(_credential); + } + } + + private sealed class RacingCredentialSource : IDeveloperGatewayCredentialSource + { + private readonly Func _clock; + private int _calls; + + public RacingCredentialSource(Func clock) + { + _clock = clock; + } + + public int CallCount => Volatile.Read(ref _calls); + + public ConcurrentQueue ForceRefreshValues { get; } = new(); + + public TaskCompletionSource FirstStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TaskCompletionSource ReleaseFirst { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async ValueTask GetCredentialAsync( + bool forceRefresh, + CancellationToken cancellationToken) + { + ForceRefreshValues.Enqueue(forceRefresh); + var call = Interlocked.Increment(ref _calls); + if (call == 1) + { + FirstStarted.TrySetResult(); + await ReleaseFirst.Task.WaitAsync(cancellationToken); + } + + return new DeveloperGatewayCredential( + "session-token-" + call.ToString(System.Globalization.CultureInfo.InvariantCulture), + _clock().AddMinutes(10), + "model:chat"); + } + } } diff --git a/tests/OpenGameAgent.Server.Tests/ServerTests.cs b/tests/OpenGameAgent.Server.Tests/ServerTests.cs index 9647bf5..287bed7 100644 --- a/tests/OpenGameAgent.Server.Tests/ServerTests.cs +++ b/tests/OpenGameAgent.Server.Tests/ServerTests.cs @@ -26,6 +26,26 @@ public void ClientAndMiddlewareRejectInvalidAuthenticationHeaders() var builder = WebApplication.CreateBuilder(); var app = builder.Build(); Assert.Throws(() => app.UseOpenGameAgentApiKey("secret", "Bad:Name")); + Assert.Throws(() => app.UseOpenGameAgentApiKey("secret\0value")); + Assert.Throws(() => app.UseOpenGameAgentApiKey("secret", scheme: new string('s', 257))); + } + + [Fact] + public void ClientRequiresTlsForRemoteServersUnlessExplicitlyOverridden() + { + using var httpClient = new HttpClient(new StaticResponseHandler("{}")); + Assert.Throws(() => new ServerGameAgentClient(new ServerGameAgentClientOptions( + httpClient, + new Uri("http://agent.test/")))); + + var client = new ServerGameAgentClient(new ServerGameAgentClientOptions( + httpClient, + new Uri("http://agent.test/")) + { + AllowInsecureHttp = true, + }); + + Assert.NotNull(client); } [Fact] @@ -59,6 +79,37 @@ public async Task JsonEndpointRunsSharedRuntime() Assert.Equal("hello", messages[1].GetProperty("content")[0].GetProperty("text").GetString()); } + [Fact] + public async Task ServerRunPreservesResourceReferencesFromTheEngineWireFormat() + { + var provider = new ResourceCaptureProvider(); + await using var app = await CreateAppAsync( + new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "test"))); + using var client = app.GetTestClient(); + var input = new GameInput( + "session", + "resource-actor", + "observe", + "{}", + new GameMoment("world", 1), + "resource-input", + resources: new[] + { + new ResourceContent("game://capture/frame", "image/png", "frame"), + }); + using var content = new StringContent( + GameAgentWire.SerializeInput(input), + Encoding.UTF8, + "application/json"); + + using var response = await client.PostAsync("/v1/run", content, TestContext.Current.CancellationToken); + + response.EnsureSuccessStatusCode(); + var resource = Assert.Single(Assert.Single(provider.Requests).Messages.SelectMany(message => message.Content).OfType()); + Assert.Equal("game://capture/frame", resource.Uri); + Assert.Equal("image/png", resource.MediaType); + } + [Fact] public async Task StreamingEndpointEmitsAgentEventsAndTerminalResult() { @@ -151,9 +202,16 @@ public async Task WireRoundTripPreservesAbsentCalendarAndStreamsToolIdentity() "actor", "event", "{}", - new GameMoment("world", 1)); + new GameMoment("world", 1), + resources: new[] + { + new ResourceContent("https://assets.example.test/frame.png", "image/png", "frame"), + }); var roundTrip = GameAgentWire.ParseInput(GameAgentWire.SerializeInput(input)); Assert.Null(roundTrip.Moment.CalendarJson); + var roundTripResource = Assert.Single(roundTrip.Resources); + Assert.Equal("https://assets.example.test/frame.png", roundTripResource.Uri); + Assert.Equal("image/png", roundTripResource.MediaType); string? json = null; var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(new ToolIdentityProvider(), "test") @@ -208,6 +266,38 @@ public async Task RuntimeLimitViolationsReturnBadRequestForJsonAndSse() } } + [Fact] + public async Task ServerRejectsOversizedBodiesBeforeEndpointParsing() + { + await using var app = await CreateAppAsync(maximumRequestBodyBytes: 128); + using var client = app.GetTestClient(); + var oversized = "{\"padding\":\"" + new string('x', 256) + "\"}"; + + foreach (var path in new[] { "/v1/run", "/v1/run/stream", "/v1/control/steer", "/v1/control/abort" }) + { + using var content = new StringContent(oversized, Encoding.UTF8, "application/json"); + using var response = await client.PostAsync(path, content, TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.Equal(System.Net.HttpStatusCode.RequestEntityTooLarge, response.StatusCode); + Assert.Contains("request_too_large", body, StringComparison.Ordinal); + } + } + + [Fact] + public async Task ServerRejectsNonJsonRequestBodies() + { + await using var app = await CreateAppAsync(); + using var client = app.GetTestClient(); + using var content = new StringContent(RequestJson("plain"), Encoding.UTF8, "text/plain"); + + using var response = await client.PostAsync("/v1/run", content, TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + Assert.Equal(System.Net.HttpStatusCode.UnsupportedMediaType, response.StatusCode); + Assert.Contains("unsupported_media_type", body, StringComparison.Ordinal); + } + [Fact] public async Task EngineCompatibleClientConsumesServerSse() { @@ -516,23 +606,27 @@ public void ServerRejectsWhitespaceOnlyApiKeyConfiguration() })); } - private static async Task CreateAppAsync(string? apiKey = null) + private static async Task CreateAppAsync( + string? apiKey = null, + int maximumRequestBodyBytes = ServerEndpoints.DefaultMaximumRequestBodyBytes) { return await CreateAppAsync( new GameAgentRuntime(new GameAgentRuntimeOptions(new StreamingProvider(), "test")), - apiKey); + apiKey, + maximumRequestBodyBytes); } private static async Task CreateAppAsync( GameAgentRuntime runtime, - string? apiKey = null) + string? apiKey = null, + int maximumRequestBodyBytes = ServerEndpoints.DefaultMaximumRequestBodyBytes) { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); builder.Services.AddSingleton(runtime); var app = builder.Build(); app.UseOpenGameAgentApiKey(apiKey); - app.MapOpenGameAgent(); + app.MapOpenGameAgent(maximumRequestBodyBytes); await app.StartAsync(TestContext.Current.CancellationToken); return app; } @@ -557,6 +651,9 @@ public async IAsyncEnumerable StreamAsync( { cancellationToken.ThrowIfCancellationRequested(); await Task.Yield(); + yield return ModelStreamEvent.Update( + ModelStreamEventKind.Started, + new ModelResponse(Array.Empty(), ModelStopReason.Pending)); yield return ModelStreamEvent.Update( ModelStreamEventKind.TextDelta, new ModelResponse(new AgentContent[] { new TextContent("hel") }, ModelStopReason.Pending), @@ -566,6 +663,22 @@ public async IAsyncEnumerable StreamAsync( } } + private sealed class ResourceCaptureProvider : IModelProvider + { + public System.Collections.Concurrent.ConcurrentQueue Requests { get; } = new(); + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Requests.Enqueue(request); + await Task.Yield(); + yield return ModelStreamEvent.Terminal( + new ModelResponse(new AgentContent[] { new TextContent("ok") }, ModelStopReason.Stop)); + } + } + private sealed class ToolIdentityProvider : IModelProvider { private int _calls; @@ -579,6 +692,9 @@ public async IAsyncEnumerable StreamAsync( await Task.Yield(); if (Interlocked.Increment(ref _calls) == 1) { + yield return ModelStreamEvent.Update( + ModelStreamEventKind.Started, + new ModelResponse(Array.Empty(), ModelStopReason.Pending)); yield return ModelStreamEvent.Update( ModelStreamEventKind.ToolCallDelta, new ModelResponse(Array.Empty(), ModelStopReason.Pending), diff --git a/tests/OpenGameAgent.Server.Tests/packages.lock.json b/tests/OpenGameAgent.Server.Tests/packages.lock.json index e4990a2..9ec18e4 100644 --- a/tests/OpenGameAgent.Server.Tests/packages.lock.json +++ b/tests/OpenGameAgent.Server.Tests/packages.lock.json @@ -230,6 +230,12 @@ "System.Text.Json": "[8.0.6, )" } }, + "opengameagent.extensions": { + "type": "Project", + "dependencies": { + "OpenGameAgent": "[0.3.0-alpha.1, )" + } + }, "opengameagent.kernel": { "type": "Project", "dependencies": { @@ -240,6 +246,7 @@ "type": "Project", "dependencies": { "OpenGameAgent": "[0.3.0-alpha.1, )", + "OpenGameAgent.Extensions": "[0.3.0-alpha.1, )", "System.Text.Json": "[8.0.6, )" } }, diff --git a/tests/OpenGameAgent.Tests/ExtensionRuntimeTests.cs b/tests/OpenGameAgent.Tests/ExtensionRuntimeTests.cs new file mode 100644 index 0000000..3fa4555 --- /dev/null +++ b/tests/OpenGameAgent.Tests/ExtensionRuntimeTests.cs @@ -0,0 +1,647 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Text.Json; +using OpenGameAgent.Kernel; +using Xunit; + +namespace OpenGameAgent.Tests; + +public sealed class ExtensionRuntimeTests +{ + [Fact] + public async Task BuilderUsesExtensionProviderPromptAndLifecycleEvents() + { + var primary = new CaptureProvider(); + var alternate = new CaptureProvider(); + var events = new ConcurrentQueue(); + await using var runtime = new GameAgentBuilder(primary, "primary-model") + .UseInstructions("base") + .UseModelSelector((input, _) => new ValueTask(new GameModelSelection( + input.Metadata["agent.model"], + input.Metadata["agent.provider"]))) + .UseExtension( + "test.provider", + "1.0.0", + api => + { + api.RegisterModelProvider("alternate", alternate); + api.RegisterPromptFragment("guidance", "extension guidance"); + api.On(GameAgentExtensionEvents.SessionLoaded, (_, _, _) => + { + events.Enqueue("loaded"); + return ValueTask.CompletedTask; + }); + api.On(GameAgentExtensionEvents.RunCompleted, (_, _, _) => + { + events.Enqueue("completed"); + return ValueTask.CompletedTask; + }); + }) + .Build(); + + var result = await runtime.RunAsync( + Input( + "chat", + "first", + new Dictionary { ["agent.provider"] = "alternate", ["agent.model"] = "fast" }), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Empty(primary.Requests); + var request = Assert.Single(alternate.Requests); + Assert.Equal("fast", request.Model); + Assert.Contains("base", request.SystemPrompt); + Assert.Contains("extension guidance", request.SystemPrompt); + Assert.Equal(new[] { "loaded", "completed" }, events.ToArray()); + } + + [Fact] + public void BuilderTransfersOwnershipOnItsFirstConstructionAttempt() + { + var builder = new GameAgentBuilder(new CaptureProvider(), "model") + .Configure(options => options.Model = " "); + + Assert.Throws(() => builder.Build()); + Assert.Throws(() => builder.Configure(options => options.Model = "model")); + Assert.Throws(() => builder.Build()); + } + + [Fact] + public void RuntimeConstructionDisposesConfiguredExtensionsAndPreservesTheOriginalFailure() + { + var probe = new ConstructionProbeExtension(throwOnDispose: true); + var builder = new GameAgentBuilder(new CaptureProvider(), "model") + .Configure(options => options.Workflows.Add(new ProbeWorkflow("duplicate"))) + .UseExtension(probe); + + var error = Assert.Throws(() => builder.Build()); + + Assert.Contains("Duplicate workflow", error.Message, StringComparison.Ordinal); + Assert.True(probe.Disposed); + } + + [Fact] + public async Task InputMetadataCannotOverrideModelWithoutAnExplicitHostSelector() + { + var primary = new CaptureProvider(); + var alternate = new CaptureProvider(); + await using var runtime = new GameAgentBuilder(primary, "primary-model") + .UseExtension("test.provider", "1.0.0", api => api.RegisterModelProvider("alternate", alternate)) + .Build(); + + var result = await runtime.RunAsync( + Input( + "chat", + "first", + new Dictionary { ["agent.provider"] = "alternate", ["agent.model"] = "expensive" }), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Empty(alternate.Requests); + Assert.Equal("primary-model", Assert.Single(primary.Requests).Model); + } + + [Fact] + public async Task ExtensionStateIsNamespacedPersistedAndNotInjectedAutomatically() + { + var provider = new CaptureProvider(); + var store = new InMemoryGameSessionStore(); + var loadedCounts = new ConcurrentQueue(); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseSessionStore(store) + .UseExtension( + "test.state", + "1.0.0", + api => + { + api.On(GameAgentExtensionEvents.SessionLoaded, (_, context, _) => + { + var value = context.State.Get("count"); + loadedCounts.Enqueue(value is null ? 0 : int.Parse(value, System.Globalization.CultureInfo.InvariantCulture)); + return ValueTask.CompletedTask; + }); + api.On(GameAgentExtensionEvents.SessionSaving, (_, context, _) => + { + var current = context.State.Get("count"); + var count = current is null + ? 0 + : int.Parse(current, System.Globalization.CultureInfo.InvariantCulture); + context.State.Set("count", (count + 1).ToString(System.Globalization.CultureInfo.InvariantCulture)); + return ValueTask.CompletedTask; + }); + }) + .Build(); + + await runtime.RunAsync(Input("chat", "one"), TestContext.Current.CancellationToken); + await runtime.RunAsync(Input("chat", "two"), TestContext.Current.CancellationToken); + + Assert.Equal(new[] { 0, 1 }, loadedCounts.ToArray()); + var snapshot = await store.LoadAsync( + new GameSessionKey("session", "actor"), + TestContext.Current.CancellationToken); + Assert.Equal("2", Assert.Single(snapshot!.ExtensionState).Value); + Assert.DoesNotContain("test.state", provider.Requests.Last().SystemPrompt, StringComparison.Ordinal); + Assert.DoesNotContain("count", provider.Requests.Last().SystemPrompt, StringComparison.Ordinal); + } + + [Fact] + public async Task CapturedRunStateIsInvalidatedAfterLifecycleCompletion() + { + var provider = new CaptureProvider(); + GameAgentExtensionRunContext? captured = null; + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension("capture", "1", api => api.On( + GameAgentExtensionEvents.SessionLoaded, + (_, context, _) => + { + captured = context; + context.State.Set("during", "true"); + return ValueTask.CompletedTask; + })) + .Build(); + + var result = await runtime.RunAsync(Input("chat", "lease"), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.NotNull(captured); + Assert.False(captured.IsActive); + Assert.Throws(() => captured.State.Set("late", "true")); + Assert.Throws(() => captured.State.Get("during")); + } + + [Fact] + public async Task RuntimeShutdownWaitsForActorLanesBeforeDisposingExtensions() + { + var provider = new BlockingProvider(); + var extension = new ShutdownProbeExtension(() => provider.Stopped); + var options = new GameAgentRuntimeOptions(provider, "model"); + options.Extensions.Add(extension); + var runtime = new GameAgentRuntime(options); + var active = runtime.RunAsync(Input("chat", "active"), TestContext.Current.CancellationToken); + await provider.Entered.Task.WaitAsync(TestContext.Current.CancellationToken); + var queued = runtime.RunAsync(Input("chat", "queued"), TestContext.Current.CancellationToken); + + await runtime.DisposeAsync(); + + Assert.True(provider.Stopped); + Assert.True(extension.Disposed); + Assert.True(extension.ProviderWasStoppedAtDispose); + Assert.Equal(1, provider.CallCount); + var settled = await active; + Assert.Equal(AgentRunStatus.Aborted, settled.AgentResult?.Status); + await Assert.ThrowsAnyAsync(async () => await queued); + } + + [Fact] + public async Task ExtensionChannelSettlementIsSafeDuringShutdown() + { + var extension = new ShutdownPublishingExtension(); + var runtime = new GameAgentBuilder(new CaptureProvider(), "model") + .UseExtension(extension) + .Build(); + + await runtime.DisposeAsync(); + + Assert.True(extension.Published); + } + + [Fact] + public void SynchronousRuntimeDisposeDoesNotDeadlockOnAnEngineSynchronizationContext() + { + var extension = new YieldingDisposeExtension(); + Exception? failure = null; + using var completed = new ManualResetEventSlim(); + var thread = new Thread(() => + { + SynchronizationContext.SetSynchronizationContext(new NonPumpingSynchronizationContext()); + try + { + using var runtime = new GameAgentBuilder(new CaptureProvider(), "model") + .UseExtension(extension) + .Build(); + } + catch (Exception exception) + { + failure = exception; + } + finally + { + completed.Set(); + } + }) + { + IsBackground = true, + }; + + thread.Start(); + + Assert.True( + completed.Wait(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken), + "Synchronous disposal deadlocked."); + Assert.Null(failure); + Assert.True(extension.Disposed); + } + + [Fact] + public void DuplicateResourcesFailWithAnAttributedConflict() + { + var provider = new CaptureProvider(); + var builder = new GameAgentBuilder(provider, "model") + .UseExtension("first", "1", api => api.RegisterPromptFragment("same", "one")) + .UseExtension("second", "1", api => api.RegisterPromptFragment("same", "two")); + + var exception = Assert.Throws(() => builder.Build()); + + Assert.Contains("first", exception.Message); + Assert.Contains("same", exception.Message); + } + + [Fact] + public async Task DisposedDynamicRegistrationIsAbsentFromFutureRuns() + { + var provider = new CaptureProvider(); + var extension = new DynamicToolExtension(); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension(extension) + .Build(); + Assert.Contains(runtime.ExtensionResources, resource => resource.Name == "temporary"); + + extension.Registration!.Dispose(); + await runtime.RunAsync(Input("chat", "run"), TestContext.Current.CancellationToken); + + Assert.DoesNotContain(runtime.ExtensionResources, resource => resource.Name == "temporary"); + Assert.Empty(Assert.Single(provider.Requests).Tools); + } + + [Fact] + public async Task HigherPriorityRouteRuleWinsDeterministically() + { + var provider = new CaptureProvider(); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension( + "routes", + "1", + api => + { + api.RegisterRouteRule( + "low", + (_, _, _, _) => new ValueTask(GameRouteDecision.Agent("low")), + priority: 0); + api.RegisterRouteRule( + "high", + (_, _, _, _) => new ValueTask(GameRouteDecision.Quick("high")), + priority: 10); + }) + .Build(); + + var result = await runtime.RunAsync(Input("event", "route"), TestContext.Current.CancellationToken); + + Assert.Equal(GameRouteKind.QuickResponse, result.Route.Route); + Assert.Equal("high", result.Route.Reason); + } + + [Fact] + public async Task EventHandlerFailureIsIsolatedAndDiagnosed() + { + var provider = new CaptureProvider(); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension( + "broken.event", + "1", + api => api.On( + GameAgentExtensionEvents.SessionLoaded, + (_, _, _) => throw new InvalidOperationException("broken"))) + .Build(); + + var result = await runtime.RunAsync(Input("chat", "safe"), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + var diagnostic = Assert.Single(runtime.ExtensionDiagnostics); + Assert.Equal("extension.event_handler_failed", diagnostic.Code); + Assert.Equal("broken.event", diagnostic.ExtensionId); + } + + [Fact] + public async Task ExtensionDiagnosticsAreBoundedAndMessagesAreTruncated() + { + var provider = new CaptureProvider(); + await using var runtime = new GameAgentBuilder(provider, "model") + .Configure(options => + { + options.Limits.MaxExtensionDiagnostics = 2; + options.Limits.MaxExtensionDiagnosticCharacters = 16; + }) + .UseExtension( + "broken.event", + "1", + api => api.On( + GameAgentExtensionEvents.SessionLoaded, + (_, _, _) => throw new InvalidOperationException(new string('x', 1_000)))) + .Build(); + + await runtime.RunAsync(Input("chat", "one"), TestContext.Current.CancellationToken); + await runtime.RunAsync(Input("chat", "two"), TestContext.Current.CancellationToken); + await runtime.RunAsync(Input("chat", "three"), TestContext.Current.CancellationToken); + + Assert.Equal(2, runtime.ExtensionDiagnostics.Count); + Assert.All(runtime.ExtensionDiagnostics, diagnostic => Assert.Equal(16, diagnostic.Message.Length)); + } + + [Fact] + public void ExtensionAndResourceRegistrationLimitsFailClosed() + { + var noExtensions = new GameAgentBuilder(new CaptureProvider(), "model") + .Configure(options => options.Limits.MaxExtensions = 0) + .UseExtension("one", "1", _ => { }); + Assert.Throws(() => noExtensions.Build()); + + var oneResource = new GameAgentBuilder(new CaptureProvider(), "model") + .Configure(options => options.Limits.MaxExtensionResources = 1) + .UseExtension("one", "1", api => + { + api.RegisterPromptFragment("first", "one"); + api.RegisterPromptFragment("second", "two"); + }); + Assert.Throws(() => oneResource.Build()); + } + + [Fact] + public async Task BeforeToolHooksComposeRevalidatedArgumentsAndCannotBypassLaterPolicy() + { + var provider = new ToolCallingProvider(); + var executions = 0; + var policySawValue = 0; + await using var runtime = new GameAgentBuilder(provider, "model") + .Configure(options => options.ToolProvider = (_, _) => + new ValueTask>(new[] + { + new AgentTool( + new ToolDefinition( + "change", + "Change a value.", + "{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"integer\"}},\"required\":[\"value\"],\"additionalProperties\":false}"), + (_, _, _) => + { + executions++; + return new ValueTask(new ToolResult(new AgentContent[] { new TextContent("changed") })); + }, + ToolRisk.IdempotentWrite), + })) + .UseExtension("rewrite", "1", api => api.RegisterAgentHooks( + "rewrite", + _ => new AgentHooks + { + BeforeToolCallAsync = (_, _, _) => new ValueTask( + ToolCallDecision.Allow("{\"value\":2}")), + }, + priority: 10)) + .UseExtension("policy", "1", api => api.RegisterAgentHooks( + "policy", + _ => new AgentHooks + { + BeforeToolCallAsync = (call, _, _) => + { + using var arguments = JsonDocument.Parse(call.ArgumentsJson); + policySawValue = arguments.RootElement.GetProperty("value").GetInt32(); + return new ValueTask(ToolCallDecision.Block("denied")); + }, + })) + .Build(); + + var result = await runtime.RunAsync(Input("chat", "hooks"), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(2, policySawValue); + Assert.Equal(0, executions); + Assert.Contains(result.AgentResult!.NewMessages, message => + message.Role == AgentRole.Tool + && message.IsError + && Assert.IsType(Assert.Single(message.Content)).Text == "denied"); + } + + private static GameInput Input( + string type, + string inputId, + IReadOnlyDictionary? metadata = null) => + new( + "session", + "actor", + type, + "{}", + new GameMoment("world", 1), + inputId, + metadata); + + private sealed class DynamicToolExtension : IGameAgentExtension + { + public GameAgentExtensionDescriptor Descriptor { get; } = new("dynamic", "1"); + + public IGameAgentExtensionRegistration? Registration { get; private set; } + + public void Configure(GameAgentExtensionApi api) + { + Registration = api.RegisterTool(new AgentTool( + new ToolDefinition("temporary", "Temporary test tool.", "{\"type\":\"object\",\"additionalProperties\":false}"), + (_, _, _) => new ValueTask(new ToolResult(new AgentContent[] { new TextContent("ok") })))); + } + } + + private sealed class ProbeWorkflow : IGameWorkflow + { + public ProbeWorkflow(string name) + { + Name = name; + } + + public string Name { get; } + + public ValueTask RunAsync( + GameWorkflowContext context, + CancellationToken cancellationToken) + { + _ = context; + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(new GameWorkflowResult( + new[] + { + new AgentMessage( + AgentRole.Assistant, + new AgentContent[] { new TextContent("ok") }, + DateTimeOffset.UnixEpoch, + model: "workflow", + stopReason: ModelStopReason.Stop), + }, + succeeded: true)); + } + } + + private sealed class ConstructionProbeExtension : IGameAgentExtension, IDisposable + { + private readonly bool _throwOnDispose; + + public ConstructionProbeExtension(bool throwOnDispose) + { + _throwOnDispose = throwOnDispose; + } + + public GameAgentExtensionDescriptor Descriptor { get; } = new("construction-probe", "1"); + + public bool Disposed { get; private set; } + + public void Configure(GameAgentExtensionApi api) => + api.RegisterWorkflow(new ProbeWorkflow("duplicate")); + + public void Dispose() + { + Disposed = true; + if (_throwOnDispose) + { + throw new InvalidOperationException("cleanup failed"); + } + } + } + + private sealed class CaptureProvider : IModelProvider + { + public ConcurrentQueue Requests { get; } = new(); + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Requests.Enqueue(request); + yield return ModelStreamEvent.Terminal(new ModelResponse( + new AgentContent[] { new TextContent("ok") }, + ModelStopReason.Stop)); + await Task.CompletedTask; + } + } + + private sealed class ToolCallingProvider : IModelProvider + { + private int _calls; + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + _ = request; + cancellationToken.ThrowIfCancellationRequested(); + var response = Interlocked.Increment(ref _calls) == 1 + ? new ModelResponse( + new AgentContent[] { new ToolCallContent("change-1", "change", "{\"value\":1}") }, + ModelStopReason.ToolUse) + : new ModelResponse(new AgentContent[] { new TextContent("done") }, ModelStopReason.Stop); + yield return ModelStreamEvent.Terminal(response); + await Task.CompletedTask; + } + } + + private sealed class BlockingProvider : IModelProvider + { + private int _calls; + private int _stopped; + + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public int CallCount => Volatile.Read(ref _calls); + + public bool Stopped => Volatile.Read(ref _stopped) != 0; + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + _ = request; + Interlocked.Increment(ref _calls); + Entered.TrySetResult(); + try + { + await Task.Delay(Timeout.Infinite, cancellationToken); + } + finally + { + Interlocked.Exchange(ref _stopped, 1); + } + + yield break; + } + } + + private sealed class ShutdownProbeExtension : IGameAgentExtension, IAsyncDisposable + { + private readonly Func _providerStopped; + + public ShutdownProbeExtension(Func providerStopped) + { + _providerStopped = providerStopped; + } + + public GameAgentExtensionDescriptor Descriptor { get; } = new("shutdown-probe", "1"); + + public bool Disposed { get; private set; } + + public bool ProviderWasStoppedAtDispose { get; private set; } + + public void Configure(GameAgentExtensionApi api) + { + _ = api; + } + + public ValueTask DisposeAsync() + { + ProviderWasStoppedAtDispose = _providerStopped(); + Disposed = true; + return ValueTask.CompletedTask; + } + } + + private sealed class YieldingDisposeExtension : IGameAgentExtension, IAsyncDisposable + { + public GameAgentExtensionDescriptor Descriptor { get; } = new("yielding-dispose", "1"); + + public bool Disposed { get; private set; } + + public void Configure(GameAgentExtensionApi api) + { + _ = api; + } + + public async ValueTask DisposeAsync() + { + await Task.Yield(); + Disposed = true; + } + } + + private sealed class ShutdownPublishingExtension : IGameAgentExtension, IAsyncDisposable + { + private static readonly GameAgentExtensionChannel Settlement = new("shutdown-settlement"); + private GameAgentExtensionApi? _api; + + public GameAgentExtensionDescriptor Descriptor { get; } = new("shutdown-publishing", "1"); + + public bool Published { get; private set; } + + public void Configure(GameAgentExtensionApi api) + { + _api = api; + } + + public async ValueTask DisposeAsync() + { + await _api!.PublishAsync(Settlement, "settled"); + Published = true; + } + } + + private sealed class NonPumpingSynchronizationContext : SynchronizationContext + { + public override void Post(SendOrPostCallback callback, object? state) + { + _ = callback; + _ = state; + } + } +} diff --git a/tests/OpenGameAgent.Tests/RuntimeTests.cs b/tests/OpenGameAgent.Tests/RuntimeTests.cs index 692994e..8fac137 100644 --- a/tests/OpenGameAgent.Tests/RuntimeTests.cs +++ b/tests/OpenGameAgent.Tests/RuntimeTests.cs @@ -8,6 +8,22 @@ namespace OpenGameAgent.Tests; public sealed class RuntimeTests { + [Fact] + public void GameCoordinatesExposeConsistentValueOperators() + { + var first = new GameMoment("world", 1); + var second = new GameMoment("world", 2); + var key = new GameSessionKey("session", "actor"); + + Assert.True(first < second); + Assert.True(first <= second); + Assert.True(second > first); + Assert.True(second >= first); + Assert.True(key == new GameSessionKey("session", "actor")); + Assert.True(key != new GameSessionKey("session", "other")); + Assert.Throws(() => first < new GameMoment("fork", 2)); + } + [Fact] public void InMemorySkillSourceRequiresPositiveCapacity() { @@ -79,6 +95,32 @@ public async Task StructuredInputRetainsNumbersBooleansAndArrays() Assert.Equal(3, payload.GetProperty("cells").GetArrayLength()); } + [Fact] + public async Task StructuredGameInputForwardsAttachedModelResources() + { + var provider = new RecordingProvider(_ => Text("ok")); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "test")); + var input = new GameInput( + "session", + "actor", + "observation", + "{\"question\":\"what is visible?\"}", + new GameMoment("world", 10), + resources: new[] + { + new ResourceContent("https://assets.example.test/frame.png", "image/png", "camera"), + }); + + var result = await runtime.RunAsync(input, TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + var message = Assert.Single(provider.Requests).Messages.Last(); + Assert.IsType(message.Content[0]); + var resource = Assert.IsType(message.Content[1]); + Assert.Equal("image/png", resource.MediaType); + Assert.Equal("https://assets.example.test/frame.png", resource.Uri); + } + [Fact] public async Task QuickRouteUsesOneModelTurnAndNoTools() { @@ -138,6 +180,103 @@ public async Task AgentRouteCommitsDurableActionOnceAndDeduplicatesInput() Assert.Equal(GameActionStatus.Committed, entry!.Receipt!.Status); } + [Fact] + public async Task SettledToolTurnIsCheckpointedBeforeTheInputIsMarkedComplete() + { + var provider = new RecordingProvider(call => call == 1 + ? Tools(new ToolCallContent("read-1", "inspect", "{}")) + : Text("done")); + var store = new RecordingSessionStore(); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "test") + { + SessionStore = store, + ToolProvider = (_, _) => new ValueTask>( + new[] { ReadTool("inspect") }), + RoutePolicy = new AutomaticGameRoutePolicy(new Dictionary + { + ["inspect"] = GameRouteDecision.Agent("typed"), + }), + }); + + var result = await runtime.RunAsync( + Input("inspect", "{}", "checkpoint-input"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(2, result.SessionRevision); + Assert.Equal(2, store.SavedSnapshots.Count); + var checkpoint = store.SavedSnapshots[0]; + Assert.Equal(1, checkpoint.Revision); + Assert.Empty(checkpoint.ProcessedInputIds); + Assert.Equal("checkpoint-input", checkpoint.PendingInputId); + Assert.Equal(3, checkpoint.Messages.Count); + Assert.Equal(AgentRole.Tool, checkpoint.Messages[^1].Role); + var final = store.SavedSnapshots[1]; + Assert.Equal(2, final.Revision); + Assert.Contains("checkpoint-input", final.ProcessedInputIds); + Assert.Null(final.PendingInputId); + } + + [Fact] + public async Task DurableToolCheckpointResumesWithoutAppendingTheInputTwice() + { + var store = new FailSecondSaveSessionStore(); + var input = Input("inspect", "{\"target\":\"gate\"}", "resume-input"); + var firstProvider = new RecordingProvider(call => call == 1 + ? Tools(new ToolCallContent("read-1", "inspect", "{}")) + : Text("lost final response")); + await using (var firstRuntime = CreateCheckpointRuntime(firstProvider, store)) + { + await Assert.ThrowsAsync(async () => + await firstRuntime.RunAsync(input, TestContext.Current.CancellationToken)); + } + + var checkpoint = await store.LoadAsync( + new GameSessionKey(input.SessionId, input.ActorId), + TestContext.Current.CancellationToken); + Assert.Equal("resume-input", checkpoint!.PendingInputId); + Assert.Equal(AgentRole.Tool, checkpoint.Messages[^1].Role); + + var unrelatedProvider = new RecordingProvider(_ => Text("must not run")); + await using (var blockedRuntime = CreateCheckpointRuntime(unrelatedProvider, store)) + { + var blocked = await blockedRuntime.RunAsync( + Input("inspect", "{}", "different-input"), + TestContext.Current.CancellationToken); + Assert.Equal(GameAgentRunStatus.SessionConflict, blocked.Status); + Assert.Equal(0, unrelatedProvider.CallCount); + } + + var resumeProvider = new RecordingProvider(_ => Text("resumed")); + await using (var resumeRuntime = CreateCheckpointRuntime(resumeProvider, store)) + { + var result = await resumeRuntime.RunAsync(input, TestContext.Current.CancellationToken); + Assert.True(result.Succeeded); + } + + var request = Assert.Single(resumeProvider.Requests); + Assert.Equal(1, request.Messages.Count(message => + message.Metadata.TryGetValue("game.input_id", out var value) + && value == "resume-input")); + var completed = await store.LoadAsync( + new GameSessionKey(input.SessionId, input.ActorId), + TestContext.Current.CancellationToken); + Assert.Null(completed!.PendingInputId); + Assert.Contains("resume-input", completed.ProcessedInputIds); + + GameAgentRuntime CreateCheckpointRuntime(IModelProvider provider, IGameSessionStore sessionStore) => + new(new GameAgentRuntimeOptions(provider, "test") + { + SessionStore = sessionStore, + ToolProvider = (_, _) => new ValueTask>( + new[] { ReadTool("inspect") }), + RoutePolicy = new AutomaticGameRoutePolicy(new Dictionary + { + ["inspect"] = GameRouteDecision.Agent("typed"), + }), + }); + } + [Fact] public async Task AgentRouteRefreshesWorldContextAfterAToolTurn() { @@ -272,6 +411,73 @@ public async Task ActiveActorCanBeAbortedBySessionAndActorKey() Assert.False(runtime.TryAbort(new GameSessionKey("session", "actor"))); } + [Fact] + public async Task CallerCancellationStillDurablySettlesAnAlreadyStartedRun() + { + var provider = new BlockingFirstResponseProvider(); + var store = new InMemoryGameSessionStore(); + await using var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "test") + { + SessionStore = store, + RoutePolicy = new AutomaticGameRoutePolicy(new Dictionary + { + ["autonomous"] = GameRouteDecision.Agent("typed"), + }), + }); + using var cancellation = new CancellationTokenSource(); + + var run = runtime.RunAsync(Input("autonomous", "{}", "cancel-input"), cancellation.Token); + await provider.FirstRequestStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + cancellation.Cancel(); + + var canceledRun = await run.WaitAsync(TestContext.Current.CancellationToken); + Assert.Equal(AgentRunStatus.Aborted, canceledRun.AgentResult?.Status); + await runtime.DisposeAsync(); + + var saved = await store.LoadAsync( + new GameSessionKey("session", "actor"), + TestContext.Current.CancellationToken); + Assert.NotNull(saved); + Assert.Equal(1, saved.Revision); + Assert.Contains("cancel-input", saved.ProcessedInputIds); + var terminal = Assert.IsType(saved.Messages.Last()); + Assert.Equal(ModelStopReason.Aborted, terminal.StopReason); + } + + [Fact] + public async Task CompletedWorkflowCommitsWithABoundedSettlementAfterCallerCancellation() + { + using var cancellation = new CancellationTokenSource(); + var store = new InMemoryGameSessionStore(); + var options = new GameAgentRuntimeOptions(new RecordingProvider(_ => Text("unused")), "test") + { + SessionStore = store, + RoutePolicy = new AutomaticGameRoutePolicy(new Dictionary + { + ["month"] = GameRouteDecision.ToWorkflow("evolve", "typed"), + }), + }; + options.Workflows.Add(new DelegateWorkflow("evolve", (_, _) => + { + cancellation.Cancel(); + return new ValueTask(new GameWorkflowResult( + new[] { Assistant("advanced") }, + succeeded: true)); + })); + await using var runtime = new GameAgentRuntime(options); + + var result = await runtime.RunAsync( + Input("month", "{}", "month-input"), + cancellation.Token).WaitAsync(TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + var saved = await store.LoadAsync( + new GameSessionKey("session", "actor"), + TestContext.Current.CancellationToken); + Assert.Equal(1, saved!.Revision); + Assert.Contains("month-input", saved.ProcessedInputIds); + } + [Fact] public async Task ActionOperationRemainsStableWhenProviderChangesToolCallIdAfterSessionConflict() { @@ -322,6 +528,51 @@ public async Task PendingWorkCanPromoteAnOtherwiseQuickInputToAgentRoute() Assert.Equal("tools-or-pending-work", result.Route.Reason); } + [Fact] + public async Task RuntimeRefreshesDynamicToolsAndDependentSkillsWithinTheActiveRun() + { + var unlocked = 0; + var unlock = new AgentTool( + new ToolDefinition("unlock", "Unlock another capability.", "{\"type\":\"object\"}"), + (_, _, _) => + { + Volatile.Write(ref unlocked, 1); + return new ValueTask(new ToolResult(new AgentContent[] { new TextContent("unlocked") })); + }); + var advanced = ReadTool("advanced"); + var provider = new RecordingProvider(call => call == 1 + ? Tools(new ToolCallContent("unlock-call", "unlock", "{}")) + : Text("done")); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") + { + ToolProvider = (_, _) => new ValueTask>( + Volatile.Read(ref unlocked) == 0 + ? new[] { unlock } + : new[] { unlock, advanced }), + SkillSource = new InMemoryGameSkillSource(new[] + { + new GameSkill( + "advanced-guidance", + "advanced-guidance", + "Instructions for the unlocked capability.", + "Use the advanced capability only after it is unlocked.", + toolNames: new[] { "advanced" }), + }), + }); + + var result = await runtime.RunAsync( + Input("command", "{}"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + var requests = provider.Requests.ToArray(); + Assert.Equal(2, requests.Length); + Assert.DoesNotContain(requests[0].Tools, tool => tool.Name == "advanced"); + Assert.Contains(requests[1].Tools, tool => tool.Name == "advanced"); + Assert.DoesNotContain("advanced-guidance", requests[0].SystemPrompt, StringComparison.Ordinal); + Assert.Contains("advanced-guidance", requests[1].SystemPrompt, StringComparison.Ordinal); + } + [Fact] public async Task UncertainDurableActionPreventsLaterWritesInTheSameToolBatch() { @@ -377,6 +628,25 @@ public async Task RecoveryFailureReturnsUncertainReceiptForLaterReconciliation() Assert.Contains("recovery failed", receipt.Message, StringComparison.Ordinal); } + [Fact] + public async Task HandlerDiagnosticsCannotCreateUnboundedUncertainReceipts() + { + var dispatcher = new DurableGameActionDispatcher( + new InMemoryGameActionJournal(), + new TestActionHandler((_, _) => throw new InvalidOperationException(new string('x', 100_000)))); + + var receipt = await dispatcher.ExecuteAsync(Intent("bounded-diagnostic"), TestContext.Current.CancellationToken); + + Assert.Equal(GameActionStatus.Uncertain, receipt.Status); + Assert.Equal(64_000, receipt.Message!.Length); + Assert.Throws(() => new GameActionReceipt( + receipt.OperationId, + GameActionStatus.Rejected, + "{}", + receipt.Moment, + code: new string('c', 1_025))); + } + [Fact] public async Task DispatcherRejectsJournalThatLosesDispatchClaim() { @@ -415,6 +685,46 @@ public async Task ConcurrentIdenticalActionIsExecutedOnce() Assert.Equal(1, handler.ExecuteCount); } + [Fact] + public async Task FinalActionReceiptIsJournaledEvenWhenCallerCancelsAfterExecution() + { + using var cancellation = new CancellationTokenSource(); + var journal = new InMemoryGameActionJournal(); + var handler = new TestActionHandler((intent, _) => + { + cancellation.Cancel(); + return new ValueTask( + GameActionReceipt.Committed(intent, "{\"committed\":true}")); + }); + var dispatcher = new DurableGameActionDispatcher(journal, handler); + var intent = Intent("cancel-after-commit"); + + var receipt = await dispatcher.ExecuteAsync(intent, cancellation.Token); + + Assert.Equal(GameActionStatus.Committed, receipt.Status); + var stored = await journal.FindAsync(intent.OperationId, TestContext.Current.CancellationToken); + Assert.Equal(GameActionStatus.Committed, stored!.Receipt!.Status); + } + + [Fact] + public async Task ReceiptCommitFailureReturnsUncertainInsteadOfEncouragingBlindReplay() + { + var durableJournal = new InMemoryGameActionJournal(); + var journal = new FailingReceiptJournal(durableJournal); + var handler = new TestActionHandler(); + var dispatcher = new DurableGameActionDispatcher(journal, handler); + var intent = Intent("commit-failure"); + + var receipt = await dispatcher.ExecuteAsync(intent, TestContext.Current.CancellationToken); + + Assert.Equal(GameActionStatus.Uncertain, receipt.Status); + Assert.Contains("journal commit failed", receipt.Message, StringComparison.Ordinal); + Assert.Equal(1, handler.ExecuteCount); + var stored = await durableJournal.FindAsync(intent.OperationId, TestContext.Current.CancellationToken); + Assert.True(stored!.Dispatched); + Assert.Null(stored.Receipt); + } + [Fact] public async Task FailedDispatchRemainsPendingUntilGameReconcilesIt() { @@ -493,6 +803,36 @@ await store.AppendAsync( Assert.Contains("0.75", memory.PayloadJson, StringComparison.Ordinal); } + [Fact] + public async Task MemoryIdentifiersAreScopedToTheirGameSessionAndOwner() + { + var store = new InMemoryGameMemoryStore(); + await store.AppendAsync( + new GameMemory("shared-id", "session-a", "npc", "personal", GameMemoryKind.Fact, "{\"value\":1}", new GameMoment("world", 1)), + TestContext.Current.CancellationToken); + await store.AppendAsync( + new GameMemory("shared-id", "session-b", "npc", "personal", GameMemoryKind.Fact, "{\"value\":2}", new GameMoment("world", 1)), + TestContext.Current.CancellationToken); + await store.AppendAsync( + new GameMemory("shared-id", "session-a", "other-npc", "personal", GameMemoryKind.Fact, "{\"value\":3}", new GameMoment("world", 1)), + TestContext.Current.CancellationToken); + + var first = await store.SearchAsync( + new GameMemoryQuery("session-a", 1, ownerId: "npc", atOrBefore: new GameMoment("world", 1)), + TestContext.Current.CancellationToken); + var second = await store.SearchAsync( + new GameMemoryQuery("session-b", 1, atOrBefore: new GameMoment("world", 1)), + TestContext.Current.CancellationToken); + + Assert.Contains("1", Assert.Single(first).PayloadJson, StringComparison.Ordinal); + Assert.Contains("2", Assert.Single(second).PayloadJson, StringComparison.Ordinal); + Assert.Equal( + 2, + (await store.SearchAsync( + new GameMemoryQuery("session-a", 2, atOrBefore: new GameMoment("world", 1)), + TestContext.Current.CancellationToken)).Count); + } + [Fact] public async Task MemoryExpiryUsesGameTimeAndOptionalRankerNeedsNoBundledModel() { @@ -709,6 +1049,74 @@ await Assert.ThrowsAnyAsync(async () => Assert.Equal(1, await first); } + [Fact] + public async Task RunningActorWorkCanReturnItsSettledOutcomeAfterCancellation() + { + var scheduler = new MultiActorScheduler(1, 1, 1); + using var cancellation = new CancellationTokenSource(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var work = scheduler.EnqueueAsync("actor", async _ => + { + entered.TrySetResult(); + await release.Task; + return 42; + }, cancellation.Token); + await entered.Task.WaitAsync(TestContext.Current.CancellationToken); + + cancellation.Cancel(); + release.TrySetResult(); + + Assert.Equal(42, await work.WaitAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task MultiActorIdleBarrierWaitsForRunningAndQueuedWorkToLeaveAllLanes() + { + var scheduler = new MultiActorScheduler(1, 2, 2); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var first = scheduler.EnqueueAsync("actor", async token => + { + entered.TrySetResult(); + await release.Task.WaitAsync(token); + return 1; + }, TestContext.Current.CancellationToken); + var second = scheduler.EnqueueAsync("actor", _ => new ValueTask(2), TestContext.Current.CancellationToken); + await entered.Task.WaitAsync(TestContext.Current.CancellationToken); + + var idle = scheduler.WaitForIdleAsync(); + Assert.False(idle.IsCompleted); + release.TrySetResult(); + + Assert.Equal(new[] { 1, 2 }, await Task.WhenAll(first, second)); + await idle.WaitAsync(TestContext.Current.CancellationToken); + } + + [Fact] + public async Task MultiActorIdleBarrierIsSharedUntilTheSchedulerBecomesIdle() + { + var scheduler = new MultiActorScheduler(1, 1, 1); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var work = scheduler.EnqueueAsync("actor", async token => + { + entered.TrySetResult(); + await release.Task.WaitAsync(token); + return 1; + }, TestContext.Current.CancellationToken); + await entered.Task.WaitAsync(TestContext.Current.CancellationToken); + + var first = scheduler.WaitForIdleAsync(); + var second = scheduler.WaitForIdleAsync(); + + Assert.Same(first, second); + release.TrySetResult(); + Assert.Equal(1, await work); + await first.WaitAsync(TestContext.Current.CancellationToken); + Assert.True(scheduler.WaitForIdleAsync().IsCompletedSuccessfully); + } + [Fact] public async Task SkillsAreSelectedByInputTypeToolsAndPriority() { @@ -815,7 +1223,111 @@ public async Task RetryingProviderCanRetryFailureAfterOnlyStartMetadata() var events = await CollectAsync(provider.StreamAsync(ModelRequest(), TestContext.Current.CancellationToken)); Assert.Equal(2, inner.CallCount); - Assert.Equal(2, events.Count(item => item.Kind == ModelStreamEventKind.Started)); + Assert.Equal(1, events.Count(item => item.Kind == ModelStreamEventKind.Started)); + Assert.True(events.Last().IsTerminal); + } + + [Fact] + public async Task MeaningfulStreamStartPreventsRetryAndFallback() + { + static async Task> CaptureAsync(IModelProvider provider) + { + var events = new List(); + await foreach (var streamEvent in provider.StreamAsync( + ModelRequest(), + TestContext.Current.CancellationToken)) + { + events.Add(streamEvent); + } + + return events; + } + + var retrySource = new MeaningfulStartThenFailureProvider(); + var retry = new RetryingModelProvider(retrySource, 2, _ => TimeSpan.Zero); + await Assert.ThrowsAsync(() => CaptureAsync(retry)); + + var fallbackSource = new MeaningfulStartThenFailureProvider(); + var fallbackTarget = new RecordingProvider(_ => Text("must not run")); + var fallback = new FallbackModelProvider(new IModelProvider[] { fallbackSource, fallbackTarget }); + await Assert.ThrowsAsync(() => CaptureAsync(fallback)); + + Assert.Equal(1, retrySource.CallCount); + Assert.Equal(1, fallbackSource.CallCount); + Assert.Equal(0, fallbackTarget.CallCount); + } + + [Fact] + public async Task RetryingProviderPreservesStreamOutcomeWhenEnumeratorCleanupFails() + { + var inner = new FailureThenTerminalProviderWithFailingCleanup(failuresBeforeSuccess: 1); + var provider = new RetryingModelProvider(inner, 2, _ => TimeSpan.Zero); + + var events = await CollectAsync(provider.StreamAsync(ModelRequest(), TestContext.Current.CancellationToken)); + + Assert.Equal(2, inner.CallCount); + Assert.Equal(2, inner.DisposeCount); + Assert.Equal("ok", Assert.IsType(Assert.Single(events.Last().Response!.Content)).Text); + } + + [Fact] + public async Task RetryingProviderRespectsTypedFailureClassificationAndServerDelay() + { + var attempts = 0; + var transient = new RecordingProvider(_ => + { + if (Interlocked.Increment(ref attempts) == 1) + { + throw new ModelProviderException("busy", isTransient: true, retryAfter: TimeSpan.Zero); + } + + return Text("ok"); + }); + var retried = new RetryingModelProvider( + transient, + 2, + _ => throw new InvalidOperationException("the server delay should take precedence")); + + var events = await CollectAsync(retried.StreamAsync(ModelRequest(), TestContext.Current.CancellationToken)); + + Assert.Equal(2, attempts); + Assert.Equal(ModelStopReason.Stop, events.Last().Response!.StopReason); + + var rejectedAttempts = 0; + var nonTransient = new RecordingProvider(_ => + { + Interlocked.Increment(ref rejectedAttempts); + throw new ModelProviderException("invalid", isTransient: false); + }); + var notRetried = new RetryingModelProvider(nonTransient, 2, _ => TimeSpan.Zero); + + await Assert.ThrowsAsync(async () => + await CollectAsync(notRetried.StreamAsync(ModelRequest(), TestContext.Current.CancellationToken))); + Assert.Equal(1, rejectedAttempts); + } + + [Fact] + public async Task RetryingProviderCapsUntrustedServerDelay() + { + var attempts = 0; + var inner = new RecordingProvider(_ => + { + if (Interlocked.Increment(ref attempts) == 1) + { + throw new ModelProviderException("busy", isTransient: true, retryAfter: TimeSpan.FromDays(1)); + } + + return Text("ok"); + }); + var provider = new RetryingModelProvider( + inner, + 2, + _ => TimeSpan.FromDays(1), + maximumDelay: TimeSpan.Zero); + + var events = await CollectAsync(provider.StreamAsync(ModelRequest(), TestContext.Current.CancellationToken)); + + Assert.Equal(2, attempts); Assert.True(events.Last().IsTerminal); } @@ -844,6 +1356,23 @@ public async Task FallbackProviderCanSwitchAfterOnlyStartMetadata() Assert.Equal(1, first.CallCount); Assert.Equal(1, second.CallCount); + Assert.InRange(events.Count(item => item.Kind == ModelStreamEventKind.Started), 0, 1); + Assert.True(events.Last().IsTerminal); + } + + [Fact] + public async Task FallbackProviderPreservesFailureAndTerminalAcrossCleanupFailures() + { + var first = new FailureThenTerminalProviderWithFailingCleanup(failuresBeforeSuccess: int.MaxValue); + var second = new FailureThenTerminalProviderWithFailingCleanup(failuresBeforeSuccess: 0); + var provider = new FallbackModelProvider(new IModelProvider[] { first, second }); + + var events = await CollectAsync(provider.StreamAsync(ModelRequest(), TestContext.Current.CancellationToken)); + + Assert.Equal(1, first.CallCount); + Assert.Equal(1, first.DisposeCount); + Assert.Equal(1, second.CallCount); + Assert.Equal(1, second.DisposeCount); Assert.True(events.Last().IsTerminal); } @@ -926,71 +1455,243 @@ public async Task MediaToolStreamsProgressAndReturnsResourceContent() return default; }); - var result = await agent.RunAsync(AgentMessage.UserJson("{}"), TestContext.Current.CancellationToken); + var result = await agent.RunAsync(AgentMessage.UserJson("{}"), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal(0.5, Assert.Single(progress).Fraction); + var toolMessage = Assert.Single(agent.State.Messages, message => message.Role == AgentRole.Tool); + var resource = Assert.Single(toolMessage.Content.OfType()); + Assert.Equal("image/png", resource.MediaType); + Assert.Contains("ink", generator.ParametersJson, StringComparison.Ordinal); + } + + [Fact] + public async Task TranscriptCompactionPreservesCompleteToolExchange() + { + var call = new ToolCallContent("call", "act", "{}"); + var toolResult = new ToolResult(new AgentContent[] { new TextContent("ok") }); + var messages = new AgentMessage[] + { + AgentMessage.User("old"), + Assistant("old answer"), + AgentMessage.User("keep"), + new(AgentRole.Assistant, new AgentContent[] { call }, DateTimeOffset.UnixEpoch, model: "m", stopReason: ModelStopReason.ToolUse), + AgentMessage.ToolResult(call, toolResult, DateTimeOffset.UnixEpoch), + Assistant("after tool"), + AgentMessage.User("latest"), + Assistant("latest answer"), + }; + var compactor = new SummarizingGameTranscriptCompactor((_, removed, _) => + new ValueTask("summary:" + removed.Count)); + + var compacted = await compactor.CompactAsync( + new GameTranscriptCompactionContext(new GameSessionKey("session", "actor"), messages, 7), + TestContext.Current.CancellationToken); + + Assert.Equal(7, compacted.Count); + Assert.Equal("transcript_summary", compacted[0].CustomRole); + Assert.Contains(compacted, message => message.Content.OfType().Any(item => item.Id == "call")); + Assert.Contains(compacted, message => message.Role == AgentRole.Tool && message.ToolCallId == "call"); + } + + [Fact] + public async Task TranscriptCompactionCanSummarizeTheEntireTranscript() + { + var call = new ToolCallContent("call", "act", "{}"); + var messages = new AgentMessage[] + { + AgentMessage.User("old"), + new(AgentRole.Assistant, new AgentContent[] { call }, DateTimeOffset.UnixEpoch, model: "m", stopReason: ModelStopReason.ToolUse), + AgentMessage.ToolResult(call, new ToolResult(new AgentContent[] { new TextContent("ok") }), DateTimeOffset.UnixEpoch), + Assistant("finished"), + }; + IReadOnlyList? summarized = null; + var compactor = new SummarizingGameTranscriptCompactor((_, removed, _) => + { + summarized = removed; + return new ValueTask("complete summary"); + }); + + var compacted = await compactor.CompactAsync( + new GameTranscriptCompactionContext(new GameSessionKey("session", "actor"), messages, 1), + TestContext.Current.CancellationToken); + + Assert.Equal(messages, summarized); + var summary = Assert.Single(compacted); + Assert.Equal("transcript_summary", summary.CustomRole); + Assert.Equal("complete summary", Assert.IsType(Assert.Single(summary.Content)).Text); + } + + [Fact] + public async Task TranscriptCompactionHonorsATokenTargetEvenWhenMessageCountFits() + { + var messages = new AgentMessage[] + { + AgentMessage.User(new string('a', 200)), + Assistant(new string('b', 200)), + AgentMessage.User("recent"), + Assistant("recent answer"), + }; + var compactor = new SummarizingGameTranscriptCompactor((_, removed, _) => + new ValueTask("short summary:" + removed.Count)); + + var compacted = await compactor.CompactAsync( + new GameTranscriptCompactionContext( + new GameSessionKey("session", "actor"), + messages, + targetMessageCount: 10, + targetEstimatedTokens: 100, + tokenEstimator: ApproximateGameTokenEstimator.EstimateMessages), + TestContext.Current.CancellationToken); + + Assert.True(compacted.Count < messages.Length); + Assert.Equal("transcript_summary", compacted[0].CustomRole); + Assert.True(ApproximateGameTokenEstimator.EstimateMessages(compacted) <= 100); + } + + [Fact] + public async Task RuntimeCompactsBeforeAnEstimatedContextWindowOverflow() + { + var store = new InMemoryGameSessionStore(); + var key = new GameSessionKey("session", "actor"); + var history = new AgentMessage[] + { + AgentMessage.User(new string('a', 1_200)), + Assistant(new string('b', 1_200)), + AgentMessage.User(new string('c', 1_200)), + Assistant(new string('d', 1_200)), + }; + await store.SaveAsync( + new GameSessionSnapshot(key, 1, history), + 0, + TestContext.Current.CancellationToken); + var compacted = false; + var provider = new RecordingProvider(_ => Text("done")); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") + { + SessionStore = store, + ContextWindowTokens = 1_000, + ContextWindowReserveTokens = 100, + TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, removed, _) => + { + compacted = true; + return new ValueTask("summary:" + removed.Count); + }), + }); + + var result = await runtime.RunAsync( + Input("chat", "{}"), + TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.True(compacted); + Assert.Equal(1, provider.CallCount); + var request = Assert.Single(provider.Requests); + Assert.True(ApproximateGameTokenEstimator.EstimateRequest( + request.Model, + request.SystemPrompt, + request.Messages, + request.Tools) <= 900); + } + + [Fact] + public async Task RuntimeRejectsAnEstimatedContextOverflowBeforeCallingTheProvider() + { + var store = new InMemoryGameSessionStore(); + var key = new GameSessionKey("session", "actor"); + await store.SaveAsync( + new GameSessionSnapshot(key, 1, new[] + { + AgentMessage.User(new string('a', 2_000)), + Assistant(new string('b', 2_000)), + }), + 0, + TestContext.Current.CancellationToken); + var provider = new RecordingProvider(_ => Text("must not run")); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") + { + SessionStore = store, + ContextWindowTokens = 800, + ContextWindowReserveTokens = 100, + }); + + var exception = await Assert.ThrowsAsync(async () => + await runtime.RunAsync(Input("chat", "{}"), TestContext.Current.CancellationToken)); - Assert.True(result.Succeeded); - Assert.Equal(0.5, Assert.Single(progress).Fraction); - var toolMessage = Assert.Single(agent.State.Messages, message => message.Role == AgentRole.Tool); - var resource = Assert.Single(toolMessage.Content.OfType()); - Assert.Equal("image/png", resource.MediaType); - Assert.Contains("ink", generator.ParametersJson, StringComparison.Ordinal); + Assert.Equal(nameof(GameAgentRuntimeOptions.ContextWindowTokens), exception.Limit); + Assert.Equal(0, provider.CallCount); } [Fact] - public async Task TranscriptCompactionPreservesCompleteToolExchange() + public async Task RuntimeRejectsContextGrowthFromTheFinalRequestHookBeforeCallingTheProvider() { - var call = new ToolCallContent("call", "act", "{}"); - var toolResult = new ToolResult(new AgentContent[] { new TextContent("ok") }); - var messages = new AgentMessage[] + var provider = new RecordingProvider(_ => Text("must not run")); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") { - AgentMessage.User("old"), - Assistant("old answer"), - AgentMessage.User("keep"), - new(AgentRole.Assistant, new AgentContent[] { call }, DateTimeOffset.UnixEpoch, model: "m", stopReason: ModelStopReason.ToolUse), - AgentMessage.ToolResult(call, toolResult, DateTimeOffset.UnixEpoch), - Assistant("after tool"), - AgentMessage.User("latest"), - Assistant("latest answer"), - }; - var compactor = new SummarizingGameTranscriptCompactor((_, removed, _) => - new ValueTask("summary:" + removed.Count)); + ContextWindowTokens = 800, + ContextWindowReserveTokens = 100, + AgentHooks = new AgentHooks + { + BeforeModelRequestAsync = (request, _) => new ValueTask(new ModelRequest( + request.Model, + new string('x', 4_000), + request.Messages, + request.Tools, + request.Parameters, + request.SessionId, + request.RunId, + request.Turn)), + }, + }); - var compacted = await compactor.CompactAsync( - new GameTranscriptCompactionContext(new GameSessionKey("session", "actor"), messages, 7), + var result = await runtime.RunAsync( + Input("chat", "{}"), TestContext.Current.CancellationToken); - Assert.Equal(7, compacted.Count); - Assert.Equal("transcript_summary", compacted[0].CustomRole); - Assert.Contains(compacted, message => message.Content.OfType().Any(item => item.Id == "call")); - Assert.Contains(compacted, message => message.Role == AgentRole.Tool && message.ToolCallId == "call"); + Assert.False(result.Succeeded); + Assert.Equal(GameAgentRunStatus.Failed, result.Status); + Assert.Equal(AgentRunStatus.KernelError, result.AgentResult!.Status); + Assert.Contains("context window", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, provider.CallCount); } [Fact] - public async Task TranscriptCompactionCanSummarizeTheEntireTranscript() + public async Task RuntimeCompactsAgainAfterALargeToolResultBeforeTheNextModelTurn() { - var call = new ToolCallContent("call", "act", "{}"); - var messages = new AgentMessage[] - { - AgentMessage.User("old"), - new(AgentRole.Assistant, new AgentContent[] { call }, DateTimeOffset.UnixEpoch, model: "m", stopReason: ModelStopReason.ToolUse), - AgentMessage.ToolResult(call, new ToolResult(new AgentContent[] { new TextContent("ok") }), DateTimeOffset.UnixEpoch), - Assistant("finished"), - }; - IReadOnlyList? summarized = null; - var compactor = new SummarizingGameTranscriptCompactor((_, removed, _) => + var provider = new RecordingProvider(call => call == 1 + ? Tools(new ToolCallContent("call", "inspect", "{}")) + : Text("done")); + var tool = new AgentTool( + new ToolDefinition("inspect", "Inspect a bounded area.", "{\"type\":\"object\"}"), + (_, _, _) => new ValueTask(new ToolResult( + new AgentContent[] { new TextContent(new string('x', 5_000)) }))); + var compacted = false; + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "model") { - summarized = removed; - return new ValueTask("complete summary"); + ContextWindowTokens = 1_000, + ContextWindowReserveTokens = 100, + ToolProvider = (_, _) => new ValueTask>(new[] { tool }), + TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, removed, _) => + { + compacted = true; + return new ValueTask("tool turn summary:" + removed.Count); + }), }); - var compacted = await compactor.CompactAsync( - new GameTranscriptCompactionContext(new GameSessionKey("session", "actor"), messages, 1), + var result = await runtime.RunAsync( + Input("inspect", "{}"), TestContext.Current.CancellationToken); - Assert.Equal(messages, summarized); - var summary = Assert.Single(compacted); - Assert.Equal("transcript_summary", summary.CustomRole); - Assert.Equal("complete summary", Assert.IsType(Assert.Single(summary.Content)).Text); + Assert.True(result.Succeeded); + Assert.True(compacted); + Assert.Equal(2, provider.CallCount); + var second = provider.Requests.Last(); + Assert.Equal("transcript_summary", Assert.Single(second.Messages).CustomRole); + Assert.True(ApproximateGameTokenEstimator.EstimateRequest( + second.Model, + second.SystemPrompt, + second.Messages, + second.Tools) <= 900); } [Fact] @@ -1107,8 +1808,12 @@ public async Task DurableWorkflowWaitsAndResumesFromCheckpoint() var first = await workflow.RunAsync( new GameWorkflowContext(firstInput, Array.Empty(), Array.Empty(), session), TestContext.Current.CancellationToken); + var committedSession = new GameSessionSnapshot( + session.Key, + 1, + processedInputIds: new[] { firstInput.InputId }); var second = await workflow.RunAsync( - new GameWorkflowContext(secondInput, Array.Empty(), Array.Empty(), session), + new GameWorkflowContext(secondInput, Array.Empty(), Array.Empty(), committedSession), TestContext.Current.CancellationToken); Assert.True(first.Succeeded); @@ -1117,6 +1822,64 @@ public async Task DurableWorkflowWaitsAndResumesFromCheckpoint() Assert.Equal(new[] { "advanced", "done" }, second.Messages.Select(message => Assert.IsType(Assert.Single(message.Content)).Text)); } + [Fact] + public async Task DurableWorkflowReplaysCompletedInvocationUntilItsInputIsCommitted() + { + var executions = 0; + var workflow = new DurableGameWorkflow( + "evolve", + new[] + { + new GameWorkflowStep("finish", (_, _) => + { + Interlocked.Increment(ref executions); + return new ValueTask( + GameWorkflowStepResult.Complete("{\"done\":true}", Assistant("replay me"))); + }), + }, + new InMemoryGameWorkflowCheckpointStore()); + var input = Input("month", "{}", "workflow-replay"); + var session = new GameSessionSnapshot(new GameSessionKey(input.SessionId, input.ActorId), 0); + var context = new GameWorkflowContext( + input, + Array.Empty(), + Array.Empty(), + session); + + var first = await workflow.RunAsync(context, TestContext.Current.CancellationToken); + var replay = await workflow.RunAsync(context, TestContext.Current.CancellationToken); + + Assert.Equal(1, executions); + Assert.Equal( + Assert.IsType(Assert.Single(Assert.Single(first.Messages).Content)).Text, + Assert.IsType(Assert.Single(Assert.Single(replay.Messages).Content)).Text); + } + + [Fact] + public async Task DurableWorkflowAcceptsEquivalentValuesRehydratedByACustomStore() + { + var workflow = new DurableGameWorkflow( + "evolve", + new[] + { + new GameWorkflowStep("finish", (_, _) => + new ValueTask( + GameWorkflowStepResult.Complete("{\"done\":true}", Assistant("persisted")))), + }, + new RehydratingCheckpointStore()); + var input = Input("month", "{}", "workflow-rehydrated"); + var context = new GameWorkflowContext( + input, + Array.Empty(), + Array.Empty(), + new GameSessionSnapshot(new GameSessionKey(input.SessionId, input.ActorId), 0)); + + var result = await workflow.RunAsync(context, TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.Equal("persisted", Assert.IsType(Assert.Single(Assert.Single(result.Messages).Content)).Text); + } + [Fact] public async Task DurableWorkflowInstanceKeysCannotCollideThroughIdentifierDelimiters() { @@ -1581,6 +2344,25 @@ public async IAsyncEnumerable StreamAsync( } } + private sealed class DelegateWorkflow : IGameWorkflow + { + private readonly Func> _run; + + public DelegateWorkflow( + string name, + Func> run) + { + Name = name; + _run = run; + } + + public string Name { get; } + + public ValueTask RunAsync( + GameWorkflowContext context, + CancellationToken cancellationToken) => _run(context, cancellationToken); + } + private sealed class BlockingFirstResponseProvider : IModelProvider { private int _calls; @@ -1650,6 +2432,49 @@ public ValueTask> GetContextAsync( CancellationToken cancellationToken) => _getContext(input, cancellationToken); } + private sealed class RecordingSessionStore : IGameSessionStore + { + private readonly InMemoryGameSessionStore _inner = new(); + + public List SavedSnapshots { get; } = new(); + + public ValueTask LoadAsync( + GameSessionKey key, + CancellationToken cancellationToken) => _inner.LoadAsync(key, cancellationToken); + + public async ValueTask SaveAsync( + GameSessionSnapshot snapshot, + long expectedRevision, + CancellationToken cancellationToken) + { + SavedSnapshots.Add(snapshot); + return await _inner.SaveAsync(snapshot, expectedRevision, cancellationToken); + } + } + + private sealed class FailSecondSaveSessionStore : IGameSessionStore + { + private readonly InMemoryGameSessionStore _inner = new(); + private int _saves; + + public ValueTask LoadAsync( + GameSessionKey key, + CancellationToken cancellationToken) => _inner.LoadAsync(key, cancellationToken); + + public ValueTask SaveAsync( + GameSessionSnapshot snapshot, + long expectedRevision, + CancellationToken cancellationToken) + { + if (Interlocked.Increment(ref _saves) == 2) + { + throw new InvalidOperationException("simulated process failure after the tool checkpoint"); + } + + return _inner.SaveAsync(snapshot, expectedRevision, cancellationToken); + } + } + private sealed class ConflictOnceSessionStore : IGameSessionStore { private readonly InMemoryGameSessionStore _inner = new(); @@ -1784,6 +2609,102 @@ public async IAsyncEnumerable StreamAsync( } } + private sealed class MeaningfulStartThenFailureProvider : IModelProvider + { + private int _calls; + + public int CallCount => Volatile.Read(ref _calls); + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + _ = request; + cancellationToken.ThrowIfCancellationRequested(); + Interlocked.Increment(ref _calls); + yield return ModelStreamEvent.Update( + ModelStreamEventKind.Started, + new ModelResponse( + new AgentContent[] { new TextContent("already visible") }, + ModelStopReason.Pending, + new ModelUsage(1))); + await Task.Yield(); + throw new InvalidOperationException("connection dropped after visible output"); + } + } + + private sealed class FailureThenTerminalProviderWithFailingCleanup : IModelProvider + { + private readonly int _failuresBeforeSuccess; + private int _calls; + private int _disposeCount; + + public FailureThenTerminalProviderWithFailingCleanup(int failuresBeforeSuccess) + { + _failuresBeforeSuccess = failuresBeforeSuccess; + } + + public int CallCount => Volatile.Read(ref _calls); + + public int DisposeCount => Volatile.Read(ref _disposeCount); + + public IAsyncEnumerable StreamAsync( + ModelRequest request, + CancellationToken cancellationToken) + { + _ = request; + cancellationToken.ThrowIfCancellationRequested(); + var call = Interlocked.Increment(ref _calls); + return new Stream( + call <= _failuresBeforeSuccess, + () => Interlocked.Increment(ref _disposeCount)); + } + + private sealed class Stream : IAsyncEnumerable, IAsyncEnumerator + { + private readonly bool _fail; + private readonly Action _onDispose; + private bool _moved; + + public Stream(bool fail, Action onDispose) + { + _fail = fail; + _onDispose = onDispose; + } + + public ModelStreamEvent Current { get; private set; } = null!; + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + return this; + } + + public ValueTask MoveNextAsync() + { + if (_moved) + { + return new ValueTask(false); + } + + _moved = true; + if (_fail) + { + return ValueTask.FromException(new ModelProviderException("offline", isTransient: true)); + } + + Current = ModelStreamEvent.Terminal(Text("ok")); + return new ValueTask(true); + } + + public ValueTask DisposeAsync() + { + _onDispose(); + return ValueTask.FromException(new InvalidOperationException("cleanup failed")); + } + } + } + private sealed class TestMediaGenerator : IGameMediaGenerator { public string ParametersJson { get; private set; } = string.Empty; @@ -1953,6 +2874,43 @@ public ValueTask> ListPendingAsync( } } + private sealed class FailingReceiptJournal : IGameActionJournal + { + private readonly IGameActionJournal _inner; + + public FailingReceiptJournal(IGameActionJournal inner) + { + _inner = inner; + } + + public ValueTask ReserveAsync( + GameActionIntent intent, + CancellationToken cancellationToken) => + _inner.ReserveAsync(intent, cancellationToken); + + public ValueTask FindAsync( + string operationId, + CancellationToken cancellationToken) => + _inner.FindAsync(operationId, cancellationToken); + + public ValueTask MarkDispatchedAsync( + string operationId, + CancellationToken cancellationToken) => + _inner.MarkDispatchedAsync(operationId, cancellationToken); + + public ValueTask SaveReceiptAsync(GameActionReceipt receipt, CancellationToken cancellationToken) + { + _ = receipt; + cancellationToken.ThrowIfCancellationRequested(); + throw new InvalidOperationException("simulated journal outage"); + } + + public ValueTask> ListPendingAsync( + int limit, + CancellationToken cancellationToken) => + _inner.ListPendingAsync(limit, cancellationToken); + } + private sealed class CorruptingCheckpointStore : IGameWorkflowCheckpointStore { public ValueTask LoadAsync( @@ -1984,6 +2942,65 @@ public ValueTask SaveAsync( } } + private sealed class RehydratingCheckpointStore : IGameWorkflowCheckpointStore + { + private readonly InMemoryGameWorkflowCheckpointStore _inner = new(); + + public async ValueTask LoadAsync( + string instanceId, + CancellationToken cancellationToken) + { + var loaded = await _inner.LoadAsync(instanceId, cancellationToken); + return loaded is null ? null : Rehydrate(loaded); + } + + public async ValueTask SaveAsync( + GameWorkflowCheckpoint checkpoint, + long expectedRevision, + CancellationToken cancellationToken) + { + var saved = await _inner.SaveAsync(checkpoint, expectedRevision, cancellationToken); + return new GameWorkflowCheckpointSaveResult(saved.Saved, Rehydrate(saved.Current)); + } + + private static GameWorkflowCheckpoint Rehydrate(GameWorkflowCheckpoint checkpoint) + { + var invocation = checkpoint.Invocation is null + ? null + : new GameWorkflowInvocationResult( + checkpoint.Invocation.InputId, + checkpoint.Invocation.Messages.Select(Rehydrate).ToArray(), + checkpoint.Invocation.Complete, + checkpoint.Invocation.Succeeded, + checkpoint.Invocation.Error); + return new GameWorkflowCheckpoint( + checkpoint.InstanceId, + checkpoint.Workflow, + checkpoint.Revision, + checkpoint.NextStep, + checkpoint.StateJson, + checkpoint.Completed, + checkpoint.Error, + invocation); + } + + private static AgentMessage Rehydrate(AgentMessage message) => + new( + message.Role, + message.Content, + message.Timestamp, + message.CustomRole, + message.ToolCallId, + message.ToolName, + message.IsError, + message.DetailsJson, + message.Metadata, + message.Model, + message.StopReason, + message.Usage, + message.ErrorMessage); + } + private sealed class LoadingCheckpointStore : IGameWorkflowCheckpointStore { private readonly Func _load; diff --git a/tools/New-ReleaseBundle.ps1 b/tools/New-ReleaseBundle.ps1 index cf6df7a..de8393b 100644 --- a/tools/New-ReleaseBundle.ps1 +++ b/tools/New-ReleaseBundle.ps1 @@ -64,7 +64,10 @@ $expectedPackages = @( 'OpenGameAgent.Persistence', 'OpenGameAgent.Providers.OpenAICompatible', 'OpenGameAgent.Providers.MediaHttp', - 'OpenGameAgent.Client' + 'OpenGameAgent.Client', + 'OpenGameAgent.Extensions', + 'OpenGameAgent.Models', + 'OpenGameAgent.Connectors.Mcp' ) $nugetRoot = Join-Path $artifactsRoot 'nuget' foreach ($packageId in $expectedPackages) { diff --git a/tools/Pack-NuGet.ps1 b/tools/Pack-NuGet.ps1 index 6108991..6b775f0 100644 --- a/tools/Pack-NuGet.ps1 +++ b/tools/Pack-NuGet.ps1 @@ -22,7 +22,10 @@ $projects = @( 'src/OpenGameAgent.Persistence/OpenGameAgent.Persistence.csproj', 'src/OpenGameAgent.Providers.OpenAICompatible/OpenGameAgent.Providers.OpenAICompatible.csproj', 'src/OpenGameAgent.Providers.MediaHttp/OpenGameAgent.Providers.MediaHttp.csproj', - 'src/OpenGameAgent.Client/OpenGameAgent.Client.csproj' + 'src/OpenGameAgent.Client/OpenGameAgent.Client.csproj', + 'src/OpenGameAgent.Extensions/OpenGameAgent.Extensions.csproj', + 'src/OpenGameAgent.Models/OpenGameAgent.Models.csproj', + 'src/OpenGameAgent.Connectors.Mcp/OpenGameAgent.Connectors.Mcp.csproj' ) foreach ($project in $projects) {