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
11 changes: 3 additions & 8 deletions docs/deployment-and-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Mutation endpoints require a JSON content type, parse with a fixed depth limit,

When `ServerApiKey` is set, run and control endpoints require `Authorization: Bearer <key>`. The middleware supplies the stable authenticated subject `server-api-key` unless an upstream authentication system already supplied a principal. If the key is omitted, those endpoints are unauthenticated; only do that behind an already authenticated trusted boundary. Health and capability endpoints remain public.

Register an `IGameAgentOwnerAuthorizer` for player-facing or multi-tenant deployments. Every run, stream, steer, and abort request is then authorized against the authenticated principal and the parsed `(session, actor)` resource before the runtime, session store, or active actor is touched. Anonymous requests receive `401`; authenticated principals that do not own the resource receive `403`. The same operation contract reserves usage and durable-action operations so those endpoints use the identical ownership decision. Derive ownership from authenticated claims or an authoritative host store—never from an owner field supplied in the request payload. Without a registered authorizer the endpoint retains its legacy single-owner behavior for compatible trusted deployments.
Register an `IGameAgentOwnerAuthorizer` for player-facing or multi-tenant deployments. Every run, stream, steer, and abort request is then authorized against the authenticated principal and the parsed `(session, actor)` resource before the runtime, session store, or active actor is touched. Anonymous requests receive `401`; authenticated principals that do not own the resource receive `403`. The same operation contract reserves usage and durable-action operations so those endpoints use the identical ownership decision. Derive ownership from authenticated claims or an authoritative host store—never from an owner field supplied in the request payload. Without a registered authorizer the endpoint is suitable only for a trusted single-owner deployment.

Control requests only address an already active `(session, actor)` loop; they cannot register tools or mutate game state directly. Put TLS, request-rate limits, tenant quotas, and abuse protection at the gateway. The included shared-secret gate identifies one deployment-wide subject; it is not a multi-user account system.

Expand Down Expand Up @@ -164,16 +164,11 @@ All action endpoints use `IGameAgentOwnerAuthorizer` before touching the exchang

The exchange coordinates delivery and recovery; it does not replace game authority. The game must validate action arguments and permissions, commit the world mutation plus its operation record atomically where possible, and return the resulting revision. Tool catalogs and schemas remain deployment-owned.

### Operation ID v2 migration
### Operation ID v2

The default `GameActionTool` identifier is `oga-action-v2:<sha256>`. Its canonical identity includes session, actor, input, turn, tool-call index, action, timeline/tick, and save generation. The output has a fixed bounded length, identical replay produces the same ID, and changing any identity dimension produces a different ID. Tool arguments and expected state revision are deliberately not part of the ID: if a replay of the same logical tool position produces different arguments or authority preconditions, the durable journal rejects it instead of allowing a second mutation.

Existing version-one action journal files remain readable and are not rewritten. Their operation IDs remain valid for claim, receipt, and reconcile. Do not silently switch an active save with unresolved v1 operations to the v2 default: the authoritative game log knows the old identifiers and an automatic rewrite could duplicate a side effect. Use one of these explicit migration paths:

1. reconcile and drain all v1 pending/dispatched operations, then switch to v2 at a save-generation boundary; or
2. temporarily pass `operationIdFactory: GameActionOperationIds.CreateLegacyV1`, drain the old journal, then remove that override when starting the next save generation.

Never copy one action journal into multiple coexisting save namespaces. `GameActionOperationIds.CreateLegacyV1` exists only for this controlled migration window and does not isolate session, actor, timeline, or action.
Do not copy one action journal into multiple coexisting save namespaces. The default identifier isolates session, actor, timeline, action, and save generation so a replay in another world cannot reuse a receipt.

## Untrusted boundaries

Expand Down
2 changes: 1 addition & 1 deletion docs/game-integration-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ game tick / month advance

`MultiActorScheduler` gives per-actor ordering and global concurrency. `GameTimeScheduler` emits bounded recurring occurrences. `IGameMailbox` carries durable work to actors that are not currently resident. The game supplies activation, distance, importance, and budget policy.

Use `GoalLoopExtension` when an actor owns semantic goals that can wait for a tick or event and continue later. Use `AgentDelegationExtension` when one actor needs bounded background research or planning without sharing its mutable transcript. Delegates still receive explicitly scoped context and tools; delegation is not permission escalation. 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 `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.

## Monthly or turn-based evolution

Expand Down
104 changes: 86 additions & 18 deletions src/OpenGameAgent.Extensions/GoalLoopExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ internal GameGoalSnapshot(GoalDocument document)
Status = document.Status;
Revision = document.Revision;
NonProgressUpdates = document.NonProgressUpdates;
TerminalSequence = document.TerminalSequence;
LastTimelineId = document.LastTimelineId;
LastTick = document.LastTick;
Error = document.Error;
Expand All @@ -95,6 +96,8 @@ internal GameGoalSnapshot(GoalDocument document)

public int NonProgressUpdates { get; }

internal long TerminalSequence { get; }

public string LastTimelineId { get; }

public long LastTick { get; }
Expand All @@ -117,6 +120,36 @@ public GameGoalChanged(GameGoalSnapshot goal, string reason)
public string Reason { get; }
}

public sealed class GoalLoopOptions
{
public int MaximumActiveGoals { get; set; } = 64;

public int MaximumRetainedTerminalGoals { get; set; } = 64;

public int MaximumNonProgressUpdates { get; set; } = 3;

internal GoalLoopOptions CopyAndValidate()
{
var copy = (GoalLoopOptions)MemberwiseClone();
if (copy.MaximumActiveGoals < 1 || copy.MaximumActiveGoals > 1_000)
{
throw new ArgumentOutOfRangeException(nameof(MaximumActiveGoals));
}

if (copy.MaximumRetainedTerminalGoals < 0 || copy.MaximumRetainedTerminalGoals > 1_000)
{
throw new ArgumentOutOfRangeException(nameof(MaximumRetainedTerminalGoals));
}

if (copy.MaximumNonProgressUpdates < 1 || copy.MaximumNonProgressUpdates > 100)
{
throw new ArgumentOutOfRangeException(nameof(MaximumNonProgressUpdates));
}

return copy;
}
}

public sealed class GoalLoopExtension : IGameAgentExtension
{
private const string GoalPrefix = "goal/";
Expand All @@ -141,23 +174,11 @@ public sealed class GoalLoopExtension : IGameAgentExtension
{"type":"object","properties":{"includeTerminal":{"type":"boolean"}},"additionalProperties":false}
""";

private readonly int _maximumGoals;
private readonly int _maximumNonProgressUpdates;
private readonly GoalLoopOptions _options;

public GoalLoopExtension(int maximumGoals = 64, int maximumNonProgressUpdates = 3)
public GoalLoopExtension(GoalLoopOptions? options = null)
{
if (maximumGoals < 1 || maximumGoals > 1_000)
{
throw new ArgumentOutOfRangeException(nameof(maximumGoals));
}

if (maximumNonProgressUpdates < 1 || maximumNonProgressUpdates > 100)
{
throw new ArgumentOutOfRangeException(nameof(maximumNonProgressUpdates));
}

_maximumGoals = maximumGoals;
_maximumNonProgressUpdates = maximumNonProgressUpdates;
_options = (options ?? new GoalLoopOptions()).CopyAndValidate();
}

public static GameAgentExtensionChannel<GameGoalChanged> GoalChanged { get; } = new("goal.changed");
Expand Down Expand Up @@ -191,6 +212,7 @@ private async ValueTask<bool> ResumeAndCheckPendingAsync(
GameAgentExtensionRunContext context,
CancellationToken cancellationToken)
{
PruneTerminalGoals(context.State);
var pending = false;
foreach (var storedGoal in ReadAll(context.State))
{
Expand Down Expand Up @@ -238,9 +260,11 @@ private AgentTool CreateManageTool(GameAgentExtensionApi api, GameAgentExtension
return ToolResult.Error($"Goal '{goalId}' already exists.");
}

if (ReadAll(context.State).Count >= _maximumGoals)
var activeGoalCount = ReadAll(context.State).Count(goal => !IsTerminal(goal.Status));
if (activeGoalCount >= _options.MaximumActiveGoals)
{
return ToolResult.Error($"At most {_maximumGoals} goals may be stored in one actor session.");
return ToolResult.Error(
$"At most {_options.MaximumActiveGoals} active or waiting goals may exist in one actor session.");
}

if (!arguments.TryGetProperty("objective", out var objective))
Expand Down Expand Up @@ -295,7 +319,7 @@ private AgentTool CreateManageTool(GameAgentExtensionApi api, GameAgentExtension
document.NonProgressUpdates = string.Equals(document.ProgressJson, nextProgress, StringComparison.Ordinal)
? checked(document.NonProgressUpdates + 1)
: 0;
if (document.NonProgressUpdates >= _maximumNonProgressUpdates)
if (document.NonProgressUpdates >= _options.MaximumNonProgressUpdates)
{
return ToolResult.Error("The goal repeated the same progress without advancing.");
}
Expand Down Expand Up @@ -326,15 +350,18 @@ private AgentTool CreateManageTool(GameAgentExtensionApi api, GameAgentExtension
break;
case "complete":
document.Status = GameGoalStatus.Completed;
document.TerminalSequence = NextTerminalSequence(context.State);
document.Wait = null;
break;
case "fail":
document.Status = GameGoalStatus.Failed;
document.TerminalSequence = NextTerminalSequence(context.State);
document.Error = ReadReason(arguments, "The goal failed.");
document.Wait = null;
break;
case "cancel":
document.Status = GameGoalStatus.Cancelled;
document.TerminalSequence = NextTerminalSequence(context.State);
document.Error = ReadReason(arguments, "The goal was cancelled.");
document.Wait = null;
break;
Expand All @@ -344,6 +371,10 @@ private AgentTool CreateManageTool(GameAgentExtensionApi api, GameAgentExtension
}

Write(context.State, document);
if (IsTerminal(document.Status))
{
PruneTerminalGoals(context.State);
}
var snapshot = new GameGoalSnapshot(document);
await api.PublishAsync(
GoalChanged,
Expand Down Expand Up @@ -376,6 +407,35 @@ private static string ReadReason(JsonElement arguments, string fallback) =>
? reason.GetString()!
: fallback;

private static bool IsTerminal(GameGoalStatus status) =>
status is GameGoalStatus.Completed or GameGoalStatus.Failed or GameGoalStatus.Cancelled;

private static long NextTerminalSequence(GameAgentExtensionState state)
{
var maximum = ReadAll(state)
.Where(goal => IsTerminal(goal.Status))
.Select(goal => goal.TerminalSequence)
.DefaultIfEmpty()
.Max();
return checked(maximum + 1);
}

private void PruneTerminalGoals(GameAgentExtensionState state)
{
var expired = ReadAll(state)
.Where(goal => IsTerminal(goal.Status))
.OrderByDescending(goal => goal.TerminalSequence)
.ThenByDescending(goal => goal.LastTimelineId, StringComparer.Ordinal)
.ThenByDescending(goal => goal.LastTick)
.ThenBy(goal => goal.Id, StringComparer.Ordinal)
.Skip(_options.MaximumRetainedTerminalGoals)
.ToArray();
foreach (var goal in expired)
{
state.Remove(GoalPrefix + goal.Id);
}
}

private static GoalDocument? Read(GameAgentExtensionState state, string goalId)
{
var json = state.Get(GoalPrefix + goalId);
Expand Down Expand Up @@ -424,6 +484,7 @@ private static void ValidateDocument(GoalDocument document, string expectedId)
|| !string.Equals(document.Id, expectedId, StringComparison.Ordinal)
|| document.Revision < 1
|| document.NonProgressUpdates < 0
|| document.TerminalSequence < 0
|| string.IsNullOrWhiteSpace(document.LastTimelineId)
|| !Enum.IsDefined(typeof(GameGoalStatus), document.Status)
|| (document.Error?.Length ?? 0) > 4_096)
Expand Down Expand Up @@ -456,6 +517,10 @@ private static void ValidateDocument(GoalDocument document, string expectedId)
{
throw new InvalidOperationException("Only a waiting goal can contain a wait condition.");
}
if (IsTerminal(document.Status) != (document.TerminalSequence > 0))
{
throw new InvalidOperationException("Terminal goal state and terminal sequence must agree.");
}
}

private static void Write(GameAgentExtensionState state, GoalDocument document) =>
Expand All @@ -472,6 +537,7 @@ private static ToolResult JsonResult(object value) =>
Status = goal.Status,
Revision = goal.Revision,
NonProgressUpdates = goal.NonProgressUpdates,
TerminalSequence = goal.TerminalSequence,
LastTimelineId = goal.LastTimelineId,
LastTick = goal.LastTick,
Error = goal.Error,
Expand Down Expand Up @@ -500,6 +566,8 @@ internal sealed class GoalDocument

public int NonProgressUpdates { get; set; }

public long TerminalSequence { get; set; }

public string LastTimelineId { get; set; } = string.Empty;

public long LastTick { get; set; }
Expand Down
25 changes: 3 additions & 22 deletions src/OpenGameAgent.Kernel/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,33 +45,14 @@ public ModelCost(
double input = 0,
double output = 0,
double cacheRead = 0,
double cacheWrite = 0)
: this(
input,
output,
cacheRead,
cacheWrite,
input != 0 || output != 0 || cacheRead != 0 || cacheWrite != 0)
{
}

public ModelCost(bool isKnown)
: this(0, 0, 0, 0, isKnown)
{
}

public ModelCost(
double input,
double output,
double cacheRead,
double cacheWrite,
bool isKnown)
double cacheWrite = 0,
bool? isKnown = null)
{
Input = RequireAmount(input, nameof(input));
Output = RequireAmount(output, nameof(output));
CacheRead = RequireAmount(cacheRead, nameof(cacheRead));
CacheWrite = RequireAmount(cacheWrite, nameof(cacheWrite));
IsKnown = isKnown;
IsKnown = isKnown ?? (input != 0 || output != 0 || cacheRead != 0 || cacheWrite != 0);
}

public double Input { get; }
Expand Down
36 changes: 8 additions & 28 deletions src/OpenGameAgent.Models/ModelDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,33 +48,8 @@ public GameModelCost(
decimal outputPerMillionTokens = 0,
decimal cacheReadPerMillionTokens = 0,
decimal cacheWritePerMillionTokens = 0,
IReadOnlyCollection<GameModelCostTier>? tiers = null)
: this(
inputPerMillionTokens,
outputPerMillionTokens,
cacheReadPerMillionTokens,
cacheWritePerMillionTokens,
tiers,
inputPerMillionTokens != 0
|| outputPerMillionTokens != 0
|| cacheReadPerMillionTokens != 0
|| cacheWritePerMillionTokens != 0
|| (tiers?.Count ?? 0) != 0)
{
}

public GameModelCost(bool isKnown)
: this(0, 0, 0, 0, null, isKnown)
{
}

public GameModelCost(
decimal inputPerMillionTokens,
decimal outputPerMillionTokens,
decimal cacheReadPerMillionTokens,
decimal cacheWritePerMillionTokens,
IReadOnlyCollection<GameModelCostTier>? tiers,
bool isKnown)
IReadOnlyCollection<GameModelCostTier>? tiers = null,
bool? isKnown = null)
{
InputPerMillionTokens = RequireCost(inputPerMillionTokens, nameof(inputPerMillionTokens));
OutputPerMillionTokens = RequireCost(outputPerMillionTokens, nameof(outputPerMillionTokens));
Expand All @@ -89,7 +64,12 @@ public GameModelCost(
throw new ArgumentException("Cost tiers must be non-null and use unique thresholds.", nameof(tiers));
}

IsKnown = isKnown;
IsKnown = isKnown
?? (inputPerMillionTokens != 0
|| outputPerMillionTokens != 0
|| cacheReadPerMillionTokens != 0
|| cacheWritePerMillionTokens != 0
|| copiedTiers.Length != 0);
Tiers = Array.AsReadOnly(copiedTiers);
}

Expand Down
Loading
Loading