From a297880700daf552066a40906c045a5da3a2dcbe Mon Sep 17 00:00:00 2001 From: Eric Sun <141227631+EricSun0218@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:43:00 +0800 Subject: [PATCH] Add persistent task plan extension --- CHANGELOG.md | 4 + README.md | 4 +- README.zh-CN.md | 4 +- docs/architecture.md | 2 +- docs/features.md | 1 + docs/game-integration-patterns.md | 36 + docs/nuget-package-readme.md | 2 +- .../OpenGameAgent.Extensions.csproj | 2 +- .../TaskPlanExtension.cs | 667 ++++++++++++++++++ .../OfficialExtensionTests.cs | 10 +- .../TaskPlanExtensionTests.cs | 421 +++++++++++ .../TaskPlanPersistenceTests.cs | 122 ++++ 12 files changed, 1266 insertions(+), 9 deletions(-) create mode 100644 src/OpenGameAgent.Extensions/TaskPlanExtension.cs create mode 100644 tests/OpenGameAgent.Extensions.Tests/TaskPlanExtensionTests.cs create mode 100644 tests/OpenGameAgent.Persistence.Tests/TaskPlanPersistenceTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ef53d25..e101655 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Add the optional `TaskPlanExtension` for session/actor-scoped persistent ordered checklists, revision-checked mutations, host-validated evidence, per-input advancement guards, pending-work routing, typed UI projection events, and bounded terminal retention. + ## 0.3.0-alpha.2 - Add the optional `OpenGameAgent.Memory` package with a model-agnostic embedding provider contract, authoritative-save verification, rebuildable local vector indexes, hybrid lexical/vector recall, structured diagnostics, and game-time-aware reranking. diff --git a/README.md b/README.md index 4bc30b1..210ca83 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) [![Status](https://img.shields.io/badge/status-alpha-orange.svg)](CHANGELOG.md) -OpenGameAgent brings the small, composable agent-kernel model to game development. Its stateful core streams model output, executes validated tools, accepts steering while running, and continues the model/tool loop until work is complete. Use that kernel by itself, add the game layer for game time and durable state, then opt into extension packages for memory, goals, artifacts, delegation, external tools, structured interaction, and workflow graphs. +OpenGameAgent brings the small, composable agent-kernel model to game development. Its stateful core streams model output, executes validated tools, accepts steering while running, and continues the model/tool loop until work is complete. Use that kernel by itself, add the game layer for game time and durable state, then opt into extension packages for memory, goals, host-verified task plans, artifacts, delegation, external tools, structured interaction, and workflow graphs. Inputs are bounded JSON. They may represent dialogue, combat observations, simulation ticks, UI events, plans, sensor state, or any other game-owned data; natural language is optional. No model is bundled. Cloud and local API endpoints are both supported. @@ -83,7 +83,7 @@ Read [Architecture](docs/architecture.md) for the ownership and failure boundari | 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 | +| Official extensions | Tool policy and search, structured player questions/recommended replies, goals, host-verified ordered task plans, 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 | diff --git a/README.zh-CN.md b/README.zh-CN.md index bb49eab..9fb4cca 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,7 +8,7 @@ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) [![Status](https://img.shields.io/badge/status-alpha-orange.svg)](CHANGELOG.md) -OpenGameAgent 把小型、可组合的 Agent 内核带进游戏开发。它的有状态核心会流式接收模型输出、执行经过校验的工具、在运行中接受 steering,并持续进行模型/工具循环直到任务结束。开发者既可以只使用这个内核,也可以叠加游戏层获得游戏时间与可靠状态,再按需加入记忆、目标、产物、委派、外部工具、结构化交互和工作流图等扩展。 +OpenGameAgent 把小型、可组合的 Agent 内核带进游戏开发。它的有状态核心会流式接收模型输出、执行经过校验的工具、在运行中接受 steering,并持续进行模型/工具循环直到任务结束。开发者既可以只使用这个内核,也可以叠加游戏层获得游戏时间与可靠状态,再按需加入记忆、目标、宿主证据校验的任务清单、产物、委派、外部工具、结构化交互和工作流图等扩展。 输入是有大小限制的 JSON,可以表示对话、战斗观察、模拟 Tick、UI 事件、计划、传感状态或任意游戏数据,不要求是自然语言。项目不捆绑模型,同时支持云端和本地 API。 @@ -81,7 +81,7 @@ GameAgentRuntime | 工具执行 | provider 请求前 schema 预检及执行期有界 JSON Schema 子集校验、每个已接受调用都有结果、安全并行读、冲突键串行、策略拦截/终止、超时与写入结果未知语义 | | 游戏 Runtime | 任意 JSON 输入、游戏时钟/时间线、快速/完整/Workflow 路由、乐观并发会话、输入去重、角色并发、运行中 steering/abort | | 扩展 API | 不可变构建器;提示词/上下文/工具/Skills/路由/Workflow/Hooks/提供方/服务注册;类型化生命周期事件与通道;命名空间持久状态 | -| 官方扩展 | 工具策略与搜索、玩家结构化提问/推荐回复、目标、记忆、产物、外部知识、委派、追踪和可持久并行工作流图 | +| 官方扩展 | 工具策略与搜索、玩家结构化提问/推荐回复、目标、宿主证据校验的有序任务清单、记忆、产物、外部知识、委派、追踪和可持久并行工作流图 | | 世界原语 | 可恢复动作、可续跑 Workflow、记忆、Skills、信号、游戏时间调度、角色邮箱 | | 模型与认证 | 内置模型能力/上下文/推理级别/成本目录、动态刷新、API Key/环境/存储/OAuth/本地认证、开发者托管短期凭证网关 | | 外部工具 | 默认按需搜索/描述/调用;小型可信目录可显式选择原生直连暴露 | diff --git a/docs/architecture.md b/docs/architecture.md index c1cdff0..647a2f7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -40,7 +40,7 @@ It does not own a universal world model. Context remains opaque JSON supplied by ### Optional packages -- `OpenGameAgent.Extensions` adds policy, searchable tools, structured player interaction, goals, memory, artifacts, external knowledge, delegation, tracing, and durable workflow graphs. +- `OpenGameAgent.Extensions` adds policy, searchable tools, structured player interaction, goals, host-verified task plans, memory, artifacts, external knowledge, delegation, tracing, and durable workflow graphs. - `OpenGameAgent.Memory` adds an optional, model-agnostic embedding contract, rebuildable vector index, lexical/vector hybrid recall, structured diagnostics, and game-time reranking. It never replaces the authoritative memory save. - `OpenGameAgent.Models` adds provider/model catalogs, capability-aware selection, reasoning levels, cost metadata, dynamic refresh, and replaceable authentication. - `OpenGameAgent.Models.BuiltIn` turns the bundled directory into an executable multi-provider model runtime; `OpenGameAgent.Models.Auth.BuiltIn` adds explicitly configured browser and device authorization flows. diff --git a/docs/features.md b/docs/features.md index 838eeae..245578c 100644 --- a/docs/features.md +++ b/docs/features.md @@ -33,6 +33,7 @@ This page maps product needs to the smallest reusable OpenGameAgent primitive. | Search a large tool catalog on demand | `ToolCatalogExtension`, `IGameToolCatalog` | | Ask the player structured questions and recommend choices | `StructuredInteractionExtension`, `IGameInteractionBroker` | | Track goals and resume them after game-time waits | `GoalLoopExtension` | +| Keep a persistent ordered checklist with host-verified progress | `TaskPlanExtension` | | Delegate bounded foreground or background work | `AgentDelegationExtension` | | Query a game-owned knowledge source | `ExternalKnowledgeExtension` | | Capture bounded lifecycle traces | `GameAgentTracingExtension` | diff --git a/docs/game-integration-patterns.md b/docs/game-integration-patterns.md index 765ac96..d94c602 100644 --- a/docs/game-integration-patterns.md +++ b/docs/game-integration-patterns.md @@ -32,6 +32,42 @@ game tick / month advance Use `GoalLoopExtension` when an actor owns semantic goals that can wait for a tick or event and continue later. `GoalLoopOptions.MaximumActiveGoals` bounds active and waiting work, while `MaximumRetainedTerminalGoals` independently retains only the most recent completed, failed, or cancelled records for audit. Terminal retention never removes active or waiting goals, so long-running sessions do not exhaust their future goal capacity. Use `AgentDelegationExtension` when one actor needs bounded background research or planning without sharing its mutable transcript. Delegates still receive explicitly scoped context and tools; delegation is not permission escalation. Delegation status can be persisted, but the included local executor runs child work in the current process and does not automatically resume an in-flight child after a process restart. Use a host-owned durable workflow or executor when child execution itself must survive restarts. +Use `TaskPlanExtension` for an ordered checklist that must survive later inputs. It is separate from `GoalLoopExtension`: goals describe durable intent and game-time waits, while a task plan records an ordered execution path. An active plan always has one `InProgress` step, a completed prefix, and a pending suffix. The model cannot advance a step merely by claiming success; the host-supplied `GameTaskPlanEvidenceValidator` must accept the evidence against the current input, plan, and step. + +```csharp +var plans = new TaskPlanExtension( + async (request, cancellationToken) => + await receipts.ExistsAsync( + request.Input.SessionId, + request.Input.ActorId, + request.Reference, + cancellationToken), + new TaskPlanOptions + { + MaximumActivePlans = 8, + MaximumRetainedTerminalPlans = 32, + }); + +var runtime = new GameAgentBuilder(provider, model) + .UseSessionStore(sessionStore) + .UseExtension(plans) + .UseExtension("plan-ui", "1", api => + api.Subscribe(TaskPlanExtension.PlanChanged, (change, _) => + { + ui.Enqueue(change.Session, change.Plan); + return ValueTask.CompletedTask; + })) + .Build(); +``` + +`advance` requires the plan revision and accepted evidence and can succeed only once per input. `replace_remaining` preserves completed steps and replaces only unfinished work. `fail` and `cancel` are terminal. Active plans contribute pending work to routing; terminal retention is independently bounded and never consumes active-plan capacity. State is namespaced by the runtime's session/actor key and persists through any `IGameSessionStore`. + +The tool payload cannot select an owner, session, or actor scope. Plans always use the already-authorized `GameInput`/`GameSessionKey`; a server host must resolve and authorize that key before invoking the runtime. + +The evidence validator is a read-only authority check, not another world mutation hook. Validate a receipt, observation revision, or game-owned fact there; perform actual state changes through ordinary authoritative tools and durable actions. + +`PlanChanged` carries the session/actor key and input ID. A UI that must show only committed state should buffer that channel and finalize it after the matching `SessionSaved` lifecycle event; a run that loses session CAS must not become authoritative UI state. + ## Monthly or turn-based evolution Represent the calendar in `GameMoment.CalendarJson` while using `Tick` for ordering. A monthly advance can be a named `DurableGameWorkflow`: diff --git a/docs/nuget-package-readme.md b/docs/nuget-package-readme.md index 6ab2e2e..50ce8a5 100644 --- a/docs/nuget-package-readme.md +++ b/docs/nuget-package-readme.md @@ -5,7 +5,7 @@ Open-source C# agent runtime for AI-native games, autonomous NPCs, and interacti - Small streaming model/tool-loop kernel - Arbitrary structured game inputs and game time - Durable game actions and workflows -- Typed extension API plus official policy, catalog, interaction, goal, memory, artifact, delegation, tracing, and workflow-graph extensions +- Typed extension API plus official policy, catalog, interaction, goal, host-verified task-plan, memory, artifact, delegation, tracing, and workflow-graph extensions - Skills, scheduling, mailboxes, large-result spill, and multi-actor concurrency - Optional model-agnostic vector memory and hybrid lexical/semantic recall - Optional Agent Plugins 1.0.0 package loading for portable skills and MCP servers diff --git a/src/OpenGameAgent.Extensions/OpenGameAgent.Extensions.csproj b/src/OpenGameAgent.Extensions/OpenGameAgent.Extensions.csproj index 4ee7468..fba15be 100644 --- a/src/OpenGameAgent.Extensions/OpenGameAgent.Extensions.csproj +++ b/src/OpenGameAgent.Extensions/OpenGameAgent.Extensions.csproj @@ -1,7 +1,7 @@ netstandard2.1 - Official policy, tool catalog, interaction, goal, memory, artifact, knowledge, delegation, tracing, and workflow extensions for OpenGameAgent. + Official policy, tool catalog, interaction, goal, task-plan, memory, artifact, knowledge, delegation, tracing, and workflow extensions for OpenGameAgent. OpenGameAgent.Extensions diff --git a/src/OpenGameAgent.Extensions/TaskPlanExtension.cs b/src/OpenGameAgent.Extensions/TaskPlanExtension.cs new file mode 100644 index 0000000..94a1a84 --- /dev/null +++ b/src/OpenGameAgent.Extensions/TaskPlanExtension.cs @@ -0,0 +1,667 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using OpenGameAgent.Kernel; + +namespace OpenGameAgent.Extensions; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum GameTaskPlanStatus +{ + Active, + Completed, + Failed, + Cancelled, +} + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum GameTaskPlanStepStatus +{ + Pending, + InProgress, + Completed, +} + +public sealed class GameTaskPlanStepSnapshot +{ + internal GameTaskPlanStepSnapshot(TaskPlanStepDocument document) + { + Id = document.Id; + Text = document.Text; + Status = document.Status; + } + + public string Id { get; } + + public string Text { get; } + + public GameTaskPlanStepStatus Status { get; } +} + +public sealed class GameTaskPlanSnapshot +{ + internal GameTaskPlanSnapshot(TaskPlanDocument document) + { + Id = document.Id; + Objective = document.Objective; + Status = document.Status; + Revision = document.Revision; + TerminalSequence = document.TerminalSequence; + LastTimelineId = document.LastTimelineId; + LastTick = document.LastTick; + Error = document.Error; + Steps = new ReadOnlyCollection( + document.Steps.Select(step => new GameTaskPlanStepSnapshot(step)).ToArray()); + } + + public string Id { get; } + + public string Objective { get; } + + public GameTaskPlanStatus Status { get; } + + public long Revision { get; } + + internal long TerminalSequence { get; } + + public string LastTimelineId { get; } + + public long LastTick { get; } + + public string? Error { get; } + + public IReadOnlyList Steps { get; } +} + +public sealed class GameTaskPlanChanged +{ + public GameTaskPlanChanged( + GameSessionKey session, + string inputId, + GameTaskPlanSnapshot plan, + string reason) + { + Session = new GameSessionKey(session.SessionId, session.ActorId); + InputId = string.IsNullOrWhiteSpace(inputId) || inputId.Length > 1_024 + ? throw new ArgumentException("An input ID must contain 1 to 1024 characters.", nameof(inputId)) + : inputId; + Plan = plan ?? throw new ArgumentNullException(nameof(plan)); + Reason = reason ?? string.Empty; + } + + public GameSessionKey Session { get; } + + public string InputId { get; } + + public GameTaskPlanSnapshot Plan { get; } + + public string Reason { get; } +} + +public sealed class GameTaskPlanEvidenceRequest +{ + public GameTaskPlanEvidenceRequest( + GameInput input, + GameTaskPlanSnapshot plan, + GameTaskPlanStepSnapshot step, + string kind, + string reference) + { + Input = input ?? throw new ArgumentNullException(nameof(input)); + Plan = plan ?? throw new ArgumentNullException(nameof(plan)); + Step = step ?? throw new ArgumentNullException(nameof(step)); + Kind = RequireBounded(kind, 128, nameof(kind)); + Reference = RequireBounded(reference, 2_048, nameof(reference)); + } + + public GameInput Input { get; } + + public GameTaskPlanSnapshot Plan { get; } + + public GameTaskPlanStepSnapshot Step { get; } + + public string Kind { get; } + + public string Reference { get; } + + private static string RequireBounded(string value, int maximumLength, string parameterName) + { + if (string.IsNullOrWhiteSpace(value) || value.Length > maximumLength) + { + throw new ArgumentException( + $"The value must contain 1 to {maximumLength} characters.", + parameterName); + } + + return value; + } +} + +public delegate ValueTask GameTaskPlanEvidenceValidator( + GameTaskPlanEvidenceRequest request, + CancellationToken cancellationToken); + +public sealed class TaskPlanOptions +{ + public int MaximumActivePlans { get; set; } = 32; + + public int MaximumRetainedTerminalPlans { get; set; } = 32; + + public int MaximumStepsPerPlan { get; set; } = 32; + + internal TaskPlanOptions CopyAndValidate() + { + var copy = (TaskPlanOptions)MemberwiseClone(); + if (copy.MaximumActivePlans < 1 || copy.MaximumActivePlans > 1_000) + { + throw new ArgumentOutOfRangeException(nameof(MaximumActivePlans)); + } + + if (copy.MaximumRetainedTerminalPlans < 0 || copy.MaximumRetainedTerminalPlans > 1_000) + { + throw new ArgumentOutOfRangeException(nameof(MaximumRetainedTerminalPlans)); + } + + if (copy.MaximumStepsPerPlan < 1 || copy.MaximumStepsPerPlan > 64) + { + throw new ArgumentOutOfRangeException(nameof(MaximumStepsPerPlan)); + } + + return copy; + } +} + +public sealed class TaskPlanExtension : IGameAgentExtension +{ + private const string PlanPrefix = "plan/"; + private const string ManageSchema = """ + { + "type":"object", + "required":["action","planId"], + "properties":{ + "action":{"type":"string","enum":["create","advance","replace_remaining","fail","cancel"]}, + "planId":{"type":"string","minLength":1,"maxLength":128}, + "expectedRevision":{"type":"integer","minimum":1}, + "objective":{"type":"string","minLength":1,"maxLength":4096}, + "steps":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"string","minLength":1,"maxLength":1024}}, + "evidence":{"type":"object","required":["kind","reference"],"properties":{"kind":{"type":"string","minLength":1,"maxLength":128},"reference":{"type":"string","minLength":1,"maxLength":2048}},"additionalProperties":false}, + "reason":{"type":"string","maxLength":4096} + }, + "additionalProperties":false + } + """; + private const string ListSchema = """ + {"type":"object","properties":{"includeTerminal":{"type":"boolean"}},"additionalProperties":false} + """; + + private readonly GameTaskPlanEvidenceValidator _evidenceValidator; + private readonly TaskPlanOptions _options; + + public TaskPlanExtension( + GameTaskPlanEvidenceValidator evidenceValidator, + TaskPlanOptions? options = null) + { + _evidenceValidator = evidenceValidator ?? throw new ArgumentNullException(nameof(evidenceValidator)); + _options = (options ?? new TaskPlanOptions()).CopyAndValidate(); + } + + public static GameAgentExtensionChannel PlanChanged { get; } = + new("task-plan.changed"); + + public GameAgentExtensionDescriptor Descriptor { get; } = new( + "opengameagent.task-plans", + "1.0.0", + "Persistent ordered task checklists with host-validated advancement.", + new[] { "task-plan", "checklist", "pending-work", "evidence" }); + + public void Configure(GameAgentExtensionApi api) + { + api.RegisterPromptFragment( + "task-plan-guidance", + "Use manage_task_plan for multi-step work that must survive later inputs. An active plan has exactly one in-progress step. Advance only with evidence the host can verify, never by assertion. Use replace_remaining when new world state invalidates unfinished work; completed steps remain immutable."); + api.RegisterToolProvider( + "task-plan-tools", + (context, _) => new ValueTask>(new[] + { + CreateManageTool(api, context), + CreateListTool(context), + })); + api.RegisterPendingWorkProvider( + "active-task-plans", + (context, _) => + { + PruneTerminalPlans(context.State); + return new ValueTask(ReadAll(context.State).Any(plan => plan.Status == GameTaskPlanStatus.Active)); + }, + priority: 450); + } + + private AgentTool CreateManageTool(GameAgentExtensionApi api, GameAgentExtensionRunContext context) => + new( + new ToolDefinition( + "manage_task_plan", + "Create, advance, replan, fail, or cancel a persistent ordered checklist for the current actor session. Advancing the final step completes the plan.", + ManageSchema), + async (arguments, _, cancellationToken) => + { + var action = arguments.GetProperty("action").GetString() ?? string.Empty; + var planId = arguments.GetProperty("planId").GetString() ?? string.Empty; + TaskPlanDocument document; + + if (string.Equals(action, "create", StringComparison.Ordinal)) + { + if (Read(context.State, planId) is not null) + { + return ToolResult.Error($"Task plan '{planId}' already exists."); + } + + PruneTerminalPlans(context.State); + var activeCount = ReadAll(context.State).Count(plan => plan.Status == GameTaskPlanStatus.Active); + if (activeCount >= _options.MaximumActivePlans) + { + return ToolResult.Error( + $"At most {_options.MaximumActivePlans} active task plans may exist in one actor session."); + } + + if (!arguments.TryGetProperty("objective", out var objectiveElement) + || !arguments.TryGetProperty("steps", out var stepsElement)) + { + return ToolResult.Error("Creating a task plan requires an objective and ordered steps."); + } + + var steps = ReadSteps(stepsElement); + if (steps.Count > _options.MaximumStepsPerPlan) + { + return ToolResult.Error( + $"A task plan can contain at most {_options.MaximumStepsPerPlan} steps."); + } + + document = new TaskPlanDocument + { + Id = planId, + Objective = objectiveElement.GetString() ?? string.Empty, + Status = GameTaskPlanStatus.Active, + Revision = 1, + LastTimelineId = context.Input.Moment.TimelineId, + LastTick = context.Input.Moment.Tick, + Steps = steps.Select((text, index) => new TaskPlanStepDocument + { + Id = $"step-{index + 1}", + Text = text, + Status = index == 0 + ? GameTaskPlanStepStatus.InProgress + : GameTaskPlanStepStatus.Pending, + }).ToList(), + }; + } + else + { + var existing = Read(context.State, planId); + if (existing is null) + { + return ToolResult.Error($"Task plan '{planId}' does not exist."); + } + + document = existing; + if (document.Status != GameTaskPlanStatus.Active) + { + return ToolResult.Error($"Task plan '{planId}' is terminal and immutable."); + } + + if (!arguments.TryGetProperty("expectedRevision", out var revisionElement) + || revisionElement.GetInt64() != document.Revision) + { + return ToolResult.Error( + $"Task plan '{planId}' revision conflict. Current revision is {document.Revision}."); + } + + switch (action) + { + case "advance": + if (string.Equals(document.LastAdvancedInputId, context.Input.InputId, StringComparison.Ordinal)) + { + return ToolResult.Error("A task plan may advance at most once per agent input."); + } + + if (!arguments.TryGetProperty("evidence", out var evidenceElement)) + { + return ToolResult.Error("Advancing a task plan requires host-verifiable evidence."); + } + + var current = document.Steps.Single(step => step.Status == GameTaskPlanStepStatus.InProgress); + if (!await ValidateEvidenceAsync( + context.Input, + document, + current, + evidenceElement, + cancellationToken).ConfigureAwait(false)) + { + return ToolResult.Error("The host rejected the evidence for advancing this task plan."); + } + + current.Status = GameTaskPlanStepStatus.Completed; + var next = document.Steps.FirstOrDefault(step => step.Status == GameTaskPlanStepStatus.Pending); + if (next is null) + { + document.Status = GameTaskPlanStatus.Completed; + document.TerminalSequence = NextTerminalSequence(context.State); + } + else + { + next.Status = GameTaskPlanStepStatus.InProgress; + } + + document.LastAdvancedInputId = context.Input.InputId; + break; + case "replace_remaining": + if (!arguments.TryGetProperty("steps", out var replacementElement)) + { + return ToolResult.Error("Replacing unfinished work requires ordered replacement steps."); + } + + var replacements = ReadSteps(replacementElement); + var completed = document.Steps + .Where(step => step.Status == GameTaskPlanStepStatus.Completed) + .Select(CloneStep) + .ToList(); + if (checked(completed.Count + replacements.Count) > _options.MaximumStepsPerPlan) + { + return ToolResult.Error( + $"A task plan can contain at most {_options.MaximumStepsPerPlan} steps."); + } + + var nextRevision = checked(document.Revision + 1); + completed.AddRange(replacements.Select((text, index) => new TaskPlanStepDocument + { + Id = $"step-r{nextRevision}-{index + 1}", + Text = text, + Status = index == 0 + ? GameTaskPlanStepStatus.InProgress + : GameTaskPlanStepStatus.Pending, + })); + document.Steps = completed; + break; + case "fail": + document.Status = GameTaskPlanStatus.Failed; + document.Error = ReadReason(arguments, "The task plan failed."); + document.TerminalSequence = NextTerminalSequence(context.State); + ClearInProgress(document); + break; + case "cancel": + document.Status = GameTaskPlanStatus.Cancelled; + document.Error = ReadReason(arguments, "The task plan was cancelled."); + document.TerminalSequence = NextTerminalSequence(context.State); + ClearInProgress(document); + break; + default: + return ToolResult.Error($"Unsupported task-plan action '{action}'."); + } + + document.Revision = checked(document.Revision + 1); + document.LastTimelineId = context.Input.Moment.TimelineId; + document.LastTick = context.Input.Moment.Tick; + } + + ValidateDocument(document, planId, _options.MaximumStepsPerPlan); + Write(context.State, document); + if (IsTerminal(document.Status)) + { + PruneTerminalPlans(context.State); + } + + var snapshot = new GameTaskPlanSnapshot(document); + await api.PublishAsync( + PlanChanged, + new GameTaskPlanChanged( + new GameSessionKey(context.Input.SessionId, context.Input.ActorId), + context.Input.InputId, + snapshot, + action), + cancellationToken).ConfigureAwait(false); + return JsonResult(snapshot); + }, + ToolRisk.IdempotentWrite, + ToolExecutionMode.Sequential, + conflictKey: arguments => arguments.TryGetProperty("planId", out var planId) + ? planId.GetString() + : null); + + private AgentTool CreateListTool(GameAgentExtensionRunContext context) => + new( + new ToolDefinition( + "list_task_plans", + "List persistent task plans for the current actor session.", + ListSchema), + (arguments, _, _) => + { + var includeTerminal = arguments.TryGetProperty("includeTerminal", out var include) + && include.GetBoolean(); + var plans = ReadAll(context.State) + .Where(plan => includeTerminal || plan.Status == GameTaskPlanStatus.Active) + .OrderBy(plan => plan.Id, StringComparer.Ordinal) + .ToArray(); + return new ValueTask(JsonResult(new { plans })); + }, + ToolRisk.ReadOnly); + + private async ValueTask ValidateEvidenceAsync( + GameInput input, + TaskPlanDocument document, + TaskPlanStepDocument current, + JsonElement evidence, + CancellationToken cancellationToken) + { + var kind = evidence.GetProperty("kind").GetString() ?? string.Empty; + var reference = evidence.GetProperty("reference").GetString() ?? string.Empty; + var request = new GameTaskPlanEvidenceRequest( + input, + new GameTaskPlanSnapshot(document), + new GameTaskPlanStepSnapshot(current), + kind, + reference); + try + { + return await _evidenceValidator(request, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception) + { + return false; + } + } + + private static List ReadSteps(JsonElement element) => + element.EnumerateArray().Select(step => step.GetString() ?? string.Empty).ToList(); + + private static string ReadReason(JsonElement arguments, string fallback) => + arguments.TryGetProperty("reason", out var reason) && !string.IsNullOrWhiteSpace(reason.GetString()) + ? reason.GetString()! + : fallback; + + private static void ClearInProgress(TaskPlanDocument document) + { + var current = document.Steps.Single(step => step.Status == GameTaskPlanStepStatus.InProgress); + current.Status = GameTaskPlanStepStatus.Pending; + } + + private static TaskPlanStepDocument CloneStep(TaskPlanStepDocument step) => new() + { + Id = step.Id, + Text = step.Text, + Status = step.Status, + }; + + private static bool IsTerminal(GameTaskPlanStatus status) => + status is GameTaskPlanStatus.Completed or GameTaskPlanStatus.Failed or GameTaskPlanStatus.Cancelled; + + private long NextTerminalSequence(GameAgentExtensionState state) + { + var maximum = ReadAll(state) + .Where(plan => IsTerminal(plan.Status)) + .Select(plan => plan.TerminalSequence) + .DefaultIfEmpty() + .Max(); + return checked(maximum + 1); + } + + private void PruneTerminalPlans(GameAgentExtensionState state) + { + var expired = ReadAll(state) + .Where(plan => IsTerminal(plan.Status)) + .OrderByDescending(plan => plan.TerminalSequence) + .ThenBy(plan => plan.Id, StringComparer.Ordinal) + .Skip(_options.MaximumRetainedTerminalPlans) + .ToArray(); + foreach (var plan in expired) + { + state.Remove(PlanPrefix + plan.Id); + } + } + + private TaskPlanDocument? Read(GameAgentExtensionState state, string planId) + { + var json = state.Get(PlanPrefix + planId); + return json is null ? null : Decode(json, planId); + } + + private IReadOnlyList ReadAll(GameAgentExtensionState state) + { + var plans = state.Snapshot() + .Where(pair => pair.Key.StartsWith(PlanPrefix, StringComparison.Ordinal)) + .Select(pair => Decode(pair.Value, pair.Key.Substring(PlanPrefix.Length))) + .Select(document => new GameTaskPlanSnapshot(document)) + .ToArray(); + var duplicate = plans.GroupBy(plan => plan.Id, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null) + { + throw new InvalidOperationException($"Task-plan state contains duplicate ID '{duplicate.Key}'."); + } + + return Array.AsReadOnly(plans); + } + + private TaskPlanDocument Decode(string json, string expectedId) + { + try + { + var document = JsonSerializer.Deserialize(json) + ?? throw new InvalidOperationException("The task-plan document is null."); + ValidateDocument(document, expectedId, _options.MaximumStepsPerPlan); + return document; + } + catch (Exception exception) when (exception is JsonException or InvalidOperationException) + { + throw new InvalidOperationException($"Task plan '{expectedId}' state is invalid.", exception); + } + } + + private static void ValidateDocument(TaskPlanDocument document, string expectedId, int maximumSteps) + { + if (string.IsNullOrWhiteSpace(document.Id) + || document.Id.Length > 128 + || !string.Equals(document.Id, expectedId, StringComparison.Ordinal) + || string.IsNullOrWhiteSpace(document.Objective) + || document.Objective.Length > 4_096 + || document.Revision < 1 + || document.TerminalSequence < 0 + || string.IsNullOrWhiteSpace(document.LastTimelineId) + || !Enum.IsDefined(typeof(GameTaskPlanStatus), document.Status) + || document.Steps is null + || document.Steps.Count < 1 + || document.Steps.Count > maximumSteps + || (document.LastAdvancedInputId?.Length ?? 0) > 1_024 + || (document.Error?.Length ?? 0) > 4_096) + { + throw new InvalidOperationException("The task-plan document contains invalid fields."); + } + + var duplicate = document.Steps.GroupBy(step => step.Id, StringComparer.Ordinal) + .FirstOrDefault(group => group.Count() > 1); + if (duplicate is not null + || document.Steps.Any(step => string.IsNullOrWhiteSpace(step.Id) + || step.Id.Length > 128 + || string.IsNullOrWhiteSpace(step.Text) + || step.Text.Length > 1_024 + || !Enum.IsDefined(typeof(GameTaskPlanStepStatus), step.Status))) + { + throw new InvalidOperationException("The task-plan steps are invalid."); + } + + var inProgress = document.Steps.Count(step => step.Status == GameTaskPlanStepStatus.InProgress); + if ((document.Status == GameTaskPlanStatus.Active && inProgress != 1) + || (document.Status != GameTaskPlanStatus.Active && inProgress != 0) + || (document.Status == GameTaskPlanStatus.Completed + && document.Steps.Any(step => step.Status != GameTaskPlanStepStatus.Completed)) + || IsTerminal(document.Status) != (document.TerminalSequence > 0) + || ((document.Status is GameTaskPlanStatus.Active or GameTaskPlanStatus.Completed) && document.Error is not null)) + { + throw new InvalidOperationException("The task-plan status does not match its checklist."); + } + + var sawInProgress = false; + var sawPending = false; + foreach (var step in document.Steps) + { + switch (step.Status) + { + case GameTaskPlanStepStatus.Completed when !sawInProgress && !sawPending: + break; + case GameTaskPlanStepStatus.InProgress when !sawInProgress && !sawPending: + sawInProgress = true; + break; + case GameTaskPlanStepStatus.Pending: + sawPending = true; + break; + default: + throw new InvalidOperationException("The task-plan checklist is not ordered."); + } + } + } + + private static void Write(GameAgentExtensionState state, TaskPlanDocument document) => + state.Set(PlanPrefix + document.Id, JsonSerializer.Serialize(document)); + + private static ToolResult JsonResult(object value) => + new(new AgentContent[] { new JsonContent(JsonSerializer.Serialize(value)) }); +} + +internal sealed class TaskPlanDocument +{ + public string Id { get; set; } = string.Empty; + + public string Objective { get; set; } = string.Empty; + + public GameTaskPlanStatus Status { get; set; } + + public long Revision { get; set; } + + public long TerminalSequence { get; set; } + + public string LastTimelineId { get; set; } = string.Empty; + + public long LastTick { get; set; } + + public string? LastAdvancedInputId { get; set; } + + public string? Error { get; set; } + + public List Steps { get; set; } = new(); +} + +internal sealed class TaskPlanStepDocument +{ + public string Id { get; set; } = string.Empty; + + public string Text { get; set; } = string.Empty; + + public GameTaskPlanStepStatus Status { get; set; } +} diff --git a/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs b/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs index d9ca93b..4b7dfb2 100644 --- a/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs +++ b/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs @@ -332,8 +332,14 @@ public async Task ConcurrentGoalUpdatesUseSessionCasWithoutLosingExistingActiveG new GameInput("session", "actor", "request", "{}", new GameMoment("world", 6), "right-input"), TestContext.Current.CancellationToken)); - Assert.Single(results, result => result.Status == GameAgentRunStatus.Completed); - Assert.Single(results, result => result.Status == GameAgentRunStatus.SessionConflict); + // A winning tool checkpoint can commit before a later usage settlement advances the + // session again, so both callers may conservatively report a conflict under load. + Assert.Contains(results, result => result.Status == GameAgentRunStatus.SessionConflict); + Assert.All( + results, + result => Assert.True( + result.Status is GameAgentRunStatus.Completed or GameAgentRunStatus.SessionConflict, + $"Unexpected concurrent run status '{result.Status}'.")); var snapshot = await store.LoadAsync( new GameSessionKey("session", "actor"), TestContext.Current.CancellationToken); diff --git a/tests/OpenGameAgent.Extensions.Tests/TaskPlanExtensionTests.cs b/tests/OpenGameAgent.Extensions.Tests/TaskPlanExtensionTests.cs new file mode 100644 index 0000000..d6b6dab --- /dev/null +++ b/tests/OpenGameAgent.Extensions.Tests/TaskPlanExtensionTests.cs @@ -0,0 +1,421 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Text.Json; +using OpenGameAgent.Kernel; +using Xunit; + +namespace OpenGameAgent.Extensions.Tests; + +public sealed class TaskPlanExtensionTests +{ + [Fact] + public async Task OrderedChecklistAdvancesOncePerInputAndPublishesScopedChanges() + { + var store = new InMemoryGameSessionStore(); + var changes = new ConcurrentQueue(); + var provider = new ScriptedProvider(call => call switch + { + 1 => ToolCall("create", "{\"action\":\"create\",\"planId\":\"build\",\"objective\":\"finish work\",\"steps\":[\"prepare\",\"execute\",\"verify\"]}"), + 2 => ToolCall("advance", "{\"action\":\"advance\",\"planId\":\"build\",\"expectedRevision\":1,\"evidence\":{\"kind\":\"receipt\",\"reference\":\"operation-1\"}}"), + 3 => ToolCall("duplicate", "{\"action\":\"advance\",\"planId\":\"build\",\"expectedRevision\":2,\"evidence\":{\"kind\":\"receipt\",\"reference\":\"operation-1\"}}"), + _ => TextResponse("done"), + }); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseSessionStore(store) + .UseExtension(new TaskPlanExtension((_, _) => new ValueTask(true))) + .UseExtension( + "task-plan.listener", + "1", + api => api.Subscribe(TaskPlanExtension.PlanChanged, (change, _) => + { + changes.Enqueue(change); + return ValueTask.CompletedTask; + })) + .Build(); + + var result = await runtime.RunAsync(Input("input-1"), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + using var document = await ReadOnlyPlanAsync(store, "session", "actor"); + Assert.Equal(2, document.RootElement.GetProperty("Revision").GetInt64()); + Assert.Equal( + new[] { "Completed", "InProgress", "Pending" }, + Statuses(document.RootElement)); + Assert.Equal(2, changes.Count); + Assert.All(changes, change => + { + Assert.Equal(new GameSessionKey("session", "actor"), change.Session); + Assert.Equal("input-1", change.InputId); + }); + } + + [Fact] + public async Task ForgedEvidenceFailsClosedWithoutChangingRevision() + { + var store = new InMemoryGameSessionStore(); + await RunAsync( + store, + new TaskPlanExtension((_, _) => new ValueTask(true)), + Input("create"), + ToolCall("create", "{\"action\":\"create\",\"planId\":\"proof\",\"objective\":\"prove work\",\"steps\":[\"act\",\"verify\"]}"), + TextResponse("created")); + await RunAsync( + store, + new TaskPlanExtension((request, _) => + new ValueTask(request.Reference == "trusted")), + Input("forged"), + ToolCall("advance", "{\"action\":\"advance\",\"planId\":\"proof\",\"expectedRevision\":1,\"evidence\":{\"kind\":\"receipt\",\"reference\":\"invented\"}}"), + TextResponse("rejected")); + + using var document = await ReadOnlyPlanAsync(store, "session", "actor"); + Assert.Equal(1, document.RootElement.GetProperty("Revision").GetInt64()); + Assert.Equal(new[] { "InProgress", "Pending" }, Statuses(document.RootElement)); + } + + [Fact] + public async Task ReplaceRemainingPreservesCompletedPrefix() + { + var store = new InMemoryGameSessionStore(); + var extension = new TaskPlanExtension((_, _) => new ValueTask(true)); + await RunAsync( + store, + extension, + Input("create"), + ToolCall("create", "{\"action\":\"create\",\"planId\":\"adaptive\",\"objective\":\"adapt\",\"steps\":[\"first\",\"obsolete\",\"later\"]}"), + TextResponse("created")); + await RunAsync( + store, + extension, + Input("advance"), + ToolCall("advance", "{\"action\":\"advance\",\"planId\":\"adaptive\",\"expectedRevision\":1,\"evidence\":{\"kind\":\"observation\",\"reference\":\"world-2\"}}"), + TextResponse("advanced")); + await RunAsync( + store, + extension, + Input("replan"), + ToolCall("replace", "{\"action\":\"replace_remaining\",\"planId\":\"adaptive\",\"expectedRevision\":2,\"steps\":[\"new second\",\"new third\"]}"), + TextResponse("replanned")); + + using var document = await ReadOnlyPlanAsync(store, "session", "actor"); + var steps = document.RootElement.GetProperty("Steps").EnumerateArray().ToArray(); + Assert.Equal(new[] { "first", "new second", "new third" }, + steps.Select(step => step.GetProperty("Text").GetString()).ToArray()); + Assert.Equal(new[] { "Completed", "InProgress", "Pending" }, Statuses(document.RootElement)); + Assert.Equal("step-1", steps[0].GetProperty("Id").GetString()); + } + + [Fact] + public async Task ConcurrentMutationsUseSessionCas() + { + var store = new InMemoryGameSessionStore(); + await RunAsync( + store, + new TaskPlanExtension((_, _) => new ValueTask(true)), + Input("seed"), + ToolCall("create", "{\"action\":\"create\",\"planId\":\"shared\",\"objective\":\"shared work\",\"steps\":[\"one\",\"two\"]}"), + TextResponse("created")); + + var gate = new ConcurrentRunGate(2); + var response = ToolCall("advance", "{\"action\":\"advance\",\"planId\":\"shared\",\"expectedRevision\":1,\"evidence\":{\"kind\":\"receipt\",\"reference\":\"valid\"}}"); + await using var left = new GameAgentBuilder(new FirstCallBarrierProvider(gate, response), "model") + .UseSessionStore(store) + .UseExtension(new TaskPlanExtension((_, _) => new ValueTask(true))) + .Build(); + await using var right = new GameAgentBuilder(new FirstCallBarrierProvider(gate, response), "model") + .UseSessionStore(store) + .UseExtension(new TaskPlanExtension((_, _) => new ValueTask(true))) + .Build(); + + var results = await Task.WhenAll( + left.RunAsync(Input("left"), TestContext.Current.CancellationToken), + right.RunAsync(Input("right"), TestContext.Current.CancellationToken)); + + // The committed checklist below is authoritative even if a later usage settlement makes + // both concurrent callers conservatively report a session conflict. + Assert.Contains(results, result => result.Status == GameAgentRunStatus.SessionConflict); + Assert.All( + results, + result => Assert.True( + result.Status is GameAgentRunStatus.Completed or GameAgentRunStatus.SessionConflict, + $"Unexpected concurrent run status '{result.Status}'.")); + using var document = await ReadOnlyPlanAsync(store, "session", "actor"); + Assert.Equal(2, document.RootElement.GetProperty("Revision").GetInt64()); + } + + [Fact] + public async Task StateIsIsolatedBySessionAndActor() + { + var store = new InMemoryGameSessionStore(); + foreach (var scope in new[] + { + (Session: "owner-a", Actor: "actor-a"), + (Session: "owner-a", Actor: "actor-b"), + (Session: "owner-b", Actor: "actor-a"), + }) + { + await RunAsync( + store, + new TaskPlanExtension((_, _) => new ValueTask(true)), + Input("create-" + scope.Session + "-" + scope.Actor, scope.Session, scope.Actor), + ToolCall("create", "{\"action\":\"create\",\"planId\":\"same-id\",\"objective\":\"scoped\",\"steps\":[\"one\"]}"), + TextResponse("created")); + } + + foreach (var scope in new[] + { + (Session: "owner-a", Actor: "actor-a"), + (Session: "owner-a", Actor: "actor-b"), + (Session: "owner-b", Actor: "actor-a"), + }) + { + using var document = await ReadOnlyPlanAsync(store, scope.Session, scope.Actor); + Assert.Equal("same-id", document.RootElement.GetProperty("Id").GetString()); + } + } + + [Fact] + public async Task TerminalRetentionDoesNotConsumeActiveCapacity() + { + var store = new InMemoryGameSessionStore(); + var options = new TaskPlanOptions + { + MaximumActivePlans = 1, + MaximumRetainedTerminalPlans = 1, + }; + for (var index = 1; index <= 3; index++) + { + var id = "plan-" + index; + await RunAsync( + store, + new TaskPlanExtension((_, _) => new ValueTask(true), options), + Input("create-" + index), + ToolCall("create", $"{{\"action\":\"create\",\"planId\":\"{id}\",\"objective\":\"work\",\"steps\":[\"one\"]}}"), + TextResponse("created")); + await RunAsync( + store, + new TaskPlanExtension((_, _) => new ValueTask(true), options), + Input("finish-" + index), + ToolCall("advance", $"{{\"action\":\"advance\",\"planId\":\"{id}\",\"expectedRevision\":1,\"evidence\":{{\"kind\":\"receipt\",\"reference\":\"op-{index}\"}}}}"), + TextResponse("finished")); + } + + var snapshot = await store.LoadAsync( + new GameSessionKey("session", "actor"), + TestContext.Current.CancellationToken); + Assert.Single(snapshot!.ExtensionState); + using var retained = JsonDocument.Parse(snapshot.ExtensionState.Single().Value); + Assert.Equal("plan-3", retained.RootElement.GetProperty("Id").GetString()); + Assert.Equal("Completed", retained.RootElement.GetProperty("Status").GetString()); + } + + [Fact] + public async Task ActivePlanIsExposedAsPendingWorkOnLaterInputs() + { + var store = new InMemoryGameSessionStore(); + var pending = new ConcurrentQueue(); + await RunAsync( + store, + new TaskPlanExtension((_, _) => new ValueTask(true)), + Input("create"), + ToolCall("create", "{\"action\":\"create\",\"planId\":\"pending\",\"objective\":\"continue later\",\"steps\":[\"one\",\"two\"]}"), + TextResponse("created")); + + await using var runtime = new GameAgentBuilder(new ScriptedProvider(new[] { TextResponse("observed") }), "model") + .UseSessionStore(store) + .UseExtension(new TaskPlanExtension((_, _) => new ValueTask(true))) + .UseExtension( + "pending-work.observer", + "1", + api => api.RegisterRouteRule( + "capture", + (_, _, hasPendingWork, _) => + { + pending.Enqueue(hasPendingWork); + return new ValueTask(GameRouteDecision.Agent("captured")); + }, + priority: 1_000)) + .Build(); + + var result = await runtime.RunAsync(Input("later"), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + Assert.True(Assert.Single(pending)); + } + + [Fact] + public async Task FailAndCancelProduceBoundedTerminalChecklists() + { + var store = new InMemoryGameSessionStore(); + var extension = new TaskPlanExtension((_, _) => new ValueTask(true)); + await RunAsync( + store, + extension, + Input("create-failed"), + ToolCall("create", "{\"action\":\"create\",\"planId\":\"failed\",\"objective\":\"work\",\"steps\":[\"one\",\"two\"]}"), + TextResponse("created")); + await RunAsync( + store, + extension, + Input("fail"), + ToolCall("fail", "{\"action\":\"fail\",\"planId\":\"failed\",\"expectedRevision\":1,\"reason\":\"blocked\"}"), + TextResponse("failed")); + await RunAsync( + store, + extension, + Input("create-cancelled"), + ToolCall("create", "{\"action\":\"create\",\"planId\":\"cancelled\",\"objective\":\"work\",\"steps\":[\"one\"]}"), + TextResponse("created")); + await RunAsync( + store, + extension, + Input("cancel"), + ToolCall("cancel", "{\"action\":\"cancel\",\"planId\":\"cancelled\",\"expectedRevision\":1}"), + TextResponse("cancelled")); + + var snapshot = await store.LoadAsync( + new GameSessionKey("session", "actor"), + TestContext.Current.CancellationToken); + var plans = snapshot!.ExtensionState.Values + .Select(value => JsonDocument.Parse(value)) + .ToArray(); + try + { + Assert.Contains(plans, plan => + plan.RootElement.GetProperty("Status").GetString() == "Failed" + && plan.RootElement.GetProperty("Error").GetString() == "blocked"); + Assert.Contains(plans, plan => + plan.RootElement.GetProperty("Status").GetString() == "Cancelled"); + Assert.All( + plans, + plan => Assert.DoesNotContain( + plan.RootElement.GetProperty("Steps").EnumerateArray(), + step => step.GetProperty("Status").GetString() == "InProgress")); + } + finally + { + foreach (var plan in plans) + { + plan.Dispose(); + } + } + } + + private static async Task RunAsync( + IGameSessionStore store, + TaskPlanExtension extension, + GameInput input, + params ModelResponse[] responses) + { + await using var runtime = new GameAgentBuilder(new ScriptedProvider(responses), "model") + .UseSessionStore(store) + .UseExtension(extension) + .Build(); + var result = await runtime.RunAsync(input, TestContext.Current.CancellationToken); + Assert.True(result.Succeeded, result.Error ?? result.AgentResult?.Error); + } + + private static GameInput Input( + string inputId, + string sessionId = "session", + string actorId = "actor") => + new(sessionId, actorId, "request", "{}", new GameMoment("world", 1), inputId); + + private static async Task ReadOnlyPlanAsync( + IGameSessionStore store, + string sessionId, + string actorId) + { + var snapshot = await store.LoadAsync( + new GameSessionKey(sessionId, actorId), + TestContext.Current.CancellationToken); + return JsonDocument.Parse(Assert.Single(snapshot!.ExtensionState).Value); + } + + private static string[] Statuses(JsonElement plan) => + plan.GetProperty("Steps") + .EnumerateArray() + .Select(step => step.GetProperty("Status").GetString()!) + .ToArray(); + + private static ModelResponse ToolCall(string id, string arguments) => + new(new AgentContent[] { new ToolCallContent(id, "manage_task_plan", arguments) }, ModelStopReason.ToolUse); + + private static ModelResponse TextResponse(string text) => + new(new AgentContent[] { new TextContent(text) }, ModelStopReason.Stop); + + private sealed class ScriptedProvider : IModelProvider + { + private readonly Func _response; + private int _calls; + + public ScriptedProvider(IReadOnlyList responses) + { + _response = call => call <= responses.Count ? responses[call - 1] : TextResponse("done"); + } + + public ScriptedProvider(Func response) + { + _response = response; + } + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return ModelStreamEvent.Terminal(_response(Interlocked.Increment(ref _calls))); + await Task.CompletedTask; + } + } + + private sealed class ConcurrentRunGate + { + private readonly int _participants; + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _arrivals; + + public ConcurrentRunGate(int participants) + { + _participants = participants; + } + + public async Task ArriveAsync(CancellationToken cancellationToken) + { + if (Interlocked.Increment(ref _arrivals) == _participants) + { + _release.TrySetResult(); + } + + await _release.Task.WaitAsync(cancellationToken); + } + } + + private sealed class FirstCallBarrierProvider : IModelProvider + { + private readonly ConcurrentRunGate _gate; + private readonly ModelResponse _first; + private int _calls; + + public FirstCallBarrierProvider(ConcurrentRunGate gate, ModelResponse first) + { + _gate = gate; + _first = first; + } + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var call = Interlocked.Increment(ref _calls); + if (call == 1) + { + await _gate.ArriveAsync(cancellationToken); + yield return ModelStreamEvent.Terminal(_first); + } + else + { + yield return ModelStreamEvent.Terminal(TextResponse("done")); + } + } + } +} diff --git a/tests/OpenGameAgent.Persistence.Tests/TaskPlanPersistenceTests.cs b/tests/OpenGameAgent.Persistence.Tests/TaskPlanPersistenceTests.cs new file mode 100644 index 0000000..0179bac --- /dev/null +++ b/tests/OpenGameAgent.Persistence.Tests/TaskPlanPersistenceTests.cs @@ -0,0 +1,122 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using OpenGameAgent.Extensions; +using OpenGameAgent.Kernel; +using Xunit; + +namespace OpenGameAgent.Persistence.Tests; + +public sealed class TaskPlanPersistenceTests +{ + [Fact] + public async Task ChecklistRevisionAndAdvanceGuardSurviveProcessRestart() + { + using var directory = new TemporaryDirectory(); + var evidenceCalls = 0; + GameTaskPlanEvidenceValidator validator = (request, _) => + { + Interlocked.Increment(ref evidenceCalls); + return new ValueTask(request.Reference == "receipt-1"); + }; + + await using (var runtime = new GameAgentBuilder( + new ScriptedProvider(call => call == 1 + ? ToolCall("create", "{\"action\":\"create\",\"planId\":\"persistent\",\"objective\":\"persist\",\"steps\":[\"one\",\"two\"]}") + : TextResponse("created")), + "model") + .UseSessionStore(new FileGameSessionStore(directory.Path)) + .UseExtension(new TaskPlanExtension(validator)) + .Build()) + { + var result = await runtime.RunAsync(Input("create"), TestContext.Current.CancellationToken); + Assert.True(result.Succeeded); + } + + await using (var runtime = new GameAgentBuilder( + new ScriptedProvider(call => call switch + { + 1 => ToolCall("advance", "{\"action\":\"advance\",\"planId\":\"persistent\",\"expectedRevision\":1,\"evidence\":{\"kind\":\"receipt\",\"reference\":\"receipt-1\"}}"), + 2 => ToolCall("duplicate", "{\"action\":\"advance\",\"planId\":\"persistent\",\"expectedRevision\":2,\"evidence\":{\"kind\":\"receipt\",\"reference\":\"receipt-1\"}}"), + _ => TextResponse("advanced"), + }), + "model") + .UseSessionStore(new FileGameSessionStore(directory.Path)) + .UseExtension(new TaskPlanExtension(validator)) + .Build()) + { + var result = await runtime.RunAsync(Input("advance"), TestContext.Current.CancellationToken); + Assert.True(result.Succeeded); + } + + var snapshot = await new FileGameSessionStore(directory.Path).LoadAsync( + new GameSessionKey("session", "actor"), + TestContext.Current.CancellationToken); + using var document = JsonDocument.Parse(Assert.Single(snapshot!.ExtensionState).Value); + Assert.Equal(2, document.RootElement.GetProperty("Revision").GetInt64()); + Assert.Equal( + new[] { "Completed", "InProgress" }, + document.RootElement.GetProperty("Steps").EnumerateArray() + .Select(step => step.GetProperty("Status").GetString()).ToArray()); + Assert.Equal("advance", document.RootElement.GetProperty("LastAdvancedInputId").GetString()); + Assert.Equal(1, Volatile.Read(ref evidenceCalls)); + } + + private static GameInput Input(string inputId) => + new("session", "actor", "request", "{}", new GameMoment("world", 1), inputId); + + private static ModelResponse ToolCall(string id, string arguments) => + new(new AgentContent[] { new ToolCallContent(id, "manage_task_plan", arguments) }, ModelStopReason.ToolUse); + + private static ModelResponse TextResponse(string text) => + new(new AgentContent[] { new TextContent(text) }, ModelStopReason.Stop); + + private sealed class ScriptedProvider : IModelProvider + { + private readonly Func _response; + private int _calls; + + public ScriptedProvider(Func response) + { + _response = response; + } + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return ModelStreamEvent.Terminal(_response(Interlocked.Increment(ref _calls))); + await Task.CompletedTask; + } + } + + private sealed class TemporaryDirectory : IDisposable + { + public TemporaryDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + "OpenGameAgent.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.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); + } + } + } +}