diff --git a/CHANGELOG.md b/CHANGELOG.md index b83e285..7842b71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Add an executable bundled model directory with provider-specific dispatch, compatibility flags, request transforms, cost tiers, response observation, and nine native wire APIs. - Add optional bounded browser/device authentication flows, explicit client registration, stored credential refresh, and cancellation-safe login settlement. - Add lazy external tool-server search/describe/call by default with explicit direct exposure for small trusted catalogs. +- Add optional Agent Plugins 1.0.0 package loading with portable Skill and MCP discovery, client namespaces, path containment, placeholder expansion, and component-level failure isolation. - Add native Anthropic, Bedrock, Google Gemini/Vertex, Mistral, OpenAI Responses/Azure, OpenAI-compatible, remote-proxy, and message-gateway providers with cross-provider transcript handoff. - Add a provider-neutral image/audio/video registry, strict generic HTTP media jobs, dedicated image generation with progressive previews, and typed partial tool output. - Add bounded request/response parsing, rotating credentials, safe response metadata observation, protocol-aware retries, and retry/fallback composition that stops before replaying meaningful streamed output. diff --git a/OpenGameAgent.sln b/OpenGameAgent.sln index 40d3fa3..08a85d4 100644 --- a/OpenGameAgent.sln +++ b/OpenGameAgent.sln @@ -94,6 +94,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.Mes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Providers.MessageGateway.Tests", "tests\OpenGameAgent.Providers.MessageGateway.Tests\OpenGameAgent.Providers.MessageGateway.Tests.csproj", "{9CFA2749-BE81-45DE-A07B-CC005F87C5BD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Plugins", "src\OpenGameAgent.Plugins\OpenGameAgent.Plugins.csproj", "{01A9B761-5567-4C17-B6ED-574B4089D413}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenGameAgent.Plugins.Tests", "tests\OpenGameAgent.Plugins.Tests\OpenGameAgent.Plugins.Tests.csproj", "{5697E98C-2249-4D4C-894B-CB0A8732238E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -276,6 +280,14 @@ Global {9CFA2749-BE81-45DE-A07B-CC005F87C5BD}.Debug|Any CPU.Build.0 = Debug|Any CPU {9CFA2749-BE81-45DE-A07B-CC005F87C5BD}.Release|Any CPU.ActiveCfg = Release|Any CPU {9CFA2749-BE81-45DE-A07B-CC005F87C5BD}.Release|Any CPU.Build.0 = Release|Any CPU + {01A9B761-5567-4C17-B6ED-574B4089D413}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {01A9B761-5567-4C17-B6ED-574B4089D413}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01A9B761-5567-4C17-B6ED-574B4089D413}.Release|Any CPU.ActiveCfg = Release|Any CPU + {01A9B761-5567-4C17-B6ED-574B4089D413}.Release|Any CPU.Build.0 = Release|Any CPU + {5697E98C-2249-4D4C-894B-CB0A8732238E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5697E98C-2249-4D4C-894B-CB0A8732238E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5697E98C-2249-4D4C-894B-CB0A8732238E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5697E98C-2249-4D4C-894B-CB0A8732238E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {01759D73-7B80-47A2-9D7D-154CC64C6851} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} @@ -306,5 +318,7 @@ Global {11929951-6AC8-445D-9539-F859E583EEC2} = {86AE6217-BFEE-4349-945A-70ECEC211437} {31B59D2C-2431-47CB-B3A1-6A2BE4C20055} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} {9CFA2749-BE81-45DE-A07B-CC005F87C5BD} = {86AE6217-BFEE-4349-945A-70ECEC211437} + {01A9B761-5567-4C17-B6ED-574B4089D413} = {EA3AF59A-9A1C-4197-B2A3-F93894D131B8} + {5697E98C-2249-4D4C-894B-CB0A8732238E} = {86AE6217-BFEE-4349-945A-70ECEC211437} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 29d5797..9728970 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ OpenGameAgent keeps the reusable agent machinery independent from the game while - 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; +- Agent Plugins 1.0.0 packages containing portable skills and MCP servers; - 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. @@ -77,13 +78,14 @@ Read [Architecture](docs/architecture.md) for the ownership and failure boundari | Area | Capability | | --- | --- | | Agent kernel | Streaming typed messages, tool loop, typed partial tool results, 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 | +| Tool execution | Provider-request schema preflight plus execution-time validation over a 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 | Bundled capability/context/reasoning/cost directory, dynamic refresh, API-key/environment/stored/OAuth/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 | +| Portable plugins | [Agent Plugins 1.0.0](docs/agent-plugins.md) `plugin.json`, immediate-child `SKILL.md` discovery, MCP stdio/Streamable HTTP, client namespaces, containment, and component-level failure isolation | | Providers | Native Anthropic, Amazon Bedrock, Google Gemini/Vertex, Mistral, OpenAI Responses/Azure, OpenAI-compatible, remote gateway, and message-gateway transports; retry/fallback decorators | | Generated media | Provider-neutral image/audio/video registry, generic async HTTP jobs, and a dedicated OpenRouter image adapter with progressive previews | | Persistence | Crash-tolerant local snapshots plus optional append-only session history, cross-process coordination, action journals, workflow checkpoints, memories, mailboxes, artifacts, delegations, skills, and prompt templates | @@ -94,7 +96,7 @@ Run inputs, model content, tool catalogs, loops, queues, progress, and concurren ### Model access without hand-wiring every provider -`OpenGameAgent.Models.BuiltIn` turns the bundled model directory into an executable runtime. It currently dispatches nine wire APIs across 27 provider definitions and hundreds of text/tool-capable models, applying provider-specific request formats, reasoning settings, compatibility flags, cost metadata, authentication, cancellation, and bounded response handling. The lower provider packages remain independently usable when a game wants an explicit model and endpoint instead of a directory. +`OpenGameAgent.Models.BuiltIn` turns the bundled model directory into an executable runtime. It currently dispatches nine wire APIs across 27 provider definitions and hundreds of text/tool-capable models, applying provider-specific request formats, reasoning settings, compatibility flags, cost metadata, authentication, cancellation, and bounded response handling. Provider usage is priced from the resolved directory when the provider does not report cost, while unavailable pricing remains explicitly unknown rather than appearing free. The lower provider packages remain independently usable when a game wants an explicit model and endpoint instead of a directory. `OpenGameAgent.Models.Auth.BuiltIn` adds opt-in browser or device authorization flows for supported subscription providers. Public client registrations are never embedded in the framework: flows that require a client ID remain disabled until the game developer supplies one. `OpenGameAgent.ProviderTransport` exposes only allowlisted, bounded response metadata to observers and never passes credentials or arbitrary response headers to tracing code. diff --git a/README.zh-CN.md b/README.zh-CN.md index fcaf029..60f467a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -43,6 +43,7 @@ OpenGameAgent 不替游戏规定玩法,而是提供可复用的游戏坐标与 - 可扩展工具、Skills、路由、Workflow、Hooks、事件与服务的类型化接口; - 能力感知模型目录与开发者托管的短期凭证; - 外部工具按需发现与大型结果产物化; +- 包含可移植 Skills 与 MCP Server 的 Agent Plugins 1.0.0 插件包; - 通过可替换 API 生成图片、语音和视频。 Runtime **不会**判断攻击是否合法、物品能否使用、资源够不够或 NPC 有没有权限。游戏只暴露窄而明确的工具,校验每次变更请求,在正确线程或服务端执行,并返回权威回执。 @@ -75,13 +76,14 @@ GameAgentRuntime | 模块 | 能力 | | --- | --- | | Agent 内核 | 流式类型化消息、工具循环、类型化工具中间结果、steering、follow-up、hooks、取消、严格会话校验、提供方错误结果化 | -| 工具执行 | 有界 JSON Schema 子集校验、每个已接受调用都有结果、安全并行读、冲突键串行、策略拦截/终止、超时与写入结果未知语义 | +| 工具执行 | provider 请求前 schema 预检及执行期有界 JSON Schema 子集校验、每个已接受调用都有结果、安全并行读、冲突键串行、策略拦截/终止、超时与写入结果未知语义 | | 游戏 Runtime | 任意 JSON 输入、游戏时钟/时间线、快速/完整/Workflow 路由、乐观并发会话、输入去重、角色并发、运行中 steering/abort | | 扩展 API | 不可变构建器;提示词/上下文/工具/Skills/路由/Workflow/Hooks/提供方/服务注册;类型化生命周期事件与通道;命名空间持久状态 | | 官方扩展 | 工具策略与搜索、玩家结构化提问/推荐回复、目标、记忆、产物、外部知识、委派、追踪和可持久并行工作流图 | | 世界原语 | 可恢复动作、可续跑 Workflow、记忆、Skills、信号、游戏时间调度、角色邮箱 | | 模型与认证 | 内置模型能力/上下文/推理级别/成本目录、动态刷新、API Key/环境/存储/OAuth/本地认证、开发者托管短期凭证网关 | | 外部工具 | 默认按需搜索/描述/调用;小型可信目录可显式选择原生直连暴露 | +| 可移植插件 | [Agent Plugins 1.0.0](docs/agent-plugins.md) `plugin.json`、直接子目录 `SKILL.md` 发现、MCP stdio/Streamable HTTP、客户端命名空间、路径限制与组件级故障隔离 | | 提供方 | Anthropic、Amazon Bedrock、Google Gemini/Vertex、Mistral、OpenAI Responses/Azure、OpenAI-compatible、远程网关和消息网关;重试与回退包装器 | | 生成式媒体 | 图片/语音/视频中立注册表、通用异步 HTTP 任务,以及带渐进预览的专用图片适配器 | | 持久化 | 崩溃安全本地快照、可选追加式会话历史、跨进程协调、动作日志、Workflow 检查点、记忆、邮箱、产物、委派、Skills 与提示词模板 | @@ -153,6 +155,8 @@ var run = await runtime.RunAsync(input); - **游戏服务端内:** 游戏本来就有权威服务端时最自然,让同一套 C# Runtime 靠近规则与存档。 - **独立 Agent 服务:** 适合官方承担推理费用、集中保管密钥、扩缩容或独立升级。引擎适配层通过 JSON/SSE 调用 `OpenGameAgent.Server`,并可经受认证的控制端点 steering 或 abort 活跃角色。 +独立服务还提供受同一会话/Actor 所有者授权保护的持久 usage 查询,完整返回推理、缓存与分项费用。模型目录可为没有上报费用的 Provider 估算费用;没有价格数据时明确返回“未知”,不会伪装成零费用。 + 若客户端使用开发者付费的模型服务,应由开发者网关签发短期、有限作用域的凭证。永久上游 Key 留在开发者基础设施;框架提供客户端凭证流程,游戏负责登录、配额、吊销和滥用防护。 部署位置不会改变权威边界:只有游戏业务代码能够确认动作成功。 diff --git a/docs/agent-plugins.md b/docs/agent-plugins.md new file mode 100644 index 0000000..c1f2324 --- /dev/null +++ b/docs/agent-plugins.md @@ -0,0 +1,78 @@ +# Agent Plugins 1.0.0 + +`OpenGameAgent.Plugins` loads the portable portion of an Agent Plugins 1.0.0 package without changing the agent kernel. A loaded package is an ordinary `IGameAgentExtension`: skills register through the existing skill-provider API and MCP servers register through `McpToolConnectorExtension`. + +## Supported package layout + +```text +my-plugin/ +├── plugin.json +├── skills/ +│ └── build/ +│ └── SKILL.md +├── mcp.json +└── org.example.client/ +``` + +The loader supports: + +- the closed `plugin.json` 1.0.0 manifest and its required canonical `$schema`; +- the specification's non-fatal handling for unknown manifest fields and a non-object `extensions` field; +- immediate-child `skills/*/SKILL.md` discovery using the Agent Skills-compatible loader; +- MCP `stdio` and `streamable-http` transports; +- client-owned HTTP headers that override package headers case-insensitively; +- `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` in stdio arguments, environment values, and working directories; +- bounded diagnostics and component-level failure isolation; +- opaque manifest extension objects and safe top-level client extension directories. + +Legacy HTTP+SSE is optional in Agent Plugins 1.0.0 and is not implemented. Its entries are diagnosed and skipped without disabling skills or other MCP servers. + +## Load and compose + +Install the optional adapter package alongside the core runtime: + +```powershell +dotnet add package OpenGameAgent.Plugins --version 0.3.0-alpha.1 +``` + +```csharp +using OpenGameAgent.Plugins; + +var package = AgentPluginLoader.Load( + @"C:\plugins\world-tools", + new AgentPluginLoadOptions + { + PluginDataDirectory = @"C:\game-data\plugins\world-tools", + McpServerHeaders = new Dictionary> + { + ["remote-world-api"] = new Dictionary + { + ["Authorization"] = "Bearer " + shortLivedToken, + }, + }, + }); + +await using var runtime = new GameAgentBuilder(provider, model) + .UseExtension(package) + .Build(); +``` + +The runtime owns the package after `UseExtension`. Disposing the runtime closes MCP clients and any default HTTP transport created by the loader. + +`PluginDataDirectory` is required for stdio servers because Agent Plugins reserves `PLUGIN_DATA` as client-managed writable storage. When it is absent, only affected stdio entries are skipped; skills and remote MCP entries still load. + +## Security and ownership + +- Plugin content is untrusted. Loading a skill grants instructions, not tool permission. +- The loader rejects package reparse points and paths that escape the package root. This is stricter than accepting an internal symbolic link and keeps behavior deterministic across Godot, Unity, and server hosts. +- Plugin-relative commands must begin with `./`; bare commands use the platform executable search behavior and are launched as one executable token, never as a shell command. +- Non-loopback HTTP MCP endpoints require HTTPS. The default HTTP client rejects redirects and does not keep cookies. +- Package headers are visible configuration, not a secret store. Supply credentials through `McpServerHeaders` or a client-owned `HttpClient`; client values take precedence. +- If a client-owned `HttpClient` is supplied, the game owns its redirect, authentication, timeout, and disposal policy. +- OpenGameAgent does not dynamically load assemblies declared by a plugin. Game-specific executable extensions remain explicit, compiled `IGameAgentExtension` registrations. + +## Portable and client-specific boundaries + +Agent Plugins 1.0.0 standardizes skills and MCP server configuration. It does not standardize plugin installation, marketplaces, permissions, sandboxing, OAuth, signatures, dependencies, hooks, or game runtime APIs. Unknown manifest extension objects are retained as bounded JSON but receive no behavior automatically. Top-level client extension directories are exposed through `ClientExtensionDirectories` and `GetClientExtensionDirectory`; the game decides whether it implements a namespace. + +The authoritative external specification and schemas are at [agent-plugins.org](https://agent-plugins.org/specification). The loader selects its locally implemented 1.0.0 rules and never downloads a schema while loading a package. diff --git a/docs/architecture.md b/docs/architecture.md index 1915ec2..9a12640 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -151,4 +151,4 @@ The framework cannot make arbitrary game code transactional. The game must make 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. +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. The final tool definitions produced by request hooks are preflighted before a provider stream is opened, and returned arguments are validated again before execution. Unsupported assertion keywords fail closed even when nested in an unselected schema branch. 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 c0671e7..44edec1 100644 --- a/docs/deployment-and-security.md +++ b/docs/deployment-and-security.md @@ -29,6 +29,7 @@ OpenGameAgent__Model=your-model OpenGameAgent__ApiKey=provider-secret OpenGameAgent__ServerApiKey=game-to-agent-secret OpenGameAgent__DataDirectory=/var/lib/opengameagent/sessions +OpenGameAgent__ActionDirectory=/var/lib/opengameagent/actions ``` The included service exposes: @@ -39,22 +40,140 @@ The included service exposes: - `POST /v1/run/stream` (Server-Sent Events) - `POST /v1/control/steer` - `POST /v1/control/abort` +- `POST /v1/usage` +- `POST /v1/actions/claim` +- `POST /v1/actions/stream` (Server-Sent Events over a JSON POST request) +- `POST /v1/actions/receipt` +- `POST /v1/actions/reconcile` 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. +When `ServerApiKey` is set, run and control endpoints require `Authorization: Bearer `. The middleware supplies the stable authenticated subject `server-api-key` unless an upstream authentication system already supplied a principal. If the key is omitted, those endpoints are unauthenticated; only do that behind an already authenticated trusted boundary. Health and capability endpoints remain public. + +Register an `IGameAgentOwnerAuthorizer` for player-facing or multi-tenant deployments. Every run, stream, steer, and abort request is then authorized against the authenticated principal and the parsed `(session, actor)` resource before the runtime, session store, or active actor is touched. Anonymous requests receive `401`; authenticated principals that do not own the resource receive `403`. The same operation contract reserves usage and durable-action operations so those endpoints use the identical ownership decision. Derive ownership from authenticated claims or an authoritative host store—never from an owner field supplied in the request payload. Without a registered authorizer the endpoint retains its legacy single-owner behavior for compatible trusted deployments. + +Control requests only address an already active `(session, actor)` loop; they cannot register tools or mutate game state directly. Put TLS, request-rate limits, tenant quotas, and abuse protection at the gateway. The included shared-secret gate identifies one deployment-wide subject; it is not a multi-user account system. + +### Output audiences + +Register an `IGameAgentAudiencePolicy` when server output can be observed by more than one trust class. The policy resolves a viewer from the authenticated principal and classifies every event or message as `Internal`, `Owner`, `Public`, or a named `Recipient`. The framework—not the model response or tool payload—applies that decision to both JSON and SSE output. Non-internal viewers never receive reasoning text or signatures, redacted reasoning, tool arguments, tool results, tool progress details, or message metadata. An internal viewer can receive the complete diagnostic stream. + +`MetadataGameAgentAudiencePolicy` is the safe stock policy for persisted annotations. `GameAgentAudienceMetadata.WithAudience` accepts only host-authored assistant or custom messages; user messages and tool results cannot promote themselves with request metadata. Audience and recipient annotations use the existing bounded message metadata and survive memory and file-session round trips. Redacted reasoning state is also preserved by the file-session format. Hosts that compute audience from an external ACL may implement the policy directly instead. + +## Trusted model routing + +The stock server can expose several named model routes without accepting an endpoint, API key, or raw provider configuration from a game request. Configure `OpenGameAgent:ModelRoutes`, choose `OpenGameAgent:DefaultModelRoute`, and optionally map trusted input types through `OpenGameAgent:InputModelRoutes`: + +```json +{ + "OpenGameAgent": { + "DefaultModelRoute": "local", + "ModelRoutes": { + "local": { + "ProviderId": "local", + "Endpoint": "http://127.0.0.1:11434/v1/chat/completions", + "Model": "local-model", + "Fallbacks": [ "cloud" ] + }, + "cloud": { + "ProviderId": "cloud", + "Endpoint": "https://model-gateway.example/v1/chat/completions", + "Model": "cloud-model", + "ApiKey": "set-this-through-a-secret-configuration-provider" + } + }, + "InputModelRoutes": { + "complex-plan": "cloud" + } + } +} +``` + +Route selection is server policy: request JSON can contain arbitrary game data, but it cannot create a provider, replace an endpoint, or supply a server credential. A custom host can build the same boundary with `TrustedGameAgentServerModelRouter` and a selector that returns only a registered route name. Fallback is allowed only before meaningful streamed output; once text, reasoning, tool calls, or usage are visible, the framework never silently replays the request. Final assistant messages expose the provider, API, response model, and response ID that actually completed, while provider credentials remain inside the server transport and never enter the model transcript or response wire. + +### Usage and cost + +The runtime keeps one durable, bounded usage ledger per `(session, actor)`. Assistant responses, tool-reported usage, transcript compaction, and other framework causes are accumulated exactly once across retries, optimistic-save conflicts, eviction of old audit records, and process restarts. `POST /v1/usage` uses the same authentication and owner authorization as run/control/action endpoints and must be authorized before the session store is read: + +```json +{"credential":"short-lived-pairing-token","sessionId":"save-1","actorId":"npc-1"} +``` + +The response contains lifetime totals, totals grouped by cause, and a bounded recent-record audit window. Token data includes reasoning and one-hour cache-write counts. Cost is itemized as input, output, cache-read, cache-write, and total. Every cost object includes `known`: when pricing is unavailable, all monetary fields are `null`; a model that is explicitly free returns `known: true` and zero amounts. Unknown price is never reported as zero cost. + +`BuiltInGameModelRuntime` preserves provider-reported itemized cost. When a provider reports usage without cost, it estimates cost from the resolved model directory entry, including tiered rates and one-hour cache writes. A directory entry with unavailable pricing remains unknown instead of silently becoming free. 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 -The included server runs tools that are registered in its process. If authoritative game state lives elsewhere, prefer one of these designs: +If authoritative game state lives in a non-C# game process, register one shared journal, exchange, and dispatcher. The dispatcher persists `Prepared` and then `Dispatched` before the intent can be claimed: + +```csharp +builder.Services.AddSingleton( + new FileGameActionJournal("data/actions")); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(services => new DurableGameActionDispatcher( + services.GetRequiredService(), + services.GetRequiredService())); +``` + +Register game tools with that dispatcher. Supply a host-controlled `generationId` that changes when a loaded save or world generation could invalidate an old receipt: + +```csharp +GameActionTool.Create( + input, + "apply_game_command", + "Submit a typed command to the authoritative game host.", + commandSchema, + dispatcher, + ToolRisk.NonIdempotentWrite, + conflictKey: args => args.GetProperty("entityId").GetString(), + expectedRevision: worldRevision, + operationIdFactory: null, + generationId: saveGeneration); +``` + +The external host calls `claim` or `stream`, reconciles every delivered `operationId` against its own authoritative operation log, and only then executes or resumes it. It submits a final receipt containing the same session, actor, timeline, tick, generation, and expected revision. A repeated claim returns the same durable operation; a service restart after delivery but before receipt leaves it `Dispatched` and requires reconciliation instead of blind replay. + +The minimal JSON exchange is: + +```json +POST /v1/actions/claim +{"credential":"short-lived-pairing-token","sessionId":"save-1","actorId":"npc-1","limit":16} + +POST /v1/actions/receipt +{ + "credential":"short-lived-pairing-token", + "sessionId":"save-1", + "actorId":"npc-1", + "operationId":"the-delivered-operation-id", + "status":"committed", + "result":{"accepted":true}, + "timelineId":"world-1", + "tick":120, + "generationId":"save-generation-8", + "expectedRevision":41, + "stateRevision":42 +} +``` + +Use `POST /v1/actions/stream` with the same claim body for SSE delivery. Use `POST /v1/actions/reconcile` with the credential, session, actor, and operation ID before acting on every delivery whose `requiresReconciliation` is true. + +All action endpoints use `IGameAgentOwnerAuthorizer` before touching the exchange or journal. Clients cannot gain access by changing `sessionId` or `actorId` in JSON. A localhost engine client that cannot set headers may include a bounded top-level `credential` in the JSON body when the host registers `IGameAgentPresentedCredentialAuthenticator`. The authenticator only maps that opaque value to a principal; the normal owner authorizer still decides access. The credential is removed at the HTTP boundary and never enters `GameInput`, model context, transcripts, session storage, action delivery, or responses. Prefer short-lived single-use pairing credentials and bind the resulting principal to the game's authoritative player identity. + +The exchange coordinates delivery and recovery; it does not replace game authority. The game must validate action arguments and permissions, commit the world mutation plus its operation record atomically where possible, and return the resulting revision. Tool catalogs and schemas remain deployment-owned. + +### Operation ID v2 migration + +The default `GameActionTool` identifier is `oga-action-v2:`. Its canonical identity includes session, actor, input, turn, tool-call index, action, timeline/tick, and save generation. The output has a fixed bounded length, identical replay produces the same ID, and changing any identity dimension produces a different ID. Tool arguments and expected state revision are deliberately not part of the ID: if a replay of the same logical tool position produces different arguments or authority preconditions, the durable journal rejects it instead of allowing a second mutation. + +Existing version-one action journal files remain readable and are not rewritten. Their operation IDs remain valid for claim, receipt, and reconcile. Do not silently switch an active save with unresolved v1 operations to the v2 default: the authoritative game log knows the old identifiers and an automatic rewrite could duplicate a side effect. Use one of these explicit migration paths: -1. run the runtime inside the authoritative game server; -2. implement server-side tools that call authenticated internal game APIs using operation IDs; -3. let the agent service return a proposal and have the game execute it as a separate command. +1. reconcile and drain all v1 pending/dispatched operations, then switch to v2 at a save-generation boundary; or +2. temporarily pass `operationIdFactory: GameActionOperationIds.CreateLegacyV1`, drain the old journal, then remove that override when starting the next save generation. -Do not create an unauthenticated generic “execute any client action” endpoint. Tool catalogs and permissions are part of the game deployment. +Never copy one action journal into multiple coexisting save namespaces. `GameActionOperationIds.CreateLegacyV1` exists only for this controlled migration window and does not isolate session, actor, timeline, or action. ## Untrusted boundaries diff --git a/docs/features.md b/docs/features.md index 3e60767..77cecef 100644 --- a/docs/features.md +++ b/docs/features.md @@ -36,6 +36,7 @@ This page maps product needs to the smallest reusable OpenGameAgent primitive. | Delegate bounded foreground or background work | `AgentDelegationExtension` | | Query a game-owned knowledge source | `ExternalKnowledgeExtension` | | Capture bounded lifecycle traces | `GameAgentTracingExtension` | +| Load a portable package of skills and MCP servers | `AgentPluginLoader`, `AgentPluginPackage` | ## Game integration @@ -84,6 +85,7 @@ This page maps product needs to the smallest reusable OpenGameAgent primitive. | Refresh a provider's model list safely | `GameModelProviderRegistration.RefreshModels`, `GameModelCatalog.RefreshAsync` | | Resolve API keys, OAuth-style tokens, or local/no-auth modes | `IGameProviderAuthentication`, `IGameCredentialStore` | | Load the bundled model directory as executable providers | `BuiltInGameModelRuntime` | +| Read durable per-session usage and explicit known/unknown cost | `GameAgentRuntime.ReadUsageAsync`, `GameSessionUsageLedger` | | Register supported browser/device authorization flows | `BuiltInGameOAuthRegistration` | | Observe bounded provider response metadata | `ProviderResponseObserver` | | Fetch short-lived developer-hosted credentials | `DeveloperGatewayProvider`, `HttpDeveloperGatewayCredentialSource` | @@ -91,6 +93,7 @@ This page maps product needs to the smallest reusable OpenGameAgent primitive. | Connect to a compatible message-gateway service | `MessageGatewayProvider` | | 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` | +| Load Agent Plugins 1.0.0 with fixed-location discovery and failure isolation | `OpenGameAgent.Plugins` | ## Included stores diff --git a/docs/getting-started.md b/docs/getting-started.md index 7d34bb1..053d181 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -160,6 +160,30 @@ For game-specific selection, use `skill.json` with `id`, `name`, optional `input 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. +To consume a portable Agent Plugins 1.0.0 package instead of a standalone skill directory, install `OpenGameAgent.Plugins` and load the package as one runtime extension: + +```powershell +dotnet add package OpenGameAgent.Plugins --version 0.3.0-alpha.1 +``` + +```csharp +using OpenGameAgent.Plugins; + +var plugin = AgentPluginLoader.Load( + pluginDirectory, + new AgentPluginLoadOptions + { + // Required only when the package contains stdio MCP servers. + PluginDataDirectory = pluginDataDirectory, + }); + +await using var runtime = new GameAgentBuilder(provider, model) + .UseExtension(plugin) + .Build(); +``` + +The adapter validates `plugin.json`, discovers only immediate `skills/*/SKILL.md` children, and maps valid `mcp.json` stdio and Streamable HTTP entries to the existing MCP connector. Invalid skills and MCP server entries are diagnosed independently. The optional legacy SSE MCP transport is reported and skipped. Package paths cannot escape the plugin root, `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` expansion is single-pass, and the default HTTP transport disables redirects so visible package headers do not cross origins. See [Agent Plugins](agent-plugins.md) for the complete boundary. + 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 diff --git a/docs/nuget-package-readme.md b/docs/nuget-package-readme.md index 3af15bd..55dccf2 100644 --- a/docs/nuget-package-readme.md +++ b/docs/nuget-package-readme.md @@ -7,6 +7,7 @@ Open-source C# agent runtime for AI-native games, autonomous NPCs, and interacti - Durable game actions and workflows - Typed extension API plus official policy, catalog, interaction, goal, memory, artifact, delegation, tracing, and workflow-graph extensions - Skills, scheduling, mailboxes, large-result spill, and multi-actor concurrency +- Optional Agent Plugins 1.0.0 package loading for portable skills and MCP servers - Capability-aware model catalogs, replaceable authentication, and developer-hosted short-lived credentials - Lazy external tool discovery; cloud or local text/image/audio/video APIs with no bundled model - Godot, Unity, and .NET server placement diff --git a/src/OpenGameAgent.Connectors.Mcp/McpToolConnectorExtension.cs b/src/OpenGameAgent.Connectors.Mcp/McpToolConnectorExtension.cs index 7bc64c4..5ee0732 100644 --- a/src/OpenGameAgent.Connectors.Mcp/McpToolConnectorExtension.cs +++ b/src/OpenGameAgent.Connectors.Mcp/McpToolConnectorExtension.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.IO; using System.Linq; using System.Net.Http; using System.Security.Cryptography; @@ -187,7 +188,7 @@ public static GameMcpServer Stdio( var copiedEnvironment = environment is null ? StdioClientTransportOptions.GetDefaultEnvironmentVariables() - : new Dictionary(environment, StringComparer.OrdinalIgnoreCase); + : new Dictionary(environment, EnvironmentNameComparer); return new GameMcpServer( id, async cancellationToken => @@ -212,6 +213,9 @@ 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; + + private static StringComparer EnvironmentNameComparer => + Path.DirectorySeparatorChar == '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; } public sealed class McpToolSetChange @@ -264,6 +268,7 @@ public sealed class McpToolConnectorExtension : IGameAgentExtension, IAsyncDispo private readonly IGameAgentArtifactStore? _artifactStore; private readonly Func _clock; private readonly GameMcpToolExposure _exposure; + private readonly bool _continueOnServerFailure; private readonly CancellationTokenSource _lifetime = new(); private int _disposed; @@ -277,6 +282,31 @@ public McpToolConnectorExtension( Func? operationalClock = null, int maximumResultCharacters = 10_000_000, GameMcpToolExposure exposure = GameMcpToolExposure.OnDemand) + : this( + servers, + continueOnServerFailure: false, + refreshInterval, + maximumToolsPerServer, + maximumSchemaCharacters, + maximumInlineResultCharacters, + artifactStore, + operationalClock, + maximumResultCharacters, + exposure) + { + } + + public McpToolConnectorExtension( + IReadOnlyList servers, + bool continueOnServerFailure, + 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)) @@ -336,6 +366,7 @@ public McpToolConnectorExtension( _artifactStore = artifactStore; _clock = operationalClock ?? (() => DateTimeOffset.UtcNow); _exposure = exposure; + _continueOnServerFailure = continueOnServerFailure; } public static GameAgentExtensionChannel ToolSetChanged { get; } = new("mcp.tools.changed"); @@ -423,8 +454,15 @@ private async ValueTask> CollectToolsAsync( 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))); + try + { + var tools = await GetToolsAsync(api, server, linked.Token).ConfigureAwait(false); + result.AddRange(tools.Select(tool => CreateTool(server, tool, context))); + } + catch (Exception exception) when (CanIsolateServerFailure(exception, linked.Token)) + { + // A portable plugin keeps its remaining MCP servers available when one server is down. + } } return result; @@ -647,10 +685,21 @@ private async ValueTask SearchAsync( } var matches = new List(); + var unavailableServers = 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); + IReadOnlyList tools; + try + { + tools = await GetToolsAsync(api, server, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (CanIsolateServerFailure(exception, cancellationToken)) + { + unavailableServers.Add(server.Id); + continue; + } + foreach (var tool in tools) { if (query.Length > 0 @@ -690,6 +739,7 @@ private async ValueTask SearchAsync( query, matches = returned, truncated, + unavailableServers, })), }); } @@ -712,7 +762,16 @@ private async ValueTask ResolveAsync( } var name = path.Substring(server.ToolPrefix.Length); - var tools = await GetToolsAsync(api, server, cancellationToken).ConfigureAwait(false); + IReadOnlyList tools; + try + { + tools = await GetToolsAsync(api, server, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (CanIsolateServerFailure(exception, cancellationToken)) + { + return ResolvedTool.Failed($"External tool server '{server.Id}' is unavailable."); + } + var tool = tools.FirstOrDefault(value => string.Equals(value.Name, name, StringComparison.Ordinal)); if (tool is not null) { @@ -723,6 +782,13 @@ private async ValueTask ResolveAsync( return ResolvedTool.Failed($"External tool '{path}' does not exist."); } + private bool CanIsolateServerFailure(Exception exception, CancellationToken cancellationToken) => + _continueOnServerFailure + && !cancellationToken.IsCancellationRequested + && !_lifetime.IsCancellationRequested + && exception is not OutOfMemoryException + && exception is not AccessViolationException; + private async ValueTask InvokeAsync( GameMcpServer server, McpClientTool remoteTool, diff --git a/src/OpenGameAgent.Kernel/AgentValidator.cs b/src/OpenGameAgent.Kernel/AgentValidator.cs index 060930a..320d1b6 100644 --- a/src/OpenGameAgent.Kernel/AgentValidator.cs +++ b/src/OpenGameAgent.Kernel/AgentValidator.cs @@ -496,6 +496,14 @@ public static void ValidateRequest( ValidateConstrainedSampling(tool, limits); + var schemaError = JsonSchemaValidator.Preflight(tool.InputSchemaJson); + if (schemaError is not null) + { + throw new ArgumentException( + $"Provider tool schema '{tool.Name}' failed preflight: {schemaError}", + nameof(request)); + } + if (!names.Add(tool.Name)) { throw new ArgumentException($"Duplicate provider tool name '{tool.Name}'.", nameof(request)); diff --git a/src/OpenGameAgent.Kernel/JsonSchemaValidator.cs b/src/OpenGameAgent.Kernel/JsonSchemaValidator.cs index 782f05b..4d82098 100644 --- a/src/OpenGameAgent.Kernel/JsonSchemaValidator.cs +++ b/src/OpenGameAgent.Kernel/JsonSchemaValidator.cs @@ -34,23 +34,34 @@ internal static class JsonSchemaValidator "multipleOf", }; + public static string? Preflight(string schemaJson) + { + try + { + using var document = JsonDocument.Parse(schemaJson); + return Preflight(document.RootElement); + } + catch (JsonException exception) + { + return "The tool schema is invalid: " + exception.Message; + } + catch (NumberLimitException exception) + { + return exception.Message; + } + } + public static string? Validate(string schemaJson, JsonElement value) { try { using var document = JsonDocument.Parse(schemaJson); - var schemaError = ValidateSchemaNode(document.RootElement, "$", 0); + var schemaError = Preflight(document.RootElement); if (schemaError is not null) { return schemaError; } - var schemaJsonError = ValidateJsonValue(document.RootElement, "$schema", 0); - if (schemaJsonError is not null) - { - return schemaJsonError; - } - var valueError = ValidateJsonValue(value, "$", 0); if (valueError is not null) { @@ -69,6 +80,12 @@ internal static class JsonSchemaValidator } } + private static string? Preflight(JsonElement schema) + { + var schemaError = ValidateSchemaNode(schema, "$", 0); + return schemaError ?? ValidateJsonValue(schema, "$schema", 0); + } + private static string? ValidateSchemaNode(JsonElement schema, string path, int depth) { if (depth > MaxDepth) diff --git a/src/OpenGameAgent.Kernel/Models.cs b/src/OpenGameAgent.Kernel/Models.cs index 4099616..b914a9e 100644 --- a/src/OpenGameAgent.Kernel/Models.cs +++ b/src/OpenGameAgent.Kernel/Models.cs @@ -46,11 +46,32 @@ public ModelCost( double output = 0, double cacheRead = 0, double cacheWrite = 0) + : this( + input, + output, + cacheRead, + cacheWrite, + input != 0 || output != 0 || cacheRead != 0 || cacheWrite != 0) + { + } + + public ModelCost(bool isKnown) + : this(0, 0, 0, 0, isKnown) + { + } + + public ModelCost( + double input, + double output, + double cacheRead, + double cacheWrite, + bool isKnown) { Input = RequireAmount(input, nameof(input)); Output = RequireAmount(output, nameof(output)); CacheRead = RequireAmount(cacheRead, nameof(cacheRead)); CacheWrite = RequireAmount(cacheWrite, nameof(cacheWrite)); + IsKnown = isKnown; } public double Input { get; } @@ -61,23 +82,33 @@ public ModelCost( public double CacheWrite { get; } + /// + /// Whether the amounts represent a complete price rather than an unavailable estimate. + /// Unknown cost is distinct from a known free request. + /// + public bool IsKnown { get; } + public double Total => Input + Output + CacheRead + CacheWrite; + public double? TotalIfKnown => IsKnown ? Total : null; + internal static ModelCost Aggregate(IEnumerable values) { var input = 0d; var output = 0d; var cacheRead = 0d; var cacheWrite = 0d; + var known = true; foreach (var value in values) { input += value.Input; output += value.Output; cacheRead += value.CacheRead; cacheWrite += value.CacheWrite; + known &= value.IsKnown; } - return new ModelCost(input, output, cacheRead, cacheWrite); + return new ModelCost(input, output, cacheRead, cacheWrite, known); } private static double RequireAmount(double value, string name) diff --git a/src/OpenGameAgent.Models.BuiltIn/BuiltInGameModelRuntime.cs b/src/OpenGameAgent.Models.BuiltIn/BuiltInGameModelRuntime.cs index 273625d..5795ae6 100644 --- a/src/OpenGameAgent.Models.BuiltIn/BuiltInGameModelRuntime.cs +++ b/src/OpenGameAgent.Models.BuiltIn/BuiltInGameModelRuntime.cs @@ -243,10 +243,70 @@ private async IAsyncEnumerable StreamRegisteredAsync( .WithCancellation(cancellationToken) .ConfigureAwait(false)) { - yield return streamEvent; + yield return ApplyCatalogCost(streamEvent, model.Cost); } } + private static ModelStreamEvent ApplyCatalogCost(ModelStreamEvent streamEvent, GameModelCost pricing) + { + if (streamEvent is null) + { + throw new InvalidOperationException("A model provider emitted a null event."); + } + + if (streamEvent.IsTerminal) + { + return ModelStreamEvent.Terminal(ApplyCatalogCost( + streamEvent.Response + ?? throw new InvalidOperationException("A terminal model event is missing its response."), + pricing)); + } + + return ModelStreamEvent.Update( + streamEvent.Kind, + ApplyCatalogCost( + streamEvent.Partial + ?? throw new InvalidOperationException("A model update is missing its partial response."), + pricing), + streamEvent.Delta, + streamEvent.ContentIndex, + streamEvent.ToolCallId, + streamEvent.ToolName, + streamEvent.ToolCall, + streamEvent.Content); + } + + private static ModelResponse ApplyCatalogCost(ModelResponse response, GameModelCost pricing) + { + if (response.Usage.Cost.IsKnown) + { + return response; + } + + var usage = response.Usage; + var pricedUsage = new ModelUsage( + usage.InputTokens, + usage.OutputTokens, + usage.CacheReadTokens, + usage.CacheWriteTokens, + usage.ReasoningTokens, + usage.CacheWriteOneHourTokens, + pricing.Estimate(usage)); + return new ModelResponse( + response.Content, + response.StopReason, + pricedUsage, + response.ErrorMessage, + response.Provider, + response.Api, + response.ResponseModel, + response.ResponseId, + response.RawStopReason, + response.EndTurn, + response.Diagnostics, + response.Deferred); + } + private static ResolvedGameModelTransportConfiguration AuthenticationTransportConfiguration( GameProviderAuthResolution? authentication) { diff --git a/src/OpenGameAgent.Models/GameModelDirectory.cs b/src/OpenGameAgent.Models/GameModelDirectory.cs index 1d50cb3..17d0733 100644 --- a/src/OpenGameAgent.Models/GameModelDirectory.cs +++ b/src/OpenGameAgent.Models/GameModelDirectory.cs @@ -183,16 +183,17 @@ private static GameModelDescriptor ParseModel(GameProviderDescriptor provider, J outputCapabilities |= GameModelOutputCapabilities.Reasoning; } + var modelId = RequiredString(element, "id", 1024); return new GameModelDescriptor( provider.ProviderId, - RequiredString(element, "id", 1024), + modelId, OptionalString(element, "name", 4096), OptionalInt32(element, "contextWindow") ?? 0, OptionalInt32(element, "maximumOutput") ?? 0, ParseInputCapabilities(element), outputCapabilities, reasoningLevels, - ParseCost(element), + ParseCost(element, modelId), ParseStringMap(element, "metadata"), ParseReasoningValues(element, reasoningLevels), OptionalString(element, "api", 256) ?? "custom", @@ -293,7 +294,7 @@ private static IReadOnlyDictionary ParseReasoningVal return new ReadOnlyDictionary(result); } - private static GameModelCost ParseCost(JsonElement element) + private static GameModelCost ParseCost(JsonElement element, string modelId) { if (!element.TryGetProperty("cost", out var cost)) { @@ -316,12 +317,24 @@ private static GameModelCost ParseCost(JsonElement element) } } + var input = OptionalDecimal(cost, "input") ?? 0; + var output = OptionalDecimal(cost, "output") ?? 0; + var cacheRead = OptionalDecimal(cost, "cacheRead") ?? 0; + var cacheWrite = OptionalDecimal(cost, "cacheWrite") ?? 0; + var known = OptionalBoolean(cost, "known") + ?? input != 0 + || output != 0 + || cacheRead != 0 + || cacheWrite != 0 + || tiers.Count != 0 + || modelId.EndsWith(":free", StringComparison.OrdinalIgnoreCase); return new GameModelCost( - OptionalDecimal(cost, "input") ?? 0, - OptionalDecimal(cost, "output") ?? 0, - OptionalDecimal(cost, "cacheRead") ?? 0, - OptionalDecimal(cost, "cacheWrite") ?? 0, - tiers); + input, + output, + cacheRead, + cacheWrite, + tiers, + known); } private static IReadOnlyDictionary ParseStringMap(JsonElement element, string propertyName) diff --git a/src/OpenGameAgent.Models/ModelDescriptors.cs b/src/OpenGameAgent.Models/ModelDescriptors.cs index 3baf677..5f3e127 100644 --- a/src/OpenGameAgent.Models/ModelDescriptors.cs +++ b/src/OpenGameAgent.Models/ModelDescriptors.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using OpenGameAgent.Kernel; namespace OpenGameAgent.Models; @@ -48,6 +49,32 @@ public GameModelCost( decimal cacheReadPerMillionTokens = 0, decimal cacheWritePerMillionTokens = 0, IReadOnlyCollection? tiers = null) + : this( + inputPerMillionTokens, + outputPerMillionTokens, + cacheReadPerMillionTokens, + cacheWritePerMillionTokens, + tiers, + inputPerMillionTokens != 0 + || outputPerMillionTokens != 0 + || cacheReadPerMillionTokens != 0 + || cacheWritePerMillionTokens != 0 + || (tiers?.Count ?? 0) != 0) + { + } + + public GameModelCost(bool isKnown) + : this(0, 0, 0, 0, null, isKnown) + { + } + + public GameModelCost( + decimal inputPerMillionTokens, + decimal outputPerMillionTokens, + decimal cacheReadPerMillionTokens, + decimal cacheWritePerMillionTokens, + IReadOnlyCollection? tiers, + bool isKnown) { InputPerMillionTokens = RequireCost(inputPerMillionTokens, nameof(inputPerMillionTokens)); OutputPerMillionTokens = RequireCost(outputPerMillionTokens, nameof(outputPerMillionTokens)); @@ -62,6 +89,7 @@ public GameModelCost( throw new ArgumentException("Cost tiers must be non-null and use unique thresholds.", nameof(tiers)); } + IsKnown = isKnown; Tiers = Array.AsReadOnly(copiedTiers); } @@ -73,6 +101,8 @@ public GameModelCost( public decimal CacheWritePerMillionTokens { get; } + public bool IsKnown { get; } + public IReadOnlyList Tiers { get; } public GameModelCost RatesForInput(long inputTokens) @@ -89,7 +119,43 @@ public GameModelCost RatesForInput(long inputTokens) tier.InputPerMillionTokens, tier.OutputPerMillionTokens, tier.CacheReadPerMillionTokens, - tier.CacheWritePerMillionTokens); + tier.CacheWritePerMillionTokens, + tiers: null, + isKnown: IsKnown); + } + + public ModelCost Estimate(ModelUsage usage) + { + if (usage is null) + { + throw new ArgumentNullException(nameof(usage)); + } + if (!IsKnown) + { + return new ModelCost(); + } + + const decimal scale = 1_000_000m; + var inputVolume = checked(usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens); + var rates = RatesForInput(inputVolume); + var longCacheWrite = usage.CacheWriteOneHourTokens ?? 0; + var shortCacheWrite = usage.CacheWriteTokens - longCacheWrite; + return new ModelCost( + DecimalToDouble(usage.InputTokens / scale * rates.InputPerMillionTokens), + DecimalToDouble(usage.OutputTokens / scale * rates.OutputPerMillionTokens), + DecimalToDouble(usage.CacheReadTokens / scale * rates.CacheReadPerMillionTokens), + DecimalToDouble( + shortCacheWrite / scale * rates.CacheWritePerMillionTokens + + longCacheWrite / scale * rates.InputPerMillionTokens * 2), + isKnown: true); + } + + private static double DecimalToDouble(decimal value) + { + var result = (double)value; + return double.IsNaN(result) || double.IsInfinity(result) + ? throw new OverflowException("The estimated model cost is too large.") + : result; } private static decimal RequireCost(decimal value, string parameterName) => diff --git a/src/OpenGameAgent.Models/ProviderCatalog.cs b/src/OpenGameAgent.Models/ProviderCatalog.cs index 1bf5c5b..86aa155 100644 --- a/src/OpenGameAgent.Models/ProviderCatalog.cs +++ b/src/OpenGameAgent.Models/ProviderCatalog.cs @@ -289,6 +289,12 @@ public decimal EstimateCost(ModelUsage usage) throw new ArgumentNullException(nameof(usage)); } + if (!Model.Cost.IsKnown) + { + throw new InvalidOperationException( + $"Pricing is unavailable for model '{Model.ProviderId}/{Model.ModelId}'."); + } + const decimal scale = 1_000_000m; var inputVolume = checked(usage.InputTokens + usage.CacheReadTokens + usage.CacheWriteTokens); var rates = Model.Cost.RatesForInput(inputVolume); @@ -300,6 +306,16 @@ public decimal EstimateCost(ModelUsage usage) + shortCacheWrite / scale * rates.CacheWritePerMillionTokens + longCacheWrite / scale * rates.InputPerMillionTokens * 2; } + + public decimal? EstimateCostOrNull(ModelUsage usage) + { + if (usage is null) + { + throw new ArgumentNullException(nameof(usage)); + } + + return Model.Cost.IsKnown ? EstimateCost(usage) : null; + } } public sealed class GameModelCatalog @@ -1037,7 +1053,8 @@ private static bool Equivalent(GameModelDescriptor left, GameModelDescriptor rig && string.Equals(left.CompatibilityJson, right.CompatibilityJson, StringComparison.Ordinal); private static bool Equivalent(GameModelCost left, GameModelCost right) => - left.InputPerMillionTokens == right.InputPerMillionTokens + left.IsKnown == right.IsKnown + && left.InputPerMillionTokens == right.InputPerMillionTokens && left.OutputPerMillionTokens == right.OutputPerMillionTokens && left.CacheReadPerMillionTokens == right.CacheReadPerMillionTokens && left.CacheWritePerMillionTokens == right.CacheWritePerMillionTokens diff --git a/src/OpenGameAgent.Persistence/AgentMessageCodec.cs b/src/OpenGameAgent.Persistence/AgentMessageCodec.cs index e97debd..9c96d3e 100644 --- a/src/OpenGameAgent.Persistence/AgentMessageCodec.cs +++ b/src/OpenGameAgent.Persistence/AgentMessageCodec.cs @@ -78,7 +78,13 @@ public static AgentMessage Decode(MessageDocument document) { TextContent text => new ContentDocument { Kind = "text", Text = text.Text }, JsonContent json => new ContentDocument { Kind = "json", Json = json.Json }, - ReasoningContent reasoning => new ContentDocument { Kind = "reasoning", Text = reasoning.Text, Detail = reasoning.Signature }, + ReasoningContent reasoning => new ContentDocument + { + Kind = "reasoning", + Text = reasoning.Text, + Detail = reasoning.Signature, + Redacted = reasoning.Redacted, + }, ResourceContent resource => new ContentDocument { Kind = "resource", Text = resource.Name, Reference = resource.Uri, Detail = resource.MediaType }, ToolCallContent call => new ContentDocument { Kind = "tool_call", Text = call.Name, Reference = call.Id, Json = call.ArgumentsJson }, _ => throw new InvalidOperationException("Unsupported agent content type."), @@ -88,7 +94,7 @@ public static AgentMessage Decode(MessageDocument document) { "text" => new TextContent(document.Text ?? string.Empty), "json" => new JsonContent(document.Json ?? throw new PersistenceException("Persisted JSON content is missing.")), - "reasoning" => new ReasoningContent(document.Text ?? string.Empty, document.Detail), + "reasoning" => new ReasoningContent(document.Text ?? string.Empty, document.Detail, document.Redacted), "resource" => new ResourceContent( document.Reference ?? throw new PersistenceException("Persisted resource URI is missing."), document.Detail ?? throw new PersistenceException("Persisted resource media type is missing."), @@ -141,6 +147,8 @@ internal sealed class ContentDocument public string? Reference { get; set; } public string? Detail { get; set; } + + public bool Redacted { get; set; } } internal sealed class UsageDocument diff --git a/src/OpenGameAgent.Persistence/FileGameActionJournal.cs b/src/OpenGameAgent.Persistence/FileGameActionJournal.cs index fd43bc9..c8fc79e 100644 --- a/src/OpenGameAgent.Persistence/FileGameActionJournal.cs +++ b/src/OpenGameAgent.Persistence/FileGameActionJournal.cs @@ -265,7 +265,7 @@ private static ActionDocument Encode( GameActionReceipt? receipt, bool dispatched) => new() { - FormatVersion = 1, + FormatVersion = 2, Dispatched = dispatched, Intent = new IntentDocument { @@ -277,6 +277,7 @@ private static ActionDocument Encode( ArgumentsJson = intent.ArgumentsJson, Moment = MomentDocument.Encode(intent.Moment), ExpectedRevision = intent.ExpectedRevision, + GenerationId = intent.GenerationId, }, Receipt = receipt is null ? null : new ReceiptDocument { @@ -295,7 +296,7 @@ private static GameActionJournalEntry Decode(ActionDocument document) => private static GameActionJournalEntry DecodeCore(ActionDocument document) { - if (document.FormatVersion != 1 || document.Intent is null) + if (document.FormatVersion is not (1 or 2) || document.Intent is null) { throw new PersistenceException("The action journal document has an unsupported format."); } @@ -308,7 +309,8 @@ private static GameActionJournalEntry DecodeCore(ActionDocument document) document.Intent.Action, document.Intent.ArgumentsJson, document.Intent.Moment?.Decode() ?? throw new PersistenceException("The action intent moment is missing."), - document.Intent.ExpectedRevision); + document.Intent.ExpectedRevision, + document.FormatVersion >= 2 ? document.Intent.GenerationId : null); GameActionReceipt? receipt = null; if (document.Receipt is not null) { @@ -353,7 +355,8 @@ private static void EnsureSameIntent(GameActionIntent expected, GameActionIntent || !string.Equals(expected.Action, actual.Action, StringComparison.Ordinal) || !string.Equals(expected.ArgumentsJson, actual.ArgumentsJson, StringComparison.Ordinal) || expected.Moment != actual.Moment - || expected.ExpectedRevision != actual.ExpectedRevision) + || expected.ExpectedRevision != actual.ExpectedRevision + || !string.Equals(expected.GenerationId, actual.GenerationId, StringComparison.Ordinal)) { throw new InvalidOperationException("The operation ID is already reserved for a different action intent."); } @@ -404,6 +407,8 @@ private sealed class IntentDocument public MomentDocument? Moment { get; set; } public long? ExpectedRevision { get; set; } + + public string? GenerationId { get; set; } } private sealed class ReceiptDocument diff --git a/src/OpenGameAgent.Persistence/FileGameSessionStore.cs b/src/OpenGameAgent.Persistence/FileGameSessionStore.cs index b4619e6..df53f86 100644 --- a/src/OpenGameAgent.Persistence/FileGameSessionStore.cs +++ b/src/OpenGameAgent.Persistence/FileGameSessionStore.cs @@ -98,7 +98,7 @@ public async ValueTask SaveAsync( private static SessionDocument Encode(GameSessionSnapshot snapshot) => new() { - FormatVersion = 3, + FormatVersion = 4, SessionId = snapshot.Key.SessionId, ActorId = snapshot.Key.ActorId, Revision = snapshot.Revision, @@ -138,7 +138,7 @@ private static string IdentityFor(GameSessionKey key) => string.Concat( return null; } - if (document.FormatVersion is not (1 or 2 or 3)) + if (document.FormatVersion is not (1 or 2 or 3 or 4)) { throw new PersistenceException($"Unsupported session format version '{document.FormatVersion}'."); } @@ -250,6 +250,8 @@ private sealed class UsageRecordDocument public double CacheWriteCost { get; set; } + public bool? CostKnown { get; set; } + public string? RunId { get; set; } public string? InputId { get; set; } @@ -270,6 +272,7 @@ private sealed class UsageRecordDocument OutputCost = record.Usage.Cost.Output, CacheReadCost = record.Usage.Cost.CacheRead, CacheWriteCost = record.Usage.Cost.CacheWrite, + CostKnown = record.Usage.Cost.IsKnown, RunId = record.RunId, InputId = record.InputId, DetailsJson = record.DetailsJson, @@ -285,7 +288,9 @@ private sealed class UsageRecordDocument CacheWriteTokens, ReasoningTokens, CacheWriteOneHourTokens, - new ModelCost(InputCost, OutputCost, CacheReadCost, CacheWriteCost)), + CostKnown.HasValue + ? new ModelCost(InputCost, OutputCost, CacheReadCost, CacheWriteCost, CostKnown.Value) + : new ModelCost(InputCost, OutputCost, CacheReadCost, CacheWriteCost)), RunId, InputId, DetailsJson); @@ -315,6 +320,8 @@ private sealed class UsageTotalsDocument public double CacheWriteCost { get; set; } + public bool? CostKnown { get; set; } + public static UsageTotalsDocument Encode( GameSessionUsageCause cause, GameSessionUsageTotals totals) => new() @@ -330,6 +337,7 @@ public static UsageTotalsDocument Encode( OutputCost = totals.OutputCost, CacheReadCost = totals.CacheReadCost, CacheWriteCost = totals.CacheWriteCost, + CostKnown = totals.CostKnown, }; public GameSessionUsageTotals Decode() => new( @@ -342,7 +350,12 @@ public static UsageTotalsDocument Encode( InputCost, OutputCost, CacheReadCost, - CacheWriteCost); + CacheWriteCost, + CostKnown + ?? InputCost != 0 + || OutputCost != 0 + || CacheReadCost != 0 + || CacheWriteCost != 0); } } diff --git a/src/OpenGameAgent.Plugins/AgentPluginContracts.cs b/src/OpenGameAgent.Plugins/AgentPluginContracts.cs new file mode 100644 index 0000000..9999474 --- /dev/null +++ b/src/OpenGameAgent.Plugins/AgentPluginContracts.cs @@ -0,0 +1,270 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Net.Http; +using OpenGameAgent.Connectors.Mcp; + +namespace OpenGameAgent.Plugins; + +public static class AgentPluginSpecification +{ + public const string Version = "1.0.0"; + + public const string ManifestSchema = + "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; + + public const string McpSchema = + "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"; +} + +public enum AgentPluginDiagnosticSeverity +{ + Information, + Warning, + Error, +} + +public sealed class AgentPluginDiagnostic +{ + internal AgentPluginDiagnostic( + AgentPluginDiagnosticSeverity severity, + string code, + string message, + string path, + string? component = null) + { + Severity = severity; + Code = code; + Message = message; + Path = path; + Component = component; + } + + public AgentPluginDiagnosticSeverity Severity { get; } + + public string Code { get; } + + public string Message { get; } + + public string Path { get; } + + public string? Component { get; } +} + +public sealed class AgentPluginAuthor +{ + internal AgentPluginAuthor(string? name, string? email, string? url) + { + Name = name; + Email = email; + Url = url; + } + + public string? Name { get; } + + public string? Email { get; } + + public string? Url { get; } +} + +public sealed class AgentPluginManifest +{ + internal AgentPluginManifest( + string name, + string? version, + string? description, + AgentPluginAuthor? author, + string? homepage, + string? repository, + string? license, + IReadOnlyList keywords, + IReadOnlyDictionary extensions) + { + Name = name; + Version = version; + Description = description; + Author = author; + Homepage = homepage; + Repository = repository; + License = license; + Keywords = keywords; + Extensions = extensions; + } + + public string Schema => AgentPluginSpecification.ManifestSchema; + + public string Name { get; } + + public string? Version { get; } + + public string? Description { get; } + + public AgentPluginAuthor? Author { get; } + + public string? Homepage { get; } + + public string? Repository { get; } + + public string? License { get; } + + public IReadOnlyList Keywords { get; } + + /// + /// Client-specific manifest values as bounded JSON objects. OpenGameAgent does not execute + /// code or assign behavior to unknown namespaces. + /// + public IReadOnlyDictionary Extensions { get; } +} + +public enum AgentPluginMcpTransport +{ + Stdio, + StreamableHttp, + LegacySse, +} + +public sealed class AgentPluginMcpServerInfo +{ + internal AgentPluginMcpServerInfo(string id, AgentPluginMcpTransport transport) + { + Id = id; + Transport = transport; + } + + public string Id { get; } + + public AgentPluginMcpTransport Transport { get; } +} + +public sealed class AgentPluginLoadOptions +{ + /// + /// Client-managed writable root used for ${PLUGIN_DATA}. A stdio server is skipped when this + /// directory is not configured; skills and remote MCP servers remain available. + /// + public string? PluginDataDirectory { get; set; } + + public bool LoadSkills { get; set; } = true; + + public bool LoadMcpServers { get; set; } = true; + + public GameMcpToolExposure McpToolExposure { get; set; } = GameMcpToolExposure.OnDemand; + + /// + /// Client-owned headers keyed by MCP server ID. They override case-insensitive package header + /// names, keeping authentication outside the plugin package. + /// + public IReadOnlyDictionary> McpServerHeaders { get; set; } = + new ReadOnlyDictionary>( + new Dictionary>(StringComparer.Ordinal)); + + /// + /// Optional client-owned HTTP transport. Redirect and authorization behavior is then owned by + /// the caller. The default transport disables redirects so package headers cannot cross origins. + /// + public HttpClient? McpHttpClient { get; set; } + + public int MaximumManifestCharacters { get; set; } = 1_000_000; + + public int MaximumMcpCharacters { get; set; } = 2_000_000; + + public int MaximumMetadataStringCharacters { get; set; } = 65_536; + + public int MaximumSkills { get; set; } = 1_000; + + public int MaximumMcpServers { get; set; } = 256; + + public int MaximumArgumentsPerServer { get; set; } = 1_024; + + public int MaximumEnvironmentVariablesPerServer { get; set; } = 1_024; + + public int MaximumHeadersPerServer { get; set; } = 64; + + public int MaximumDiagnostics { get; set; } = 1_024; + + public int MaximumDiagnosticCharacters { get; set; } = 128_000; + + internal void Validate() + { + if (!Enum.IsDefined(typeof(GameMcpToolExposure), McpToolExposure)) + { + throw new ArgumentOutOfRangeException(nameof(McpToolExposure)); + } + + RequireRange(MaximumManifestCharacters, 2, 100_000_000, nameof(MaximumManifestCharacters)); + RequireRange(MaximumMcpCharacters, 2, 100_000_000, nameof(MaximumMcpCharacters)); + RequireRange(MaximumMetadataStringCharacters, 1, 10_000_000, nameof(MaximumMetadataStringCharacters)); + RequireRange(MaximumSkills, 0, 100_000, nameof(MaximumSkills)); + RequireRange(MaximumMcpServers, 0, 10_000, nameof(MaximumMcpServers)); + RequireRange(MaximumArgumentsPerServer, 0, 100_000, nameof(MaximumArgumentsPerServer)); + RequireRange(MaximumEnvironmentVariablesPerServer, 0, 100_000, nameof(MaximumEnvironmentVariablesPerServer)); + RequireRange(MaximumHeadersPerServer, 0, 10_000, nameof(MaximumHeadersPerServer)); + RequireRange(MaximumDiagnostics, 0, 100_000, nameof(MaximumDiagnostics)); + RequireRange(MaximumDiagnosticCharacters, 1, 10_000_000, nameof(MaximumDiagnosticCharacters)); + + if (McpServerHeaders is null + || McpServerHeaders.Any(pair => pair.Key is null || pair.Value is null)) + { + throw new ArgumentException("MCP client header mappings cannot contain null values.", nameof(McpServerHeaders)); + } + } + + private static void RequireRange(int value, int minimum, int maximum, string name) + { + if (value < minimum || value > maximum) + { + throw new ArgumentOutOfRangeException(name); + } + } +} + +public sealed class AgentPluginLoadException : Exception +{ + public AgentPluginLoadException(string message, string path, Exception? innerException = null) + : base(message, innerException) + { + Path = path ?? throw new ArgumentNullException(nameof(path)); + } + + public string Path { get; } +} + +internal sealed class AgentPluginMcpConfiguration +{ + public AgentPluginMcpConfiguration( + string id, + AgentPluginMcpTransport transport, + string? command, + IReadOnlyList arguments, + string? workingDirectory, + IReadOnlyDictionary environment, + Uri? endpoint, + IReadOnlyDictionary headers) + { + Id = id; + Transport = transport; + Command = command; + Arguments = arguments; + WorkingDirectory = workingDirectory; + Environment = environment; + Endpoint = endpoint; + Headers = headers; + } + + public string Id { get; } + + public AgentPluginMcpTransport Transport { get; } + + public string? Command { get; } + + public IReadOnlyList Arguments { get; } + + public string? WorkingDirectory { get; } + + public IReadOnlyDictionary Environment { get; } + + public Uri? Endpoint { get; } + + public IReadOnlyDictionary Headers { get; } +} diff --git a/src/OpenGameAgent.Plugins/AgentPluginLoader.cs b/src/OpenGameAgent.Plugins/AgentPluginLoader.cs new file mode 100644 index 0000000..5e1b2be --- /dev/null +++ b/src/OpenGameAgent.Plugins/AgentPluginLoader.cs @@ -0,0 +1,1334 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using OpenGameAgent.Connectors.Mcp; +using OpenGameAgent.Persistence; + +namespace OpenGameAgent.Plugins; + +public static class AgentPluginLoader +{ + private static readonly HashSet ManifestFields = new(StringComparer.Ordinal) + { + "$schema", + "name", + "version", + "description", + "author", + "homepage", + "repository", + "license", + "keywords", + "extensions", + }; + + private static readonly HashSet AuthorFields = new(StringComparer.Ordinal) + { + "name", + "email", + "url", + }; + + private static readonly HashSet StdioFields = new(StringComparer.Ordinal) + { + "type", + "command", + "args", + "env", + "cwd", + }; + + private static readonly HashSet HttpFields = new(StringComparer.Ordinal) + { + "type", + "url", + "headers", + }; + + public static AgentPluginPackage Load( + string pluginDirectory, + AgentPluginLoadOptions? options = null) + { + if (string.IsNullOrWhiteSpace(pluginDirectory)) + { + throw new ArgumentException("An Agent Plugin directory is required.", nameof(pluginDirectory)); + } + + options ??= new AgentPluginLoadOptions(); + options.Validate(); + + var root = Path.GetFullPath(pluginDirectory); + RequireDirectory(root, "Agent Plugin root"); + RejectReparsePoint(root, "Agent Plugin root"); + + var manifestPath = Path.Combine(root, "plugin.json"); + RequirePackageFile(root, manifestPath, "Agent Plugin manifest"); + + var diagnostics = new DiagnosticBuffer(options.MaximumDiagnostics, options.MaximumDiagnosticCharacters); + var manifest = ParseManifest( + ReadBounded(manifestPath, options.MaximumManifestCharacters), + manifestPath, + options, + diagnostics); + + var dataDirectory = ResolveDataDirectory(options.PluginDataDirectory); + var skills = options.LoadSkills + ? LoadSkills(root, manifest.Name, options, diagnostics) + : Array.Empty(); + var extensionDirectories = DiscoverClientExtensionDirectories(root, diagnostics); + + var mcpConfigurations = options.LoadMcpServers + ? LoadMcpConfigurations(root, dataDirectory, manifest.Name, options, diagnostics) + : Array.Empty(); + + HttpClient? ownedHttpClient = null; + var httpClient = options.McpHttpClient; + if (httpClient is null && mcpConfigurations.Any(value => value.Transport == AgentPluginMcpTransport.StreamableHttp)) + { + ownedHttpClient = new HttpClient(new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = false, + }) + { + Timeout = System.Threading.Timeout.InfiniteTimeSpan, + }; + httpClient = ownedHttpClient; + } + + var mcpServers = new List(); + var mcpInfos = new List(); + for (var index = 0; index < mcpConfigurations.Count; index++) + { + var configuration = mcpConfigurations[index]; + var internalId = manifest.Name + ".mcp." + index; + var prefix = CreateToolPrefix(manifest.Name, configuration.Id, index); + switch (configuration.Transport) + { + case AgentPluginMcpTransport.Stdio: + mcpServers.Add(GameMcpServer.Stdio( + internalId, + configuration.Command!, + configuration.Arguments, + configuration.WorkingDirectory, + configuration.Environment, + prefix)); + mcpInfos.Add(new AgentPluginMcpServerInfo(configuration.Id, configuration.Transport)); + break; + case AgentPluginMcpTransport.StreamableHttp: + mcpServers.Add(GameMcpServer.Http( + internalId, + configuration.Endpoint!, + httpClient, + configuration.Headers, + allowInsecureHttp: configuration.Endpoint!.Scheme == Uri.UriSchemeHttp, + toolPrefix: prefix)); + mcpInfos.Add(new AgentPluginMcpServerInfo(configuration.Id, configuration.Transport)); + break; + } + } + + var mcpExtension = mcpServers.Count == 0 + ? null + : new McpToolConnectorExtension( + mcpServers, + continueOnServerFailure: true, + exposure: options.McpToolExposure); + + return new AgentPluginPackage( + root, + dataDirectory, + manifest, + AgentPluginPackage.ReadOnly(skills), + AgentPluginPackage.ReadOnly(mcpInfos), + extensionDirectories, + AgentPluginPackage.ReadOnly(diagnostics.Items), + mcpExtension, + ownedHttpClient, + AgentPluginPackage.ReadOnly(mcpConfigurations)); + } + + private static AgentPluginManifest ParseManifest( + string json, + string path, + AgentPluginLoadOptions options, + DiagnosticBuffer diagnostics) + { + try + { + using var document = ParseJson(json, path, validateDescendantObjects: false); + var root = document.RootElement; + RequireKind(root, JsonValueKind.Object, "The Agent Plugin manifest must be a JSON object."); + + foreach (var property in root.EnumerateObject()) + { + if (!ManifestFields.Contains(property.Name)) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Warning, + "manifest.unknown-field", + $"Unknown manifest field '{property.Name}' was ignored.", + path, + "manifest"); + } + } + + var schema = RequiredString(root, "$schema", options.MaximumMetadataStringCharacters); + if (!string.Equals(schema, AgentPluginSpecification.ManifestSchema, StringComparison.Ordinal)) + { + throw new PluginConfigurationException("The Agent Plugin manifest targets an unsupported schema."); + } + + var name = RequiredString(root, "name", 64); + if (!IsPluginName(name)) + { + throw new PluginConfigurationException("The Agent Plugin name does not satisfy the 1.0.0 name rules."); + } + + var version = OptionalString(root, "version", options.MaximumMetadataStringCharacters); + var description = OptionalString(root, "description", options.MaximumMetadataStringCharacters); + var homepage = OptionalString(root, "homepage", options.MaximumMetadataStringCharacters); + var repository = OptionalString(root, "repository", options.MaximumMetadataStringCharacters); + var license = OptionalString(root, "license", options.MaximumMetadataStringCharacters); + var author = ParseAuthor(root, options.MaximumMetadataStringCharacters); + var keywords = ParseKeywords(root, options.MaximumMetadataStringCharacters); + var extensions = ParseManifestExtensions(root, path, diagnostics); + return new AgentPluginManifest( + name, + version, + description, + author, + homepage, + repository, + license, + keywords, + extensions); + } + catch (AgentPluginLoadException) + { + throw; + } + catch (Exception exception) when (IsConfigurationFailure(exception)) + { + throw new AgentPluginLoadException( + $"Agent Plugin manifest '{path}' is invalid: {exception.Message}", + path, + exception); + } + } + + private static AgentPluginAuthor? ParseAuthor(JsonElement root, int maximumCharacters) + { + if (!root.TryGetProperty("author", out var author)) + { + return null; + } + + RequireKind(author, JsonValueKind.Object, "Manifest author must be an object."); + EnsureObjectNamesUnique(author); + foreach (var property in author.EnumerateObject()) + { + if (!AuthorFields.Contains(property.Name)) + { + throw new PluginConfigurationException($"Unknown author field '{property.Name}'."); + } + } + + return new AgentPluginAuthor( + OptionalString(author, "name", maximumCharacters), + OptionalString(author, "email", maximumCharacters), + OptionalString(author, "url", maximumCharacters)); + } + + private static IReadOnlyList ParseKeywords(JsonElement root, int maximumCharacters) + { + if (!root.TryGetProperty("keywords", out var keywords)) + { + return Array.Empty(); + } + + RequireKind(keywords, JsonValueKind.Array, "Manifest keywords must be an array."); + var values = keywords.EnumerateArray().ToArray(); + if (values.Length > 1_024) + { + throw new PluginConfigurationException("Manifest keywords exceed the client safety limit."); + } + + return Array.AsReadOnly(values.Select(value => RequireString(value, "keyword", maximumCharacters)).ToArray()); + } + + private static IReadOnlyDictionary ParseManifestExtensions( + JsonElement root, + string path, + DiagnosticBuffer diagnostics) + { + if (!root.TryGetProperty("extensions", out var extensions)) + { + return new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + } + + if (extensions.ValueKind != JsonValueKind.Object) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Warning, + "manifest.invalid-extensions", + "The non-object manifest extensions field was ignored.", + path, + "manifest"); + return new ReadOnlyDictionary(new Dictionary(StringComparer.Ordinal)); + } + + EnsureObjectNamesUnique(extensions); + var result = new Dictionary(StringComparer.Ordinal); + foreach (var property in extensions.EnumerateObject()) + { + if (property.Name.Length > 256 || property.Name.IndexOf('\0') >= 0) + { + throw new PluginConfigurationException("An extension namespace exceeds the client safety bounds."); + } + + RequireKind( + property.Value, + JsonValueKind.Object, + $"Extension namespace '{property.Name}' must contain an object."); + result.Add(property.Name, property.Value.GetRawText()); + } + + return new ReadOnlyDictionary(result); + } + + private static IReadOnlyList LoadSkills( + string root, + string pluginName, + AgentPluginLoadOptions options, + DiagnosticBuffer diagnostics) + { + var skillsRoot = Path.Combine(root, "skills"); + if (!Directory.Exists(skillsRoot)) + { + if (File.Exists(skillsRoot)) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Error, + "skills.invalid-location", + "The skills component exists but is not a directory.", + skillsRoot, + "skills"); + } + + return Array.Empty(); + } + + if (!IsWithin(root, skillsRoot) || IsReparsePoint(skillsRoot)) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Error, + "skills.invalid-location", + "The skills component does not resolve safely inside the plugin root.", + skillsRoot, + "skills"); + return Array.Empty(); + } + + string[] directories; + try + { + directories = Directory.EnumerateDirectories(skillsRoot, "*", SearchOption.TopDirectoryOnly) + .OrderBy(value => value, StringComparer.Ordinal) + .Take(options.MaximumSkills + 1) + .ToArray(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Error, + "skills.discovery-failed", + "The skills component could not be enumerated.", + skillsRoot, + "skills"); + return Array.Empty(); + } + + if (directories.Length > options.MaximumSkills) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Error, + "skills.limit-exceeded", + "The skills component exceeds the configured immediate-child limit.", + skillsRoot, + "skills"); + directories = directories.Take(options.MaximumSkills).ToArray(); + } + + var loaded = new List(); + var ids = new HashSet(StringComparer.Ordinal); + foreach (var directory in directories) + { + var skillPath = Path.Combine(directory, "SKILL.md"); + if (!File.Exists(skillPath)) + { + continue; + } + + if (!IsWithin(root, directory) + || IsReparsePoint(directory) + || IsReparsePoint(skillPath)) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Warning, + "skills.unsafe-entry", + "A skill that does not resolve safely inside the plugin root was skipped.", + skillPath, + "skills"); + continue; + } + + try + { + var source = new DirectoryGameSkillSource( + directory, + maximumSkills: 1, + maximumManifestCharacters: Math.Min(options.MaximumManifestCharacters, 100_000_000), + maximumInstructionsCharacters: 10_000_000, + maximumScannedDirectories: 1, + maximumIgnoreCharacters: 0, + continueOnError: true, + honorIgnoreFiles: false, + source: "agent-plugin", + sourceScope: pluginName, + maximumDiagnostics: Math.Min(options.MaximumDiagnostics, 1_000_000), + maximumDiagnosticCharacters: Math.Min(options.MaximumDiagnosticCharacters, 10_000_000)); + var discovered = source.Discover(); + foreach (var diagnostic in discovered.Diagnostics) + { + diagnostics.Add( + MapSeverity(diagnostic.Severity), + "skills." + diagnostic.Code, + diagnostic.Message, + diagnostic.Path, + "skills"); + } + + foreach (var skill in discovered.Skills) + { + if (!ids.Add(skill.SkillId)) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Warning, + "skills.duplicate-name", + $"Duplicate skill name '{skill.SkillId}' was skipped.", + skillPath, + "skills"); + continue; + } + + loaded.Add(skill); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or PersistenceException or GameRuntimeLimitException) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Warning, + "skills.invalid-entry", + "An invalid skill was skipped: " + Bound(exception.Message, 2_048), + skillPath, + "skills"); + } + } + + return Array.AsReadOnly(loaded.ToArray()); + } + + private static IReadOnlyList LoadMcpConfigurations( + string root, + string? dataDirectory, + string pluginName, + AgentPluginLoadOptions options, + DiagnosticBuffer diagnostics) + { + var path = Path.Combine(root, "mcp.json"); + if (!File.Exists(path)) + { + if (Directory.Exists(path)) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Error, + "mcp.invalid-location", + "The MCP component exists but is not a regular file.", + path, + "mcp"); + } + + return Array.Empty(); + } + + if (!IsWithin(root, path) || IsReparsePoint(path)) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Error, + "mcp.invalid-location", + "The MCP component does not resolve safely inside the plugin root.", + path, + "mcp"); + return Array.Empty(); + } + + try + { + using var document = ParseJson(ReadBounded(path, options.MaximumMcpCharacters), path); + var value = document.RootElement; + RequireKind(value, JsonValueKind.Object, "The MCP configuration must be an object."); + RequireExactFields(value, new HashSet(StringComparer.Ordinal) { "$schema", "mcpServers" }); + var schema = RequiredString(value, "$schema", options.MaximumMetadataStringCharacters); + if (!string.Equals(schema, AgentPluginSpecification.McpSchema, StringComparison.Ordinal)) + { + throw new PluginConfigurationException("The MCP configuration targets a different or unsupported version."); + } + + if (!value.TryGetProperty("mcpServers", out var servers)) + { + throw new PluginConfigurationException("The MCP configuration requires mcpServers."); + } + + RequireKind(servers, JsonValueKind.Object, "mcpServers must be an object."); + var entries = servers.EnumerateObject().OrderBy(entry => entry.Name, StringComparer.Ordinal).ToArray(); + if (entries.Length > options.MaximumMcpServers) + { + throw new PluginConfigurationException("The MCP configuration exceeds the server limit."); + } + + var result = new List(); + foreach (var entry in entries) + { + try + { + var parsed = ParseMcpServer( + entry.Name, + entry.Value, + root, + dataDirectory, + options); + if (parsed.Transport == AgentPluginMcpTransport.LegacySse) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Warning, + "mcp.unsupported-transport", + $"MCP server '{entry.Name}' uses the optional legacy SSE transport and was skipped.", + path, + entry.Name); + continue; + } + + result.Add(parsed); + } + catch (Exception exception) when (IsConfigurationFailure(exception)) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Warning, + "mcp.invalid-server", + $"MCP server '{entry.Name}' was skipped: {Bound(exception.Message, 2_048)}", + path, + entry.Name); + } + } + + foreach (var clientHeader in options.McpServerHeaders.Keys) + { + if (!entries.Any(entry => string.Equals(entry.Name, clientHeader, StringComparison.Ordinal))) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Warning, + "mcp.unknown-client-header-target", + $"Client headers target unknown MCP server '{clientHeader}' and were ignored.", + path, + clientHeader); + } + } + + return Array.AsReadOnly(result.ToArray()); + } + catch (Exception exception) when (IsConfigurationFailure(exception) || exception is AgentPluginLoadException) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Error, + "mcp.invalid-component", + "The MCP component was disabled: " + Bound(exception.Message, 2_048), + path, + "mcp"); + return Array.Empty(); + } + } + + private static AgentPluginMcpConfiguration ParseMcpServer( + string id, + JsonElement value, + string root, + string? dataDirectory, + AgentPluginLoadOptions options) + { + if (id.Length > options.MaximumMetadataStringCharacters) + { + throw new PluginConfigurationException("The MCP server ID exceeds the client safety limit."); + } + + RequireKind(value, JsonValueKind.Object, "An MCP server entry must be an object."); + var type = RequiredString(value, "type", 64); + return type switch + { + "stdio" => ParseStdioServer(id, value, root, dataDirectory, options), + "streamable-http" => ParseHttpServer(id, value, AgentPluginMcpTransport.StreamableHttp, options), + "sse" => ParseHttpServer(id, value, AgentPluginMcpTransport.LegacySse, options), + _ => throw new PluginConfigurationException($"Unsupported MCP transport '{type}'."), + }; + } + + private static AgentPluginMcpConfiguration ParseStdioServer( + string id, + JsonElement value, + string root, + string? dataDirectory, + AgentPluginLoadOptions options) + { + RequireExactFields(value, StdioFields); + if (dataDirectory is null) + { + throw new PluginConfigurationException("A client-managed PluginDataDirectory is required for stdio MCP servers."); + } + + EnsureDataDirectory(dataDirectory); + var command = RequiredString(value, "command", 32_768); + if (command.IndexOf('\0') >= 0) + { + throw new PluginConfigurationException("The MCP command contains a null character."); + } + + if (command.StartsWith("./", StringComparison.Ordinal)) + { + if (command.Length == 2) + { + throw new PluginConfigurationException("The plugin-relative MCP command is empty."); + } + + command = ResolveContained(root, command.Substring(2), "MCP command"); + } + else if (command.IndexOf('/') >= 0 || command.IndexOf('\\') >= 0) + { + throw new PluginConfigurationException("An MCP command must be a bare executable name or begin with './'."); + } + + var arguments = new List(); + if (value.TryGetProperty("args", out var args)) + { + RequireKind(args, JsonValueKind.Array, "MCP args must be an array."); + var items = args.EnumerateArray().ToArray(); + if (items.Length > options.MaximumArgumentsPerServer) + { + throw new PluginConfigurationException("MCP arguments exceed the client safety limit."); + } + + foreach (var item in items) + { + arguments.Add(ExpandPlaceholders( + RequireString(item, "MCP argument", 65_536), + root, + dataDirectory)); + } + } + + var environmentNameComparer = Path.DirectorySeparatorChar == '\\' + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + var environment = new Dictionary(environmentNameComparer); + if (value.TryGetProperty("env", out var env)) + { + RequireKind(env, JsonValueKind.Object, "MCP env must be an object."); + var entries = env.EnumerateObject().ToArray(); + if (entries.Length > options.MaximumEnvironmentVariablesPerServer) + { + throw new PluginConfigurationException("MCP environment variables exceed the client safety limit."); + } + + foreach (var pair in entries) + { + if (environmentNameComparer.Equals(pair.Name, "PLUGIN_ROOT") + || environmentNameComparer.Equals(pair.Name, "PLUGIN_DATA") + || pair.Name.Length == 0 + || pair.Name.Length > 512 + || pair.Name.IndexOfAny(new[] { '=', '\0' }) >= 0 + || !environment.TryAdd( + pair.Name, + ExpandPlaceholders( + RequireString(pair.Value, "MCP environment value", 65_536), + root, + dataDirectory))) + { + throw new PluginConfigurationException("MCP environment variables are invalid or reserved."); + } + } + } + + environment.Add("PLUGIN_ROOT", root); + environment.Add("PLUGIN_DATA", dataDirectory); + + var workingDirectory = root; + if (value.TryGetProperty("cwd", out var cwd)) + { + workingDirectory = ResolveWorkingDirectory( + RequireString(cwd, "MCP cwd", 32_768), + root, + dataDirectory); + } + + return new AgentPluginMcpConfiguration( + id, + AgentPluginMcpTransport.Stdio, + command, + Array.AsReadOnly(arguments.ToArray()), + workingDirectory, + new ReadOnlyDictionary(environment), + null, + new ReadOnlyDictionary(new Dictionary(StringComparer.OrdinalIgnoreCase))); + } + + private static AgentPluginMcpConfiguration ParseHttpServer( + string id, + JsonElement value, + AgentPluginMcpTransport transport, + AgentPluginLoadOptions options) + { + RequireExactFields(value, HttpFields); + var url = RequiredString(value, "url", 32_768); + if (!Uri.TryCreate(url, UriKind.Absolute, out var endpoint) + || (endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps) + || endpoint.UserInfo.Length > 0 + || endpoint.Fragment.Length > 0 + || (endpoint.Scheme == Uri.UriSchemeHttp && !IsLoopbackHost(endpoint.Host))) + { + throw new PluginConfigurationException( + "A remote MCP endpoint must be absolute HTTPS; HTTP is allowed only for localhost or a loopback IP literal."); + } + + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (value.TryGetProperty("headers", out var configuredHeaders)) + { + ParseHeaders(configuredHeaders, headers, options.MaximumHeadersPerServer, overwrite: false); + } + + if (options.McpServerHeaders.TryGetValue(id, out var clientHeaders)) + { + if (clientHeaders.Count > options.MaximumHeadersPerServer) + { + throw new PluginConfigurationException("Client MCP headers exceed the configured limit."); + } + + var clientHeaderNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var pair in clientHeaders) + { + if (!clientHeaderNames.Add(pair.Key)) + { + throw new PluginConfigurationException("Client MCP headers contain duplicate case-insensitive names."); + } + + ValidateHeader(pair.Key, pair.Value); + headers[pair.Key] = pair.Value; + } + } + + if (headers.Count > options.MaximumHeadersPerServer) + { + throw new PluginConfigurationException("Merged MCP headers exceed the configured limit."); + } + + return new AgentPluginMcpConfiguration( + id, + transport, + null, + Array.Empty(), + null, + new ReadOnlyDictionary(new Dictionary(StringComparer.OrdinalIgnoreCase)), + endpoint, + new ReadOnlyDictionary(headers)); + } + + private static void ParseHeaders( + JsonElement value, + Dictionary destination, + int maximumHeaders, + bool overwrite) + { + RequireKind(value, JsonValueKind.Object, "MCP headers must be an object."); + var properties = value.EnumerateObject().ToArray(); + if (properties.Length > maximumHeaders) + { + throw new PluginConfigurationException("MCP headers exceed the configured limit."); + } + + foreach (var property in properties) + { + var headerValue = RequireString(property.Value, "MCP header value", 65_536); + ValidateHeader(property.Name, headerValue); + if (overwrite) + { + destination[property.Name] = headerValue; + } + else if (!destination.TryAdd(property.Name, headerValue)) + { + throw new PluginConfigurationException("MCP headers contain duplicate case-insensitive names."); + } + } + } + + private static void ValidateHeader(string name, string value) + { + if (string.IsNullOrEmpty(name) + || name.Length > 256 + || value is null + || value.Length > 65_536 + || value.IndexOfAny(new[] { '\r', '\n', '\0' }) >= 0 + || name.Any(character => !IsHeaderTokenCharacter(character))) + { + throw new PluginConfigurationException("An MCP HTTP header is invalid."); + } + } + + private static bool IsHeaderTokenCharacter(char value) => + value is >= '0' and <= '9' + or >= 'A' and <= 'Z' + or >= 'a' and <= 'z' + or '!' + or '#' + or '$' + or '%' + or '&' + or '\'' + or '*' + or '+' + or '-' + or '.' + or '^' + or '_' + or '`' + or '|' + or '~'; + + private static string ResolveWorkingDirectory(string value, string root, string dataDirectory) + { + if (value.StartsWith("./", StringComparison.Ordinal)) + { + return value.Length == 2 + ? root + : ResolveContained(root, value.Substring(2), "MCP cwd"); + } + + if (string.Equals(value, "${PLUGIN_ROOT}", StringComparison.Ordinal)) + { + return root; + } + + if (value.StartsWith("${PLUGIN_ROOT}/", StringComparison.Ordinal)) + { + var relative = value.Substring("${PLUGIN_ROOT}/".Length); + return relative.Length == 0 ? root : ResolveContained(root, relative, "MCP cwd"); + } + + if (string.Equals(value, "${PLUGIN_DATA}", StringComparison.Ordinal)) + { + return dataDirectory; + } + + if (value.StartsWith("${PLUGIN_DATA}/", StringComparison.Ordinal)) + { + var relative = value.Substring("${PLUGIN_DATA}/".Length); + return relative.Length == 0 ? dataDirectory : ResolveContained(dataDirectory, relative, "MCP cwd"); + } + + throw new PluginConfigurationException( + "MCP cwd must begin with './', '${PLUGIN_ROOT}', or '${PLUGIN_DATA}'."); + } + + private static string ExpandPlaceholders(string value, string root, string dataDirectory) + { + const string rootToken = "${PLUGIN_ROOT}"; + const string dataToken = "${PLUGIN_DATA}"; + var builder = new StringBuilder(value.Length + root.Length + dataDirectory.Length); + for (var index = 0; index < value.Length;) + { + if (value.AsSpan(index).StartsWith(rootToken.AsSpan(), StringComparison.Ordinal)) + { + builder.Append(root); + index += rootToken.Length; + } + else if (value.AsSpan(index).StartsWith(dataToken.AsSpan(), StringComparison.Ordinal)) + { + builder.Append(dataDirectory); + index += dataToken.Length; + } + else + { + builder.Append(value[index]); + index++; + } + } + + return builder.ToString(); + } + + private static IReadOnlyDictionary DiscoverClientExtensionDirectories( + string root, + DiagnosticBuffer diagnostics) + { + var result = new Dictionary(StringComparer.Ordinal); + string[] directories; + try + { + directories = Directory.EnumerateDirectories(root, "*", SearchOption.TopDirectoryOnly) + .OrderBy(value => value, StringComparer.Ordinal) + .Take(1_025) + .ToArray(); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Warning, + "extensions.discovery-failed", + "Client extension directories could not be enumerated.", + root, + "extensions"); + return new ReadOnlyDictionary(result); + } + + if (directories.Length > 1_024) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Warning, + "extensions.limit-exceeded", + "Client extension directory discovery reached its safety limit.", + root, + "extensions"); + directories = directories.Take(1_024).ToArray(); + } + + foreach (var directory in directories) + { + var name = Path.GetFileName(directory); + if (!IsExtensionNamespace(name)) + { + continue; + } + + if (!IsWithin(root, directory) || IsReparsePoint(directory)) + { + diagnostics.Add( + AgentPluginDiagnosticSeverity.Warning, + "extensions.unsafe-directory", + $"Client extension directory '{name}' was skipped because it does not resolve safely.", + directory, + name); + continue; + } + + result.Add(name, directory); + } + + return new ReadOnlyDictionary(result); + } + + private static string? ResolveDataDirectory(string? value) + { + if (value is null) + { + return null; + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("PluginDataDirectory cannot be empty.", nameof(value)); + } + + return Path.GetFullPath(value); + } + + private static void EnsureDataDirectory(string path) + { + Directory.CreateDirectory(path); + RequireDirectory(path, "Plugin data directory"); + RejectReparsePoint(path, "Plugin data directory"); + } + + private static string ResolveContained(string root, string relativePath, string description) + { + if (relativePath.Length == 0 || Path.IsPathRooted(relativePath)) + { + throw new PluginConfigurationException($"The {description} path is invalid."); + } + + var normalized = relativePath.Replace('/', Path.DirectorySeparatorChar); + var resolved = Path.GetFullPath(Path.Combine(root, normalized)); + if (!IsWithin(root, resolved)) + { + throw new PluginConfigurationException($"The {description} path escapes its configured root."); + } + + RejectExistingReparsePoints(root, resolved, description); + return resolved; + } + + private static void RejectExistingReparsePoints(string root, string resolved, string description) + { + var relative = resolved.Substring(Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Length) + .TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var current = Path.GetFullPath(root); + foreach (var segment in relative.Split( + new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + if ((File.Exists(current) || Directory.Exists(current)) && IsReparsePoint(current)) + { + throw new PluginConfigurationException($"The {description} path crosses a symbolic link or reparse point."); + } + } + } + + private static bool IsWithin(string root, string path) + { + var comparison = Path.DirectorySeparatorChar == '\\' + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + var canonicalRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var canonicalPath = Path.GetFullPath(path); + return string.Equals(canonicalRoot, canonicalPath, comparison) + || canonicalPath.StartsWith(canonicalRoot + Path.DirectorySeparatorChar, comparison) + || canonicalPath.StartsWith(canonicalRoot + Path.AltDirectorySeparatorChar, comparison); + } + + private static bool IsLoopbackHost(string host) + { + if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return IPAddress.TryParse(host, out var address) && IPAddress.IsLoopback(address); + } + + private static string CreateToolPrefix(string pluginName, string serverId, int index) + { + var safePlugin = SanitizeToolName(pluginName); + var safeServer = SanitizeToolName(serverId); + using var sha = SHA256.Create(); + var hash = BitConverter.ToString(sha.ComputeHash(Encoding.UTF8.GetBytes(serverId)), 0, 6) + .Replace("-", string.Empty) + .ToLowerInvariant(); + var prefix = safePlugin + "__" + safeServer + "__" + index + "_" + hash + "__"; + return prefix.Length <= 256 ? prefix : safePlugin.Substring(0, Math.Min(64, safePlugin.Length)) + "__" + hash + "__"; + } + + private static string SanitizeToolName(string value) + { + var result = new string(value.Select(character => + character is >= 'a' and <= 'z' + or >= 'A' and <= 'Z' + or >= '0' and <= '9' + or '_' ? character : '_').ToArray()); + return string.IsNullOrEmpty(result) ? "server" : result; + } + + private static bool IsPluginName(string value) + { + if (value.Length is < 1 or > 64 + || !IsLowerAlphaNumeric(value[0]) + || !IsLowerAlphaNumeric(value[value.Length - 1]) + || value.Contains("--", StringComparison.Ordinal) + || value.Contains("..", StringComparison.Ordinal)) + { + return false; + } + + return value.All(character => IsLowerAlphaNumeric(character) || character is '-' or '.'); + } + + private static bool IsExtensionNamespace(string value) + { + if (string.IsNullOrWhiteSpace(value) + || value.Length > 256 + || value.IndexOfAny(new[] { '/', '\\', '\0' }) >= 0) + { + return false; + } + + var labels = value.Split('.'); + return labels.Length >= 2 && labels.All(label => + label.Length is >= 1 and <= 63 + && IsLowerAlphaNumeric(label[0]) + && IsLowerAlphaNumeric(label[label.Length - 1]) + && label.All(character => IsLowerAlphaNumeric(character) || character == '-')); + } + + private static bool IsLowerAlphaNumeric(char value) => + value is >= 'a' and <= 'z' or >= '0' and <= '9'; + + private static AgentPluginDiagnosticSeverity MapSeverity(GameResourceDiagnosticSeverity severity) => + severity == GameResourceDiagnosticSeverity.Warning + ? AgentPluginDiagnosticSeverity.Warning + : AgentPluginDiagnosticSeverity.Error; + + private static JsonDocument ParseJson( + string value, + string path, + bool validateDescendantObjects = true) + { + try + { + var document = JsonDocument.Parse(value, new JsonDocumentOptions { MaxDepth = 128 }); + if (validateDescendantObjects) + { + EnsureUnambiguous(document.RootElement); + } + else + { + EnsureObjectNamesUnique(document.RootElement); + } + + return document; + } + catch (JsonException exception) + { + throw new PluginConfigurationException($"JSON document '{path}' is invalid.", exception); + } + } + + private static void EnsureObjectNamesUnique(JsonElement value) + { + if (value.ValueKind != JsonValueKind.Object) + { + return; + } + + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in value.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new PluginConfigurationException($"Duplicate JSON property '{property.Name}' is not allowed."); + } + } + } + + 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 PluginConfigurationException($"Duplicate JSON property '{property.Name}' is not allowed."); + } + + EnsureUnambiguous(property.Value); + } + } + else if (value.ValueKind == JsonValueKind.Array) + { + foreach (var item in value.EnumerateArray()) + { + EnsureUnambiguous(item); + } + } + } + + private static void RequireExactFields(JsonElement value, HashSet fields) + { + foreach (var property in value.EnumerateObject()) + { + if (!fields.Contains(property.Name)) + { + throw new PluginConfigurationException($"Unknown field '{property.Name}' is not allowed here."); + } + } + } + + private static string RequiredString(JsonElement value, string propertyName, int maximumCharacters) + { + if (!value.TryGetProperty(propertyName, out var property)) + { + throw new PluginConfigurationException($"Required field '{propertyName}' is missing."); + } + + return RequireString(property, propertyName, maximumCharacters, requireNonEmpty: true); + } + + private static string? OptionalString(JsonElement value, string propertyName, int maximumCharacters) + { + return value.TryGetProperty(propertyName, out var property) + ? RequireString(property, propertyName, maximumCharacters) + : null; + } + + private static string RequireString( + JsonElement value, + string description, + int maximumCharacters, + bool requireNonEmpty = false) + { + if (value.ValueKind != JsonValueKind.String) + { + throw new PluginConfigurationException($"{description} must be a string."); + } + + var result = value.GetString()!; + if ((requireNonEmpty && result.Length == 0) || result.Length > maximumCharacters || result.IndexOf('\0') >= 0) + { + throw new PluginConfigurationException($"{description} violates its client safety bounds."); + } + + return result; + } + + private static void RequireKind(JsonElement value, JsonValueKind kind, string message) + { + if (value.ValueKind != kind) + { + throw new PluginConfigurationException(message); + } + } + + private static string ReadBounded(string path, int maximumCharacters) + { + try + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + using var reader = new StreamReader( + stream, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), + detectEncodingFromByteOrderMarks: true); + var buffer = new char[Math.Min(maximumCharacters + 1, 65_536)]; + var builder = new StringBuilder(Math.Min(maximumCharacters, 65_536)); + while (builder.Length <= maximumCharacters) + { + var remaining = maximumCharacters + 1 - builder.Length; + var read = reader.Read(buffer, 0, Math.Min(buffer.Length, remaining)); + if (read == 0) + { + return builder.ToString(); + } + + builder.Append(buffer, 0, read); + } + + throw new PluginConfigurationException($"File '{path}' exceeds its configured character limit."); + } + catch (DecoderFallbackException exception) + { + throw new PluginConfigurationException($"File '{path}' is not valid UTF-8.", exception); + } + catch (IOException exception) + { + throw new AgentPluginLoadException($"File '{path}' could not be read.", path, exception); + } + catch (UnauthorizedAccessException exception) + { + throw new AgentPluginLoadException($"File '{path}' could not be read.", path, exception); + } + } + + private static void RequirePackageFile(string root, string path, string description) + { + if (!IsWithin(root, path) || !File.Exists(path) || IsReparsePoint(path)) + { + throw new AgentPluginLoadException($"The {description} is missing or unsafe.", path); + } + } + + private static void RequireDirectory(string path, string description) + { + if (!Directory.Exists(path)) + { + throw new AgentPluginLoadException($"The {description} does not exist.", path); + } + } + + private static void RejectReparsePoint(string path, string description) + { + if (IsReparsePoint(path)) + { + throw new AgentPluginLoadException($"The {description} cannot be a symbolic link or reparse point.", path); + } + } + + private static bool IsReparsePoint(string path) => + (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; + + private static bool IsConfigurationFailure(Exception exception) => + exception is PluginConfigurationException + or JsonException + or OverflowException + or ArgumentException + or NotSupportedException + or PathTooLongException; + + private static string Bound(string value, int maximumCharacters) => + value.Length <= maximumCharacters ? value : value.Substring(0, maximumCharacters); + + private sealed class PluginConfigurationException : Exception + { + public PluginConfigurationException(string message, Exception? innerException = null) + : base(message, innerException) + { + } + } + + private sealed class DiagnosticBuffer + { + private readonly int _maximumCount; + private readonly int _maximumCharacters; + private readonly List _items = new(); + private int _characters; + + public DiagnosticBuffer(int maximumCount, int maximumCharacters) + { + _maximumCount = maximumCount; + _maximumCharacters = maximumCharacters; + } + + public IReadOnlyList Items => _items; + + public void Add( + AgentPluginDiagnosticSeverity severity, + string code, + string message, + string path, + string? component) + { + if (_items.Count >= _maximumCount || _characters >= _maximumCharacters) + { + return; + } + + var bounded = Bound(SanitizeDiagnostic(message), Math.Min(8_192, _maximumCharacters - _characters)); + if (bounded.Length == 0) + { + return; + } + + _items.Add(new AgentPluginDiagnostic(severity, code, bounded, path, component)); + _characters += bounded.Length; + } + + private static string SanitizeDiagnostic(string value) + { + var characters = value.Select(character => + char.IsControl(character) && character != '\t' ? ' ' : character).ToArray(); + return new string(characters); + } + } +} diff --git a/src/OpenGameAgent.Plugins/AgentPluginPackage.cs b/src/OpenGameAgent.Plugins/AgentPluginPackage.cs new file mode 100644 index 0000000..ddcd93b --- /dev/null +++ b/src/OpenGameAgent.Plugins/AgentPluginPackage.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Connectors.Mcp; + +namespace OpenGameAgent.Plugins; + +/// +/// A loaded Agent Plugins 1.0.0 package. Add this object to a GameAgentBuilder as a normal +/// extension; the runtime then owns its MCP connections and transport lifetime. +/// +public sealed class AgentPluginPackage : IGameAgentExtension, IAsyncDisposable +{ + private readonly InMemoryGameSkillSource _skillSource; + private readonly McpToolConnectorExtension? _mcpExtension; + private readonly HttpClient? _ownedHttpClient; + private int _configured; + private int _disposed; + + internal AgentPluginPackage( + string rootDirectory, + string? dataDirectory, + AgentPluginManifest manifest, + IReadOnlyList skills, + IReadOnlyList mcpServers, + IReadOnlyDictionary clientExtensionDirectories, + IReadOnlyList diagnostics, + McpToolConnectorExtension? mcpExtension, + HttpClient? ownedHttpClient, + IReadOnlyList mcpConfigurations) + { + RootDirectory = rootDirectory; + DataDirectory = dataDirectory; + Manifest = manifest; + Skills = skills; + McpServers = mcpServers; + ClientExtensionDirectories = clientExtensionDirectories; + Diagnostics = diagnostics; + _skillSource = new InMemoryGameSkillSource(skills, Math.Max(1, skills.Count)); + _mcpExtension = mcpExtension; + _ownedHttpClient = ownedHttpClient; + McpConfigurations = mcpConfigurations; + + var capabilities = new List { "agent-plugins-1.0" }; + if (skills.Count > 0) + { + capabilities.Add("skills"); + } + + if (mcpServers.Count > 0) + { + capabilities.Add("mcp"); + } + + Descriptor = new GameAgentExtensionDescriptor( + "agent-plugin." + manifest.Name, + string.IsNullOrWhiteSpace(manifest.Version) ? "0.0.0" : manifest.Version!, + manifest.Description, + capabilities); + } + + public string RootDirectory { get; } + + public string? DataDirectory { get; } + + public AgentPluginManifest Manifest { get; } + + public IReadOnlyList Skills { get; } + + public IReadOnlyList McpServers { get; } + + public IReadOnlyDictionary ClientExtensionDirectories { get; } + + public IReadOnlyList Diagnostics { get; } + + public GameAgentExtensionDescriptor Descriptor { get; } + + internal IReadOnlyList McpConfigurations { get; } + + public string? GetClientExtensionDirectory(string extensionNamespace) + { + if (string.IsNullOrWhiteSpace(extensionNamespace)) + { + throw new ArgumentException("An extension namespace is required.", nameof(extensionNamespace)); + } + + return ClientExtensionDirectories.TryGetValue(extensionNamespace, out var path) ? path : null; + } + + public void Configure(GameAgentExtensionApi api) + { + if (api is null) + { + throw new ArgumentNullException(nameof(api)); + } + + if (Volatile.Read(ref _disposed) != 0) + { + throw new ObjectDisposedException(nameof(AgentPluginPackage)); + } + + if (Interlocked.Exchange(ref _configured, 1) != 0) + { + throw new InvalidOperationException("An Agent Plugin package can configure only one runtime."); + } + + if (Skills.Count > 0) + { + api.RegisterSkillProvider( + "portable-skills", + (context, activeTools, maximumSkills, cancellationToken) => + _skillSource.SelectAsync( + new GameSkillQuery(context.Input, activeTools, maximumSkills), + cancellationToken)); + } + + _mcpExtension?.Configure(api); + } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + Exception? failure = null; + if (_mcpExtension is not null) + { + try + { + await _mcpExtension.DisposeAsync().ConfigureAwait(false); + } + catch (Exception exception) + { + failure = exception; + } + } + + _ownedHttpClient?.Dispose(); + if (failure is not null) + { + throw failure; + } + } + + internal static IReadOnlyList ReadOnly(IEnumerable values) => + Array.AsReadOnly(values.ToArray()); + + internal static IReadOnlyDictionary ReadOnlyDictionary( + IDictionary values, + StringComparer comparer) => + new ReadOnlyDictionary(new Dictionary(values, comparer)); +} diff --git a/src/OpenGameAgent.Plugins/AssemblyInfo.cs b/src/OpenGameAgent.Plugins/AssemblyInfo.cs new file mode 100644 index 0000000..c2a5699 --- /dev/null +++ b/src/OpenGameAgent.Plugins/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("OpenGameAgent.Plugins.Tests")] diff --git a/src/OpenGameAgent.Plugins/OpenGameAgent.Plugins.csproj b/src/OpenGameAgent.Plugins/OpenGameAgent.Plugins.csproj new file mode 100644 index 0000000..65bfcca --- /dev/null +++ b/src/OpenGameAgent.Plugins/OpenGameAgent.Plugins.csproj @@ -0,0 +1,14 @@ + + + netstandard2.1 + Agent Plugins 1.0.0 package loading for OpenGameAgent skills and MCP servers. + OpenGameAgent.Plugins + + + + + + + + + diff --git a/src/OpenGameAgent.Plugins/packages.lock.json b/src/OpenGameAgent.Plugins/packages.lock.json new file mode 100644 index 0000000..fdf51c6 --- /dev/null +++ b/src/OpenGameAgent.Plugins/packages.lock.json @@ -0,0 +1,189 @@ +{ + "version": 1, + "dependencies": { + ".NETStandard,Version=v2.1": { + "System.Text.Json": { + "type": "Direct", + "requested": "[10.0.10, )", + "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" + } + }, + "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" + } + }, + "ModelContextProtocol.Core": { + "type": "Transitive", + "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" + } + }, + "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.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.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.Models": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.persistence": { + "type": "Project", + "dependencies": { + "OpenGameAgent": "[0.3.0-alpha.1, )", + "OpenGameAgent.Extensions": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + } + } + } +} \ No newline at end of file diff --git a/src/OpenGameAgent.Providers.MessageGateway/MessageGatewayWire.cs b/src/OpenGameAgent.Providers.MessageGateway/MessageGatewayWire.cs index b01d746..d0e60a5 100644 --- a/src/OpenGameAgent.Providers.MessageGateway/MessageGatewayWire.cs +++ b/src/OpenGameAgent.Providers.MessageGateway/MessageGatewayWire.cs @@ -1140,7 +1140,8 @@ private ModelUsage ParseUsage(JsonElement usage) MessageGatewayJson.RequiredDouble(cost, "input"), MessageGatewayJson.RequiredDouble(cost, "output"), MessageGatewayJson.RequiredDouble(cost, "cacheRead"), - MessageGatewayJson.RequiredDouble(cost, "cacheWrite")); + MessageGatewayJson.RequiredDouble(cost, "cacheWrite"), + isKnown: true); var reportedCostTotal = MessageGatewayJson.RequiredDouble(cost, "total"); var computedCostTotal = modelCost.Input + modelCost.Output + modelCost.CacheRead + modelCost.CacheWrite; if (double.IsNaN(computedCostTotal) || double.IsInfinity(computedCostTotal)) diff --git a/src/OpenGameAgent.Providers.Remote/ProxyWire.cs b/src/OpenGameAgent.Providers.Remote/ProxyWire.cs index cf59f75..225f0d3 100644 --- a/src/OpenGameAgent.Providers.Remote/ProxyWire.cs +++ b/src/OpenGameAgent.Providers.Remote/ProxyWire.cs @@ -586,6 +586,9 @@ internal sealed class WireParameters internal sealed class WireCost { + [JsonPropertyName("k")] + public bool? Known { get; set; } + [JsonPropertyName("i")] public double Input { get; set; } @@ -600,13 +603,16 @@ internal sealed class WireCost public static WireCost From(ModelCost cost) => new() { + Known = cost.IsKnown, Input = cost.Input, Output = cost.Output, CacheRead = cost.CacheRead, CacheWrite = cost.CacheWrite, }; - public ModelCost ToModelCost() => new(Input, Output, CacheRead, CacheWrite); + public ModelCost ToModelCost() => Known.HasValue + ? new ModelCost(Input, Output, CacheRead, CacheWrite, Known.Value) + : new ModelCost(Input, Output, CacheRead, CacheWrite); } internal sealed class WireUsage diff --git a/src/OpenGameAgent.Server/GameActionExchange.cs b/src/OpenGameAgent.Server/GameActionExchange.cs new file mode 100644 index 0000000..53740a2 --- /dev/null +++ b/src/OpenGameAgent.Server/GameActionExchange.cs @@ -0,0 +1,359 @@ +using System.Runtime.CompilerServices; + +namespace OpenGameAgent.Server; + +public sealed class GameActionExchangeOptions +{ + public int MaximumClaimsPerRequest { get; set; } = 256; + + public int MaximumJournalScan { get; set; } = 10_000; + + public int PollIntervalMilliseconds { get; set; } = 250; + + internal GameActionExchangeOptions CopyAndValidate() + { + if (MaximumClaimsPerRequest is < 1 or > 10_000) + { + throw new ArgumentOutOfRangeException(nameof(MaximumClaimsPerRequest)); + } + + if (MaximumJournalScan < MaximumClaimsPerRequest || MaximumJournalScan > 1_000_000) + { + throw new ArgumentOutOfRangeException(nameof(MaximumJournalScan)); + } + + if (PollIntervalMilliseconds is < 10 or > 60_000) + { + throw new ArgumentOutOfRangeException(nameof(PollIntervalMilliseconds)); + } + + return new GameActionExchangeOptions + { + MaximumClaimsPerRequest = MaximumClaimsPerRequest, + MaximumJournalScan = MaximumJournalScan, + PollIntervalMilliseconds = PollIntervalMilliseconds, + }; + } +} + +public sealed class GameActionDelivery +{ + internal GameActionDelivery(GameActionIntent intent) + { + Intent = intent ?? throw new ArgumentNullException(nameof(intent)); + } + + public GameActionIntent Intent { get; } + + /// + /// A durable external action is marked dispatched before it is exposed. The engine must reconcile + /// the operation ID against its authoritative operation log before executing or resuming it. + /// + public bool RequiresReconciliation => true; +} + +public enum GameActionExchangeStatus +{ + Prepared = 0, + Dispatched = 1, + Completed = 2, +} + +public sealed class GameActionExchangeState +{ + internal GameActionExchangeState(GameActionJournalEntry entry) + { + Intent = entry.Intent; + Receipt = entry.Receipt; + Status = entry.Receipt is not null + ? GameActionExchangeStatus.Completed + : entry.Dispatched + ? GameActionExchangeStatus.Dispatched + : GameActionExchangeStatus.Prepared; + } + + public GameActionExchangeStatus Status { get; } + + public GameActionIntent Intent { get; } + + public GameActionReceipt? Receipt { get; } + + public bool RequiresReconciliation => Status == GameActionExchangeStatus.Dispatched; +} + +/// +/// Bridges the durable action dispatcher to an external authoritative game process. The same instance +/// is used as the dispatcher's handler and by the HTTP action endpoints. +/// +public sealed class GameActionExchange : IGameActionHandler +{ + private readonly IGameActionJournal _journal; + private readonly GameActionExchangeOptions _options; + private readonly object _changeGate = new(); + private TaskCompletionSource _changed = NewChangeSource(); + + public GameActionExchange( + IGameActionJournal journal, + GameActionExchangeOptions? options = null) + { + _journal = journal ?? throw new ArgumentNullException(nameof(journal)); + _options = (options ?? new GameActionExchangeOptions()).CopyAndValidate(); + } + + public async ValueTask ExecuteAsync( + GameActionIntent intent, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(intent); + var entry = await _journal.FindAsync(intent.OperationId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The external action was not reserved before dispatch."); + EnsureIntentIdentity(entry.Intent, intent); + if (!entry.Dispatched) + { + throw new InvalidOperationException("The external action must be marked dispatched before delivery."); + } + + if (entry.Receipt is not null) + { + return entry.Receipt; + } + + SignalChanged(); + while (true) + { + entry = await _journal.FindAsync(intent.OperationId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The external action disappeared after dispatch."); + EnsureIntentIdentity(entry.Intent, intent); + if (entry.Receipt is not null) + { + return entry.Receipt; + } + + if (!entry.Dispatched) + { + throw new InvalidOperationException("The external action lost its durable dispatch marker."); + } + + await WaitForChangeOrPollAsync(cancellationToken).ConfigureAwait(false); + } + } + + public async ValueTask RecoverAsync( + GameActionIntent intent, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(intent); + var entry = await _journal.FindAsync(intent.OperationId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The external action does not exist."); + EnsureIntentIdentity(entry.Intent, intent); + if (entry.Receipt is not null) + { + return entry.Receipt; + } + + if (entry.Dispatched) + { + SignalChanged(); + } + + return null; + } + + public async ValueTask> ClaimPendingAsync( + GameSessionKey key, + int limit, + CancellationToken cancellationToken) + { + key = ValidateKey(key); + if (limit < 1 || limit > _options.MaximumClaimsPerRequest) + { + throw new ArgumentOutOfRangeException(nameof(limit)); + } + + var pending = await _journal.ListPendingAsync( + _options.MaximumJournalScan, + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The action journal returned no pending collection."); + var result = new List(Math.Min(limit, pending.Count)); + foreach (var intent in pending) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!string.Equals(intent.SessionId, key.SessionId, StringComparison.Ordinal) + || !string.Equals(intent.ActorId, key.ActorId, StringComparison.Ordinal)) + { + continue; + } + + var entry = await _journal.FindAsync(intent.OperationId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("A listed action disappeared from its journal."); + EnsureIntentIdentity(entry.Intent, intent); + if (entry.Dispatched && entry.Receipt is null) + { + result.Add(new GameActionDelivery(entry.Intent)); + if (result.Count == limit) + { + break; + } + } + } + + return result; + } + + public async IAsyncEnumerable StreamPendingAsync( + GameSessionKey key, + int batchLimit, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + key = ValidateKey(key); + var emitted = new HashSet(StringComparer.Ordinal); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + foreach (var delivery in await ClaimPendingAsync(key, batchLimit, cancellationToken).ConfigureAwait(false)) + { + if (emitted.Add(delivery.Intent.OperationId)) + { + if (emitted.Count > _options.MaximumJournalScan) + { + throw new GameRuntimeLimitException( + nameof(_options.MaximumJournalScan), + "The action stream exceeded its bounded operation history."); + } + + yield return delivery; + } + } + + await WaitForChangeOrPollAsync(cancellationToken).ConfigureAwait(false); + } + } + + public async ValueTask ReconcileAsync( + GameSessionKey key, + string operationId, + CancellationToken cancellationToken) + { + key = ValidateKey(key); + var entry = await _journal.FindAsync(operationId, cancellationToken).ConfigureAwait(false); + if (entry is null) + { + return null; + } + + EnsureOwnedBy(entry.Intent, key); + return new GameActionExchangeState(entry); + } + + public async ValueTask SubmitReceiptAsync( + GameSessionKey key, + long? expectedRevision, + string? generationId, + GameActionReceipt receipt, + CancellationToken cancellationToken) + { + key = ValidateKey(key); + ArgumentNullException.ThrowIfNull(receipt); + if (!receipt.IsFinal) + { + throw new ArgumentException("An external action receipt must be final.", nameof(receipt)); + } + + var entry = await _journal.FindAsync(receipt.OperationId, cancellationToken).ConfigureAwait(false) + ?? throw new KeyNotFoundException("The external action operation does not exist."); + EnsureOwnedBy(entry.Intent, key); + if (!entry.Dispatched) + { + throw new InvalidOperationException("A receipt cannot be submitted before durable dispatch."); + } + + if (entry.Intent.ExpectedRevision != expectedRevision) + { + throw new InvalidOperationException("The receipt expected revision does not match the reserved intent."); + } + + if (!string.Equals(entry.Intent.GenerationId, generationId, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The receipt generation does not match the reserved intent."); + } + + if (entry.Intent.Moment != receipt.Moment) + { + throw new InvalidOperationException("The receipt game moment does not match the reserved intent."); + } + + if (receipt.Status == GameActionStatus.Committed + && entry.Intent.ExpectedRevision is { } minimumRevision + && (receipt.StateRevision is null || receipt.StateRevision < minimumRevision)) + { + throw new InvalidOperationException("A committed receipt must report a state revision at or after its expected revision."); + } + + await _journal.SaveReceiptAsync(receipt, cancellationToken).ConfigureAwait(false); + var stored = await _journal.FindAsync(receipt.OperationId, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The action journal lost the submitted receipt."); + if (stored.Receipt is null) + { + throw new InvalidOperationException("The action journal did not retain the submitted receipt."); + } + + SignalChanged(); + return stored.Receipt; + } + + private async Task WaitForChangeOrPollAsync(CancellationToken cancellationToken) + { + Task changed; + lock (_changeGate) + { + changed = _changed.Task; + } + + var poll = Task.Delay(_options.PollIntervalMilliseconds, cancellationToken); + await Task.WhenAny(changed, poll).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + } + + private void SignalChanged() + { + TaskCompletionSource changed; + lock (_changeGate) + { + changed = _changed; + _changed = NewChangeSource(); + } + + changed.TrySetResult(null); + } + + private static TaskCompletionSource NewChangeSource() => + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private static GameSessionKey ValidateKey(GameSessionKey key) => + new(key.SessionId, key.ActorId); + + private static void EnsureOwnedBy(GameActionIntent intent, GameSessionKey key) + { + if (!string.Equals(intent.SessionId, key.SessionId, StringComparison.Ordinal) + || !string.Equals(intent.ActorId, key.ActorId, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The action operation belongs to a different session or actor."); + } + } + + private static void EnsureIntentIdentity(GameActionIntent expected, GameActionIntent actual) + { + if (!string.Equals(expected.OperationId, actual.OperationId, StringComparison.Ordinal) + || !string.Equals(expected.InputId, actual.InputId, StringComparison.Ordinal) + || !string.Equals(expected.SessionId, actual.SessionId, StringComparison.Ordinal) + || !string.Equals(expected.ActorId, actual.ActorId, StringComparison.Ordinal) + || !string.Equals(expected.Action, actual.Action, StringComparison.Ordinal) + || !string.Equals(expected.ArgumentsJson, actual.ArgumentsJson, StringComparison.Ordinal) + || expected.Moment != actual.Moment + || expected.ExpectedRevision != actual.ExpectedRevision + || !string.Equals(expected.GenerationId, actual.GenerationId, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The external action intent does not match its durable reservation."); + } + } +} diff --git a/src/OpenGameAgent.Server/Program.cs b/src/OpenGameAgent.Server/Program.cs index 0cea7f4..fd7def7 100644 --- a/src/OpenGameAgent.Server/Program.cs +++ b/src/OpenGameAgent.Server/Program.cs @@ -6,24 +6,48 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddProblemDetails(); builder.Services.AddHttpClient("model"); +builder.Services.AddSingleton(serviceProvider => +{ + var configuration = serviceProvider.GetRequiredService(); + var actionDirectory = configuration["OpenGameAgent:ActionDirectory"] + ?? Path.Combine(AppContext.BaseDirectory, "data", "actions"); + return new FileGameActionJournal(actionDirectory); +}); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(serviceProvider => new DurableGameActionDispatcher( + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService())); builder.Services.AddSingleton(serviceProvider => { var configuration = serviceProvider.GetRequiredService(); - var endpointText = configuration["OpenGameAgent:ModelEndpoint"] - ?? throw new InvalidOperationException("Configure OpenGameAgent:ModelEndpoint."); - var model = configuration["OpenGameAgent:Model"] - ?? throw new InvalidOperationException("Configure OpenGameAgent:Model."); - var httpClient = serviceProvider.GetRequiredService().CreateClient("model"); - var providerOptions = new OpenAICompatibleProviderOptions(httpClient, new Uri(endpointText)) + var httpClientFactory = serviceProvider.GetRequiredService(); + GameAgentRuntimeOptions runtimeOptions; + if (configuration.GetSection("OpenGameAgent:ModelRoutes").GetChildren().Any()) { - ApiKey = configuration["OpenGameAgent:ApiKey"], - }; - var runtimeOptions = new GameAgentRuntimeOptions(new OpenAICompatibleProvider(providerOptions), model) + var routing = StockGameAgentModelRouting.Create(configuration, httpClientFactory); + runtimeOptions = new GameAgentRuntimeOptions(routing.DefaultProvider, routing.DefaultModel) + { + ModelSelector = routing.SelectAsync, + }; + } + else { - Instructions = configuration["OpenGameAgent:Instructions"] ?? string.Empty, - SessionStore = new FileGameSessionStore( - configuration["OpenGameAgent:DataDirectory"] ?? Path.Combine(AppContext.BaseDirectory, "data", "sessions")), - }; + var endpointText = configuration["OpenGameAgent:ModelEndpoint"] + ?? throw new InvalidOperationException("Configure OpenGameAgent:ModelEndpoint or ModelRoutes."); + var model = configuration["OpenGameAgent:Model"] + ?? throw new InvalidOperationException("Configure OpenGameAgent:Model."); + var providerOptions = new OpenAICompatibleProviderOptions( + httpClientFactory.CreateClient("model"), + new Uri(endpointText)) + { + ApiKey = configuration["OpenGameAgent:ApiKey"], + }; + runtimeOptions = new GameAgentRuntimeOptions(new OpenAICompatibleProvider(providerOptions), model); + } + + runtimeOptions.Instructions = configuration["OpenGameAgent:Instructions"] ?? string.Empty; + runtimeOptions.SessionStore = new FileGameSessionStore( + configuration["OpenGameAgent:DataDirectory"] ?? Path.Combine(AppContext.BaseDirectory, "data", "sessions")); return new GameAgentRuntime(runtimeOptions); }); diff --git a/src/OpenGameAgent.Server/ServerActionEndpoints.cs b/src/OpenGameAgent.Server/ServerActionEndpoints.cs new file mode 100644 index 0000000..304ddd4 --- /dev/null +++ b/src/OpenGameAgent.Server/ServerActionEndpoints.cs @@ -0,0 +1,438 @@ +using System.Text.Json; + +namespace OpenGameAgent.Server; + +public static partial class ServerEndpoints +{ + private static void MapGameActionExchangeEndpoints( + IEndpointRouteBuilder endpoints, + int maximumRequestBodyBytes) + { + endpoints.MapPost( + "/v1/actions/claim", + (HttpContext context, CancellationToken cancellationToken) => + ClaimActionsAsync(context, maximumRequestBodyBytes, cancellationToken)); + endpoints.MapPost( + "/v1/actions/stream", + (HttpContext context, CancellationToken cancellationToken) => + StreamActionsAsync(context, maximumRequestBodyBytes, cancellationToken)); + endpoints.MapPost( + "/v1/actions/receipt", + (HttpContext context, CancellationToken cancellationToken) => + SubmitActionReceiptAsync(context, maximumRequestBodyBytes, cancellationToken)); + endpoints.MapPost( + "/v1/actions/reconcile", + (HttpContext context, CancellationToken cancellationToken) => + ReconcileActionAsync(context, maximumRequestBodyBytes, cancellationToken)); + } + + private static async Task ClaimActionsAsync( + HttpContext context, + int maximumRequestBodyBytes, + CancellationToken cancellationToken) + { + ActionClaimRequest request; + GameSessionKey key; + try + { + using var document = await ReadRequestDocumentAsync( + context.Request, + maximumRequestBodyBytes, + cancellationToken); + request = ParseRequest(document.RootElement); + key = request.ToKey(); + } + catch (Exception exception) when (IsActionRequestFailure(exception)) + { + return ActionRequestFailure(exception); + } + + var failure = await AuthenticateAndAuthorizeActionAsync( + context, + request.Credential, + key, + GameAgentServerOperation.ClaimActions, + cancellationToken); + if (failure is not null) + { + return failure; + } + + var exchange = context.RequestServices.GetService(); + if (exchange is null) + { + return ActionExchangeUnavailable(); + } + + try + { + var deliveries = await exchange.ClaimPendingAsync(key, request.Limit, cancellationToken); + return Results.Json(new { actions = deliveries.Select(ToActionDocument).ToArray() }); + } + catch (Exception exception) when (exception is ArgumentException or GameRuntimeLimitException) + { + return RequestError(StatusCodes.Status400BadRequest, "invalid_request", exception.Message); + } + } + + private static async Task StreamActionsAsync( + HttpContext context, + int maximumRequestBodyBytes, + CancellationToken cancellationToken) + { + ActionClaimRequest request; + GameSessionKey key; + try + { + using var document = await ReadRequestDocumentAsync( + context.Request, + maximumRequestBodyBytes, + cancellationToken); + request = ParseRequest(document.RootElement); + key = request.ToKey(); + } + catch (Exception exception) when (IsActionRequestFailure(exception)) + { + await ActionRequestFailure(exception).ExecuteAsync(context); + return; + } + + var failure = await AuthenticateAndAuthorizeActionAsync( + context, + request.Credential, + key, + GameAgentServerOperation.StreamActions, + cancellationToken); + if (failure is not null) + { + await failure.ExecuteAsync(context); + return; + } + + var exchange = context.RequestServices.GetService(); + if (exchange is null) + { + await ActionExchangeUnavailable().ExecuteAsync(context); + return; + } + + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "text/event-stream"; + context.Response.Headers.CacheControl = "no-cache"; + context.Response.Headers.Connection = "keep-alive"; + try + { + await foreach (var delivery in exchange.StreamPendingAsync(key, request.Limit, cancellationToken)) + { + await WriteEventAsync( + context.Response, + "action", + JsonSerializer.Serialize(ToActionDocument(delivery), ActionJsonOptions), + cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private static async Task SubmitActionReceiptAsync( + HttpContext context, + int maximumRequestBodyBytes, + CancellationToken cancellationToken) + { + ActionReceiptRequest request; + GameSessionKey key; + GameActionReceipt receipt; + try + { + using var document = await ReadRequestDocumentAsync( + context.Request, + maximumRequestBodyBytes, + cancellationToken); + request = ParseRequest(document.RootElement); + key = request.ToKey(); + receipt = request.ToReceipt(); + } + catch (Exception exception) when (IsActionRequestFailure(exception)) + { + return ActionRequestFailure(exception); + } + + var failure = await AuthenticateAndAuthorizeActionAsync( + context, + request.Credential, + key, + GameAgentServerOperation.SubmitActionReceipt, + cancellationToken); + if (failure is not null) + { + return failure; + } + + var exchange = context.RequestServices.GetService(); + if (exchange is null) + { + return ActionExchangeUnavailable(); + } + + try + { + var stored = await exchange.SubmitReceiptAsync( + key, + request.ExpectedRevision, + request.GenerationId, + receipt, + cancellationToken); + return Results.Json(new { receipt = ToReceiptDocument(stored) }); + } + catch (KeyNotFoundException exception) + { + return RequestError(StatusCodes.Status404NotFound, "operation_not_found", exception.Message); + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) + { + return RequestError(StatusCodes.Status409Conflict, "receipt_rejected", exception.Message); + } + } + + private static async Task ReconcileActionAsync( + HttpContext context, + int maximumRequestBodyBytes, + CancellationToken cancellationToken) + { + ActionReconcileRequest request; + GameSessionKey key; + try + { + using var document = await ReadRequestDocumentAsync( + context.Request, + maximumRequestBodyBytes, + cancellationToken); + request = ParseRequest(document.RootElement); + key = request.ToKey(); + RequireActionIdentifier(request.OperationId, nameof(request.OperationId)); + } + catch (Exception exception) when (IsActionRequestFailure(exception)) + { + return ActionRequestFailure(exception); + } + + var failure = await AuthenticateAndAuthorizeActionAsync( + context, + request.Credential, + key, + GameAgentServerOperation.ReconcileAction, + cancellationToken); + if (failure is not null) + { + return failure; + } + + var exchange = context.RequestServices.GetService(); + if (exchange is null) + { + return ActionExchangeUnavailable(); + } + + try + { + var state = await exchange.ReconcileAsync(key, request.OperationId, cancellationToken); + return state is null + ? RequestError(StatusCodes.Status404NotFound, "operation_not_found", "The action operation does not exist.") + : Results.Json(ToActionStateDocument(state)); + } + catch (InvalidOperationException exception) + { + return RequestError(StatusCodes.Status409Conflict, "operation_mismatch", exception.Message); + } + } + + private static async ValueTask AuthenticateAndAuthorizeActionAsync( + HttpContext context, + string? credential, + GameSessionKey key, + GameAgentServerOperation operation, + CancellationToken cancellationToken) + { + var authenticationFailure = await AuthenticatePresentedCredentialAsync( + context, + credential, + key, + operation, + cancellationToken); + return authenticationFailure ?? await GetAuthorizationFailureAsync( + context, + key, + operation, + cancellationToken); + } + + private static object ToActionDocument(GameActionDelivery delivery) => ToIntentDocument( + delivery.Intent, + delivery.RequiresReconciliation); + + private static object ToIntentDocument(GameActionIntent intent, bool requiresReconciliation) => new + { + operationId = intent.OperationId, + sessionId = intent.SessionId, + actorId = intent.ActorId, + inputId = intent.InputId, + action = intent.Action, + arguments = ParseJsonElement(intent.ArgumentsJson), + timelineId = intent.Moment.TimelineId, + tick = intent.Moment.Tick, + calendar = intent.Moment.CalendarJson is null ? (JsonElement?)null : ParseJsonElement(intent.Moment.CalendarJson), + generationId = intent.GenerationId, + expectedRevision = intent.ExpectedRevision, + requiresReconciliation, + }; + + private static object ToReceiptDocument(GameActionReceipt receipt) => new + { + operationId = receipt.OperationId, + status = receipt.Status.ToString().ToLowerInvariant(), + result = ParseJsonElement(receipt.ResultJson), + timelineId = receipt.Moment.TimelineId, + tick = receipt.Moment.Tick, + calendar = receipt.Moment.CalendarJson is null ? (JsonElement?)null : ParseJsonElement(receipt.Moment.CalendarJson), + stateRevision = receipt.StateRevision, + code = receipt.Code, + message = receipt.Message, + }; + + private static object ToActionStateDocument(GameActionExchangeState state) => new + { + status = state.Status.ToString().ToLowerInvariant(), + action = ToIntentDocument(state.Intent, state.RequiresReconciliation), + receipt = state.Receipt is null ? null : ToReceiptDocument(state.Receipt), + }; + + private static bool IsActionRequestFailure(Exception exception) => + exception is RequestBodyTooLargeException + or UnsupportedRequestContentTypeException + or ArgumentException + or JsonException + or GameRuntimeLimitException; + + private static IResult ActionRequestFailure(Exception exception) => exception switch + { + RequestBodyTooLargeException => RequestError( + StatusCodes.Status413PayloadTooLarge, + "request_too_large", + exception.Message), + UnsupportedRequestContentTypeException => RequestError( + StatusCodes.Status415UnsupportedMediaType, + "unsupported_media_type", + exception.Message), + _ => RequestError(StatusCodes.Status400BadRequest, "invalid_request", exception.Message), + }; + + private static IResult ActionExchangeUnavailable() => RequestError( + StatusCodes.Status501NotImplemented, + "action_exchange_unavailable", + "The host did not configure an external action exchange."); + + private static string RequireActionIdentifier(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 16_384) + { + throw new ArgumentException("A non-empty action identifier of at most 16384 characters is required.", parameterName); + } + + return value; + } + + private static JsonElement ParseJsonElement(string json) + { + using var document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 128 }); + return document.RootElement.Clone(); + } + + private static readonly JsonSerializerOptions ActionJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private sealed class ActionClaimRequest + { + public string? Credential { get; set; } + + public string SessionId { get; set; } = string.Empty; + + public string ActorId { get; set; } = string.Empty; + + public int Limit { get; set; } = 32; + + public GameSessionKey ToKey() => new(SessionId, ActorId); + } + + private sealed class ActionReconcileRequest + { + public string? Credential { get; set; } + + public string SessionId { get; set; } = string.Empty; + + public string ActorId { get; set; } = string.Empty; + + public string OperationId { get; set; } = string.Empty; + + public GameSessionKey ToKey() => new(SessionId, ActorId); + } + + private sealed class ActionReceiptRequest + { + public string? Credential { get; set; } + + public string SessionId { get; set; } = string.Empty; + + public string ActorId { get; set; } = string.Empty; + + public string OperationId { get; set; } = string.Empty; + + public string Status { get; set; } = string.Empty; + + public JsonElement Result { get; set; } + + public string TimelineId { get; set; } = string.Empty; + + public long Tick { get; set; } + + public JsonElement Calendar { get; set; } + + public string? GenerationId { get; set; } + + public long? ExpectedRevision { get; set; } + + public long? StateRevision { get; set; } + + public string? Code { get; set; } + + public string? Message { get; set; } + + public GameSessionKey ToKey() => new(SessionId, ActorId); + + public GameActionReceipt ToReceipt() + { + if (!Enum.TryParse(Status, ignoreCase: true, out var status) + || !Enum.IsDefined(typeof(GameActionStatus), status) + || status == GameActionStatus.Uncertain) + { + throw new ArgumentException("The receipt status must be committed, rejected, or failed.", nameof(Status)); + } + + var resultJson = Result.ValueKind == JsonValueKind.Undefined ? "{}" : Result.GetRawText(); + var calendarJson = Calendar.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null + ? null + : Calendar.GetRawText(); + return new GameActionReceipt( + OperationId, + status, + resultJson, + new GameMoment(TimelineId, Tick, calendarJson), + StateRevision, + Code, + Message); + } + } +} diff --git a/src/OpenGameAgent.Server/ServerAudience.cs b/src/OpenGameAgent.Server/ServerAudience.cs new file mode 100644 index 0000000..d343cd9 --- /dev/null +++ b/src/OpenGameAgent.Server/ServerAudience.cs @@ -0,0 +1,414 @@ +using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Nodes; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Server; + +public enum GameAgentAudienceKind +{ + Internal = 0, + Owner = 1, + Public = 2, + Recipient = 3, +} + +public sealed class GameAgentAudience +{ + private GameAgentAudience(GameAgentAudienceKind kind, string? recipientId) + { + if (!Enum.IsDefined(kind)) + { + throw new ArgumentOutOfRangeException(nameof(kind)); + } + + if (kind == GameAgentAudienceKind.Recipient) + { + RecipientId = RequireViewerId(recipientId, nameof(recipientId)); + } + else if (recipientId is not null) + { + throw new ArgumentException("Only a recipient audience can carry a recipient ID.", nameof(recipientId)); + } + + Kind = kind; + } + + public static GameAgentAudience Internal { get; } = new(GameAgentAudienceKind.Internal, null); + + public static GameAgentAudience Owner { get; } = new(GameAgentAudienceKind.Owner, null); + + public static GameAgentAudience Public { get; } = new(GameAgentAudienceKind.Public, null); + + public GameAgentAudienceKind Kind { get; } + + public string? RecipientId { get; } + + public static GameAgentAudience Recipient(string recipientId) => + new(GameAgentAudienceKind.Recipient, recipientId); + + public bool IsVisibleTo(GameAgentViewer viewer) + { + ArgumentNullException.ThrowIfNull(viewer); + return viewer.IsInternal + || Kind == GameAgentAudienceKind.Public + || Kind == GameAgentAudienceKind.Owner && viewer.IsOwner + || Kind == GameAgentAudienceKind.Recipient + && string.Equals(RecipientId, viewer.ViewerId, StringComparison.Ordinal); + } + + internal static string RequireViewerId(string? value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value) + || value.Length > 1024 + || value.Any(character => char.IsControl(character))) + { + throw new ArgumentException("A bounded, non-control viewer ID is required.", parameterName); + } + + return value; + } +} + +public sealed class GameAgentViewer +{ + public GameAgentViewer(string? viewerId, bool isOwner, bool isInternal = false) + { + ViewerId = viewerId is null + ? null + : GameAgentAudience.RequireViewerId(viewerId, nameof(viewerId)); + if (isOwner && ViewerId is null) + { + throw new ArgumentException("An owner viewer requires a viewer ID.", nameof(viewerId)); + } + + IsOwner = isOwner; + IsInternal = isInternal; + } + + public string? ViewerId { get; } + + public bool IsOwner { get; } + + public bool IsInternal { get; } +} + +public enum GameAgentAudienceOutputKind +{ + Event = 0, + Message = 1, +} + +public sealed class GameAgentAudienceContext +{ + internal GameAgentAudienceContext( + GameSessionKey key, + GameAgentAudienceOutputKind outputKind, + AgentMessage? message, + AgentEvent? agentEvent) + { + Key = new GameSessionKey(key.SessionId, key.ActorId); + OutputKind = outputKind; + Message = message; + AgentEvent = agentEvent; + } + + public GameSessionKey Key { get; } + + public GameAgentAudienceOutputKind OutputKind { get; } + + public AgentMessage? Message { get; } + + public AgentEvent? AgentEvent { get; } +} + +public interface IGameAgentAudiencePolicy +{ + ValueTask ResolveViewerAsync( + ClaimsPrincipal principal, + GameSessionKey key, + CancellationToken cancellationToken); + + ValueTask ResolveAudienceAsync( + GameAgentAudienceContext context, + CancellationToken cancellationToken); +} + +/// +/// Optional host-authored audience annotations that survive the existing session persistence format. +/// The stock metadata policy trusts annotations only on assistant and custom messages. User input and +/// tool output cannot promote themselves by supplying similarly named data. +/// +public static class GameAgentAudienceMetadata +{ + public const string AudienceKey = "opengameagent.audience"; + public const string RecipientKey = "opengameagent.recipient"; + + public static AgentMessage WithAudience(AgentMessage message, GameAgentAudience audience) + { + ArgumentNullException.ThrowIfNull(message); + ArgumentNullException.ThrowIfNull(audience); + if (message.Role is not (AgentRole.Assistant or AgentRole.Custom)) + { + throw new ArgumentException( + "Persisted audience annotations are accepted only on host-authored assistant or custom messages.", + nameof(message)); + } + + var metadata = new Dictionary(message.Metadata, StringComparer.Ordinal) + { + [AudienceKey] = audience.Kind.ToString().ToLowerInvariant(), + }; + if (audience.RecipientId is null) + { + metadata.Remove(RecipientKey); + } + else + { + metadata[RecipientKey] = audience.RecipientId; + } + + return Copy(message, metadata); + } + + public static bool TryGetAudience(AgentMessage message, out GameAgentAudience audience) + { + ArgumentNullException.ThrowIfNull(message); + audience = GameAgentAudience.Owner; + if (message.Role is not (AgentRole.Assistant or AgentRole.Custom) + || !message.Metadata.TryGetValue(AudienceKey, out var value)) + { + return false; + } + + switch (value) + { + case "internal": + audience = GameAgentAudience.Internal; + return true; + case "owner": + audience = GameAgentAudience.Owner; + return true; + case "public": + audience = GameAgentAudience.Public; + return true; + case "recipient" when message.Metadata.TryGetValue(RecipientKey, out var recipient): + try + { + audience = GameAgentAudience.Recipient(recipient); + return true; + } + catch (ArgumentException) + { + return false; + } + default: + return false; + } + } + + private static AgentMessage Copy(AgentMessage message, IReadOnlyDictionary metadata) => + new( + message.Role, + message.Content, + message.Timestamp, + message.CustomRole, + message.ToolCallId, + message.ToolName, + message.IsError, + message.DetailsJson, + metadata, + message.Model, + message.StopReason, + message.Usage, + message.ErrorMessage, + message.Provider, + message.Api, + message.ResponseModel, + message.ResponseId, + message.RawStopReason, + message.EndTurn, + message.Role == AgentRole.Assistant ? message.Diagnostics : null, + message.Deferred, + addedToolNames: null); +} + +public delegate ValueTask GameAgentViewerResolver( + ClaimsPrincipal principal, + GameSessionKey key, + CancellationToken cancellationToken); + +public sealed class MetadataGameAgentAudiencePolicy : IGameAgentAudiencePolicy +{ + private readonly GameAgentViewerResolver _viewerResolver; + private readonly GameAgentAudience _defaultAudience; + + public MetadataGameAgentAudiencePolicy( + GameAgentViewerResolver viewerResolver, + GameAgentAudience? defaultAudience = null) + { + _viewerResolver = viewerResolver ?? throw new ArgumentNullException(nameof(viewerResolver)); + _defaultAudience = defaultAudience ?? GameAgentAudience.Owner; + } + + public async ValueTask ResolveViewerAsync( + ClaimsPrincipal principal, + GameSessionKey key, + CancellationToken cancellationToken) => + await _viewerResolver(principal, key, cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The game audience viewer resolver returned null."); + + public ValueTask ResolveAudienceAsync( + GameAgentAudienceContext context, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + cancellationToken.ThrowIfCancellationRequested(); + if (context.AgentEvent?.Kind is AgentEventKind.ToolStarted + or AgentEventKind.ToolProgressed + or AgentEventKind.ToolEnded + || context.Message?.Role == AgentRole.Tool) + { + return new ValueTask(GameAgentAudience.Internal); + } + + return new ValueTask( + context.Message is not null + && GameAgentAudienceMetadata.TryGetAudience(context.Message, out var audience) + ? audience + : _defaultAudience); + } +} + +internal sealed class GameAgentAudienceProjection +{ + private readonly IGameAgentAudiencePolicy _policy; + private readonly GameAgentViewer _viewer; + private readonly GameSessionKey _key; + + public GameAgentAudienceProjection( + IGameAgentAudiencePolicy policy, + GameAgentViewer viewer, + GameSessionKey key) + { + _policy = policy; + _viewer = viewer; + _key = key; + } + + public async ValueTask ProjectResultAsync( + GameAgentRunResult result, + CancellationToken cancellationToken) + { + var root = JsonNode.Parse(GameAgentWire.SerializeResult(result))?.AsObject() + ?? throw new InvalidOperationException("The game agent result projection was not an object."); + if (result.AgentResult is null || root["agent"] is not JsonObject agent + || agent["newMessages"] is not JsonArray serializedMessages) + { + return root.ToJsonString(JsonOptions); + } + + var projected = new JsonArray(); + for (var index = 0; index < result.AgentResult.NewMessages.Count; index++) + { + var message = result.AgentResult.NewMessages[index]; + var audience = await _policy.ResolveAudienceAsync( + new GameAgentAudienceContext(_key, GameAgentAudienceOutputKind.Message, message, null), + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The game audience policy returned null."); + if (!audience.IsVisibleTo(_viewer)) + { + continue; + } + + var node = serializedMessages[index]?.DeepClone() as JsonObject; + if (node is not null && (_viewer.IsInternal || SanitizeMessage(node))) + { + projected.Add(node); + } + } + + agent["newMessages"] = projected; + return root.ToJsonString(JsonOptions); + } + + public async ValueTask ProjectEventAsync( + AgentEvent agentEvent, + CancellationToken cancellationToken) + { + var audience = await _policy.ResolveAudienceAsync( + new GameAgentAudienceContext( + _key, + GameAgentAudienceOutputKind.Event, + agentEvent.Message, + agentEvent), + cancellationToken).ConfigureAwait(false) + ?? throw new InvalidOperationException("The game audience policy returned null."); + if (!audience.IsVisibleTo(_viewer)) + { + return null; + } + + var root = JsonNode.Parse(GameAgentWire.SerializeEvent(agentEvent))?.AsObject() + ?? throw new InvalidOperationException("The game agent event projection was not an object."); + if (_viewer.IsInternal) + { + return root.ToJsonString(JsonOptions); + } + + var modelKind = root["modelEvent"]?["kind"]?.GetValue(); + if (modelKind is "ReasoningStarted" or "ReasoningDelta" or "ReasoningEnded") + { + return null; + } + + if (modelKind is "ToolCallStarted" or "ToolCallDelta" or "ToolCallEnded") + { + root["modelEvent"] = new JsonObject { ["kind"] = modelKind }; + } + + root["toolCall"] = null; + root["toolResult"] = null; + root["progress"] = null; + if (root["message"] is JsonObject message && !SanitizeMessage(message)) + { + root["message"] = null; + } + + return root.ToJsonString(JsonOptions); + } + + private static bool SanitizeMessage(JsonObject message) + { + if (string.Equals(message["role"]?.GetValue(), "Tool", StringComparison.Ordinal)) + { + return false; + } + + if (message["content"] is JsonArray content) + { + var safe = new JsonArray(); + foreach (var part in content) + { + var kind = part?["kind"]?.GetValue(); + if (kind is not ("reasoning" or "tool_call")) + { + safe.Add(part?.DeepClone()); + } + } + + message["content"] = safe; + } + + message["toolCallId"] = null; + message["toolName"] = null; + message["details"] = null; + message["metadata"] = new JsonObject(); + return true; + } + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; +} diff --git a/src/OpenGameAgent.Server/ServerAuthorization.cs b/src/OpenGameAgent.Server/ServerAuthorization.cs new file mode 100644 index 0000000..f39467b --- /dev/null +++ b/src/OpenGameAgent.Server/ServerAuthorization.cs @@ -0,0 +1,98 @@ +using System.Security.Claims; + +namespace OpenGameAgent.Server; + +/// +/// Identifies a server operation that is scoped to a game session and actor. +/// +public enum GameAgentServerOperation +{ + Run = 0, + Stream = 1, + Steer = 2, + Abort = 3, + ReadUsage = 4, + ClaimActions = 5, + StreamActions = 6, + SubmitActionReceipt = 7, + ReconcileAction = 8, +} + +/// +/// Carries the authenticated request principal and the server-owned resource being accessed. +/// Resource ownership must be derived from , never from an owner identifier in a request body. +/// +public sealed class GameAgentAuthorizationContext +{ + public GameAgentAuthorizationContext( + ClaimsPrincipal principal, + GameSessionKey key, + GameAgentServerOperation operation) + { + Principal = principal ?? throw new ArgumentNullException(nameof(principal)); + Key = new GameSessionKey(key.SessionId, key.ActorId); + Operation = operation; + } + + public ClaimsPrincipal Principal { get; } + + public GameSessionKey Key { get; } + + public GameAgentServerOperation Operation { get; } +} + +/// +/// Authorizes an authenticated principal for a session/actor resource. +/// Hosts can resolve ownership from claims, a database, a lease service, or another authoritative source. +/// +public interface IGameAgentOwnerAuthorizer +{ + ValueTask AuthorizeAsync( + GameAgentAuthorizationContext context, + CancellationToken cancellationToken); +} + +/// +/// A bounded opaque credential presented in a JSON request body. This is intended for local +/// engine clients that cannot set HTTP headers. The credential is authentication input only and +/// must never be copied into a game input, transcript, session snapshot, or response. +/// +public sealed class GameAgentPresentedCredentialContext +{ + public GameAgentPresentedCredentialContext( + string credential, + GameSessionKey key, + GameAgentServerOperation operation) + { + if (string.IsNullOrWhiteSpace(credential) || credential.Length > 4_096) + { + throw new ArgumentException("A presented credential must contain between 1 and 4096 characters.", nameof(credential)); + } + + if (credential.Any(static character => char.IsControl(character))) + { + throw new ArgumentException("A presented credential cannot contain control characters.", nameof(credential)); + } + + Credential = credential; + Key = new GameSessionKey(key.SessionId, key.ActorId); + Operation = operation; + } + + public string Credential { get; } + + public GameSessionKey Key { get; } + + public GameAgentServerOperation Operation { get; } +} + +/// +/// Maps a host-issued body credential to an authenticated principal. Ownership is still decided +/// independently by using the returned principal. +/// +public interface IGameAgentPresentedCredentialAuthenticator +{ + ValueTask AuthenticateAsync( + GameAgentPresentedCredentialContext context, + CancellationToken cancellationToken); +} diff --git a/src/OpenGameAgent.Server/ServerEndpoints.cs b/src/OpenGameAgent.Server/ServerEndpoints.cs index b45ee36..8f848fc 100644 --- a/src/OpenGameAgent.Server/ServerEndpoints.cs +++ b/src/OpenGameAgent.Server/ServerEndpoints.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Security.Claims; using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -6,7 +7,7 @@ namespace OpenGameAgent.Server; -public static class ServerEndpoints +public static partial class ServerEndpoints { public const int DefaultMaximumRequestBodyBytes = 8_000_000; @@ -53,7 +54,9 @@ public static IApplicationBuilder UseOpenGameAgentApiKey( return app.Use(async (context, next) => { if (!context.Request.Path.StartsWithSegments("/v1/run") - && !context.Request.Path.StartsWithSegments("/v1/control")) + && !context.Request.Path.StartsWithSegments("/v1/control") + && !context.Request.Path.StartsWithSegments("/v1/actions") + && !context.Request.Path.StartsWithSegments("/v1/usage")) { await next(context); return; @@ -66,6 +69,13 @@ public static IApplicationBuilder UseOpenGameAgentApiKey( return; } + if (context.User.Identity?.IsAuthenticated != true) + { + context.User = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, "server-api-key") }, + "OpenGameAgent.ApiKey")); + } + await next(context); }); } @@ -109,6 +119,9 @@ public static IEndpointRouteBuilder MapOpenGameAgent( routes = new[] { "quick", "agent", "workflow" }, execution = new[] { "in-process", "server" }, control = new[] { "steer", "abort" }, + audience = new[] { "internal", "owner", "public", "recipient" }, + actions = new[] { "claim", "stream", "receipt", "reconcile" }, + usage = new[] { "session-ledger", "by-cause", "itemized-cost" }, })); endpoints.MapPost( "/v1/run", @@ -126,28 +139,89 @@ public static IEndpointRouteBuilder MapOpenGameAgent( "/v1/control/abort", (HttpRequest request, GameAgentRuntime runtime, CancellationToken cancellationToken) => AbortAsync(request, runtime, maximumRequestBodyBytes, cancellationToken)); + endpoints.MapPost( + "/v1/usage", + (HttpRequest request, GameAgentRuntime runtime, CancellationToken cancellationToken) => + ReadUsageAsync(request, runtime, maximumRequestBodyBytes, cancellationToken)); + MapGameActionExchangeEndpoints(endpoints, maximumRequestBodyBytes); return endpoints; } + private static async Task ReadUsageAsync( + HttpRequest httpRequest, + GameAgentRuntime runtime, + int maximumRequestBodyBytes, + CancellationToken cancellationToken) + { + ControlRequest request; + GameSessionKey key; + try + { + using var requestDocument = await ReadRequestDocumentAsync( + httpRequest, + maximumRequestBodyBytes, + cancellationToken); + request = ParseRequest(requestDocument.RootElement); + key = request.ToKey(); + } + 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 { error = "invalid_request", message = exception.Message }, + statusCode: StatusCodes.Status400BadRequest); + } + + var authenticationFailure = await AuthenticatePresentedCredentialAsync( + httpRequest.HttpContext, + request.Credential, + key, + GameAgentServerOperation.ReadUsage, + cancellationToken); + if (authenticationFailure is not null) + { + return authenticationFailure; + } + + var authorizationFailure = await GetAuthorizationFailureAsync( + httpRequest.HttpContext, + key, + GameAgentServerOperation.ReadUsage, + cancellationToken); + if (authorizationFailure is not null) + { + return authorizationFailure; + } + + var usage = await runtime.ReadUsageAsync(key, cancellationToken); + return usage is null + ? Results.NotFound(new { error = "session_not_found" }) + : Results.Text(GameAgentWire.SerializeUsage(usage), "application/json", Encoding.UTF8); + } + private static async Task SteerAsync( HttpRequest httpRequest, GameAgentRuntime runtime, int maximumRequestBodyBytes, CancellationToken cancellationToken) { + ControlRequest request; + GameSessionKey key; try { using var requestDocument = await ReadRequestDocumentAsync( httpRequest, maximumRequestBodyBytes, cancellationToken); - var request = ParseRequest(requestDocument.RootElement); - var accepted = runtime.TrySteer( - request.ToKey(), - AgentMessage.UserJson(request.GetPayloadJson())); - return accepted - ? Results.Ok(new { accepted = true }) - : Results.NotFound(new { accepted = false, error = "actor_not_running" }); + request = ParseRequest(requestDocument.RootElement); + key = request.ToKey(); } catch (RequestBodyTooLargeException exception) { @@ -166,6 +240,44 @@ or GameRuntimeLimitException new { accepted = false, error = "invalid_request", message = exception.Message }, statusCode: StatusCodes.Status400BadRequest); } + + var authenticationFailure = await AuthenticatePresentedCredentialAsync( + httpRequest.HttpContext, + request.Credential, + key, + GameAgentServerOperation.Steer, + cancellationToken); + if (authenticationFailure is not null) + { + return authenticationFailure; + } + + var authorizationFailure = await GetAuthorizationFailureAsync( + httpRequest.HttpContext, + key, + GameAgentServerOperation.Steer, + cancellationToken); + if (authorizationFailure is not null) + { + return authorizationFailure; + } + + try + { + var accepted = runtime.TrySteer(key, AgentMessage.UserJson(request.GetPayloadJson())); + return accepted + ? Results.Ok(new { accepted = true }) + : Results.NotFound(new { accepted = false, error = "actor_not_running" }); + } + catch (Exception exception) when (exception is ArgumentException + or AgentLimitException + or GameRuntimeLimitException + or JsonException) + { + return Results.Json( + new { accepted = false, error = "invalid_request", message = exception.Message }, + statusCode: StatusCodes.Status400BadRequest); + } } private static async Task AbortAsync( @@ -174,17 +286,16 @@ private static async Task AbortAsync( int maximumRequestBodyBytes, CancellationToken cancellationToken) { + ControlRequest request; + GameSessionKey key; try { 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" }); + request = ParseRequest(requestDocument.RootElement); + key = request.ToKey(); } catch (RequestBodyTooLargeException exception) { @@ -200,6 +311,41 @@ private static async Task AbortAsync( new { accepted = false, error = "invalid_request", message = exception.Message }, statusCode: StatusCodes.Status400BadRequest); } + + var authenticationFailure = await AuthenticatePresentedCredentialAsync( + httpRequest.HttpContext, + request.Credential, + key, + GameAgentServerOperation.Abort, + cancellationToken); + if (authenticationFailure is not null) + { + return authenticationFailure; + } + + var authorizationFailure = await GetAuthorizationFailureAsync( + httpRequest.HttpContext, + key, + GameAgentServerOperation.Abort, + cancellationToken); + if (authorizationFailure is not null) + { + return authorizationFailure; + } + + try + { + var accepted = runtime.TryAbort(key); + return accepted + ? Results.Ok(new { accepted = true }) + : Results.NotFound(new { accepted = false, error = "actor_not_running" }); + } + catch (Exception exception) when (exception is ArgumentException or JsonException) + { + return Results.Json( + new { accepted = false, error = "invalid_request", message = exception.Message }, + statusCode: StatusCodes.Status400BadRequest); + } } private static async Task RunAsync( @@ -209,12 +355,15 @@ private static async Task RunAsync( CancellationToken cancellationToken) { GameInput input; + string? credential; try { using var requestDocument = await ReadRequestDocumentAsync( httpRequest, maximumRequestBodyBytes, cancellationToken); + EnsureRequestIsUnambiguous(requestDocument.RootElement); + credential = GetPresentedCredential(requestDocument.RootElement); input = GameAgentWire.ParseInput(requestDocument.RootElement.GetRawText()); } catch (RequestBodyTooLargeException exception) @@ -232,6 +381,33 @@ private static async Task RunAsync( statusCode: StatusCodes.Status400BadRequest); } + var key = new GameSessionKey(input.SessionId, input.ActorId); + var authenticationFailure = await AuthenticatePresentedCredentialAsync( + httpRequest.HttpContext, + credential, + key, + GameAgentServerOperation.Run, + cancellationToken); + if (authenticationFailure is not null) + { + return authenticationFailure; + } + + var authorizationFailure = await GetAuthorizationFailureAsync( + httpRequest.HttpContext, + key, + GameAgentServerOperation.Run, + cancellationToken); + if (authorizationFailure is not null) + { + return authorizationFailure; + } + + var audienceProjection = await CreateAudienceProjectionAsync( + httpRequest.HttpContext, + key, + cancellationToken); + Task pendingRun; try { @@ -245,7 +421,10 @@ private static async Task RunAsync( } var result = await pendingRun; - return Results.Text(GameAgentWire.SerializeResult(result), "application/json", Encoding.UTF8); + var resultJson = audienceProjection is null + ? GameAgentWire.SerializeResult(result) + : await audienceProjection.ProjectResultAsync(result, cancellationToken); + return Results.Text(resultJson, "application/json", Encoding.UTF8); } private static async Task StreamAsync( @@ -256,12 +435,15 @@ private static async Task StreamAsync( CancellationToken cancellationToken) { GameInput input; + string? credential; try { using var requestDocument = await ReadRequestDocumentAsync( httpRequest, maximumRequestBodyBytes, cancellationToken); + EnsureRequestIsUnambiguous(requestDocument.RootElement); + credential = GetPresentedCredential(requestDocument.RootElement); input = GameAgentWire.ParseInput(requestDocument.RootElement.GetRawText()); } catch (RequestBodyTooLargeException exception) @@ -289,6 +471,35 @@ await response.WriteAsJsonAsync( return; } + var key = new GameSessionKey(input.SessionId, input.ActorId); + var authenticationFailure = await AuthenticatePresentedCredentialAsync( + httpRequest.HttpContext, + credential, + key, + GameAgentServerOperation.Stream, + cancellationToken); + if (authenticationFailure is not null) + { + await authenticationFailure.ExecuteAsync(httpRequest.HttpContext); + return; + } + + var authorizationFailure = await GetAuthorizationFailureAsync( + httpRequest.HttpContext, + key, + GameAgentServerOperation.Stream, + cancellationToken); + if (authorizationFailure is not null) + { + await authorizationFailure.ExecuteAsync(httpRequest.HttpContext); + return; + } + + var audienceProjection = await CreateAudienceProjectionAsync( + httpRequest.HttpContext, + key, + cancellationToken); + response.StatusCode = StatusCodes.Status200OK; response.ContentType = "text/event-stream"; response.Headers.CacheControl = "no-cache"; @@ -300,7 +511,13 @@ await response.WriteAsJsonAsync( input, async (_, agentEvent, token) => { - await WriteEventAsync(response, "agent", GameAgentWire.SerializeEvent(agentEvent), token); + var eventJson = audienceProjection is null + ? GameAgentWire.SerializeEvent(agentEvent) + : await audienceProjection.ProjectEventAsync(agentEvent, token); + if (eventJson is not null) + { + await WriteEventAsync(response, "agent", eventJson, token); + } }, cancellationToken); } @@ -319,7 +536,10 @@ await response.WriteAsJsonAsync( try { var result = await pendingRun; - await WriteEventAsync(response, "result", GameAgentWire.SerializeResult(result), cancellationToken); + var resultJson = audienceProjection is null + ? GameAgentWire.SerializeResult(result) + : await audienceProjection.ProjectResultAsync(result, cancellationToken); + await WriteEventAsync(response, "result", resultJson, cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -358,6 +578,114 @@ private static bool IsInvalidRequest(Exception exception) => private static IResult RequestError(int statusCode, string error, string message) => Results.Json(new { error, message }, statusCode: statusCode); + private static string? GetPresentedCredential(JsonElement root) + { + if (!root.TryGetProperty("credential", out var value)) + { + foreach (var property in root.EnumerateObject()) + { + if (string.Equals(property.Name, "credential", StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + break; + } + } + } + + if (value.ValueKind == JsonValueKind.Undefined || value.ValueKind == JsonValueKind.Null) + { + return null; + } + + if (value.ValueKind != JsonValueKind.String) + { + throw new ArgumentException("The presented credential must be a string.", nameof(root)); + } + + return value.GetString(); + } + + private static async ValueTask AuthenticatePresentedCredentialAsync( + HttpContext httpContext, + string? credential, + GameSessionKey key, + GameAgentServerOperation operation, + CancellationToken cancellationToken) + { + if (credential is null) + { + return null; + } + + GameAgentPresentedCredentialContext credentialContext; + try + { + credentialContext = new GameAgentPresentedCredentialContext(credential, key, operation); + } + catch (ArgumentException) + { + return Results.Json(new { error = "unauthorized" }, statusCode: StatusCodes.Status401Unauthorized); + } + + var authenticator = httpContext.RequestServices.GetService(); + if (authenticator is null) + { + return Results.Json(new { error = "unauthorized" }, statusCode: StatusCodes.Status401Unauthorized); + } + + var principal = await authenticator.AuthenticateAsync(credentialContext, cancellationToken); + if (principal?.Identity?.IsAuthenticated != true) + { + return Results.Json(new { error = "unauthorized" }, statusCode: StatusCodes.Status401Unauthorized); + } + + httpContext.User = principal; + return null; + } + + private static async ValueTask GetAuthorizationFailureAsync( + HttpContext httpContext, + GameSessionKey key, + GameAgentServerOperation operation, + CancellationToken cancellationToken) + { + var authorizer = httpContext.RequestServices.GetService(); + if (authorizer is null) + { + return null; + } + + if (httpContext.User.Identity?.IsAuthenticated != true) + { + return Results.Json( + new { error = "unauthorized" }, + statusCode: StatusCodes.Status401Unauthorized); + } + + var allowed = await authorizer.AuthorizeAsync( + new GameAgentAuthorizationContext(httpContext.User, key, operation), + cancellationToken); + return allowed + ? null + : Results.Json(new { error = "forbidden" }, statusCode: StatusCodes.Status403Forbidden); + } + + private static async ValueTask CreateAudienceProjectionAsync( + HttpContext httpContext, + GameSessionKey key, + CancellationToken cancellationToken) + { + var policy = httpContext.RequestServices.GetService(); + if (policy is null) + { + return null; + } + + var viewer = await policy.ResolveViewerAsync(httpContext.User, key, cancellationToken) + ?? throw new InvalidOperationException("The game audience policy returned no viewer."); + return new GameAgentAudienceProjection(policy, viewer, key); + } + private static async Task ReadRequestDocumentAsync( HttpRequest request, int maximumRequestBodyBytes, @@ -487,6 +815,8 @@ public UnsupportedRequestContentTypeException() public sealed class ControlRequest { + public string? Credential { get; set; } + public string SessionId { get; set; } = string.Empty; public string ActorId { get; set; } = string.Empty; diff --git a/src/OpenGameAgent.Server/ServerModelRouting.cs b/src/OpenGameAgent.Server/ServerModelRouting.cs new file mode 100644 index 0000000..f4d3a3a --- /dev/null +++ b/src/OpenGameAgent.Server/ServerModelRouting.cs @@ -0,0 +1,331 @@ +using System.Collections.ObjectModel; +using System.Runtime.CompilerServices; +using OpenGameAgent.Kernel; +using OpenGameAgent.Providers.OpenAICompatible; + +namespace OpenGameAgent.Server; + +public delegate ValueTask GameAgentServerModelRouteSelector( + GameInput input, + CancellationToken cancellationToken); + +/// +/// A server-owned model target. Provider transports and credentials are supplied by the host and +/// are never read from a game request. +/// +public sealed class GameAgentServerModelTarget +{ + public GameAgentServerModelTarget( + string providerId, + string model, + IModelProvider provider, + string? apiId = null) + { + ProviderId = RequireIdentifier(providerId, nameof(providerId)); + Model = RequireIdentifier(model, nameof(model)); + Provider = provider ?? throw new ArgumentNullException(nameof(provider)); + ApiId = apiId is null ? null : RequireIdentifier(apiId, nameof(apiId)); + } + + public string ProviderId { get; } + + public string Model { get; } + + public IModelProvider Provider { get; } + + public string? ApiId { get; } + + private static string RequireIdentifier(string value, string parameterName) => + string.IsNullOrWhiteSpace(value) || value.Length > 512 || value.Any(char.IsControl) + ? throw new ArgumentException("A bounded model target identifier is required.", parameterName) + : value; +} + +public sealed class GameAgentServerModelRoute +{ + public GameAgentServerModelRoute(string name, IEnumerable targets) + { + Name = ServerModelRoutingValidation.RequireIdentifier(name, nameof(name)); + var copy = targets?.ToArray() ?? throw new ArgumentNullException(nameof(targets)); + if (copy.Length == 0 || copy.Length > 16 || copy.Any(target => target is null)) + { + throw new ArgumentException("A model route requires between one and sixteen targets.", nameof(targets)); + } + + if (copy.Select(target => target.ProviderId).Distinct(StringComparer.Ordinal).Count() != copy.Length) + { + throw new ArgumentException("A model route cannot repeat a provider target.", nameof(targets)); + } + + Targets = Array.AsReadOnly(copy); + } + + public string Name { get; } + + public IReadOnlyList Targets { get; } +} + +/// +/// Resolves only host-registered routes. The selector may inspect trusted game input, but its +/// result is always checked against the immutable route allowlist before a provider is called. +/// +public sealed class TrustedGameAgentServerModelRouter +{ + private readonly IReadOnlyDictionary _routes; + private readonly GameAgentServerModelRouteSelector _selector; + + public TrustedGameAgentServerModelRouter( + IEnumerable routes, + string defaultRouteName, + GameAgentServerModelRouteSelector? selector = null) + { + var copy = routes?.ToArray() ?? throw new ArgumentNullException(nameof(routes)); + if (copy.Length == 0 || copy.Length > 128 || copy.Any(route => route is null)) + { + throw new ArgumentException("Between one and 128 trusted model routes are required.", nameof(routes)); + } + + var duplicate = copy.GroupBy(route => route.Name, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new ArgumentException($"Duplicate trusted model route '{duplicate.Key}'.", nameof(routes)); + } + + DefaultRouteName = ServerModelRoutingValidation.RequireIdentifier(defaultRouteName, nameof(defaultRouteName)); + _routes = new ReadOnlyDictionary(copy.ToDictionary( + route => route.Name, + route => new RouteRuntime(route), + StringComparer.Ordinal)); + if (!_routes.ContainsKey(DefaultRouteName)) + { + throw new ArgumentException("The default model route is not registered.", nameof(defaultRouteName)); + } + + _selector = selector ?? ((_, _) => new ValueTask(DefaultRouteName)); + } + + public string DefaultRouteName { get; } + + public IReadOnlyCollection RouteNames => Array.AsReadOnly(_routes.Keys.OrderBy(name => name, StringComparer.Ordinal).ToArray()); + + public string DefaultModel => _routes[DefaultRouteName].Model; + + public IModelProvider DefaultProvider => _routes[DefaultRouteName].Provider; + + public async ValueTask SelectAsync( + GameInput input, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(input); + cancellationToken.ThrowIfCancellationRequested(); + var selected = await _selector(input, cancellationToken).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(selected) + || !_routes.TryGetValue(selected, out var route)) + { + throw new InvalidOperationException("The server model policy selected an unregistered route."); + } + + return new GameModelSelection(route.Model, provider: route.Provider); + } + + private sealed class RouteRuntime + { + public RouteRuntime(GameAgentServerModelRoute route) + { + Model = route.Targets[0].Model; + var candidates = route.Targets.Select(target => (IModelProvider)new BoundTargetProvider(target)).ToArray(); + Provider = candidates.Length == 1 ? candidates[0] : new FallbackModelProvider(candidates); + } + + public string Model { get; } + + public IModelProvider Provider { get; } + } + + private sealed class BoundTargetProvider : IModelProvider + { + private readonly GameAgentServerModelTarget _target; + + public BoundTargetProvider(GameAgentServerModelTarget target) + { + _target = target; + } + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var routed = new ModelRequest( + _target.Model, + request.SystemPrompt, + request.Messages, + request.Tools, + request.Parameters, + request.SessionId, + request.RunId, + request.Turn); + await foreach (var streamEvent in _target.Provider.StreamAsync(routed, cancellationToken) + .WithCancellation(cancellationToken).ConfigureAwait(false)) + { + if (streamEvent is null) + { + throw new InvalidOperationException("A routed model provider emitted a null event."); + } + + yield return Project(streamEvent); + } + } + + private ModelStreamEvent Project(ModelStreamEvent streamEvent) + { + if (streamEvent.IsTerminal) + { + return ModelStreamEvent.Terminal(Project(streamEvent.Response + ?? throw new InvalidOperationException("A terminal model event is missing its response."))); + } + + return ModelStreamEvent.Update( + streamEvent.Kind, + Project(streamEvent.Partial + ?? throw new InvalidOperationException("A model update is missing its partial response.")), + streamEvent.Delta, + streamEvent.ContentIndex, + streamEvent.ToolCallId, + streamEvent.ToolName, + streamEvent.ToolCall, + streamEvent.Content); + } + + private ModelResponse Project(ModelResponse response) => new( + response.Content, + response.StopReason, + response.Usage, + response.ErrorMessage, + _target.ProviderId, + response.Api ?? _target.ApiId, + response.ResponseModel ?? _target.Model, + response.ResponseId, + response.RawStopReason, + response.EndTurn, + response.Diagnostics, + response.Deferred); + } +} + +/// +/// Builds the stock server's OpenAI-compatible route allowlist from configuration. All endpoint, +/// header, and credential values come from server configuration; requests can select only through +/// the configured input-type policy. +/// +public static class StockGameAgentModelRouting +{ + public static TrustedGameAgentServerModelRouter Create( + IConfiguration configuration, + IHttpClientFactory httpClientFactory) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(httpClientFactory); + var section = configuration.GetSection("OpenGameAgent:ModelRoutes"); + var routeSections = section.GetChildren().ToArray(); + if (routeSections.Length == 0 || routeSections.Length > 128) + { + throw new InvalidOperationException("Configure between one and 128 OpenGameAgent:ModelRoutes entries."); + } + + var targets = new Dictionary(StringComparer.Ordinal); + var fallbackNames = new Dictionary(StringComparer.Ordinal); + foreach (var routeSection in routeSections) + { + var name = ServerModelRoutingValidation.RequireIdentifier(routeSection.Key, "model route name"); + var endpointText = routeSection["Endpoint"] + ?? throw new InvalidOperationException($"Model route '{name}' requires Endpoint."); + var model = routeSection["Model"] + ?? throw new InvalidOperationException($"Model route '{name}' requires Model."); + var providerId = routeSection["ProviderId"] ?? name; + var apiId = routeSection["ApiId"] ?? "openai-completions"; + if (!Uri.TryCreate(endpointText, UriKind.Absolute, out var endpoint)) + { + throw new InvalidOperationException($"Model route '{name}' has an invalid Endpoint."); + } + + var providerOptions = new OpenAICompatibleProviderOptions( + httpClientFactory.CreateClient("model:" + name), + endpoint) + { + ApiKey = routeSection["ApiKey"], + ProviderId = providerId, + ApiId = apiId, + AllowInsecureHttp = routeSection.GetValue("AllowInsecureHttp", false), + }; + targets.Add(name, new GameAgentServerModelTarget( + providerId, + model, + new OpenAICompatibleProvider(providerOptions), + apiId)); + fallbackNames.Add( + name, + routeSection.GetSection("Fallbacks").GetChildren() + .Select(item => item.Value) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Cast() + .ToArray()); + } + + var routes = new List(targets.Count); + foreach (var pair in targets) + { + var chain = new List { pair.Value }; + foreach (var fallbackName in fallbackNames[pair.Key]) + { + if (!targets.TryGetValue(fallbackName, out var fallback)) + { + throw new InvalidOperationException( + $"Model route '{pair.Key}' references unknown fallback '{fallbackName}'."); + } + + chain.Add(fallback); + } + + routes.Add(new GameAgentServerModelRoute(pair.Key, chain)); + } + + var defaultRoute = configuration["OpenGameAgent:DefaultModelRoute"] + ?? throw new InvalidOperationException("Configure OpenGameAgent:DefaultModelRoute."); + var inputRoutes = configuration.GetSection("OpenGameAgent:InputModelRoutes").GetChildren() + .ToDictionary( + child => ServerModelRoutingValidation.RequireIdentifier(child.Key, "input model route type"), + child => ServerModelRoutingValidation.RequireIdentifier( + child.Value ?? throw new InvalidOperationException($"Input route '{child.Key}' has no model route."), + "input model route"), + StringComparer.Ordinal); + foreach (var route in inputRoutes.Values) + { + if (!targets.ContainsKey(route)) + { + throw new InvalidOperationException($"An input type references unknown model route '{route}'."); + } + } + + return new TrustedGameAgentServerModelRouter( + routes, + defaultRoute, + (input, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(inputRoutes.TryGetValue(input.Type, out var selected) + ? selected + : defaultRoute); + }); + } +} + +internal static class ServerModelRoutingValidation +{ + public static string RequireIdentifier(string value, string parameterName) => + string.IsNullOrWhiteSpace(value) + || value.Length > 512 + || value.Any(character => char.IsControl(character) || char.IsWhiteSpace(character)) + ? throw new ArgumentException("A bounded identifier is required.", parameterName) + : value; +} diff --git a/src/OpenGameAgent.Server/appsettings.json b/src/OpenGameAgent.Server/appsettings.json index 686d75d..f897b91 100644 --- a/src/OpenGameAgent.Server/appsettings.json +++ b/src/OpenGameAgent.Server/appsettings.json @@ -1,8 +1,17 @@ { "OpenGameAgent": { - "ModelEndpoint": "http://127.0.0.1:11434/v1/chat/completions", - "Model": "local-model", + "DefaultModelRoute": "local", + "ModelRoutes": { + "local": { + "ProviderId": "local", + "ApiId": "openai-completions", + "Endpoint": "http://127.0.0.1:11434/v1/chat/completions", + "Model": "local-model" + } + }, + "InputModelRoutes": {}, "DataDirectory": "data/sessions", + "ActionDirectory": "data/actions", "Instructions": "Respond as a game agent. Treat the supplied game context as authoritative." } } diff --git a/src/OpenGameAgent/Actions.cs b/src/OpenGameAgent/Actions.cs index e8c08d6..4156ca6 100644 --- a/src/OpenGameAgent/Actions.cs +++ b/src/OpenGameAgent/Actions.cs @@ -2,6 +2,8 @@ using System.Collections.Concurrent; 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; @@ -28,6 +30,29 @@ public GameActionIntent( string argumentsJson, GameMoment moment, long? expectedRevision = null) + : this( + operationId, + inputId, + sessionId, + actorId, + action, + argumentsJson, + moment, + expectedRevision, + generationId: null) + { + } + + public GameActionIntent( + string operationId, + string inputId, + string sessionId, + string actorId, + string action, + string argumentsJson, + GameMoment moment, + long? expectedRevision, + string? generationId) { OperationId = GameJson.RequireId(operationId, nameof(operationId)); InputId = GameJson.RequireId(inputId, nameof(inputId)); @@ -42,6 +67,9 @@ public GameActionIntent( } ExpectedRevision = expectedRevision; + GenerationId = generationId is null + ? null + : GameJson.RequireId(generationId, nameof(generationId)); } public string OperationId { get; } @@ -59,6 +87,13 @@ public GameActionIntent( public GameMoment Moment { get; } public long? ExpectedRevision { get; } + + /// + /// Identifies the authoritative save/world generation in which this intent is valid. + /// Hosts should change it whenever loading or replacing a world snapshot could make an old + /// external receipt unsafe to apply. + /// + public string? GenerationId { get; } } public sealed class GameActionReceipt @@ -199,6 +234,156 @@ public delegate string GameActionOperationIdFactory( JsonElement arguments, ToolExecutionContext execution); +/// +/// Creates stable, bounded operation identifiers for authoritative game actions. +/// +public static class GameActionOperationIds +{ + public const string Version2Prefix = "oga-action-v2:"; + + public static string CreateV2( + GameInput input, + string action, + ToolExecutionContext execution, + string? generationId = null) + { + if (input is null) + { + throw new ArgumentNullException(nameof(input)); + } + + if (execution is null) + { + throw new ArgumentNullException(nameof(execution)); + } + return CreateV2( + input.SessionId, + input.ActorId, + input.InputId, + execution.Turn, + execution.ToolCallIndex, + action, + input.Moment, + generationId); + } + + public static string CreateV2( + string sessionId, + string actorId, + string inputId, + int turn, + int toolCallIndex, + string action, + GameMoment moment, + string? generationId = null) + { + if (turn < 0) + { + throw new ArgumentOutOfRangeException(nameof(turn)); + } + + if (toolCallIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(toolCallIndex)); + } + + moment = moment.EnsureValid(nameof(moment)); + using var canonical = new MemoryStream(); + using (var writer = new BinaryWriter(canonical, Encoding.UTF8, leaveOpen: true)) + { + WriteComponent(writer, "OpenGameAgent.GameActionOperationId.v2", "version"); + WriteComponent(writer, sessionId, nameof(sessionId)); + WriteComponent(writer, actorId, nameof(actorId)); + WriteComponent(writer, inputId, nameof(inputId)); + writer.Write(turn); + writer.Write(toolCallIndex); + WriteComponent(writer, action, nameof(action)); + WriteComponent(writer, moment.TimelineId, nameof(moment)); + writer.Write(moment.Tick); + WriteNullableComponent(writer, generationId, nameof(generationId)); + } + + canonical.Position = 0; + using var algorithm = SHA256.Create(); + var hash = algorithm.ComputeHash(canonical); + return Version2Prefix + BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); + } + + /// + /// Reproduces the pre-v2 default ID for a controlled migration window. Do not use it for new + /// save namespaces because it does not isolate sessions, actors, timelines, or actions. + /// + public static string CreateLegacyV1( + GameInput input, + JsonElement arguments, + ToolExecutionContext execution) + { + if (input is null) + { + throw new ArgumentNullException(nameof(input)); + } + + if (execution is null) + { + throw new ArgumentNullException(nameof(execution)); + } + _ = arguments; + return CreateLegacyV1(input.InputId, execution.Turn, execution.ToolCallIndex); + } + + public static string CreateLegacyV1(string inputId, int turn, int toolCallIndex) + { + if (turn < 0) + { + throw new ArgumentOutOfRangeException(nameof(turn)); + } + + if (toolCallIndex < 0) + { + throw new ArgumentOutOfRangeException(nameof(toolCallIndex)); + } + + return GameJson.JoinIds( + RequireComponent(inputId, nameof(inputId)), + turn.ToString(System.Globalization.CultureInfo.InvariantCulture), + toolCallIndex.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + public static bool IsVersion2(string operationId) => + operationId?.StartsWith(Version2Prefix, StringComparison.Ordinal) == true + && operationId.Length == Version2Prefix.Length + 64 + && operationId.Skip(Version2Prefix.Length).All(static character => + character is >= '0' and <= '9' or >= 'a' and <= 'f'); + + private static void WriteComponent(BinaryWriter writer, string value, string parameterName) + { + var bytes = Encoding.UTF8.GetBytes(RequireComponent(value, parameterName)); + writer.Write(bytes.Length); + writer.Write(bytes); + } + + private static void WriteNullableComponent(BinaryWriter writer, string? value, string parameterName) + { + writer.Write(value is not null); + if (value is not null) + { + WriteComponent(writer, value, parameterName); + } + } + + private static string RequireComponent(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > 16_384) + { + throw new ArgumentException( + "An operation identity component must contain between 1 and 16384 characters.", + parameterName); + } + + return value; + } +} + public sealed class InMemoryGameActionJournal : IGameActionJournal { private readonly object _gate = new(); @@ -362,7 +547,8 @@ private static void EnsureSameIntent(GameActionIntent expected, GameActionIntent || !string.Equals(expected.Action, actual.Action, StringComparison.Ordinal) || !string.Equals(expected.ArgumentsJson, actual.ArgumentsJson, StringComparison.Ordinal) || expected.Moment != actual.Moment - || expected.ExpectedRevision != actual.ExpectedRevision) + || expected.ExpectedRevision != actual.ExpectedRevision + || !string.Equals(expected.GenerationId, actual.GenerationId, StringComparison.Ordinal)) { throw new InvalidOperationException("The operation ID is already reserved for a different action intent."); } @@ -695,7 +881,8 @@ private static void EnsureIntentsEqual(GameActionIntent left, GameActionIntent r || !string.Equals(left.Action, right.Action, StringComparison.Ordinal) || !string.Equals(left.ArgumentsJson, right.ArgumentsJson, StringComparison.Ordinal) || left.Moment != right.Moment - || left.ExpectedRevision != right.ExpectedRevision) + || left.ExpectedRevision != right.ExpectedRevision + || !string.Equals(left.GenerationId, right.GenerationId, StringComparison.Ordinal)) { throw new InvalidOperationException("The action journal returned a different reserved intent."); } @@ -723,6 +910,29 @@ public static AgentTool Create( Func? conflictKey = null, long? expectedRevision = null, GameActionOperationIdFactory? operationIdFactory = null) + => Create( + input, + action, + description, + inputSchemaJson, + dispatcher, + risk, + conflictKey, + expectedRevision, + operationIdFactory, + generationId: null); + + public static AgentTool Create( + GameInput input, + string action, + string description, + string inputSchemaJson, + DurableGameActionDispatcher dispatcher, + ToolRisk risk, + Func? conflictKey, + long? expectedRevision, + GameActionOperationIdFactory? operationIdFactory, + string? generationId) { if (input is null) { @@ -740,10 +950,11 @@ public static AgentTool Create( async (arguments, execution, cancellationToken) => { var operationId = operationIdFactory is null - ? GameJson.JoinIds( - input.InputId, - execution.Turn.ToString(System.Globalization.CultureInfo.InvariantCulture), - execution.ToolCallIndex.ToString(System.Globalization.CultureInfo.InvariantCulture)) + ? GameActionOperationIds.CreateV2( + input, + action, + execution, + generationId) : GameJson.RequireId(operationIdFactory(input, arguments, execution), nameof(operationIdFactory)); var intent = new GameActionIntent( operationId: operationId, @@ -753,7 +964,8 @@ public static AgentTool Create( action: action, argumentsJson: arguments.GetRawText(), moment: input.Moment, - expectedRevision: expectedRevision); + expectedRevision: expectedRevision, + generationId: generationId); var receipt = await dispatcher.ExecuteAsync(intent, cancellationToken).ConfigureAwait(false); var json = JsonSerializer.Serialize(new ReceiptPayload(receipt), ReceiptJsonOptions); return new ToolResult( diff --git a/src/OpenGameAgent/GameAgentRuntime.cs b/src/OpenGameAgent/GameAgentRuntime.cs index 21319a5..fc4c5a4 100644 --- a/src/OpenGameAgent/GameAgentRuntime.cs +++ b/src/OpenGameAgent/GameAgentRuntime.cs @@ -476,6 +476,31 @@ public Task RunAsync(GameInput input, CancellationToken canc public IReadOnlyList ExtensionDiagnostics => _extensions.GetDiagnostics(); + /// + /// Reads the durable usage ledger for one session actor. Server hosts must authorize the + /// caller before invoking this method. + /// + public async ValueTask ReadUsageAsync( + GameSessionKey key, + CancellationToken cancellationToken = default) + { + key.EnsureValid(nameof(key)); + if (Volatile.Read(ref _disposed) != 0) + { + throw new ObjectDisposedException(nameof(GameAgentRuntime)); + } + + var snapshot = await _sessionStore.LoadAsync(key, cancellationToken).ConfigureAwait(false); + if (snapshot is not null && !snapshot.Key.Equals(key)) + { + throw new InvalidOperationException("The game session store returned a snapshot for a different session key."); + } + + return snapshot is null + ? null + : new GameSessionUsageSnapshot(snapshot.Key, snapshot.Revision, snapshot.UsageLedger); + } + public Task RunAsync( GameInput input, GameAgentEventHandler? observer, diff --git a/src/OpenGameAgent/GameAgentWire.cs b/src/OpenGameAgent/GameAgentWire.cs index cfa65d0..011e5a4 100644 --- a/src/OpenGameAgent/GameAgentWire.cs +++ b/src/OpenGameAgent/GameAgentWire.cs @@ -166,18 +166,46 @@ public static string SerializeResult(GameAgentRunResult result) newMessages = result.AgentResult.NewMessages.Select(ProjectMessage).ToArray(), subscriberErrors = result.AgentResult.SubscriberErrors, error = result.AgentResult.Error, - usage = new - { - inputTokens = result.AgentResult.Usage.InputTokens, - outputTokens = result.AgentResult.Usage.OutputTokens, - cacheReadTokens = result.AgentResult.Usage.CacheReadTokens, - cacheWriteTokens = result.AgentResult.Usage.CacheWriteTokens, - }, + usage = ProjectUsage(result.AgentResult.Usage), }, error = result.Error, }, JsonOptions); } + public static string SerializeUsage(GameSessionUsageSnapshot snapshot) + { + if (snapshot is null) + { + throw new ArgumentNullException(nameof(snapshot)); + } + + return JsonSerializer.Serialize(new + { + sessionId = snapshot.Key.SessionId, + actorId = snapshot.Key.ActorId, + sessionRevision = snapshot.SessionRevision, + totalRecordCount = snapshot.Ledger.TotalRecordCount, + recentRecordCapacity = snapshot.Ledger.RecentRecordCapacity, + total = ProjectTotals(snapshot.Ledger.Stats.Total), + byCause = snapshot.Ledger.TotalsByCause + .OrderBy(pair => pair.Key) + .Select(pair => new + { + cause = pair.Key.ToString(), + usage = ProjectTotals(pair.Value), + }) + .ToArray(), + recentRecords = snapshot.Ledger.Records.Select(record => new + { + recordId = record.RecordId, + cause = record.Cause.ToString(), + runId = record.RunId, + inputId = record.InputId, + usage = ProjectUsage(record.Usage), + }).ToArray(), + }, JsonOptions); + } + private static object ProjectMessage(AgentMessage message) => new { role = message.Role.ToString(), @@ -190,6 +218,11 @@ public static string SerializeResult(GameAgentRunResult result) details = message.DetailsJson is null ? (JsonElement?)null : ParseElement(message.DetailsJson), metadata = message.Metadata, model = message.Model, + provider = message.Provider, + api = message.Api, + responseModel = message.ResponseModel, + responseId = message.ResponseId, + rawStopReason = message.RawStopReason, stopReason = message.StopReason?.ToString(), usage = ProjectUsage(message.Usage), error = message.ErrorMessage, @@ -201,7 +234,40 @@ public static string SerializeResult(GameAgentRunResult result) outputTokens = usage.OutputTokens, cacheReadTokens = usage.CacheReadTokens, cacheWriteTokens = usage.CacheWriteTokens, + reasoningTokens = usage.ReasoningTokens, + cacheWriteOneHourTokens = usage.CacheWriteOneHourTokens, totalTokens = usage.TotalTokens, + cost = ProjectCost(usage.Cost), + }; + + private static object ProjectTotals(GameSessionUsageTotals totals) => new + { + inputTokens = totals.InputTokens, + outputTokens = totals.OutputTokens, + cacheReadTokens = totals.CacheReadTokens, + cacheWriteTokens = totals.CacheWriteTokens, + reasoningTokens = totals.ReasoningTokens, + cacheWriteOneHourTokens = totals.CacheWriteOneHourTokens, + totalTokens = totals.TotalTokens, + cost = new + { + known = totals.CostKnown, + input = totals.CostKnown ? totals.InputCost : (double?)null, + output = totals.CostKnown ? totals.OutputCost : (double?)null, + cacheRead = totals.CostKnown ? totals.CacheReadCost : (double?)null, + cacheWrite = totals.CostKnown ? totals.CacheWriteCost : (double?)null, + total = totals.CostTotalIfKnown, + }, + }; + + private static object ProjectCost(ModelCost cost) => new + { + known = cost.IsKnown, + input = cost.IsKnown ? cost.Input : (double?)null, + output = cost.IsKnown ? cost.Output : (double?)null, + cacheRead = cost.IsKnown ? cost.CacheRead : (double?)null, + cacheWrite = cost.IsKnown ? cost.CacheWrite : (double?)null, + total = cost.TotalIfKnown, }; private static bool IsDeltaOnlyUpdate(AgentEvent agentEvent) => diff --git a/src/OpenGameAgent/Sessions.cs b/src/OpenGameAgent/Sessions.cs index 07031c1..3e52054 100644 --- a/src/OpenGameAgent/Sessions.cs +++ b/src/OpenGameAgent/Sessions.cs @@ -113,6 +113,7 @@ private static bool UsageEquals(ModelUsage left, ModelUsage right) => && left.CacheWriteTokens == right.CacheWriteTokens && left.ReasoningTokens == right.ReasoningTokens && left.CacheWriteOneHourTokens == right.CacheWriteOneHourTokens + && left.Cost.IsKnown == right.Cost.IsKnown && left.Cost.Input.Equals(right.Cost.Input) && left.Cost.Output.Equals(right.Cost.Output) && left.Cost.CacheRead.Equals(right.Cost.CacheRead) @@ -131,7 +132,8 @@ public sealed class GameSessionUsageTotals 0, 0, 0, - 0); + 0, + costKnown: true); public GameSessionUsageTotals( long inputTokens, @@ -144,6 +146,33 @@ public GameSessionUsageTotals( double outputCost, double cacheReadCost, double cacheWriteCost) + : this( + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + reasoningTokens, + cacheWriteOneHourTokens, + inputCost, + outputCost, + cacheReadCost, + cacheWriteCost, + costKnown: true) + { + } + + public GameSessionUsageTotals( + long inputTokens, + long outputTokens, + long cacheReadTokens, + long cacheWriteTokens, + long reasoningTokens, + long cacheWriteOneHourTokens, + double inputCost, + double outputCost, + double cacheReadCost, + double cacheWriteCost, + bool costKnown) { if (inputTokens < 0 || outputTokens < 0 @@ -172,6 +201,7 @@ public GameSessionUsageTotals( OutputCost = outputCost; CacheReadCost = cacheReadCost; CacheWriteCost = cacheWriteCost; + CostKnown = costKnown; } public long InputTokens { get; } @@ -194,10 +224,14 @@ public GameSessionUsageTotals( public double CacheWriteCost { get; } + public bool CostKnown { get; } + public long TotalTokens => checked(InputTokens + OutputTokens + CacheReadTokens + CacheWriteTokens); public double CostTotal => InputCost + OutputCost + CacheReadCost + CacheWriteCost; + public double? CostTotalIfKnown => CostKnown ? CostTotal : null; + internal static GameSessionUsageTotals Empty => EmptyValue; internal static GameSessionUsageTotals Add(GameSessionUsageTotals left, GameSessionUsageTotals right) => new( @@ -210,7 +244,8 @@ public GameSessionUsageTotals( AddCost(left.InputCost, right.InputCost), AddCost(left.OutputCost, right.OutputCost), AddCost(left.CacheReadCost, right.CacheReadCost), - AddCost(left.CacheWriteCost, right.CacheWriteCost)); + AddCost(left.CacheWriteCost, right.CacheWriteCost), + left.CostKnown && right.CostKnown); internal static bool AtLeast(GameSessionUsageTotals candidate, GameSessionUsageTotals previous) => candidate.InputTokens >= previous.InputTokens @@ -219,6 +254,7 @@ internal static bool AtLeast(GameSessionUsageTotals candidate, GameSessionUsageT && candidate.CacheWriteTokens >= previous.CacheWriteTokens && candidate.ReasoningTokens >= previous.ReasoningTokens && candidate.CacheWriteOneHourTokens >= previous.CacheWriteOneHourTokens + && (previous.CostKnown || !candidate.CostKnown) && candidate.InputCost >= previous.InputCost && candidate.OutputCost >= previous.OutputCost && candidate.CacheReadCost >= previous.CacheReadCost @@ -231,6 +267,7 @@ internal static bool ValueEquals(GameSessionUsageTotals left, GameSessionUsageTo && left.CacheWriteTokens == right.CacheWriteTokens && left.ReasoningTokens == right.ReasoningTokens && left.CacheWriteOneHourTokens == right.CacheWriteOneHourTokens + && left.CostKnown == right.CostKnown && left.InputCost.Equals(right.InputCost) && left.OutputCost.Equals(right.OutputCost) && left.CacheReadCost.Equals(right.CacheReadCost) @@ -248,6 +285,7 @@ internal static GameSessionUsageTotals Aggregate(IEnumerable= 0 + ? sessionRevision + : throw new ArgumentOutOfRangeException(nameof(sessionRevision)); + Ledger = ledger ?? throw new ArgumentNullException(nameof(ledger)); + } + + public GameSessionKey Key { get; } + + public long SessionRevision { get; } + + public GameSessionUsageLedger Ledger { get; } +} + public sealed class GameSessionSnapshot { public GameSessionSnapshot( diff --git a/src/OpenGameAgent/Transcripts.cs b/src/OpenGameAgent/Transcripts.cs index c9ecd68..79744d7 100644 --- a/src/OpenGameAgent/Transcripts.cs +++ b/src/OpenGameAgent/Transcripts.cs @@ -1838,6 +1838,7 @@ public static ModelUsage AggregateUsage(IEnumerable values) usages.Sum(usage => usage.Cost.Input), usages.Sum(usage => usage.Cost.Output), usages.Sum(usage => usage.Cost.CacheRead), - usages.Sum(usage => usage.Cost.CacheWrite))); + usages.Sum(usage => usage.Cost.CacheWrite), + usages.All(usage => usage.Cost.IsKnown))); } } diff --git a/tests/OpenGameAgent.Connectors.Mcp.Tests/McpConnectorTests.cs b/tests/OpenGameAgent.Connectors.Mcp.Tests/McpConnectorTests.cs index 77ae7f3..8b8c3ef 100644 --- a/tests/OpenGameAgent.Connectors.Mcp.Tests/McpConnectorTests.cs +++ b/tests/OpenGameAgent.Connectors.Mcp.Tests/McpConnectorTests.cs @@ -12,6 +12,49 @@ namespace OpenGameAgent.Connectors.Mcp.Tests; public sealed class McpConnectorTests { + [Fact] + public async Task FailureIsolationKeepsOtherServersAvailable() + { + 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 unavailable = new GameMcpServer( + "unavailable", + _ => throw new InvalidOperationException("server unavailable")); + var available = new GameMcpServer( + "available", + async cancellationToken => await McpClient.CreateAsync( + new StreamClientTransport(clientToServer.Writer.AsStream(), serverToClient.Reader.AsStream()), + cancellationToken: cancellationToken)); + var provider = new ScriptedProvider(_ => + new ModelResponse(new AgentContent[] { new TextContent("done") }, ModelStopReason.Stop)); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseExtension(new McpToolConnectorExtension( + new[] { unavailable, available }, + continueOnServerFailure: true, + 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(Assert.Single(provider.Requests).Tools, tool => tool.Name == "available__echo"); + await runtime.DisposeAsync(); + await server.DisposeAsync(); + await serverTask; + } + [Fact] public async Task DiscoversAndCallsAStandardExternalTool() { diff --git a/tests/OpenGameAgent.Kernel.Tests/AgentLoopTests.cs b/tests/OpenGameAgent.Kernel.Tests/AgentLoopTests.cs index a89ba4a..4d03b35 100644 --- a/tests/OpenGameAgent.Kernel.Tests/AgentLoopTests.cs +++ b/tests/OpenGameAgent.Kernel.Tests/AgentLoopTests.cs @@ -208,11 +208,9 @@ public async Task UnknownAndInvalidToolsAlwaysReceiveResults() } [Fact] - public async Task UnsupportedSchemaAssertionsFailClosedBeforeToolExecution() + public async Task UnsupportedSchemaAssertionsFailClosedBeforeProviderRequest() { - var provider = ScriptedProvider.FromResponses( - Responses.Tools(ModelStopReason.ToolUse, new ToolCallContent("ref", "referenced", "{}")), - Responses.Text("done")); + var provider = ScriptedProvider.FromResponses(Responses.Text("unused")); var executed = 0; var options = new AgentOptions(provider, "test"); options.Tools.Add(Responses.Tool( @@ -225,20 +223,18 @@ public async Task UnsupportedSchemaAssertionsFailClosedBeforeToolExecution() schema: "{\"$ref\":\"#/$defs/input\",\"$defs\":{\"input\":{\"type\":\"object\"}}}")); var agent = new Agent(options); - await agent.RunAsync("go", TestContext.Current.CancellationToken); + var run = await agent.RunAsync("go", TestContext.Current.CancellationToken); Assert.Equal(0, executed); - var result = Assert.Single(agent.State.Messages, message => message.Role == AgentRole.Tool); - Assert.True(result.IsError); - Assert.Contains("not supported", Assert.IsType(Assert.Single(result.Content)).Text, StringComparison.Ordinal); + Assert.Equal(0, provider.CallCount); + Assert.Equal(AgentRunStatus.KernelError, run.Status); + Assert.Contains("not supported", run.Error, StringComparison.Ordinal); } [Fact] - public async Task MalformedSupportedSchemaAssertionsFailClosedBeforeToolExecution() + public async Task MalformedSupportedSchemaAssertionsFailClosedBeforeProviderRequest() { - var provider = ScriptedProvider.FromResponses( - Responses.Tools(ModelStopReason.ToolUse, new ToolCallContent("invalid-schema", "write", "{\"value\":1.5}")), - Responses.Text("done")); + var provider = ScriptedProvider.FromResponses(Responses.Text("unused")); var executed = false; var options = new AgentOptions(provider, "test"); options.Tools.Add(Responses.Tool( @@ -251,20 +247,18 @@ public async Task MalformedSupportedSchemaAssertionsFailClosedBeforeToolExecutio schema: "{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"number\",\"minimum\":\"zero\"}}}")); var agent = new Agent(options); - await agent.RunAsync("go", TestContext.Current.CancellationToken); + var run = await agent.RunAsync("go", TestContext.Current.CancellationToken); Assert.False(executed); - var result = Assert.Single(agent.State.Messages, message => message.Role == AgentRole.Tool); - Assert.True(result.IsError); - Assert.Contains("finite number", Assert.IsType(Assert.Single(result.Content)).Text, StringComparison.Ordinal); + Assert.Equal(0, provider.CallCount); + Assert.Equal(AgentRunStatus.KernelError, run.Status); + Assert.Contains("finite number", run.Error, StringComparison.Ordinal); } [Fact] - public async Task UnsupportedAssertionsInUnselectedSchemaBranchesStillFailClosed() + public async Task UnsupportedAssertionsInUnselectedSchemaBranchesFailBeforeProviderRequest() { - var provider = ScriptedProvider.FromResponses( - Responses.Tools(ModelStopReason.ToolUse, new ToolCallContent("nested-ref", "write", "{}")), - Responses.Text("done")); + var provider = ScriptedProvider.FromResponses(Responses.Text("unused")); var executed = false; var options = new AgentOptions(provider, "test"); options.Tools.Add(Responses.Tool( @@ -277,11 +271,12 @@ public async Task UnsupportedAssertionsInUnselectedSchemaBranchesStillFailClosed schema: "{\"anyOf\":[{\"type\":\"object\"},{\"$ref\":\"#/unsafe\"}]}")); var agent = new Agent(options); - await agent.RunAsync("go", TestContext.Current.CancellationToken); + var run = await agent.RunAsync("go", TestContext.Current.CancellationToken); Assert.False(executed); - var result = Assert.Single(agent.State.Messages, message => message.Role == AgentRole.Tool); - Assert.Contains("not supported", Assert.IsType(Assert.Single(result.Content)).Text, StringComparison.Ordinal); + Assert.Equal(0, provider.CallCount); + Assert.Equal(AgentRunStatus.KernelError, run.Status); + Assert.Contains("not supported", run.Error, StringComparison.Ordinal); } [Fact] @@ -388,15 +383,12 @@ public async Task SchemaIntegerAcceptsExactNumbersOutsideDecimalAndDoubleRanges( } [Theory] - [InlineData("{}", "{\"allOf\":[]}", "at least one")] - public async Task AmbiguousJsonAndSchemasFailClosed( - string arguments, + [InlineData("{\"allOf\":[]}", "at least one")] + public async Task AmbiguousSchemasFailBeforeProviderRequest( string schema, string expectedError) { - var provider = ScriptedProvider.FromResponses( - Responses.Tools(ModelStopReason.ToolUse, new ToolCallContent("ambiguous", "write", arguments)), - Responses.Text("done")); + var provider = ScriptedProvider.FromResponses(Responses.Text("unused")); var executed = false; var options = new AgentOptions(provider, "test"); options.Tools.Add(Responses.Tool( @@ -409,11 +401,97 @@ public async Task AmbiguousJsonAndSchemasFailClosed( schema: schema)); var agent = new Agent(options); - await agent.RunAsync("go", TestContext.Current.CancellationToken); + var run = await agent.RunAsync("go", TestContext.Current.CancellationToken); Assert.False(executed); - var result = Assert.Single(agent.State.Messages, message => message.Role == AgentRole.Tool); - Assert.Contains(expectedError, Assert.IsType(Assert.Single(result.Content)).Text, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, provider.CallCount); + Assert.Equal(AgentRunStatus.KernelError, run.Status); + Assert.Contains(expectedError, run.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ClosedStandardToolSchemaPassesPreflightAndExecutionValidation() + { + const string schema = """ + { + "type":"object", + "required":["mode","values"], + "properties":{ + "mode":{"type":"string","enum":["add","remove"]}, + "values":{"type":"array","minItems":1,"maxItems":3,"items":{"type":"number","minimum":-10,"maximum":10}} + }, + "additionalProperties":false + } + """; + var provider = ScriptedProvider.FromResponses( + Responses.Tools( + ModelStopReason.ToolUse, + new ToolCallContent("valid", "apply", "{\"mode\":\"add\",\"values\":[-1,2.5]}")), + Responses.Text("done")); + var executed = false; + var options = new AgentOptions(provider, "test"); + options.Tools.Add(Responses.Tool( + "apply", + (_, _, _) => + { + executed = true; + return new ValueTask(Responses.Result("ok")); + }, + schema: schema)); + + var run = await new Agent(options).RunAsync("go", TestContext.Current.CancellationToken); + + Assert.True(run.Succeeded); + Assert.True(executed); + Assert.Equal(2, provider.CallCount); + } + + [Fact] + public async Task BeforeModelHookCannotInjectAnInvalidToolSchema() + { + 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, + new[] + { + new ToolDefinition( + "injected", + "Injected tool", + "{\"type\":\"object\",\"properties\":{\"count\":{\"minimum\":\"zero\"}}}"), + }, + request.Parameters, + request.SessionId, + request.RunId, + request.Turn)), + }, + }; + + var run = await new Agent(options).RunAsync("go", TestContext.Current.CancellationToken); + + Assert.Equal(AgentRunStatus.KernelError, run.Status); + Assert.Equal(0, provider.CallCount); + Assert.Contains("failed preflight", run.Error, StringComparison.Ordinal); + } + + [Fact] + public void ExcessivelyDeepToolSchemasAreRejectedBeforeAnyProviderRequest() + { + var provider = ScriptedProvider.FromResponses(Responses.Text("unused")); + var schema = "{\"type\":\"object\"}"; + for (var index = 0; index < 80; index++) + { + schema = "{\"allOf\":[" + schema + "]}"; + } + + Assert.Throws(() => Responses.Tool("deep", (_, _, _) => + new ValueTask(Responses.Result("unused")), schema: schema)); + Assert.Equal(0, provider.CallCount); } [Fact] @@ -971,6 +1049,38 @@ public async Task NextTurnHookCanReplaceProviderModelAndContext() Assert.IsType(Assert.Single(second.Messages[0].Content)); } + [Fact] + public async Task NextTurnToolReplacementIsPreflightedBeforeAnotherProviderRequest() + { + var provider = ScriptedProvider.FromResponses( + Responses.Tools(ModelStopReason.ToolUse, new ToolCallContent("1", "next", "{}")), + Responses.Text("unused")); + var invalid = Responses.Tool( + "invalid", + (_, _, _) => new ValueTask(Responses.Result("unused")), + schema: "{\"anyOf\":[{\"type\":\"object\"},{\"$ref\":\"#/unsupported\"}]}"); + var options = new AgentOptions(provider, "test") + { + Hooks = new AgentHooks + { + PrepareNextTurnAsync = (turn, _) => new ValueTask(new NextTurnUpdate + { + Context = new AgentContext( + turn.Context.SystemPrompt, + turn.Context.Messages, + new[] { invalid }), + }), + }, + }; + options.Tools.Add(Responses.Tool("next", (_, _, _) => new ValueTask(Responses.Result("ok")))); + + var run = await new Agent(options).RunAsync("go", TestContext.Current.CancellationToken); + + Assert.Equal(AgentRunStatus.KernelError, run.Status); + Assert.Equal(1, provider.CallCount); + Assert.Contains("failed preflight", run.Error, StringComparison.Ordinal); + } + [Fact] public async Task StopHookSeesContextPreparedForTheNextTurn() { diff --git a/tests/OpenGameAgent.Kernel.Tests/ProtocolContractsTests.cs b/tests/OpenGameAgent.Kernel.Tests/ProtocolContractsTests.cs index 3e4f813..9717da6 100644 --- a/tests/OpenGameAgent.Kernel.Tests/ProtocolContractsTests.cs +++ b/tests/OpenGameAgent.Kernel.Tests/ProtocolContractsTests.cs @@ -41,6 +41,18 @@ public void UsagePreservesReasoningLongCacheAndItemizedCost() Assert.Throws(() => new ModelUsage(cacheWriteTokens: 1, cacheWriteOneHourTokens: 2)); } + [Fact] + public void CostDistinguishesUnavailablePricingFromKnownFreePricing() + { + var unknown = new ModelCost(); + var free = new ModelCost(isKnown: true); + + Assert.False(unknown.IsKnown); + Assert.Null(unknown.TotalIfKnown); + Assert.True(free.IsKnown); + Assert.Equal(0, free.TotalIfKnown); + } + [Fact] public async Task DeferredResponseIdentitySurvivesTheAgentLoop() { diff --git a/tests/OpenGameAgent.Kernel.Tests/PublicApiCompatibilityTests.cs b/tests/OpenGameAgent.Kernel.Tests/PublicApiCompatibilityTests.cs index cf9a3a0..5230427 100644 --- a/tests/OpenGameAgent.Kernel.Tests/PublicApiCompatibilityTests.cs +++ b/tests/OpenGameAgent.Kernel.Tests/PublicApiCompatibilityTests.cs @@ -6,7 +6,7 @@ namespace OpenGameAgent.Kernel.Tests; public sealed class PublicApiCompatibilityTests { - private const string ApprovedApiHash = "F2EEC0C444DCDB501C44D0DEE0E1C8047D979E04FFB4A5567F6C36CDD95EEB68"; + private const string ApprovedApiHash = "357AD3156EC99989A213D0184F050AADEDEEC8B82392CA29A8E0D9685A44E04C"; [Fact] public void KernelPublicApiMatchesTheApprovedStableSurface() diff --git a/tests/OpenGameAgent.Models.BuiltIn.Tests/BuiltInGameModelRuntimeTests.cs b/tests/OpenGameAgent.Models.BuiltIn.Tests/BuiltInGameModelRuntimeTests.cs index 19211f5..de4cf85 100644 --- a/tests/OpenGameAgent.Models.BuiltIn.Tests/BuiltInGameModelRuntimeTests.cs +++ b/tests/OpenGameAgent.Models.BuiltIn.Tests/BuiltInGameModelRuntimeTests.cs @@ -501,6 +501,67 @@ public async Task DirectoryDeclaredNonstandardEnvironmentCredentialPrecedesApiFa Assert.All(reads, name => Assert.Equal("HF_TOKEN", name)); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task DirectoryPricingIsAppliedWithoutTurningUnknownPriceIntoFree(bool pricingKnown) + { + const string response = """ + data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":4,"prompt_tokens_details":{"cached_tokens":3,"cache_write_tokens":2},"completion_tokens_details":{"reasoning_tokens":1}}} + + data: {"id":"response-1","model":"served-model","choices":[{"delta":{},"finish_reason":"stop"}]} + + data: [DONE] + + """; + var handler = new RecordingHandler(BuiltInGameModelApis.OpenAiCompletions, responseBody: response); + using var client = new HttpClient(handler); + var cost = pricingKnown + ? new Dictionary + { + ["known"] = true, + ["input"] = 1m, + ["output"] = 2m, + ["cacheRead"] = 0.5m, + ["cacheWrite"] = 1.5m, + } + : new Dictionary { ["known"] = false }; + var options = Options(client, Directory( + "compatible", + BuiltInGameModelApis.OpenAiCompletions, + "https://compatible.invalid/v1", + cost: cost)); + options.Authentications.Add( + "compatible", + new StaticGameProviderAuthentication( + credential: new GameCredential(GameCredentialKind.ApiKey, "secret"))); + var runtime = new BuiltInGameModelRuntime(options); + + var events = await CollectAsync(runtime.StreamAsync( + "compatible", + Request(), + TestContext.Current.CancellationToken)); + + var usage = Assert.Single(events, item => item.IsTerminal).Response!.Usage; + Assert.Equal(5, usage.InputTokens); + Assert.Equal(4, usage.OutputTokens); + Assert.Equal(3, usage.CacheReadTokens); + Assert.Equal(2, usage.CacheWriteTokens); + Assert.Equal(1, usage.ReasoningTokens); + Assert.Equal(pricingKnown, usage.Cost.IsKnown); + if (pricingKnown) + { + Assert.Equal(0.000005, usage.Cost.Input, 10); + Assert.Equal(0.000008, usage.Cost.Output, 10); + Assert.Equal(0.0000015, usage.Cost.CacheRead, 10); + Assert.Equal(0.000003, usage.Cost.CacheWrite, 10); + } + else + { + Assert.Null(usage.Cost.TotalIfKnown); + } + } + [Fact] public void InvalidDirectoryEnvironmentVariableMetadataIsRejected() { @@ -1825,7 +1886,8 @@ private static GameModelDirectorySnapshot Directory( string? environmentVariables = null, string modelId = "model", Dictionary? compatibility = null, - Dictionary? modelHeaders = null) + Dictionary? modelHeaders = null, + Dictionary? cost = null) { var json = JsonSerializer.Serialize(new { @@ -1855,6 +1917,7 @@ private static GameModelDirectorySnapshot Directory( maximumOutput = 512, input = new[] { "text" }, output = new[] { "text", "tools" }, + cost = cost ?? new Dictionary(), sampling = new Dictionary { ["directory_marker"] = "applied", diff --git a/tests/OpenGameAgent.Models.Tests/GameModelDirectoryTests.cs b/tests/OpenGameAgent.Models.Tests/GameModelDirectoryTests.cs index 1df0965..b624adb 100644 --- a/tests/OpenGameAgent.Models.Tests/GameModelDirectoryTests.cs +++ b/tests/OpenGameAgent.Models.Tests/GameModelDirectoryTests.cs @@ -51,6 +51,34 @@ public void UnknownProviderReturnsAnEmptyList() Assert.Null(directory.GetProvider("not-configured")); } + [Fact] + public void DirectoryPricingDistinguishesUnavailableKnownFreeAndFreeModelIds() + { + const string json = """ + { + "version": "test", + "generatedAt": "2026-08-12T00:00:00Z", + "providers": [{ + "id": "provider", + "models": [ + { "id": "unknown", "cost": {} }, + { "id": "known-free", "cost": { "known": true } }, + { "id": "model:free", "cost": {} }, + { "id": "priced", "cost": { "input": 1.25 } } + ] + }] + } + """; + + var models = GameModelDirectory.ParseJson(json).GetModels("provider") + .ToDictionary(model => model.ModelId, StringComparer.Ordinal); + + Assert.False(models["unknown"].Cost.IsKnown); + Assert.True(models["known-free"].Cost.IsKnown); + Assert.True(models["model:free"].Cost.IsKnown); + Assert.True(models["priced"].Cost.IsKnown); + } + [Fact] public void BundledDirectoryApisMatchExecutableProviderCapabilities() { diff --git a/tests/OpenGameAgent.Models.Tests/ModelCatalogTests.cs b/tests/OpenGameAgent.Models.Tests/ModelCatalogTests.cs index c4be7ac..490236e 100644 --- a/tests/OpenGameAgent.Models.Tests/ModelCatalogTests.cs +++ b/tests/OpenGameAgent.Models.Tests/ModelCatalogTests.cs @@ -70,6 +70,39 @@ public void DescriptorClampsReasoningAndResolutionBoundsParametersAndCost() requiredInput: GameModelInputCapabilities.Video)); } + [Fact] + public void PricingDistinguishesUnknownFromKnownFreeAndEstimatesItemizedUsage() + { + var unknown = Catalog(Registration( + "provider", + new ScriptedProvider(), + Model("provider", "unknown", cost: new GameModelCost()))) + .Resolve("provider", "unknown"); + Assert.False(unknown.Model.Cost.IsKnown); + Assert.Null(unknown.EstimateCostOrNull(new ModelUsage(1, 1))); + Assert.Throws(() => unknown.EstimateCostOrNull(null!)); + Assert.Throws(() => unknown.EstimateCost(new ModelUsage(1, 1))); + + var free = new GameModelCost(isKnown: true); + Assert.True(free.IsKnown); + Assert.True(free.Estimate(new ModelUsage(1, 1)).IsKnown); + Assert.Equal(0, free.Estimate(new ModelUsage(1, 1)).Total); + + var priced = new GameModelCost(1, 2, 0.5m, 1.5m, tiers: null, isKnown: true); + var estimate = priced.Estimate(new ModelUsage( + inputTokens: 5, + outputTokens: 4, + cacheReadTokens: 3, + cacheWriteTokens: 2, + reasoningTokens: 1, + cacheWriteOneHourTokens: 1)); + Assert.True(estimate.IsKnown); + Assert.Equal(0.000005, estimate.Input, 10); + Assert.Equal(0.000008, estimate.Output, 10); + Assert.Equal(0.0000015, estimate.CacheRead, 10); + Assert.Equal(0.0000035, estimate.CacheWrite, 10); + } + [Fact] public void DescriptorPreservesAlwaysThinkingAndProviderSpecificOffValues() { @@ -163,6 +196,7 @@ public async Task RefreshStatusDetectsEveryBehaviorRelevantDescriptorField() ($"{nameof(GameModelDescriptor.Cost)}.Output", () => ComparableModel(cost: ComparableCost(output: 12))), ($"{nameof(GameModelDescriptor.Cost)}.CacheRead", () => ComparableModel(cost: ComparableCost(cacheRead: 13))), ($"{nameof(GameModelDescriptor.Cost)}.CacheWrite", () => ComparableModel(cost: ComparableCost(cacheWrite: 14))), + ($"{nameof(GameModelDescriptor.Cost)}.IsKnown", () => ComparableModel(cost: new GameModelCost(isKnown: false))), ($"{nameof(GameModelDescriptor.Cost)}.TierCount", () => ComparableModel(cost: new GameModelCost( 1, 2, diff --git a/tests/OpenGameAgent.Models.Tests/PublicApiCompatibilityTests.cs b/tests/OpenGameAgent.Models.Tests/PublicApiCompatibilityTests.cs index 15820e8..c9dc2ec 100644 --- a/tests/OpenGameAgent.Models.Tests/PublicApiCompatibilityTests.cs +++ b/tests/OpenGameAgent.Models.Tests/PublicApiCompatibilityTests.cs @@ -6,7 +6,7 @@ namespace OpenGameAgent.Models.Tests; public sealed class PublicApiCompatibilityTests { - private const string ApprovedApiHash = "2E7B2A1A0F19FF66AE6672B5B1F82E55BCC9F25149DCC90BF7A6814009D8B073"; + private const string ApprovedApiHash = "8ECA4E20BBEE409CC73F57431CE89D23F5C5C99B2D98C38A31FF7BB8ADBD4F01"; [Fact] public void ModelsPublicApiMatchesTheApprovedStableSurface() diff --git a/tests/OpenGameAgent.Persistence.Tests/PersistenceTests.cs b/tests/OpenGameAgent.Persistence.Tests/PersistenceTests.cs index 7086407..806c664 100644 --- a/tests/OpenGameAgent.Persistence.Tests/PersistenceTests.cs +++ b/tests/OpenGameAgent.Persistence.Tests/PersistenceTests.cs @@ -29,7 +29,7 @@ public async Task SessionRoundTripsEveryCanonicalContentKindAcrossRestart() AgentRole.Assistant, new AgentContent[] { - new ReasoningContent("plan", "signature"), + new ReasoningContent("plan", "signature", redacted: true), new ToolCallContent("call", "move", "{\"x\":2.5}"), }, DateTimeOffset.UnixEpoch.AddSeconds(1), @@ -59,6 +59,7 @@ public async Task SessionRoundTripsEveryCanonicalContentKindAcrossRestart() Assert.Equal(3, loaded.Messages.Count); Assert.Equal("{\"value\":1.25}", Assert.IsType(loaded.Messages[0].Content[1]).Json); Assert.Equal("signature", Assert.IsType(loaded.Messages[1].Content[0]).Signature); + Assert.True(Assert.IsType(loaded.Messages[1].Content[0]).Redacted); Assert.Equal(10, loaded.Messages[1].Usage!.InputTokens); Assert.Equal("{\"revision\":7}", loaded.Messages[2].DetailsJson); Assert.Equal(3, loaded.Messages[2].Usage!.TotalTokens); @@ -113,12 +114,44 @@ await store.SaveAsync( Assert.Equal("{\"removed\":8}", loaded.UsageLedger.Records[1].DetailsJson); Assert.Equal(2, loaded.UsageLedger.Records[0].Usage.ReasoningTokens); Assert.Equal(1, loaded.UsageLedger.Records[0].Usage.CacheWriteOneHourTokens); + Assert.True(loaded.UsageLedger.Records[0].Usage.Cost.IsKnown); + Assert.True(loaded.UsageLedger.Stats.Total.CostKnown); var file = Assert.Single(Directory.GetFiles(directory.Path, "*.session.json")); - Assert.Equal(3, JsonNode.Parse(await File.ReadAllTextAsync( + Assert.Equal(4, JsonNode.Parse(await File.ReadAllTextAsync( file, TestContext.Current.CancellationToken))!["FormatVersion"]!.GetValue()); } + [Fact] + public async Task UnknownUsageCostRemainsUnknownAcrossRestart() + { + using var directory = new TemporaryDirectory(); + var key = new GameSessionKey("unknown-cost", "actor"); + var store = new FileGameSessionStore(directory.Path); + var saved = await store.SaveAsync( + new GameSessionSnapshot( + key, + 1, + usageLedger: new GameSessionUsageLedger(new[] + { + new GameSessionUsageRecord( + "unknown-cost-record", + GameSessionUsageCause.Assistant, + new ModelUsage(3, 1)), + })), + 0, + TestContext.Current.CancellationToken); + + var loaded = await new FileGameSessionStore(directory.Path) + .LoadAsync(key, TestContext.Current.CancellationToken); + + Assert.True(saved.Saved); + Assert.NotNull(loaded); + Assert.False(Assert.Single(loaded.UsageLedger.Records).Usage.Cost.IsKnown); + Assert.False(loaded.UsageLedger.Stats.Total.CostKnown); + Assert.Null(loaded.UsageLedger.Stats.Total.CostTotalIfKnown); + } + [Fact] public async Task BoundedUsageLedgerTotalsSurviveEvictionAndRestart() { @@ -363,6 +396,67 @@ await restarted.SaveReceiptAsync( Assert.Empty(await finalRestart.ListPendingAsync(10, TestContext.Current.CancellationToken)); } + [Fact] + public async Task ActionGenerationBindingSurvivesRestart() + { + using var directory = new TemporaryDirectory(); + var baseline = Intent("generation-operation"); + var intent = new GameActionIntent( + baseline.OperationId, + baseline.InputId, + baseline.SessionId, + baseline.ActorId, + baseline.Action, + baseline.ArgumentsJson, + baseline.Moment, + baseline.ExpectedRevision, + "save-generation-4"); + await new FileGameActionJournal(directory.Path).ReserveAsync( + intent, + TestContext.Current.CancellationToken); + + var restored = await new FileGameActionJournal(directory.Path).FindAsync( + intent.OperationId, + TestContext.Current.CancellationToken); + + Assert.Equal("save-generation-4", restored!.Intent.GenerationId); + var conflicting = new GameActionIntent( + intent.OperationId, + intent.InputId, + intent.SessionId, + intent.ActorId, + intent.Action, + intent.ArgumentsJson, + intent.Moment, + intent.ExpectedRevision, + "different-generation"); + await Assert.ThrowsAsync(async () => + await new FileGameActionJournal(directory.Path).ReserveAsync( + conflicting, + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task VersionOneActionJournalRemainsReadableWithoutGenerationBinding() + { + using var directory = new TemporaryDirectory(); + var intent = Intent("legacy-action"); + await new FileGameActionJournal(directory.Path).ReserveAsync( + intent, + TestContext.Current.CancellationToken); + var path = Assert.Single(Directory.GetFiles(directory.Path, "*.action.json")); + var document = JsonNode.Parse(await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken))!.AsObject(); + document["FormatVersion"] = 1; + document["Intent"]!.AsObject().Remove("GenerationId"); + await File.WriteAllTextAsync(path, document.ToJsonString(), TestContext.Current.CancellationToken); + + var restored = await new FileGameActionJournal(directory.Path).FindAsync( + intent.OperationId, + TestContext.Current.CancellationToken); + + Assert.Null(restored!.Intent.GenerationId); + } + [Fact] public async Task IndependentFileDispatchersNeverExecuteTheSameOperationTwice() { diff --git a/tests/OpenGameAgent.Plugins.Tests/AgentPluginLoaderTests.cs b/tests/OpenGameAgent.Plugins.Tests/AgentPluginLoaderTests.cs new file mode 100644 index 0000000..7bc199c --- /dev/null +++ b/tests/OpenGameAgent.Plugins.Tests/AgentPluginLoaderTests.cs @@ -0,0 +1,416 @@ +using System.Runtime.CompilerServices; +using OpenGameAgent.Kernel; +using OpenGameAgent.Plugins; +using Xunit; + +namespace OpenGameAgent.Plugins.Tests; + +public sealed class AgentPluginLoaderTests +{ + [Fact] + public async Task LoadsPortableSkillsStdioMcpAndClientExtensions() + { + using var fixture = new PluginFixture(); + fixture.Write( + "plugin.json", + """ + { + "$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name":"game-tools", + "version":"1.2.3", + "description":"Portable game tools", + "keywords":["game","tools"], + "extensions":{"org.opengameagent":{"profile":"safe"}}, + "futureField":true + } + """); + fixture.Write( + "skills/greet/SKILL.md", + """ + --- + name: greet + description: Greet a game actor. + --- + + Greet the current actor using the supplied game context. + """); + fixture.Write("org.opengameagent/settings.json", "{}"); + fixture.Write( + "mcp.json", + """ + { + "$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers":{ + "local-tools":{ + "type":"stdio", + "command":"node", + "args":["${PLUGIN_ROOT}/server.js","${PLUGIN_DATA}/state","${UNKNOWN}"], + "env":{"CONFIG":"${PLUGIN_ROOT}/config.json"}, + "cwd":"${PLUGIN_DATA}/work" + } + } + } + """); + + var data = Path.Combine(fixture.Parent, "data-${PLUGIN_ROOT}"); + var package = AgentPluginLoader.Load( + fixture.Root, + new AgentPluginLoadOptions { PluginDataDirectory = data }); + + Assert.Equal("game-tools", package.Manifest.Name); + Assert.Equal("1.2.3", package.Manifest.Version); + Assert.Equal(new[] { "game", "tools" }, package.Manifest.Keywords); + Assert.Equal("{\"profile\":\"safe\"}", package.Manifest.Extensions["org.opengameagent"]); + Assert.Equal(Path.Combine(fixture.Root, "org.opengameagent"), package.GetClientExtensionDirectory("org.opengameagent")); + Assert.Single(package.Skills); + Assert.Equal("greet", package.Skills[0].SkillId); + Assert.Single(package.McpServers); + Assert.Contains(package.Diagnostics, value => value.Code == "manifest.unknown-field"); + + var mcp = Assert.Single(package.McpConfigurations); + Assert.Equal("node", mcp.Command); + Assert.Equal(fixture.Root + "/server.js", mcp.Arguments[0]); + Assert.Equal(data + "/state", mcp.Arguments[1]); + Assert.Equal("${UNKNOWN}", mcp.Arguments[2]); + Assert.Equal(Path.Combine(data, "work"), mcp.WorkingDirectory); + Assert.Equal(fixture.Root, mcp.Environment["PLUGIN_ROOT"]); + Assert.Equal(data, mcp.Environment["PLUGIN_DATA"]); + Assert.Equal(fixture.Root + "/config.json", mcp.Environment["CONFIG"]); + Assert.Contains("${PLUGIN_ROOT}", mcp.Arguments[1], StringComparison.Ordinal); + + await using var runtime = new GameAgentBuilder(new EmptyProvider(), "test") + .UseExtension(package) + .Build(); + Assert.Contains(runtime.ExtensionResources, value => value.Kind == GameAgentExtensionResourceKind.SkillProvider); + Assert.Contains(runtime.ExtensionResources, value => value.Kind == GameAgentExtensionResourceKind.ToolProvider); + } + + [Fact] + public async Task InvalidMcpComponentDoesNotDisableValidSkills() + { + using var fixture = PluginFixture.Minimal("partial-plugin"); + fixture.Write( + "skills/valid/SKILL.md", + """ + --- + name: valid + description: A valid skill. + --- + Do valid work. + """); + fixture.Write( + "mcp.json", + """ + { + "$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers":{}, + "unknown":true + } + """); + + await using var package = AgentPluginLoader.Load(fixture.Root); + + Assert.Single(package.Skills); + Assert.Empty(package.McpServers); + Assert.Contains(package.Diagnostics, value => value.Code == "mcp.invalid-component"); + } + + [Fact] + public async Task DiscoversOnlyImmediateSkillChildrenAndSkipsInvalidSkills() + { + using var fixture = PluginFixture.Minimal("skill-layout"); + fixture.Write( + "skills/valid/SKILL.md", + """ + --- + name: valid + description: A valid skill. + --- + Valid instructions. + """); + fixture.Write( + "skills/bad/SKILL.md", + """ + --- + name: bad + --- + Missing description. + """); + fixture.Write( + "skills/container/nested/SKILL.md", + """ + --- + name: nested + description: Must not be recursively discovered. + --- + Nested instructions. + """); + fixture.Write( + "skills/duplicate/SKILL.md", + """ + --- + name: valid + description: Duplicate portable name. + --- + Duplicate instructions. + """); + + await using var package = AgentPluginLoader.Load(fixture.Root); + + var skill = Assert.Single(package.Skills); + Assert.Equal("valid", skill.SkillId); + Assert.DoesNotContain(package.Skills, value => value.SkillId == "nested"); + Assert.Contains(package.Diagnostics, value => value.Code == "skills.duplicate-name"); + Assert.Contains(package.Diagnostics, value => value.Component == "skills"); + } + + [Theory] + [InlineData("{\"name\":\"missing-schema\"}")] + [InlineData("{\"$schema\":\"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json\",\"name\":\"Uppercase\"}")] + [InlineData("{\"$schema\":\"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json\",\"name\":\"bad-author\",\"author\":{\"company\":\"x\"}}")] + [InlineData("{\"$schema\":\"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json\",\"name\":\"bad-extension\",\"extensions\":{\"com.example\":true}}")] + [InlineData("{\"$schema\":\"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json\",\"name\":\"duplicate\",\"name\":\"duplicate\"}")] + public void RejectsInvalidCoreManifests(string manifest) + { + using var fixture = new PluginFixture(); + fixture.Write("plugin.json", manifest); + + Assert.Throws(() => AgentPluginLoader.Load(fixture.Root)); + } + + [Fact] + public async Task IgnoresANonObjectExtensionsFieldAsRequiredByTheFailureBoundary() + { + using var fixture = new PluginFixture(); + fixture.Write( + "plugin.json", + """ + { + "$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name":"ignored-extensions", + "extensions":"not-an-object" + } + """); + + await using var package = AgentPluginLoader.Load(fixture.Root); + + Assert.Empty(package.Manifest.Extensions); + Assert.Contains(package.Diagnostics, value => value.Code == "manifest.invalid-extensions"); + } + + [Fact] + public async Task DoesNotValidateContentsOfUnknownManifestAndClientExtensionObjects() + { + using var fixture = new PluginFixture(); + fixture.Write( + "plugin.json", + """ + { + "$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name":"opaque-extensions", + "future":{"same":1,"same":2}, + "extensions":{"ANY opaque namespace":{"same":1,"same":2}} + } + """); + + await using var package = AgentPluginLoader.Load(fixture.Root); + + Assert.Contains("\"same\":1,\"same\":2", package.Manifest.Extensions["ANY opaque namespace"]); + Assert.Contains(package.Diagnostics, value => value.Code == "manifest.unknown-field"); + } + + [Fact] + public async Task LoadsStreamableHttpWithClientHeaderPrecedenceAndSkipsLegacySse() + { + using var fixture = PluginFixture.Minimal("remote-tools"); + fixture.Write( + "mcp.json", + """ + { + "$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers":{ + "remote":{ + "type":"streamable-http", + "url":"https://example.test/mcp", + "headers":{"X-Tenant":"package","X-Public":"visible"} + }, + "legacy":{"type":"sse","url":"https://example.test/sse"} + } + } + """); + var options = new AgentPluginLoadOptions + { + McpServerHeaders = new Dictionary> + { + ["remote"] = new Dictionary + { + ["x-tenant"] = "client", + ["Authorization"] = "Bearer client-owned", + }, + }, + }; + + await using var package = AgentPluginLoader.Load(fixture.Root, options); + + var server = Assert.Single(package.McpServers); + Assert.Equal("remote", server.Id); + Assert.Equal(AgentPluginMcpTransport.StreamableHttp, server.Transport); + var configuration = Assert.Single(package.McpConfigurations); + Assert.Equal("client", configuration.Headers["X-Tenant"]); + Assert.Equal("visible", configuration.Headers["X-Public"]); + Assert.Equal("Bearer client-owned", configuration.Headers["Authorization"]); + Assert.Contains(package.Diagnostics, value => value.Code == "mcp.unsupported-transport" && value.Component == "legacy"); + } + + [Fact] + public async Task EnforcesRemoteHttpAndPackagePathSafetyPerServer() + { + using var fixture = PluginFixture.Minimal("safe-tools"); + fixture.Write( + "mcp.json", + """ + { + "$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers":{ + "public-http":{"type":"streamable-http","url":"http://example.test/mcp"}, + "loopback":{"type":"streamable-http","url":"http://127.0.0.1:9123/mcp"}, + "escape":{"type":"stdio","command":"./../outside.exe"} + } + } + """); + + await using var package = AgentPluginLoader.Load( + fixture.Root, + new AgentPluginLoadOptions { PluginDataDirectory = fixture.Data }); + + var server = Assert.Single(package.McpServers); + Assert.Equal("loopback", server.Id); + Assert.Equal(2, package.Diagnostics.Count(value => value.Code == "mcp.invalid-server")); + } + + [Fact] + public async Task MissingPluginDataSkipsOnlyStdioServers() + { + using var fixture = PluginFixture.Minimal("mixed-tools"); + fixture.Write( + "mcp.json", + """ + { + "$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers":{ + "local":{"type":"stdio","command":"node"}, + "remote":{"type":"streamable-http","url":"https://example.test/mcp"} + } + } + """); + + await using var package = AgentPluginLoader.Load(fixture.Root); + + var server = Assert.Single(package.McpServers); + Assert.Equal("remote", server.Id); + Assert.Contains(package.Diagnostics, value => value.Component == "local" && value.Code == "mcp.invalid-server"); + } + + [Fact] + public async Task DuplicateCaseInsensitiveHeadersInvalidateOnlyTheirServer() + { + using var fixture = PluginFixture.Minimal("header-tools"); + fixture.Write( + "mcp.json", + """ + { + "$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers":{ + "bad":{"type":"streamable-http","url":"https://example.test/mcp","headers":{"X-Key":"a","x-key":"b"}}, + "good":{"type":"streamable-http","url":"https://example.test/good"} + } + } + """); + + await using var package = AgentPluginLoader.Load(fixture.Root); + + Assert.Equal("good", Assert.Single(package.McpServers).Id); + Assert.Contains(package.Diagnostics, value => value.Component == "bad" && value.Code == "mcp.invalid-server"); + } + + [Theory] + [InlineData("skills", "skills.invalid-location")] + [InlineData("mcp.json", "mcp.invalid-location")] + public async Task WrongFixedComponentKindsAreDiagnosedWithoutRejectingThePlugin( + string componentPath, + string expectedDiagnostic) + { + using var fixture = PluginFixture.Minimal("wrong-component-kind"); + if (string.Equals(componentPath, "skills", StringComparison.Ordinal)) + { + fixture.Write(componentPath, "not a directory"); + } + else + { + Directory.CreateDirectory(Path.Combine(fixture.Root, componentPath)); + } + + await using var package = AgentPluginLoader.Load(fixture.Root); + + Assert.Empty(package.Skills); + Assert.Empty(package.McpServers); + Assert.Contains(package.Diagnostics, value => value.Code == expectedDiagnostic); + } + + private sealed class EmptyProvider : IModelProvider + { + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + await Task.CompletedTask; + yield break; + } + } + + private sealed class PluginFixture : IDisposable + { + public PluginFixture() + { + Parent = Path.Combine(Path.GetTempPath(), "oga-plugin-tests-" + Guid.NewGuid().ToString("N")); + Root = Path.Combine(Parent, "plugin"); + Data = Path.Combine(Parent, "data"); + Directory.CreateDirectory(Root); + } + + public string Parent { get; } + + public string Root { get; } + + public string Data { get; } + + public static PluginFixture Minimal(string name) + { + var fixture = new PluginFixture(); + fixture.Write( + "plugin.json", + $$""" + { + "$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name":"{{name}}" + } + """); + return fixture; + } + + public void Write(string relativePath, string value) + { + var path = Path.Combine(Root, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, value); + } + + public void Dispose() + { + if (Directory.Exists(Parent)) + { + Directory.Delete(Parent, recursive: true); + } + } + } +} diff --git a/tests/OpenGameAgent.Plugins.Tests/OpenGameAgent.Plugins.Tests.csproj b/tests/OpenGameAgent.Plugins.Tests/OpenGameAgent.Plugins.Tests.csproj new file mode 100644 index 0000000..13f625e --- /dev/null +++ b/tests/OpenGameAgent.Plugins.Tests/OpenGameAgent.Plugins.Tests.csproj @@ -0,0 +1,18 @@ + + + net8.0 + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/tests/OpenGameAgent.Plugins.Tests/packages.lock.json b/tests/OpenGameAgent.Plugins.Tests/packages.lock.json new file mode 100644 index 0000000..5efc829 --- /dev/null +++ b/tests/OpenGameAgent.Plugins.Tests/packages.lock.json @@ -0,0 +1,308 @@ +{ + "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.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.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" + } + }, + "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" + } + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "AurL6Y5BA1WotzlEvVaIDpqzpIPvYnnldxru8oXJU2yFxFUy3+pNXjXd1ymO+RA0rq0+590Q8gaz2l3Sr7fmqg==" + }, + "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": "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.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.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.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.Models": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.kernel": { + "type": "Project", + "dependencies": { + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.models": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Kernel": "[0.3.0-alpha.1, )" + } + }, + "opengameagent.persistence": { + "type": "Project", + "dependencies": { + "OpenGameAgent": "[0.3.0-alpha.1, )", + "OpenGameAgent.Extensions": "[0.3.0-alpha.1, )", + "System.Text.Json": "[8.0.6, )" + } + }, + "opengameagent.plugins": { + "type": "Project", + "dependencies": { + "OpenGameAgent.Connectors.Mcp": "[0.3.0-alpha.1, )", + "OpenGameAgent.Persistence": "[0.3.0-alpha.1, )", + "System.Text.Json": "[10.0.10, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/OpenGameAgent.Providers.MessageGateway.Tests/MessageGatewayProviderTests.cs b/tests/OpenGameAgent.Providers.MessageGateway.Tests/MessageGatewayProviderTests.cs index 2b3621c..b28be46 100644 --- a/tests/OpenGameAgent.Providers.MessageGateway.Tests/MessageGatewayProviderTests.cs +++ b/tests/OpenGameAgent.Providers.MessageGateway.Tests/MessageGatewayProviderTests.cs @@ -81,6 +81,7 @@ public async Task ProjectsContextOptionsAndDecodesTheCompleteEventProtocol() Assert.Equal("message-gateway", terminal.Api); Assert.Equal("world-model", terminal.ResponseModel); Assert.Equal(10, terminal.Usage.TotalTokens); + Assert.True(terminal.Usage.Cost.IsKnown); Assert.Equal("plan", Assert.IsType(terminal.Content[0]).Text); Assert.Equal("reason-signature", Assert.IsType(terminal.Content[0]).Signature); Assert.Equal("text-signature", Assert.IsType(terminal.Content[1]).Signature); diff --git a/tests/OpenGameAgent.Providers.Remote.Tests/RemoteModelProviderTests.cs b/tests/OpenGameAgent.Providers.Remote.Tests/RemoteModelProviderTests.cs index c3ae644..b76cada 100644 --- a/tests/OpenGameAgent.Providers.Remote.Tests/RemoteModelProviderTests.cs +++ b/tests/OpenGameAgent.Providers.Remote.Tests/RemoteModelProviderTests.cs @@ -86,6 +86,7 @@ public async Task RoundTripsNormalizedRequestStreamTerminalHeadersAndCredentials Assert.Equal(3, response.Usage.CacheWriteTokens); Assert.Equal(4, response.Usage.ReasoningTokens); Assert.Equal(1, response.Usage.CacheWriteOneHourTokens); + Assert.True(response.Usage.Cost.IsKnown); Assert.Equal(0.11, response.Usage.Cost.Input); Assert.Equal(0.07, response.Usage.Cost.Output); Assert.Equal(0.02, response.Usage.Cost.CacheRead); @@ -102,6 +103,28 @@ public async Task RoundTripsNormalizedRequestStreamTerminalHeadersAndCredentials AssertRequestEqual(request, captured); } + [Fact] + public async Task UnknownCostRemainsUnknownAcrossTheRemoteWire() + { + var upstream = new ScriptedProvider(new[] + { + ModelStreamEvent.Update(ModelStreamEventKind.Started, Pending()), + ModelStreamEvent.Terminal(new ModelResponse( + new AgentContent[] { new TextContent("ok") }, + ModelStopReason.Stop, + new ModelUsage(2, 1))), + }); + var remote = CreateRemote(new LoopbackHandler(new ModelProviderProxyServer(upstream))); + + var events = await CollectAsync(remote.StreamAsync( + SimpleRequest(), + TestContext.Current.CancellationToken)); + + var usage = Assert.Single(events, item => item.IsTerminal).Response!.Usage; + Assert.False(usage.Cost.IsKnown); + Assert.Null(usage.Cost.TotalIfKnown); + } + [Fact] public async Task PreservesDeferredTerminalHandle() { diff --git a/tests/OpenGameAgent.Server.Tests/GameActionExchangeTests.cs b/tests/OpenGameAgent.Server.Tests/GameActionExchangeTests.cs new file mode 100644 index 0000000..ead7b9b --- /dev/null +++ b/tests/OpenGameAgent.Server.Tests/GameActionExchangeTests.cs @@ -0,0 +1,432 @@ +using System.Runtime.CompilerServices; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using OpenGameAgent.Kernel; +using Xunit; + +namespace OpenGameAgent.Server.Tests; + +public sealed class GameActionExchangeTests +{ + [Fact] + public async Task ExternalDeliveryIsDurableDeduplicatedAndCompletesDispatcher() + { + var journal = new InMemoryGameActionJournal(); + var dispatchExchange = new GameActionExchange(journal); + var deliveryExchange = new GameActionExchange(journal); + var dispatcher = new DurableGameActionDispatcher(journal, dispatchExchange); + var intent = Intent("operation-1"); + + var pendingExecution = dispatcher.ExecuteAsync(intent, TestContext.Current.CancellationToken).AsTask(); + var first = await WaitForDeliveryAsync(deliveryExchange, intent, TestContext.Current.CancellationToken); + var second = Assert.Single(await deliveryExchange.ClaimPendingAsync( + new GameSessionKey(intent.SessionId, intent.ActorId), + 10, + TestContext.Current.CancellationToken)); + + Assert.Equal(intent.OperationId, first.Intent.OperationId); + Assert.Equal(first.Intent.OperationId, second.Intent.OperationId); + Assert.True(first.RequiresReconciliation); + Assert.True((await journal.FindAsync(intent.OperationId, TestContext.Current.CancellationToken))!.Dispatched); + + var submitted = await deliveryExchange.SubmitReceiptAsync( + new GameSessionKey(intent.SessionId, intent.ActorId), + intent.ExpectedRevision, + intent.GenerationId, + GameActionReceipt.Committed(intent, "{\"placed\":true}", 8), + TestContext.Current.CancellationToken); + var completed = await pendingExecution.WaitAsync(TestContext.Current.CancellationToken); + + Assert.Equal(GameActionStatus.Committed, submitted.Status); + Assert.Equal(submitted.ResultJson, completed.ResultJson); + Assert.Empty(await deliveryExchange.ClaimPendingAsync( + new GameSessionKey(intent.SessionId, intent.ActorId), + 10, + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task RestartedExchangeOnlyReconcilesAPreviouslyDeliveredOperation() + { + using var directory = new TemporaryDirectory(); + var intent = Intent("operation-restart"); + var firstJournal = new Persistence.FileGameActionJournal(directory.Path); + await firstJournal.ReserveAsync(intent, TestContext.Current.CancellationToken); + Assert.True(await firstJournal.MarkDispatchedAsync(intent.OperationId, TestContext.Current.CancellationToken)); + + var restartedJournal = new Persistence.FileGameActionJournal(directory.Path); + var restartedExchange = new GameActionExchange(restartedJournal); + var delivery = Assert.Single(await restartedExchange.ClaimPendingAsync( + new GameSessionKey(intent.SessionId, intent.ActorId), + 10, + TestContext.Current.CancellationToken)); + var beforeReceipt = await restartedExchange.ReconcileAsync( + new GameSessionKey(intent.SessionId, intent.ActorId), + delivery.Intent.OperationId, + TestContext.Current.CancellationToken); + + Assert.Equal(GameActionExchangeStatus.Dispatched, beforeReceipt!.Status); + Assert.True(beforeReceipt.RequiresReconciliation); + + await restartedExchange.SubmitReceiptAsync( + new GameSessionKey(intent.SessionId, intent.ActorId), + intent.ExpectedRevision, + intent.GenerationId, + GameActionReceipt.Committed(intent, "{\"reconciled\":true}", 9), + TestContext.Current.CancellationToken); + + var finalExchange = new GameActionExchange(new Persistence.FileGameActionJournal(directory.Path)); + var finalDispatcher = new DurableGameActionDispatcher( + new Persistence.FileGameActionJournal(directory.Path), + finalExchange); + var recovered = await finalDispatcher.ExecuteAsync(intent, TestContext.Current.CancellationToken); + + Assert.Equal(GameActionStatus.Committed, recovered.Status); + Assert.Empty(await finalExchange.ClaimPendingAsync( + new GameSessionKey(intent.SessionId, intent.ActorId), + 10, + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ReceiptMustMatchEveryAuthoritativeIntentBinding() + { + var journal = new InMemoryGameActionJournal(); + var exchange = new GameActionExchange(journal); + var intent = Intent("operation-bindings"); + await journal.ReserveAsync(intent, TestContext.Current.CancellationToken); + Assert.True(await journal.MarkDispatchedAsync(intent.OperationId, TestContext.Current.CancellationToken)); + var receipt = GameActionReceipt.Committed(intent, "{}", 8); + + await Assert.ThrowsAsync(async () => await exchange.SubmitReceiptAsync( + new GameSessionKey("other", intent.ActorId), intent.ExpectedRevision, intent.GenerationId, receipt, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await exchange.SubmitReceiptAsync( + new GameSessionKey(intent.SessionId, "other"), intent.ExpectedRevision, intent.GenerationId, receipt, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await exchange.SubmitReceiptAsync( + new GameSessionKey(intent.SessionId, intent.ActorId), intent.ExpectedRevision, "other-generation", receipt, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await exchange.SubmitReceiptAsync( + new GameSessionKey(intent.SessionId, intent.ActorId), intent.ExpectedRevision + 1, intent.GenerationId, receipt, TestContext.Current.CancellationToken)); + var wrongMoment = new GameActionReceipt( + intent.OperationId, + GameActionStatus.Committed, + "{}", + new GameMoment("other-timeline", intent.Moment.Tick), + 8); + await Assert.ThrowsAsync(async () => await exchange.SubmitReceiptAsync( + new GameSessionKey(intent.SessionId, intent.ActorId), intent.ExpectedRevision, intent.GenerationId, wrongMoment, TestContext.Current.CancellationToken)); + var stale = GameActionReceipt.Committed(intent, "{}", 6); + await Assert.ThrowsAsync(async () => await exchange.SubmitReceiptAsync( + new GameSessionKey(intent.SessionId, intent.ActorId), intent.ExpectedRevision, intent.GenerationId, stale, TestContext.Current.CancellationToken)); + + Assert.Null((await journal.FindAsync(intent.OperationId, TestContext.Current.CancellationToken))!.Receipt); + } + + [Fact] + public async Task BodyCredentialMapsPrincipalButCannotOverrideSessionActorOwnership() + { + var innerJournal = new InMemoryGameActionJournal(); + var journal = new CountingJournal(innerJournal); + var intent = Intent("operation-http"); + await innerJournal.ReserveAsync(intent, TestContext.Current.CancellationToken); + Assert.True(await innerJournal.MarkDispatchedAsync(intent.OperationId, TestContext.Current.CancellationToken)); + await using var app = await CreateAppAsync( + journal, + new PairingAuthenticator(), + new OwnerAuthorizer()); + using var client = app.GetTestClient(); + + using var allowed = await client.PostAsync( + "/v1/actions/claim", + JsonContent("pair-owner-a", intent.SessionId, intent.ActorId), + TestContext.Current.CancellationToken); + var allowedJson = await allowed.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + allowed.EnsureSuccessStatusCode(); + Assert.Contains(intent.OperationId, allowedJson, StringComparison.Ordinal); + Assert.DoesNotContain("pair-owner-a", allowedJson, StringComparison.Ordinal); + + var scansBeforeDenial = journal.ListPendingCalls; + using var denied = await client.PostAsync( + "/v1/actions/claim", + JsonContent("pair-owner-a", "session-b", intent.ActorId), + TestContext.Current.CancellationToken); + + Assert.Equal(System.Net.HttpStatusCode.Forbidden, denied.StatusCode); + Assert.Equal(scansBeforeDenial, journal.ListPendingCalls); + + using var receiptResponse = await client.PostAsync( + "/v1/actions/receipt", + new StringContent( + """ + { + "credential":"pair-owner-a", + "sessionId":"session-a", + "actorId":"actor-a", + "operationId":"operation-http", + "status":"committed", + "result":{"placed":true}, + "timelineId":"world", + "tick":42, + "calendar":{"month":3}, + "generationId":"save-generation-3", + "expectedRevision":7, + "stateRevision":8 + } + """, + Encoding.UTF8, + "application/json"), + TestContext.Current.CancellationToken); + receiptResponse.EnsureSuccessStatusCode(); + Assert.Equal( + GameActionStatus.Committed, + (await innerJournal.FindAsync(intent.OperationId, TestContext.Current.CancellationToken))!.Receipt!.Status); + + using var reconcile = await client.PostAsync( + "/v1/actions/reconcile", + new StringContent( + """ + {"credential":"pair-owner-a","sessionId":"session-a","actorId":"actor-a","operationId":"operation-http"} + """, + Encoding.UTF8, + "application/json"), + TestContext.Current.CancellationToken); + var reconcileJson = await reconcile.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + reconcile.EnsureSuccessStatusCode(); + Assert.Contains("completed", reconcileJson, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task BodyCredentialNeverEntersModelContextTranscriptOrResponse() + { + var provider = new CapturingProvider(); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "test")); + var journal = new InMemoryGameActionJournal(); + await using var app = await CreateAppAsync( + journal, + new PairingAuthenticator(), + new OwnerAuthorizer(), + runtime); + using var client = app.GetTestClient(); + var json = """ + { + "credential":"pair-owner-a", + "inputId":"credential-input", + "sessionId":"session-a", + "actorId":"actor-a", + "type":"chat", + "payload":{"text":"hello"}, + "timelineId":"world", + "tick":1 + } + """; + + using var response = await client.PostAsync( + "/v1/run", + new StringContent(json, Encoding.UTF8, "application/json"), + TestContext.Current.CancellationToken); + var responseJson = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + response.EnsureSuccessStatusCode(); + Assert.DoesNotContain("pair-owner-a", responseJson, StringComparison.Ordinal); + Assert.DoesNotContain( + provider.Requests.SelectMany(static request => request.Messages) + .SelectMany(static message => message.Content) + .OfType() + .Select(static content => content.Text), + static value => value.Contains("pair-owner-a", StringComparison.Ordinal)); + } + + [Fact] + public async Task ActionStreamUsesPostBodyAuthenticationAndEmitsDurableIntent() + { + var journal = new InMemoryGameActionJournal(); + var intent = Intent("operation-stream"); + await journal.ReserveAsync(intent, TestContext.Current.CancellationToken); + Assert.True(await journal.MarkDispatchedAsync(intent.OperationId, TestContext.Current.CancellationToken)); + await using var app = await CreateAppAsync(journal, new PairingAuthenticator(), new OwnerAuthorizer()); + using var client = app.GetTestClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/actions/stream") + { + Content = JsonContent("pair-owner-a", intent.SessionId, intent.ActorId), + }; + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(5)); + + using var response = await client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + timeout.Token); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(timeout.Token); + using var reader = new StreamReader(stream); + var eventLine = await reader.ReadLineAsync(timeout.Token); + var dataLine = await reader.ReadLineAsync(timeout.Token); + timeout.Cancel(); + + Assert.Equal("event: action", eventLine); + Assert.StartsWith("data: ", dataLine, StringComparison.Ordinal); + Assert.Contains(intent.OperationId, dataLine, StringComparison.Ordinal); + Assert.DoesNotContain("pair-owner-a", dataLine, StringComparison.Ordinal); + } + + private static GameActionIntent Intent(string operationId) => new( + operationId, + "input-1", + "session-a", + "actor-a", + "place_block", + "{\"x\":1}", + new GameMoment("world", 42, "{\"month\":3}"), + expectedRevision: 7, + generationId: "save-generation-3"); + + private static async Task WaitForDeliveryAsync( + GameActionExchange exchange, + GameActionIntent intent, + CancellationToken cancellationToken) + { + for (var attempt = 0; attempt < 100; attempt++) + { + var deliveries = await exchange.ClaimPendingAsync( + new GameSessionKey(intent.SessionId, intent.ActorId), + 10, + cancellationToken); + if (deliveries.Count > 0) + { + return Assert.Single(deliveries); + } + + await Task.Delay(10, cancellationToken); + } + + throw new TimeoutException("The dispatcher did not publish the action delivery."); + } + + private static StringContent JsonContent(string credential, string sessionId, string actorId) => new( + $$"""{"credential":"{{credential}}","sessionId":"{{sessionId}}","actorId":"{{actorId}}","limit":10}""", + Encoding.UTF8, + "application/json"); + + private static async Task CreateAppAsync( + IGameActionJournal journal, + IGameAgentPresentedCredentialAuthenticator authenticator, + IGameAgentOwnerAuthorizer authorizer, + GameAgentRuntime? runtime = null) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddSingleton(runtime ?? new GameAgentRuntime( + new GameAgentRuntimeOptions(new CapturingProvider(), "test"))); + builder.Services.AddSingleton(journal); + builder.Services.AddSingleton(new GameActionExchange(journal)); + builder.Services.AddSingleton(authenticator); + builder.Services.AddSingleton(authorizer); + var app = builder.Build(); + app.MapOpenGameAgent(); + await app.StartAsync(TestContext.Current.CancellationToken); + return app; + } + + private sealed class PairingAuthenticator : IGameAgentPresentedCredentialAuthenticator + { + public ValueTask AuthenticateAsync( + GameAgentPresentedCredentialContext context, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!string.Equals(context.Credential, "pair-owner-a", StringComparison.Ordinal)) + { + return new ValueTask((ClaimsPrincipal?)null); + } + + return new ValueTask(new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, "owner-a") }, + "pairing"))); + } + } + + private sealed class OwnerAuthorizer : IGameAgentOwnerAuthorizer + { + public ValueTask AuthorizeAsync( + GameAgentAuthorizationContext context, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var subject = context.Principal.FindFirstValue(ClaimTypes.NameIdentifier); + return new ValueTask( + string.Equals(subject, "owner-a", StringComparison.Ordinal) + && string.Equals(context.Key.SessionId, "session-a", StringComparison.Ordinal) + && string.Equals(context.Key.ActorId, "actor-a", StringComparison.Ordinal)); + } + } + + private sealed class CountingJournal : IGameActionJournal + { + private readonly IGameActionJournal _inner; + + public CountingJournal(IGameActionJournal inner) + { + _inner = inner; + } + + public int ListPendingCalls { get; private set; } + + 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) => + _inner.SaveReceiptAsync(receipt, cancellationToken); + + public ValueTask> ListPendingAsync(int limit, CancellationToken cancellationToken) + { + ListPendingCalls++; + return _inner.ListPendingAsync(limit, cancellationToken); + } + } + + private sealed class CapturingProvider : IModelProvider + { + public List Requests { get; } = new(); + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + Requests.Add(request); + yield return ModelStreamEvent.Update( + ModelStreamEventKind.Started, + new ModelResponse(Array.Empty(), ModelStopReason.Pending)); + yield return ModelStreamEvent.Update( + ModelStreamEventKind.TextDelta, + new ModelResponse(new AgentContent[] { new TextContent("ok") }, ModelStopReason.Pending), + "ok"); + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + yield return ModelStreamEvent.Terminal( + new ModelResponse(new AgentContent[] { new TextContent("ok") }, ModelStopReason.Stop)); + } + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "oga-action-exchange-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() => Directory.Delete(Path, recursive: true); + } +} diff --git a/tests/OpenGameAgent.Server.Tests/ServerTests.cs b/tests/OpenGameAgent.Server.Tests/ServerTests.cs index 148e703..f633019 100644 --- a/tests/OpenGameAgent.Server.Tests/ServerTests.cs +++ b/tests/OpenGameAgent.Server.Tests/ServerTests.cs @@ -1,12 +1,15 @@ using System.Runtime.CompilerServices; +using System.Security.Claims; using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using OpenGameAgent.Client; using OpenGameAgent.Kernel; +using OpenGameAgent.Persistence; using Xunit; namespace OpenGameAgent.Server.Tests; @@ -110,6 +113,175 @@ public async Task ServerRunPreservesResourceReferencesFromTheEngineWireFormat() Assert.Equal("image/png", resource.MediaType); } + [Fact] + public async Task TrustedModelRoutesCanSwitchProvidersWithinOneSession() + { + var local = new RoutedProvider("local-answer", "local-response"); + var cloud = new RoutedProvider("cloud-answer", "cloud-response"); + var router = new TrustedGameAgentServerModelRouter( + new[] + { + new GameAgentServerModelRoute("local", new[] + { + new GameAgentServerModelTarget("local-provider", "local-model", local, "test-api"), + }), + new GameAgentServerModelRoute("cloud", new[] + { + new GameAgentServerModelTarget("cloud-provider", "cloud-model", cloud, "test-api"), + }), + }, + "local", + (input, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return new ValueTask(input.Type == "complex" ? "cloud" : "local"); + }); + await using var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions( + router.DefaultProvider, + router.DefaultModel) + { + ModelSelector = router.SelectAsync, + }); + + var first = await runtime.RunAsync(new GameInput( + "routing-session", + "actor", + "chat", + "{\"endpoint\":\"https://attacker.invalid\",\"apiKey\":\"attacker-key\"}", + new GameMoment("world", 1), + "route-local"), TestContext.Current.CancellationToken); + var second = await runtime.RunAsync(new GameInput( + "routing-session", + "actor", + "complex", + "{}", + new GameMoment("world", 2), + "route-cloud"), TestContext.Current.CancellationToken); + + Assert.Equal("local-model", Assert.Single(local.Requests).Model); + Assert.Equal("cloud-model", Assert.Single(cloud.Requests).Model); + var firstAssistant = Assert.Single(first.AgentResult!.NewMessages, message => message.Role == AgentRole.Assistant); + var secondAssistant = Assert.Single(second.AgentResult!.NewMessages, message => message.Role == AgentRole.Assistant); + Assert.Equal("local-provider", firstAssistant.Provider); + Assert.Equal("local-model", firstAssistant.ResponseModel); + Assert.Equal("cloud-provider", secondAssistant.Provider); + Assert.Equal("cloud-model", secondAssistant.ResponseModel); + } + + [Fact] + public async Task TrustedFallbackReportsTheProviderModelAndResponseThatActuallyCompleted() + { + var failing = new TransientFailureProvider(); + var fallback = new RoutedProvider("fallback-answer", "fallback-response"); + var router = new TrustedGameAgentServerModelRouter( + new[] + { + new GameAgentServerModelRoute("balanced", new[] + { + new GameAgentServerModelTarget("primary-provider", "primary-model", failing, "primary-api"), + new GameAgentServerModelTarget("fallback-provider", "fallback-model", fallback, "fallback-api"), + }), + }, + "balanced"); + await using var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions( + router.DefaultProvider, + router.DefaultModel) + { + ModelSelector = router.SelectAsync, + }); + + var result = await runtime.RunAsync(new GameInput( + "fallback-session", + "actor", + "chat", + "{}", + new GameMoment("world", 1), + "fallback-input"), TestContext.Current.CancellationToken); + using var document = JsonDocument.Parse(GameAgentWire.SerializeResult(result)); + var messages = document.RootElement.GetProperty("agent").GetProperty("newMessages"); + var assistant = messages.EnumerateArray().Single(message => message.GetProperty("role").GetString() == "Assistant"); + + Assert.Equal(1, failing.Calls); + Assert.Equal("fallback-model", Assert.Single(fallback.Requests).Model); + Assert.Equal("fallback-provider", assistant.GetProperty("provider").GetString()); + Assert.Equal("fallback-api", assistant.GetProperty("api").GetString()); + Assert.Equal("fallback-model", assistant.GetProperty("responseModel").GetString()); + Assert.Equal("fallback-response", assistant.GetProperty("responseId").GetString()); + } + + [Fact] + public async Task TrustedModelRouterRejectsPolicyNamesOutsideTheAllowlistBeforeProviderUse() + { + var provider = new RoutedProvider("unused", "unused-response"); + var router = new TrustedGameAgentServerModelRouter( + new[] + { + new GameAgentServerModelRoute("allowed", new[] + { + new GameAgentServerModelTarget("allowed-provider", "allowed-model", provider), + }), + }, + "allowed", + (_, _) => new ValueTask("https://attacker.invalid/v1")); + await using var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions( + router.DefaultProvider, + router.DefaultModel) + { + ModelSelector = router.SelectAsync, + }); + + await Assert.ThrowsAsync(() => runtime.RunAsync(new GameInput( + "unknown-route-session", + "actor", + "chat", + "{\"apiKey\":\"attacker-key\"}", + new GameMoment("world", 1), + "unknown-route-input"), TestContext.Current.CancellationToken)); + + Assert.Empty(provider.Requests); + } + + [Fact] + public async Task StockModelRoutingUsesOnlyConfiguredNamedTargetsAndInputTypePolicy() + { + const string serverSecret = "server-only-secret"; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["OpenGameAgent:DefaultModelRoute"] = "local", + ["OpenGameAgent:ModelRoutes:local:ProviderId"] = "local-provider", + ["OpenGameAgent:ModelRoutes:local:Endpoint"] = "http://127.0.0.1:11434/v1/chat/completions", + ["OpenGameAgent:ModelRoutes:local:Model"] = "local-model", + ["OpenGameAgent:ModelRoutes:cloud:ProviderId"] = "cloud-provider", + ["OpenGameAgent:ModelRoutes:cloud:Endpoint"] = "https://cloud.invalid/v1/chat/completions", + ["OpenGameAgent:ModelRoutes:cloud:Model"] = "cloud-model", + ["OpenGameAgent:ModelRoutes:cloud:ApiKey"] = serverSecret, + ["OpenGameAgent:InputModelRoutes:complex"] = "cloud", + }) + .Build(); + var routing = StockGameAgentModelRouting.Create( + configuration, + new StaticHttpClientFactory(new StaticResponseHandler(string.Empty))); + var hostileInput = new GameInput( + "configured-route-session", + "actor", + "complex", + "{\"endpoint\":\"https://attacker.invalid\",\"apiKey\":\"attacker-key\"}", + new GameMoment("world", 1), + "configured-route-input", + new Dictionary + { + ["modelRoute"] = "https://attacker.invalid/v1", + }); + + var selection = await routing.SelectAsync(hostileInput, TestContext.Current.CancellationToken); + + Assert.Equal(new[] { "cloud", "local" }, routing.RouteNames); + Assert.Equal("cloud-model", selection!.Model); + Assert.NotNull(selection.Provider); + Assert.DoesNotContain(serverSecret, GameAgentWire.SerializeInput(hostileInput), StringComparison.Ordinal); + } + [Fact] public async Task StreamingEndpointEmitsAgentEventsAndTerminalResult() { @@ -139,6 +311,7 @@ public async Task CapabilityAndHealthEndpointsAreAvailableWithoutRunningModel() Assert.Contains("healthy", health, StringComparison.Ordinal); Assert.Contains("in-process", capabilities, StringComparison.Ordinal); Assert.Contains("server", capabilities, StringComparison.Ordinal); + Assert.Contains("session-ledger", capabilities, StringComparison.Ordinal); } [Fact] @@ -273,7 +446,14 @@ public async Task ServerRejectsOversizedBodiesBeforeEndpointParsing() 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" }) + foreach (var path in new[] + { + "/v1/run", + "/v1/run/stream", + "/v1/control/steer", + "/v1/control/abort", + "/v1/usage", + }) { using var content = new StringContent(oversized, Encoding.UTF8, "application/json"); using var response = await client.PostAsync(path, content, TestContext.Current.CancellationToken); @@ -420,6 +600,16 @@ public async Task OptionalApiKeyProtectsRunEndpointsButNotHealth() TestContext.Current.CancellationToken); Assert.Equal(System.Net.HttpStatusCode.Unauthorized, deniedControl.StatusCode); + using var deniedUsageContent = new StringContent( + ControlJson(new GameSessionKey("session", "actor")), + Encoding.UTF8, + "application/json"); + using var deniedUsage = await client.PostAsync( + "/v1/usage", + deniedUsageContent, + TestContext.Current.CancellationToken); + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, deniedUsage.StatusCode); + using var allowedContent = new StringContent(RequestJson("allowed"), Encoding.UTF8, "application/json"); using var allowedRequest = new HttpRequestMessage(HttpMethod.Post, "/v1/run") { Content = allowedContent }; allowedRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "secret"); @@ -430,6 +620,369 @@ public async Task OptionalApiKeyProtectsRunEndpointsButNotHealth() health.EnsureSuccessStatusCode(); } + [Fact] + public async Task OwnerAuthorizationRejectsAnonymousAndCrossOwnerRunsBeforeRuntimeStateIsTouched() + { + var provider = new ResourceCaptureProvider(); + var sessionStore = new CountingGameSessionStore(); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "test") + { + SessionStore = sessionStore, + }); + var authorizer = new TestOwnerAuthorizer((subject, key, _) => + subject == "owner-a" && key == new GameSessionKey("session", "actor")); + await using var app = await CreateAppAsync(runtime, authorizer: authorizer); + using var client = app.GetTestClient(); + + foreach (var path in new[] { "/v1/run", "/v1/run/stream" }) + { + using var anonymousContent = new StringContent(RequestJson("anonymous"), Encoding.UTF8, "application/json"); + using var anonymous = await client.PostAsync(path, anonymousContent, TestContext.Current.CancellationToken); + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, anonymous.StatusCode); + + using var denied = CreateOwnedRequest( + HttpMethod.Post, + path, + "owner-a", + RequestJson("cross-owner", "other-session", "actor")); + using var deniedResponse = await client.SendAsync(denied, TestContext.Current.CancellationToken); + Assert.Equal(System.Net.HttpStatusCode.Forbidden, deniedResponse.StatusCode); + } + + Assert.DoesNotContain(authorizer.Calls, call => call.SubjectId.Length == 0); + Assert.Equal(0, sessionStore.LoadCalls); + Assert.Equal(0, sessionStore.SaveCalls); + Assert.Empty(provider.Requests); + + using var allowed = CreateOwnedRequest( + HttpMethod.Post, + "/v1/run", + "owner-a", + RequestJson("allowed")); + using var allowedResponse = await client.SendAsync(allowed, TestContext.Current.CancellationToken); + + allowedResponse.EnsureSuccessStatusCode(); + Assert.True(sessionStore.LoadCalls > 0); + Assert.True(sessionStore.SaveCalls > 0); + Assert.Single(provider.Requests); + Assert.Contains(authorizer.Calls, call => + call.SubjectId == "owner-a" + && call.Key == new GameSessionKey("session", "actor") + && call.Operation == GameAgentServerOperation.Run); + Assert.Contains(authorizer.Calls, call => call.Operation == GameAgentServerOperation.Stream); + } + + [Fact] + public async Task ApiKeyAuthenticationProducesAStablePrincipalForOwnerAuthorization() + { + var authorizer = new TestOwnerAuthorizer((subject, key, operation) => + subject == "server-api-key" + && key == new GameSessionKey("session", "actor") + && operation == GameAgentServerOperation.Run); + await using var app = await CreateAppAsync( + new GameAgentRuntime(new GameAgentRuntimeOptions(new StreamingProvider(), "test")), + apiKey: "secret", + authorizer: authorizer); + using var client = app.GetTestClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, "/v1/run") + { + Content = new StringContent(RequestJson("api-key-owner"), Encoding.UTF8, "application/json"), + }; + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "secret"); + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + response.EnsureSuccessStatusCode(); + Assert.Contains(authorizer.Calls, call => call.SubjectId == "server-api-key"); + } + + [Fact] + public async Task UsageEndpointIsOwnerAuthorizedAndReturnsCompleteDurableCauseTotals() + { + var key = new GameSessionKey("usage-session", "usage-actor"); + var sessionStore = new CountingGameSessionStore(); + await sessionStore.SeedAsync(new GameSessionSnapshot( + key, + 1, + usageLedger: new GameSessionUsageLedger(new[] + { + new GameSessionUsageRecord( + "usage-assistant", + GameSessionUsageCause.Assistant, + new ModelUsage( + 10, + 4, + 3, + 2, + reasoningTokens: 2, + cacheWriteOneHourTokens: 1, + cost: new ModelCost(0.1, 0.2, 0.03, 0.04, isKnown: true)), + "run-1", + "input-1"), + new GameSessionUsageRecord( + "usage-tool", + GameSessionUsageCause.Tool, + new ModelUsage(2, 1, cost: new ModelCost(0.02, 0.01, 0, 0, isKnown: true)), + "run-1", + "input-1"), + new GameSessionUsageRecord( + "usage-compaction", + GameSessionUsageCause.Compaction, + new ModelUsage(5, 2, cost: new ModelCost(0.05, 0.02, 0, 0, isKnown: true)), + "run-1", + "input-1"), + }))); + sessionStore.ResetCounters(); + await using var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(new StreamingProvider(), "test") + { + SessionStore = sessionStore, + }); + var authorizer = new TestOwnerAuthorizer((subject, resource, operation) => + subject == "owner-a" + && resource == key + && operation == GameAgentServerOperation.ReadUsage); + await using var app = await CreateAppAsync(runtime, authorizer: authorizer); + using var client = app.GetTestClient(); + var requestJson = ControlJson(key); + + using var deniedRequest = CreateOwnedRequest(HttpMethod.Post, "/v1/usage", "owner-b", requestJson); + using var denied = await client.SendAsync(deniedRequest, TestContext.Current.CancellationToken); + Assert.Equal(System.Net.HttpStatusCode.Forbidden, denied.StatusCode); + Assert.Equal(0, sessionStore.LoadCalls); + + using var allowedRequest = CreateOwnedRequest(HttpMethod.Post, "/v1/usage", "owner-a", requestJson); + using var allowed = await client.SendAsync(allowedRequest, TestContext.Current.CancellationToken); + var json = await allowed.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + allowed.EnsureSuccessStatusCode(); + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + Assert.Equal(3, root.GetProperty("totalRecordCount").GetInt64()); + Assert.Equal(29, root.GetProperty("total").GetProperty("totalTokens").GetInt64()); + Assert.Equal(2, root.GetProperty("total").GetProperty("reasoningTokens").GetInt64()); + Assert.Equal(1, root.GetProperty("total").GetProperty("cacheWriteOneHourTokens").GetInt64()); + Assert.True(root.GetProperty("total").GetProperty("cost").GetProperty("known").GetBoolean()); + Assert.Equal(3, root.GetProperty("byCause").GetArrayLength()); + Assert.Equal(3, root.GetProperty("recentRecords").GetArrayLength()); + Assert.Contains(root.GetProperty("byCause").EnumerateArray(), item => + item.GetProperty("cause").GetString() == "Compaction" + && item.GetProperty("usage").GetProperty("totalTokens").GetInt64() == 7); + Assert.Equal(1, sessionStore.LoadCalls); + } + + [Fact] + public void UsageWireDistinguishesUnknownCostFromKnownFreeCost() + { + var key = new GameSessionKey("cost-session", "actor"); + var unknown = new GameSessionUsageSnapshot( + key, + 1, + new GameSessionUsageLedger(new[] + { + new GameSessionUsageRecord("unknown", GameSessionUsageCause.Assistant, new ModelUsage(1, 1)), + })); + var free = new GameSessionUsageSnapshot( + key, + 1, + new GameSessionUsageLedger(new[] + { + new GameSessionUsageRecord( + "free", + GameSessionUsageCause.Assistant, + new ModelUsage(1, 1, cost: new ModelCost(isKnown: true))), + })); + + using var unknownDocument = JsonDocument.Parse(GameAgentWire.SerializeUsage(unknown)); + using var freeDocument = JsonDocument.Parse(GameAgentWire.SerializeUsage(free)); + + var unknownCost = unknownDocument.RootElement.GetProperty("total").GetProperty("cost"); + Assert.False(unknownCost.GetProperty("known").GetBoolean()); + Assert.Equal(JsonValueKind.Null, unknownCost.GetProperty("total").ValueKind); + var freeCost = freeDocument.RootElement.GetProperty("total").GetProperty("cost"); + Assert.True(freeCost.GetProperty("known").GetBoolean()); + Assert.Equal(0, freeCost.GetProperty("total").GetDouble()); + } + + [Fact] + public async Task AudienceProjectionProtectsReasoningAndToolDetailsForOwnerAndPublicViewers() + { + var key = new GameSessionKey("audience-session", "audience-actor"); + var authorizer = new TestOwnerAuthorizer((subject, resource, _) => + resource == key && subject is "owner-a" or "internal-viewer"); + var ownerPolicy = CreateAudiencePolicy(defaultAudience: GameAgentAudience.Owner); + await using var ownerApp = await CreateAppAsync( + CreateAudienceRuntime(), + authorizer: authorizer, + audiencePolicy: ownerPolicy); + using var ownerClient = ownerApp.GetTestClient(); + + using var ownerRequest = CreateOwnedRequest( + HttpMethod.Post, + "/v1/run", + "owner-a", + AudienceInputJson(key, "owner-input")); + using var ownerResponse = await ownerClient.SendAsync(ownerRequest, TestContext.Current.CancellationToken); + var ownerJson = await ownerResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + ownerResponse.EnsureSuccessStatusCode(); + Assert.Contains("visible-answer", ownerJson, StringComparison.Ordinal); + Assert.DoesNotContain("private-reasoning", ownerJson, StringComparison.Ordinal); + Assert.DoesNotContain("reasoning-signature", ownerJson, StringComparison.Ordinal); + Assert.DoesNotContain("private-tool-result", ownerJson, StringComparison.Ordinal); + Assert.DoesNotContain("private-tool-details", ownerJson, StringComparison.Ordinal); + Assert.DoesNotContain("secret-argument", ownerJson, StringComparison.Ordinal); + + using var deniedRequest = CreateOwnedRequest( + HttpMethod.Post, + "/v1/run", + "owner-b", + AudienceInputJson(key, "denied-input")); + using var deniedResponse = await ownerClient.SendAsync(deniedRequest, TestContext.Current.CancellationToken); + Assert.Equal(System.Net.HttpStatusCode.Forbidden, deniedResponse.StatusCode); + + using var internalRequest = CreateOwnedRequest( + HttpMethod.Post, + "/v1/run/stream", + "internal-viewer", + AudienceInputJson(key, "internal-input")); + internalRequest.Headers.Add("X-Test-Internal", "true"); + using var internalResponse = await ownerClient.SendAsync(internalRequest, TestContext.Current.CancellationToken); + var internalStream = await internalResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + internalResponse.EnsureSuccessStatusCode(); + Assert.Contains("private-reasoning", internalStream, StringComparison.Ordinal); + Assert.Contains("reasoning-signature", internalStream, StringComparison.Ordinal); + Assert.Contains("private-tool-result", internalStream, StringComparison.Ordinal); + Assert.Contains("private-tool-details", internalStream, StringComparison.Ordinal); + Assert.Contains("secret-argument", internalStream, StringComparison.Ordinal); + + var publicAuthorizer = new TestOwnerAuthorizer((subject, resource, _) => + subject == "public-viewer" && resource == key); + await using var publicApp = await CreateAppAsync( + CreateAudienceRuntime(), + authorizer: publicAuthorizer, + audiencePolicy: CreateAudiencePolicy(defaultAudience: GameAgentAudience.Public)); + using var publicClient = publicApp.GetTestClient(); + using var publicRequest = CreateOwnedRequest( + HttpMethod.Post, + "/v1/run/stream", + "public-viewer", + AudienceInputJson(key, "public-input")); + using var publicResponse = await publicClient.SendAsync(publicRequest, TestContext.Current.CancellationToken); + var publicStream = await publicResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + publicResponse.EnsureSuccessStatusCode(); + Assert.Contains("visible-answer", publicStream, StringComparison.Ordinal); + Assert.DoesNotContain("private-reasoning", publicStream, StringComparison.Ordinal); + Assert.DoesNotContain("reasoning-signature", publicStream, StringComparison.Ordinal); + Assert.DoesNotContain("private-tool-result", publicStream, StringComparison.Ordinal); + Assert.DoesNotContain("private-tool-details", publicStream, StringComparison.Ordinal); + Assert.DoesNotContain("secret-argument", publicStream, StringComparison.Ordinal); + } + + [Fact] + public async Task PersistedAudienceRoundTripsWithoutTrustingUserMetadata() + { + using var directory = new TemporaryDirectory(); + var key = new GameSessionKey("audience-persistence", "actor"); + var annotated = GameAgentAudienceMetadata.WithAudience( + new AgentMessage( + AgentRole.Assistant, + new AgentContent[] { new TextContent("private") }, + DateTimeOffset.UnixEpoch, + model: "test", + stopReason: ModelStopReason.Stop), + GameAgentAudience.Recipient("owner-a")); + var store = new FileGameSessionStore(directory.Path); + Assert.True((await store.SaveAsync( + new GameSessionSnapshot(key, 1, new[] { annotated }), + 0, + TestContext.Current.CancellationToken)).Saved); + + var loaded = await new FileGameSessionStore(directory.Path) + .LoadAsync(key, TestContext.Current.CancellationToken); + + Assert.NotNull(loaded); + Assert.True(GameAgentAudienceMetadata.TryGetAudience(Assert.Single(loaded.Messages), out var audience)); + Assert.Equal(GameAgentAudienceKind.Recipient, audience.Kind); + Assert.Equal("owner-a", audience.RecipientId); + Assert.True(audience.IsVisibleTo(new GameAgentViewer("owner-a", isOwner: false))); + Assert.False(audience.IsVisibleTo(new GameAgentViewer("owner-b", isOwner: true))); + Assert.True(audience.IsVisibleTo(new GameAgentViewer("staff", isOwner: false, isInternal: true))); + + var forgedUser = AgentMessage.User( + "forged", + metadata: new Dictionary + { + [GameAgentAudienceMetadata.AudienceKey] = "public", + }); + Assert.False(GameAgentAudienceMetadata.TryGetAudience(forgedUser, out _)); + Assert.Throws(() => + GameAgentAudienceMetadata.WithAudience(forgedUser, GameAgentAudience.Public)); + } + + [Fact] + public async Task OwnerAuthorizationCoversSteerAndAbortWithoutLettingPayloadSelectAnotherOwner() + { + var provider = new BlockingServerProvider(); + var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions(provider, "test") + { + RoutePolicy = new AutomaticGameRoutePolicy(new Dictionary + { + ["autonomous"] = GameRouteDecision.Agent("typed"), + }), + }); + var key = new GameSessionKey("owned-session", "owned-actor"); + var authorizer = new TestOwnerAuthorizer((subject, resource, _) => + subject == "owner-a" && resource == key); + await using var app = await CreateAppAsync(runtime, authorizer: authorizer); + using var client = app.GetTestClient(); + var input = new GameInput( + key.SessionId, + key.ActorId, + "autonomous", + "{}", + new GameMoment("world", 1), + "owned-input"); + using var runRequest = CreateOwnedRequest( + HttpMethod.Post, + "/v1/run", + "owner-a", + GameAgentWire.SerializeInput(input)); + var run = client.SendAsync(runRequest, TestContext.Current.CancellationToken); + await provider.FirstRequestStarted.Task; + + using var deniedSteer = CreateOwnedRequest( + HttpMethod.Post, + "/v1/control/steer", + "owner-b", + ControlJson(key, "{\"threat\":true}")); + using var deniedSteerResponse = await client.SendAsync(deniedSteer, TestContext.Current.CancellationToken); + using var deniedAbort = CreateOwnedRequest( + HttpMethod.Post, + "/v1/control/abort", + "owner-b", + ControlJson(key)); + using var deniedAbortResponse = await client.SendAsync(deniedAbort, TestContext.Current.CancellationToken); + + Assert.Equal(System.Net.HttpStatusCode.Forbidden, deniedSteerResponse.StatusCode); + Assert.Equal(System.Net.HttpStatusCode.Forbidden, deniedAbortResponse.StatusCode); + Assert.False(run.IsCompleted); + Assert.Single(provider.Requests); + + using var allowedAbort = CreateOwnedRequest( + HttpMethod.Post, + "/v1/control/abort", + "owner-a", + ControlJson(key)); + using var allowedAbortResponse = await client.SendAsync(allowedAbort, TestContext.Current.CancellationToken); + allowedAbortResponse.EnsureSuccessStatusCode(); + using var runResponse = await run; + + Assert.Contains(authorizer.Calls, call => call.SubjectId == "owner-b" && call.Operation == GameAgentServerOperation.Steer); + Assert.Contains(authorizer.Calls, call => call.SubjectId == "owner-b" && call.Operation == GameAgentServerOperation.Abort); + Assert.Contains(authorizer.Calls, call => call.SubjectId == "owner-a" && call.Operation == GameAgentServerOperation.Abort); + } + [Fact] public async Task EngineClientRejectsOversizedStreamingLineBeforeDispatchingEvent() { @@ -619,23 +1172,61 @@ private static async Task CreateAppAsync( private static async Task CreateAppAsync( GameAgentRuntime runtime, string? apiKey = null, - int maximumRequestBodyBytes = ServerEndpoints.DefaultMaximumRequestBodyBytes) + int maximumRequestBodyBytes = ServerEndpoints.DefaultMaximumRequestBodyBytes, + IGameAgentOwnerAuthorizer? authorizer = null, + IGameAgentAudiencePolicy? audiencePolicy = null) { var builder = WebApplication.CreateBuilder(); builder.WebHost.UseTestServer(); builder.Services.AddSingleton(runtime); + if (authorizer is not null) + { + builder.Services.AddSingleton(authorizer); + } + + if (audiencePolicy is not null) + { + builder.Services.AddSingleton(audiencePolicy); + } + var app = builder.Build(); app.UseOpenGameAgentApiKey(apiKey); + if (authorizer is not null) + { + app.Use(async (context, next) => + { + if (context.Request.Headers.TryGetValue("X-Test-Subject", out var values) + && values.Count == 1 + && !string.IsNullOrWhiteSpace(values[0])) + { + context.User = new ClaimsPrincipal(new ClaimsIdentity( + new[] + { + new Claim(ClaimTypes.NameIdentifier, values[0]!), + new Claim( + "opengameagent.internal", + context.Request.Headers["X-Test-Internal"].ToString()), + }, + "test")); + } + + await next(context); + }); + } + app.MapOpenGameAgent(maximumRequestBodyBytes); await app.StartAsync(TestContext.Current.CancellationToken); return app; } - private static string RequestJson(string inputId) => $$""" + private static string RequestJson( + string inputId, + string sessionId = "session", + string actorId = "actor") => $$""" { "inputId": "{{inputId}}", - "sessionId": "session", - "actorId": "actor", + "sessionId": "{{sessionId}}", + "actorId": "{{actorId}}", "type": "chat", "payload": { "text": "hello", "weight": 1.5 }, "timelineId": "world", @@ -643,6 +1234,140 @@ private static string RequestJson(string inputId) => $$""" } """; + private static HttpRequestMessage CreateOwnedRequest( + HttpMethod method, + string path, + string subjectId, + string json) + { + var request = new HttpRequestMessage(method, path) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + request.Headers.Add("X-Test-Subject", subjectId); + return request; + } + + private static string ControlJson(GameSessionKey key, string payloadJson = "{}") => $$""" + { + "sessionId": "{{key.SessionId}}", + "actorId": "{{key.ActorId}}", + "payload": {{payloadJson}} + } + """; + + private static string AudienceInputJson(GameSessionKey key, string inputId) => + GameAgentWire.SerializeInput(new GameInput( + key.SessionId, + key.ActorId, + "autonomous", + "{}", + new GameMoment("world", 1), + inputId)); + + private static MetadataGameAgentAudiencePolicy CreateAudiencePolicy(GameAgentAudience defaultAudience) => + new( + (principal, _, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + var id = principal.FindFirstValue(ClaimTypes.NameIdentifier); + var isInternal = string.Equals( + principal.FindFirstValue("opengameagent.internal"), + "true", + StringComparison.OrdinalIgnoreCase); + return new ValueTask(new GameAgentViewer( + id, + isOwner: string.Equals(id, "owner-a", StringComparison.Ordinal), + isInternal)); + }, + defaultAudience); + + private static GameAgentRuntime CreateAudienceRuntime() => + new(new GameAgentRuntimeOptions(new AudienceProvider(), "test") + { + RoutePolicy = new AutomaticGameRoutePolicy(new Dictionary + { + ["autonomous"] = GameRouteDecision.Agent("audience-test"), + }), + ToolProvider = (_, _) => new ValueTask>(new[] + { + new AgentTool( + new ToolDefinition( + "private_tool", + "Returns private tool data.", + "{\"type\":\"object\",\"properties\":{\"value\":{\"type\":\"string\"}},\"required\":[\"value\"],\"additionalProperties\":false}"), + (_, _, _) => new ValueTask(new ToolResult( + new AgentContent[] { new TextContent("private-tool-result") }, + detailsJson: "{\"secret\":\"private-tool-details\"}")), + ToolRisk.ReadOnly), + }), + }); + + private sealed class TestOwnerAuthorizer : IGameAgentOwnerAuthorizer + { + private readonly Func _authorize; + + public TestOwnerAuthorizer(Func authorize) + { + _authorize = authorize; + } + + public System.Collections.Concurrent.ConcurrentQueue Calls { get; } = new(); + + public ValueTask AuthorizeAsync( + GameAgentAuthorizationContext context, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var subjectId = context.Principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty; + Calls.Enqueue(new AuthorizationCall(subjectId, context.Key, context.Operation)); + return new ValueTask(_authorize(subjectId, context.Key, context.Operation)); + } + } + + private sealed record AuthorizationCall( + string SubjectId, + GameSessionKey Key, + GameAgentServerOperation Operation); + + private sealed class CountingGameSessionStore : IGameSessionStore + { + private readonly InMemoryGameSessionStore _inner = new(); + + public int LoadCalls { get; private set; } + + public int SaveCalls { get; private set; } + + public async Task SeedAsync(GameSessionSnapshot snapshot) + { + var result = await _inner.SaveAsync(snapshot, 0, TestContext.Current.CancellationToken); + Assert.True(result.Saved); + } + + public void ResetCounters() + { + LoadCalls = 0; + SaveCalls = 0; + } + + public ValueTask LoadAsync( + GameSessionKey key, + CancellationToken cancellationToken) + { + LoadCalls++; + return _inner.LoadAsync(key, cancellationToken); + } + + public ValueTask SaveAsync( + GameSessionSnapshot snapshot, + long expectedRevision, + CancellationToken cancellationToken) + { + SaveCalls++; + return _inner.SaveAsync(snapshot, expectedRevision, cancellationToken); + } + } + private sealed class StreamingProvider : IModelProvider { public async IAsyncEnumerable StreamAsync( @@ -679,6 +1404,52 @@ public async IAsyncEnumerable StreamAsync( } } + private sealed class RoutedProvider : IModelProvider + { + private readonly string _text; + private readonly string _responseId; + + public RoutedProvider(string text, string responseId) + { + _text = text; + _responseId = responseId; + } + + 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(_text) }, + ModelStopReason.Stop, + responseId: _responseId)); + } + } + + private sealed class TransientFailureProvider : IModelProvider + { + public int Calls { get; private set; } + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + _ = request; + cancellationToken.ThrowIfCancellationRequested(); + Calls++; + await Task.Yield(); + throw new ModelProviderException("temporary failure", isTransient: true); +#pragma warning disable CS0162 + yield break; +#pragma warning restore CS0162 + } + } + private sealed class ToolIdentityProvider : IModelProvider { private int _calls; @@ -752,6 +1523,78 @@ public async IAsyncEnumerable StreamAsync( } } + private sealed class AudienceProvider : IModelProvider + { + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + if (request.Turn == 1) + { + var reasoning = new ReasoningContent( + "private-reasoning", + "reasoning-signature", + redacted: true); + yield return ModelStreamEvent.Update( + ModelStreamEventKind.Started, + new ModelResponse(Array.Empty(), ModelStopReason.Pending)); + yield return ModelStreamEvent.Update( + ModelStreamEventKind.ReasoningDelta, + new ModelResponse(new AgentContent[] { reasoning }, ModelStopReason.Pending), + "private-reasoning"); + yield return ModelStreamEvent.Terminal(new ModelResponse( + new AgentContent[] + { + reasoning, + new ToolCallContent( + "private-call", + "private_tool", + "{\"value\":\"secret-argument\"}", + "tool-thought-signature"), + }, + ModelStopReason.ToolUse)); + yield break; + } + + yield return ModelStreamEvent.Terminal(new ModelResponse( + new AgentContent[] { new TextContent("visible-answer") }, + ModelStopReason.Stop)); + } + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "OpenGameAgent.Server.Tests", + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + var root = System.IO.Path.GetFullPath(System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "OpenGameAgent.Server.Tests")); + var target = System.IO.Path.GetFullPath(Path); + if (!target.StartsWith(root + System.IO.Path.DirectorySeparatorChar, StringComparison.Ordinal)) + { + throw new InvalidOperationException("Refusing to remove a directory outside the test root."); + } + + if (Directory.Exists(target)) + { + Directory.Delete(target, recursive: true); + } + } + } + private sealed class StaticResponseHandler : HttpMessageHandler { private readonly string _body; @@ -772,4 +1615,20 @@ protected override Task SendAsync( }); } } + + private sealed class StaticHttpClientFactory : IHttpClientFactory + { + private readonly HttpMessageHandler _handler; + + public StaticHttpClientFactory(HttpMessageHandler handler) + { + _handler = handler; + } + + public HttpClient CreateClient(string name) + { + _ = name; + return new HttpClient(_handler, disposeHandler: false); + } + } } diff --git a/tests/OpenGameAgent.Tests/PublicApiCompatibilityTests.cs b/tests/OpenGameAgent.Tests/PublicApiCompatibilityTests.cs index 2db464b..7cda29b 100644 --- a/tests/OpenGameAgent.Tests/PublicApiCompatibilityTests.cs +++ b/tests/OpenGameAgent.Tests/PublicApiCompatibilityTests.cs @@ -5,7 +5,7 @@ namespace OpenGameAgent.Tests; public sealed class PublicApiCompatibilityTests { - private const string ApprovedApiHash = "293E301CD64AC756A502F0DAE2B0A167C296B9DF6259B8E595F8921DAA394A84"; + private const string ApprovedApiHash = "4B4E24261A64AA1944A7A416B751BF0D47693C7034F8A226A7283EE684C6C2A8"; [Fact] public void RuntimePublicApiMatchesTheApprovedStableSurface() diff --git a/tests/OpenGameAgent.Tests/RuntimeTests.cs b/tests/OpenGameAgent.Tests/RuntimeTests.cs index d9ad1c4..f0c5247 100644 --- a/tests/OpenGameAgent.Tests/RuntimeTests.cs +++ b/tests/OpenGameAgent.Tests/RuntimeTests.cs @@ -176,10 +176,66 @@ public async Task AgentRouteCommitsDurableActionOnceAndDeduplicatesInput() Assert.Equal(GameAgentRunStatus.Duplicate, duplicate.Status); Assert.Equal(1, handler.ExecuteCount); Assert.Equal(2, provider.CallCount); - var entry = await journal.FindAsync("same-input:1:0", TestContext.Current.CancellationToken); + var operationId = GameActionOperationIds.CreateV2( + input.SessionId, + input.ActorId, + input.InputId, + 1, + 0, + "place_block", + input.Moment); + var entry = await journal.FindAsync(operationId, TestContext.Current.CancellationToken); Assert.Equal(GameActionStatus.Committed, entry!.Receipt!.Status); } + [Fact] + public void DefaultActionOperationIdV2IsStableBoundedAndSeparatesEveryAuthorityDimension() + { + static string Create( + string session = "session", + string actor = "actor", + string input = "input", + int turn = 1, + int index = 0, + string action = "act", + string timeline = "world", + long tick = 10, + string? generation = "generation") => GameActionOperationIds.CreateV2( + session, + actor, + input, + turn, + index, + action, + new GameMoment(timeline, tick), + generation); + + var baseline = Create(); + Assert.Equal(baseline, Create()); + Assert.True(GameActionOperationIds.IsVersion2(baseline)); + Assert.Equal(GameActionOperationIds.Version2Prefix.Length + 64, baseline.Length); + Assert.All( + new[] + { + Create(session: "other-session"), + Create(actor: "other-actor"), + Create(input: "other-input"), + Create(turn: 2), + Create(index: 1), + Create(action: "other-action"), + Create(timeline: "other-world"), + Create(tick: 11), + Create(generation: "other-generation"), + }, + candidate => Assert.NotEqual(baseline, candidate)); + Assert.Equal( + GameActionOperationIds.Version2Prefix.Length + 64, + Create(session: new string('s', 16_384)).Length); + Assert.Equal("input:1:0", GameActionOperationIds.CreateLegacyV1("input", 1, 0)); + GameActionOperationIdFactory legacyFactory = GameActionOperationIds.CreateLegacyV1; + Assert.NotNull(legacyFactory); + } + [Fact] public async Task SettledToolTurnIsCheckpointedBeforeTheInputIsMarkedComplete() { @@ -510,7 +566,16 @@ public async Task ActionOperationRemainsStableWhenProviderChangesToolCallIdAfter Assert.Equal(GameAgentRunStatus.SessionConflict, conflicted.Status); Assert.True(retried.Succeeded); Assert.Equal(1, handler.ExecuteCount); - Assert.NotNull(await journal.FindAsync("stable-input:1:0", TestContext.Current.CancellationToken)); + Assert.NotNull(await journal.FindAsync( + GameActionOperationIds.CreateV2( + input.SessionId, + input.ActorId, + input.InputId, + 1, + 0, + "place_block", + input.Moment), + TestContext.Current.CancellationToken)); } [Fact] @@ -2134,6 +2199,8 @@ public void UsageLedgerAppendIsIdempotentAndRejectsRecordIdentityReuse() Assert.Same(ledger, replayed); Assert.Single(replayed.Records); + Assert.False(replayed.Stats.Total.CostKnown); + Assert.Null(replayed.Stats.Total.CostTotalIfKnown); Assert.Throws(() => replayed.Append(new[] { new GameSessionUsageRecord( diff --git a/tools/release-packages.json b/tools/release-packages.json index 458aaae..24e47f1 100644 --- a/tools/release-packages.json +++ b/tools/release-packages.json @@ -81,6 +81,10 @@ "id": "OpenGameAgent.Persistence", "project": "src/OpenGameAgent.Persistence/OpenGameAgent.Persistence.csproj" }, + { + "id": "OpenGameAgent.Plugins", + "project": "src/OpenGameAgent.Plugins/OpenGameAgent.Plugins.csproj" + }, { "id": "OpenGameAgent.Providers.OpenRouter", "project": "src/OpenGameAgent.Providers.OpenRouter/OpenGameAgent.Providers.OpenRouter.csproj" diff --git a/tools/update-model-directory.ps1 b/tools/update-model-directory.ps1 index 4c0326c..f295e6e 100644 --- a/tools/update-model-directory.ps1 +++ b/tools/update-model-directory.ps1 @@ -379,6 +379,9 @@ foreach ($providerId in $providerIds) { }) } if ($costTiers.Count -gt 0) { $cost["tiers"] = @($costTiers) } + $cost["known"] = $cost.input -gt 0 -or $cost.output -gt 0 -or + $cost.cacheRead -gt 0 -or $cost.cacheWrite -gt 0 -or + $costTiers.Count -gt 0 -or $modelProperty.Name.EndsWith(":free", [StringComparison]::OrdinalIgnoreCase) $reasoning = Get-ReasoningProfile $providerId $apiId $modelProperty.Name $model $compatibility = Get-ModelCompatibility $providerId $apiId $modelEndpoint $modelProperty.Name $model