From a7e8664d7debbbe5ca202b10ce53490c81460296 Mon Sep 17 00:00:00 2001 From: Eric Sun <141227631+EricSun0218@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:35:06 +0800 Subject: [PATCH] Add typed host queries for goals and task plans --- CHANGELOG.md | 1 + docs/game-integration-patterns.md | 25 ++++- .../GoalLoopExtension.cs | 92 ++++++++++++++++++- .../StoredExtensionStateReader.cs | 37 ++++++++ .../TaskPlanExtension.cs | 78 +++++++++++++++- .../OfficialExtensionTests.cs | 7 +- .../TaskPlanExtensionTests.cs | 37 +++++++- .../GoalLoopPersistenceTests.cs | 31 ++++--- .../TaskPlanPersistenceTests.cs | 18 ++-- 9 files changed, 287 insertions(+), 39 deletions(-) create mode 100644 src/OpenGameAgent.Extensions/StoredExtensionStateReader.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e101655..f09976a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 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. +- Add typed, model-free host queries for persisted goals and task plans, including session revisions, and scope goal-change events with their session/actor key and input ID. ## 0.3.0-alpha.2 diff --git a/docs/game-integration-patterns.md b/docs/game-integration-patterns.md index d94c602..a34c817 100644 --- a/docs/game-integration-patterns.md +++ b/docs/game-integration-patterns.md @@ -32,6 +32,25 @@ 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. +The host can project goals and task plans after loading a save without invoking a model and without parsing extension-owned JSON keys: + +```csharp +var authorizedSession = new GameSessionKey(sessionId, actorId); +var goals = await GoalLoopExtension.ReadAsync( + sessionStore, + authorizedSession, + includeTerminal: true, + cancellationToken); +var taskPlans = await TaskPlanExtension.ReadAsync( + sessionStore, + authorizedSession, + cancellationToken: cancellationToken); + +ui.Render(goals.SessionRevision, goals.Goals, taskPlans.Plans); +``` + +These readers are read-only projections over `IGameSessionStore`. They do not run routing, providers, tools, pruning, or other extension lifecycle work. A missing session returns revision `0` and an empty collection. The caller must authorize the `GameSessionKey` before querying it; the readers deliberately do not replace host ownership policy. + 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 @@ -66,7 +85,11 @@ The tool payload cannot select an owner, session, or actor scope. Plans always u 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. +`PlanChanged` and `GoalChanged` carry the session/actor key and input ID. A UI that must show only committed state should buffer those channels and finalize them after the matching `SessionSaved` lifecycle event; a run that loses session CAS must not become authoritative UI state. + +### Host query migration + +Hosts that previously inspected `GameSessionSnapshot.ExtensionState` should migrate to `GoalLoopExtension.ReadAsync` and `TaskPlanExtension.ReadAsync`. Treat extension-state key encoding and JSON documents as private storage details. `GameGoalChanged` now follows `GameTaskPlanChanged`: its constructor and every published event include `GameSessionKey` and `InputId`, so event consumers should correlate the change with the matching saved input before updating authoritative UI. ## Monthly or turn-based evolution diff --git a/src/OpenGameAgent.Extensions/GoalLoopExtension.cs b/src/OpenGameAgent.Extensions/GoalLoopExtension.cs index 478c9a2..94395b0 100644 --- a/src/OpenGameAgent.Extensions/GoalLoopExtension.cs +++ b/src/OpenGameAgent.Extensions/GoalLoopExtension.cs @@ -109,17 +109,56 @@ internal GameGoalSnapshot(GoalDocument document) public sealed class GameGoalChanged { - public GameGoalChanged(GameGoalSnapshot goal, string reason) + public GameGoalChanged( + GameSessionKey session, + string inputId, + GameGoalSnapshot goal, + 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; Goal = goal ?? throw new ArgumentNullException(nameof(goal)); Reason = reason ?? string.Empty; } + public GameSessionKey Session { get; } + + public string InputId { get; } + public GameGoalSnapshot Goal { get; } public string Reason { get; } } +public sealed class GameGoalQueryResult +{ + internal GameGoalQueryResult( + GameSessionKey session, + long sessionRevision, + IEnumerable goals) + { + Session = new GameSessionKey(session.SessionId, session.ActorId); + SessionRevision = sessionRevision >= 0 + ? sessionRevision + : throw new ArgumentOutOfRangeException(nameof(sessionRevision)); + var copy = (goals ?? throw new ArgumentNullException(nameof(goals))).ToArray(); + if (copy.Any(goal => goal is null)) + { + throw new ArgumentException("Goal query results cannot contain null goals.", nameof(goals)); + } + + Goals = Array.AsReadOnly(copy); + } + + public GameSessionKey Session { get; } + + public long SessionRevision { get; } + + public IReadOnlyList Goals { get; } +} + public sealed class GoalLoopOptions { public int MaximumActiveGoals { get; set; } = 64; @@ -152,6 +191,7 @@ internal GoalLoopOptions CopyAndValidate() public sealed class GoalLoopExtension : IGameAgentExtension { + private const string ExtensionId = "opengameagent.goals"; private const string GoalPrefix = "goal/"; private const string ManageSchema = """ { @@ -184,11 +224,42 @@ public GoalLoopExtension(GoalLoopOptions? options = null) public static GameAgentExtensionChannel GoalChanged { get; } = new("goal.changed"); public GameAgentExtensionDescriptor Descriptor { get; } = new( - "opengameagent.goals", + ExtensionId, "1.0.0", "Durable goal state that can wait on game time or game events and resume on later inputs.", new[] { "goals", "durable-loop", "game-time", "game-events" }); + public static async ValueTask ReadAsync( + IGameSessionStore sessionStore, + GameSessionKey session, + bool includeTerminal = false, + CancellationToken cancellationToken = default) + { + if (sessionStore is null) + { + throw new ArgumentNullException(nameof(sessionStore)); + } + + var key = new GameSessionKey(session.SessionId, session.ActorId); + cancellationToken.ThrowIfCancellationRequested(); + var snapshot = await sessionStore.LoadAsync(key, cancellationToken).ConfigureAwait(false); + if (snapshot is null) + { + return new GameGoalQueryResult(key, 0, Array.Empty()); + } + + if (snapshot.Key != key) + { + throw new InvalidOperationException("The session store returned a different session key."); + } + + var goals = ReadAll(StoredExtensionStateReader.Read(snapshot, ExtensionId)) + .Where(goal => includeTerminal || goal.Status is GameGoalStatus.Active or GameGoalStatus.Waiting) + .OrderBy(goal => goal.Id, StringComparer.Ordinal) + .ToArray(); + return new GameGoalQueryResult(key, snapshot.Revision, goals); + } + public void Configure(GameAgentExtensionApi api) { api.RegisterPromptFragment( @@ -232,7 +303,11 @@ private async ValueTask ResumeAndCheckPendingAsync( goal = new GameGoalSnapshot(resumed); await api.PublishAsync( GoalChanged, - new GameGoalChanged(goal, "resumed"), + new GameGoalChanged( + new GameSessionKey(context.Input.SessionId, context.Input.ActorId), + context.Input.InputId, + goal, + "resumed"), cancellationToken).ConfigureAwait(false); } @@ -378,7 +453,11 @@ private AgentTool CreateManageTool(GameAgentExtensionApi api, GameAgentExtension var snapshot = new GameGoalSnapshot(document); await api.PublishAsync( GoalChanged, - new GameGoalChanged(snapshot, action), + new GameGoalChanged( + new GameSessionKey(context.Input.SessionId, context.Input.ActorId), + context.Input.InputId, + snapshot, + action), cancellationToken).ConfigureAwait(false); return JsonResult(snapshot); }, @@ -445,8 +524,11 @@ private void PruneTerminalGoals(GameAgentExtensionState state) } private static IReadOnlyList ReadAll(GameAgentExtensionState state) + => ReadAll(state.Snapshot()); + + private static IReadOnlyList ReadAll(IReadOnlyDictionary state) { - var goals = state.Snapshot() + var goals = state .Where(pair => pair.Key.StartsWith(GoalPrefix, StringComparison.Ordinal)) .Select(pair => Decode(pair.Value, pair.Key.Substring(GoalPrefix.Length))) .Select(document => new GameGoalSnapshot(document)) diff --git a/src/OpenGameAgent.Extensions/StoredExtensionStateReader.cs b/src/OpenGameAgent.Extensions/StoredExtensionStateReader.cs new file mode 100644 index 0000000..cfa866c --- /dev/null +++ b/src/OpenGameAgent.Extensions/StoredExtensionStateReader.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace OpenGameAgent.Extensions; + +internal static class StoredExtensionStateReader +{ + public static IReadOnlyDictionary Read( + GameSessionSnapshot session, + string extensionId) + { + if (session is null) + { + throw new ArgumentNullException(nameof(session)); + } + + var prefix = Uri.EscapeDataString(extensionId) + ":"; + var state = new Dictionary(StringComparer.Ordinal); + foreach (var pair in session.ExtensionState) + { + if (!pair.Key.StartsWith(prefix, StringComparison.Ordinal)) + { + continue; + } + + var key = Uri.UnescapeDataString(pair.Key.Substring(prefix.Length)); + if (!state.TryAdd(key, pair.Value)) + { + throw new InvalidOperationException( + $"Extension state contains duplicate decoded key '{key}'."); + } + } + + return new ReadOnlyDictionary(state); + } +} diff --git a/src/OpenGameAgent.Extensions/TaskPlanExtension.cs b/src/OpenGameAgent.Extensions/TaskPlanExtension.cs index 94a1a84..d7151d3 100644 --- a/src/OpenGameAgent.Extensions/TaskPlanExtension.cs +++ b/src/OpenGameAgent.Extensions/TaskPlanExtension.cs @@ -103,6 +103,33 @@ public GameTaskPlanChanged( public string Reason { get; } } +public sealed class GameTaskPlanQueryResult +{ + internal GameTaskPlanQueryResult( + GameSessionKey session, + long sessionRevision, + IEnumerable plans) + { + Session = new GameSessionKey(session.SessionId, session.ActorId); + SessionRevision = sessionRevision >= 0 + ? sessionRevision + : throw new ArgumentOutOfRangeException(nameof(sessionRevision)); + var copy = (plans ?? throw new ArgumentNullException(nameof(plans))).ToArray(); + if (copy.Any(plan => plan is null)) + { + throw new ArgumentException("Task-plan query results cannot contain null plans.", nameof(plans)); + } + + Plans = Array.AsReadOnly(copy); + } + + public GameSessionKey Session { get; } + + public long SessionRevision { get; } + + public IReadOnlyList Plans { get; } +} + public sealed class GameTaskPlanEvidenceRequest { public GameTaskPlanEvidenceRequest( @@ -178,6 +205,8 @@ internal TaskPlanOptions CopyAndValidate() public sealed class TaskPlanExtension : IGameAgentExtension { + private const string ExtensionId = "opengameagent.task-plans"; + private const int AbsoluteMaximumStepsPerPlan = 64; private const string PlanPrefix = "plan/"; private const string ManageSchema = """ { @@ -214,11 +243,44 @@ public TaskPlanExtension( new("task-plan.changed"); public GameAgentExtensionDescriptor Descriptor { get; } = new( - "opengameagent.task-plans", + ExtensionId, "1.0.0", "Persistent ordered task checklists with host-validated advancement.", new[] { "task-plan", "checklist", "pending-work", "evidence" }); + public static async ValueTask ReadAsync( + IGameSessionStore sessionStore, + GameSessionKey session, + bool includeTerminal = false, + CancellationToken cancellationToken = default) + { + if (sessionStore is null) + { + throw new ArgumentNullException(nameof(sessionStore)); + } + + var key = new GameSessionKey(session.SessionId, session.ActorId); + cancellationToken.ThrowIfCancellationRequested(); + var snapshot = await sessionStore.LoadAsync(key, cancellationToken).ConfigureAwait(false); + if (snapshot is null) + { + return new GameTaskPlanQueryResult(key, 0, Array.Empty()); + } + + if (snapshot.Key != key) + { + throw new InvalidOperationException("The session store returned a different session key."); + } + + var plans = ReadAll( + StoredExtensionStateReader.Read(snapshot, ExtensionId), + AbsoluteMaximumStepsPerPlan) + .Where(plan => includeTerminal || plan.Status == GameTaskPlanStatus.Active) + .OrderBy(plan => plan.Id, StringComparer.Ordinal) + .ToArray(); + return new GameTaskPlanQueryResult(key, snapshot.Revision, plans); + } + public void Configure(GameAgentExtensionApi api) { api.RegisterPromptFragment( @@ -533,10 +595,15 @@ private void PruneTerminalPlans(GameAgentExtensionState state) } private IReadOnlyList ReadAll(GameAgentExtensionState state) + => ReadAll(state.Snapshot(), _options.MaximumStepsPerPlan); + + private static IReadOnlyList ReadAll( + IReadOnlyDictionary state, + int maximumSteps) { - var plans = state.Snapshot() + var plans = state .Where(pair => pair.Key.StartsWith(PlanPrefix, StringComparison.Ordinal)) - .Select(pair => Decode(pair.Value, pair.Key.Substring(PlanPrefix.Length))) + .Select(pair => Decode(pair.Value, pair.Key.Substring(PlanPrefix.Length), maximumSteps)) .Select(document => new GameTaskPlanSnapshot(document)) .ToArray(); var duplicate = plans.GroupBy(plan => plan.Id, StringComparer.Ordinal) @@ -550,12 +617,15 @@ private IReadOnlyList ReadAll(GameAgentExtensionState stat } private TaskPlanDocument Decode(string json, string expectedId) + => Decode(json, expectedId, _options.MaximumStepsPerPlan); + + private static TaskPlanDocument Decode(string json, string expectedId, int maximumSteps) { try { var document = JsonSerializer.Deserialize(json) ?? throw new InvalidOperationException("The task-plan document is null."); - ValidateDocument(document, expectedId, _options.MaximumStepsPerPlan); + ValidateDocument(document, expectedId, maximumSteps); return document; } catch (Exception exception) when (exception is JsonException or InvalidOperationException) diff --git a/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs b/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs index 4b7dfb2..5a91d82 100644 --- a/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs +++ b/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs @@ -210,7 +210,7 @@ public async Task GoalLoopWaitsOnGameTimeAndEventThenResumesDurably() _ => TextResponse("complete"), }); var store = new InMemoryGameSessionStore(); - var changes = new ConcurrentQueue(); + var changes = new ConcurrentQueue(); await using var runtime = new GameAgentBuilder(provider, "model") .UseSessionStore(store) .UseExtension(new GoalLoopExtension()) @@ -219,7 +219,7 @@ public async Task GoalLoopWaitsOnGameTimeAndEventThenResumesDurably() "1", api => api.Subscribe(GoalLoopExtension.GoalChanged, (change, _) => { - changes.Enqueue(change.Reason); + changes.Enqueue(change); return ValueTask.CompletedTask; })) .Build(); @@ -241,7 +241,8 @@ await runtime.RunAsync( using var document = System.Text.Json.JsonDocument.Parse(stateJson); Assert.Equal("Completed", document.RootElement.GetProperty("Status").GetString()); Assert.Equal(4, document.RootElement.GetProperty("Revision").GetInt64()); - Assert.Contains("resumed", changes); + Assert.All(changes, change => Assert.Equal(new GameSessionKey("session", "actor"), change.Session)); + Assert.Contains(changes, change => change.Reason == "resumed" && change.InputId == "three"); } [Fact] diff --git a/tests/OpenGameAgent.Extensions.Tests/TaskPlanExtensionTests.cs b/tests/OpenGameAgent.Extensions.Tests/TaskPlanExtensionTests.cs index d6b6dab..3ba63ed 100644 --- a/tests/OpenGameAgent.Extensions.Tests/TaskPlanExtensionTests.cs +++ b/tests/OpenGameAgent.Extensions.Tests/TaskPlanExtensionTests.cs @@ -168,11 +168,44 @@ await RunAsync( (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()); + var query = await TaskPlanExtension.ReadAsync( + store, + new GameSessionKey(scope.Session, scope.Actor), + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(new GameSessionKey(scope.Session, scope.Actor), query.Session); + Assert.True(query.SessionRevision > 0); + Assert.Equal("same-id", Assert.Single(query.Plans).Id); } } + [Fact] + public async Task HostQueryReturnsEmptyForMissingSessionAndFailsClosedOnInvalidOwnedState() + { + var store = new InMemoryGameSessionStore(); + var missing = await TaskPlanExtension.ReadAsync( + store, + new GameSessionKey("missing", "actor"), + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(0, missing.SessionRevision); + Assert.Empty(missing.Plans); + + var key = new GameSessionKey("corrupt", "actor"); + var invalid = new GameSessionSnapshot( + key, + 1, + extensionState: new Dictionary + { + ["opengameagent.task-plans:plan%2Finvalid"] = "{}", + }); + Assert.True((await store.SaveAsync(invalid, 0, TestContext.Current.CancellationToken)).Saved); + await Assert.ThrowsAsync(async () => + await TaskPlanExtension.ReadAsync( + store, + key, + includeTerminal: true, + cancellationToken: TestContext.Current.CancellationToken)); + } + [Fact] public async Task TerminalRetentionDoesNotConsumeActiveCapacity() { diff --git a/tests/OpenGameAgent.Persistence.Tests/GoalLoopPersistenceTests.cs b/tests/OpenGameAgent.Persistence.Tests/GoalLoopPersistenceTests.cs index cebc729..dfa998c 100644 --- a/tests/OpenGameAgent.Persistence.Tests/GoalLoopPersistenceTests.cs +++ b/tests/OpenGameAgent.Persistence.Tests/GoalLoopPersistenceTests.cs @@ -1,5 +1,4 @@ using System.Runtime.CompilerServices; -using System.Text.Json; using OpenGameAgent.Extensions; using OpenGameAgent.Kernel; using Xunit; @@ -56,23 +55,25 @@ public async Task TerminalRetentionAndActiveCapacitySurviveSessionStoreRestart() Assert.True(result.Succeeded); } - var snapshot = await new FileGameSessionStore(directory.Path).LoadAsync( + var query = await GoalLoopExtension.ReadAsync( + new FileGameSessionStore(directory.Path), new GameSessionKey("session", "actor"), - TestContext.Current.CancellationToken); - var goals = snapshot!.ExtensionState.Values - .Select(json => - { - using var document = JsonDocument.Parse(json); - return ( - Id: document.RootElement.GetProperty("Id").GetString(), - Status: document.RootElement.GetProperty("Status").GetString()); - }) - .ToDictionary(goal => goal.Id!, goal => goal.Status, StringComparer.Ordinal); + includeTerminal: true, + cancellationToken: TestContext.Current.CancellationToken); + var goals = query.Goals.ToDictionary(goal => goal.Id, goal => goal.Status, StringComparer.Ordinal); + Assert.Equal(new GameSessionKey("session", "actor"), query.Session); + Assert.True(query.SessionRevision > 0); Assert.Equal(3, goals.Count); - Assert.Equal("Waiting", goals["waiting"]); - Assert.Equal("Completed", goals["recent"]); - Assert.Equal("Active", goals["active"]); + Assert.Equal(GameGoalStatus.Waiting, goals["waiting"]); + Assert.Equal(GameGoalStatus.Completed, goals["recent"]); + Assert.Equal(GameGoalStatus.Active, goals["active"]); Assert.DoesNotContain("old", goals); + + var activeOnly = await GoalLoopExtension.ReadAsync( + new FileGameSessionStore(directory.Path), + query.Session, + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(new[] { "active", "waiting" }, activeOnly.Goals.Select(goal => goal.Id).ToArray()); } private static ModelResponse ToolCall(string id, string arguments) => diff --git a/tests/OpenGameAgent.Persistence.Tests/TaskPlanPersistenceTests.cs b/tests/OpenGameAgent.Persistence.Tests/TaskPlanPersistenceTests.cs index 0179bac..29109aa 100644 --- a/tests/OpenGameAgent.Persistence.Tests/TaskPlanPersistenceTests.cs +++ b/tests/OpenGameAgent.Persistence.Tests/TaskPlanPersistenceTests.cs @@ -1,5 +1,4 @@ using System.Runtime.CompilerServices; -using System.Text.Json; using OpenGameAgent.Extensions; using OpenGameAgent.Kernel; using Xunit; @@ -48,16 +47,17 @@ public async Task ChecklistRevisionAndAdvanceGuardSurviveProcessRestart() Assert.True(result.Succeeded); } - var snapshot = await new FileGameSessionStore(directory.Path).LoadAsync( + var query = await TaskPlanExtension.ReadAsync( + new FileGameSessionStore(directory.Path), 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()); + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(new GameSessionKey("session", "actor"), query.Session); + Assert.True(query.SessionRevision > 0); + var plan = Assert.Single(query.Plans); + Assert.Equal(2, plan.Revision); 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()); + new[] { GameTaskPlanStepStatus.Completed, GameTaskPlanStepStatus.InProgress }, + plan.Steps.Select(step => step.Status).ToArray()); Assert.Equal(1, Volatile.Read(ref evidenceCalls)); }