Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 24 additions & 1 deletion docs/game-integration-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
92 changes: 87 additions & 5 deletions src/OpenGameAgent.Extensions/GoalLoopExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GameGoalSnapshot> 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<GameGoalSnapshot> Goals { get; }
}

public sealed class GoalLoopOptions
{
public int MaximumActiveGoals { get; set; } = 64;
Expand Down Expand Up @@ -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 = """
{
Expand Down Expand Up @@ -184,11 +224,42 @@ public GoalLoopExtension(GoalLoopOptions? options = null)
public static GameAgentExtensionChannel<GameGoalChanged> 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<GameGoalQueryResult> 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<GameGoalSnapshot>());
}

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(
Expand Down Expand Up @@ -232,7 +303,11 @@ private async ValueTask<bool> 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);
}

Expand Down Expand Up @@ -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);
},
Expand Down Expand Up @@ -445,8 +524,11 @@ private void PruneTerminalGoals(GameAgentExtensionState state)
}

private static IReadOnlyList<GameGoalSnapshot> ReadAll(GameAgentExtensionState state)
=> ReadAll(state.Snapshot());

private static IReadOnlyList<GameGoalSnapshot> ReadAll(IReadOnlyDictionary<string, string> 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))
Expand Down
37 changes: 37 additions & 0 deletions src/OpenGameAgent.Extensions/StoredExtensionStateReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;

namespace OpenGameAgent.Extensions;

internal static class StoredExtensionStateReader
{
public static IReadOnlyDictionary<string, string> Read(
GameSessionSnapshot session,
string extensionId)
{
if (session is null)
{
throw new ArgumentNullException(nameof(session));
}

var prefix = Uri.EscapeDataString(extensionId) + ":";
var state = new Dictionary<string, string>(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<string, string>(state);
}
}
78 changes: 74 additions & 4 deletions src/OpenGameAgent.Extensions/TaskPlanExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,33 @@ public GameTaskPlanChanged(
public string Reason { get; }
}

public sealed class GameTaskPlanQueryResult
{
internal GameTaskPlanQueryResult(
GameSessionKey session,
long sessionRevision,
IEnumerable<GameTaskPlanSnapshot> 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<GameTaskPlanSnapshot> Plans { get; }
}

public sealed class GameTaskPlanEvidenceRequest
{
public GameTaskPlanEvidenceRequest(
Expand Down Expand Up @@ -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 = """
{
Expand Down Expand Up @@ -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<GameTaskPlanQueryResult> 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<GameTaskPlanSnapshot>());
}

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(
Expand Down Expand Up @@ -533,10 +595,15 @@ private void PruneTerminalPlans(GameAgentExtensionState state)
}

private IReadOnlyList<GameTaskPlanSnapshot> ReadAll(GameAgentExtensionState state)
=> ReadAll(state.Snapshot(), _options.MaximumStepsPerPlan);

private static IReadOnlyList<GameTaskPlanSnapshot> ReadAll(
IReadOnlyDictionary<string, string> 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)
Expand All @@ -550,12 +617,15 @@ private IReadOnlyList<GameTaskPlanSnapshot> 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<TaskPlanDocument>(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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ public async Task GoalLoopWaitsOnGameTimeAndEventThenResumesDurably()
_ => TextResponse("complete"),
});
var store = new InMemoryGameSessionStore();
var changes = new ConcurrentQueue<string>();
var changes = new ConcurrentQueue<GameGoalChanged>();
await using var runtime = new GameAgentBuilder(provider, "model")
.UseSessionStore(store)
.UseExtension(new GoalLoopExtension())
Expand All @@ -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();
Expand All @@ -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]
Expand Down
Loading
Loading