diff --git a/docs/deployment-and-security.md b/docs/deployment-and-security.md index 44edec1..ab71003 100644 --- a/docs/deployment-and-security.md +++ b/docs/deployment-and-security.md @@ -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 `. 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. @@ -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:`. 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 diff --git a/docs/game-integration-patterns.md b/docs/game-integration-patterns.md index 5a77757..765ac96 100644 --- a/docs/game-integration-patterns.md +++ b/docs/game-integration-patterns.md @@ -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 diff --git a/src/OpenGameAgent.Extensions/GoalLoopExtension.cs b/src/OpenGameAgent.Extensions/GoalLoopExtension.cs index 2751cf8..478c9a2 100644 --- a/src/OpenGameAgent.Extensions/GoalLoopExtension.cs +++ b/src/OpenGameAgent.Extensions/GoalLoopExtension.cs @@ -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; @@ -95,6 +96,8 @@ internal GameGoalSnapshot(GoalDocument document) public int NonProgressUpdates { get; } + internal long TerminalSequence { get; } + public string LastTimelineId { get; } public long LastTick { get; } @@ -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/"; @@ -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 GoalChanged { get; } = new("goal.changed"); @@ -191,6 +212,7 @@ private async ValueTask ResumeAndCheckPendingAsync( GameAgentExtensionRunContext context, CancellationToken cancellationToken) { + PruneTerminalGoals(context.State); var pending = false; foreach (var storedGoal in ReadAll(context.State)) { @@ -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)) @@ -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."); } @@ -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; @@ -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, @@ -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); @@ -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) @@ -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) => @@ -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, @@ -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; } diff --git a/src/OpenGameAgent.Kernel/Models.cs b/src/OpenGameAgent.Kernel/Models.cs index b914a9e..beb5ed3 100644 --- a/src/OpenGameAgent.Kernel/Models.cs +++ b/src/OpenGameAgent.Kernel/Models.cs @@ -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; } diff --git a/src/OpenGameAgent.Models/ModelDescriptors.cs b/src/OpenGameAgent.Models/ModelDescriptors.cs index 5f3e127..f28a0f9 100644 --- a/src/OpenGameAgent.Models/ModelDescriptors.cs +++ b/src/OpenGameAgent.Models/ModelDescriptors.cs @@ -48,33 +48,8 @@ public GameModelCost( decimal outputPerMillionTokens = 0, decimal cacheReadPerMillionTokens = 0, decimal cacheWritePerMillionTokens = 0, - IReadOnlyCollection? tiers = null) - : this( - inputPerMillionTokens, - outputPerMillionTokens, - cacheReadPerMillionTokens, - cacheWritePerMillionTokens, - tiers, - inputPerMillionTokens != 0 - || outputPerMillionTokens != 0 - || cacheReadPerMillionTokens != 0 - || cacheWritePerMillionTokens != 0 - || (tiers?.Count ?? 0) != 0) - { - } - - public GameModelCost(bool isKnown) - : this(0, 0, 0, 0, null, isKnown) - { - } - - public GameModelCost( - decimal inputPerMillionTokens, - decimal outputPerMillionTokens, - decimal cacheReadPerMillionTokens, - decimal cacheWritePerMillionTokens, - IReadOnlyCollection? tiers, - bool isKnown) + IReadOnlyCollection? tiers = null, + bool? isKnown = null) { InputPerMillionTokens = RequireCost(inputPerMillionTokens, nameof(inputPerMillionTokens)); OutputPerMillionTokens = RequireCost(outputPerMillionTokens, nameof(outputPerMillionTokens)); @@ -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); } diff --git a/src/OpenGameAgent.Persistence/FileGameActionJournal.cs b/src/OpenGameAgent.Persistence/FileGameActionJournal.cs index c8fc79e..8973935 100644 --- a/src/OpenGameAgent.Persistence/FileGameActionJournal.cs +++ b/src/OpenGameAgent.Persistence/FileGameActionJournal.cs @@ -9,6 +9,7 @@ namespace OpenGameAgent.Persistence; public sealed class FileGameActionJournal : IGameActionJournal { + private const int CurrentFormatVersion = 2; private const string Suffix = ".action.json"; private readonly FileStore _files; private readonly int _maximumEntries; @@ -265,7 +266,7 @@ private static ActionDocument Encode( GameActionReceipt? receipt, bool dispatched) => new() { - FormatVersion = 2, + FormatVersion = CurrentFormatVersion, Dispatched = dispatched, Intent = new IntentDocument { @@ -296,7 +297,7 @@ private static GameActionJournalEntry Decode(ActionDocument document) => private static GameActionJournalEntry DecodeCore(ActionDocument document) { - if (document.FormatVersion is not (1 or 2) || document.Intent is null) + if (document.FormatVersion != CurrentFormatVersion || document.Intent is null) { throw new PersistenceException("The action journal document has an unsupported format."); } @@ -310,7 +311,7 @@ private static GameActionJournalEntry DecodeCore(ActionDocument document) document.Intent.ArgumentsJson, document.Intent.Moment?.Decode() ?? throw new PersistenceException("The action intent moment is missing."), document.Intent.ExpectedRevision, - document.FormatVersion >= 2 ? document.Intent.GenerationId : null); + document.Intent.GenerationId); GameActionReceipt? receipt = null; if (document.Receipt is not null) { diff --git a/src/OpenGameAgent.Persistence/FileGameSessionStore.cs b/src/OpenGameAgent.Persistence/FileGameSessionStore.cs index df53f86..ac77d3f 100644 --- a/src/OpenGameAgent.Persistence/FileGameSessionStore.cs +++ b/src/OpenGameAgent.Persistence/FileGameSessionStore.cs @@ -10,6 +10,7 @@ namespace OpenGameAgent.Persistence; public sealed class FileGameSessionStore : IGameSessionStore { + private const int CurrentFormatVersion = 4; private const string Suffix = ".session.json"; private readonly FileStore _files; @@ -98,7 +99,7 @@ public async ValueTask SaveAsync( private static SessionDocument Encode(GameSessionSnapshot snapshot) => new() { - FormatVersion = 4, + FormatVersion = CurrentFormatVersion, SessionId = snapshot.Key.SessionId, ActorId = snapshot.Key.ActorId, Revision = snapshot.Revision, @@ -138,7 +139,7 @@ private static string IdentityFor(GameSessionKey key) => string.Concat( return null; } - if (document.FormatVersion is not (1 or 2 or 3 or 4)) + if (document.FormatVersion != CurrentFormatVersion) { throw new PersistenceException($"Unsupported session format version '{document.FormatVersion}'."); } @@ -148,42 +149,34 @@ private static string IdentityFor(GameSessionKey key) => string.Concat( () => new GameSessionSnapshot( new GameSessionKey(document.SessionId, document.ActorId), document.Revision, - (document.Messages ?? new List()).Select(AgentMessageCodec.Decode).ToArray(), - document.ProcessedInputIds ?? new List(), + (document.Messages ?? throw new PersistenceException("Session messages are missing.")) + .Select(AgentMessageCodec.Decode) + .ToArray(), + document.ProcessedInputIds ?? throw new PersistenceException("Processed input IDs are missing."), document.LastMoment?.Decode(), - document.ExtensionState ?? new Dictionary(StringComparer.Ordinal), - document.FormatVersion >= 2 ? document.PendingInputId : null, - document.FormatVersion >= 3 - ? DecodeUsageLedger(document) - : null)); + document.ExtensionState ?? throw new PersistenceException("Extension state is missing."), + document.PendingInputId, + DecodeUsageLedger(document))); } private static GameSessionUsageLedger DecodeUsageLedger(SessionDocument document) { - var records = (document.UsageRecords ?? new List()) + var records = (document.UsageRecords ?? throw new PersistenceException("Usage records are missing.")) .Select(record => record.Decode()) .ToArray(); - var capacity = document.UsageRecentRecordCapacity > 0 - ? document.UsageRecentRecordCapacity - : GameSessionUsageLedger.DefaultRecentRecordCapacity; - if (document.UsageTotals is null && document.UsageTotalRecordCount == 0) - { - // Early v3 previews persisted only raw records. Fold them into the bounded representation. - return new GameSessionUsageLedger(records, capacity); - } - return GameSessionUsageLedger.Restore( records, - DecodeUsageTotals(document.UsageTotals), + DecodeUsageTotals(document.UsageTotals + ?? throw new PersistenceException("Usage totals are missing.")), document.UsageTotalRecordCount, - capacity); + document.UsageRecentRecordCapacity); } private static IReadOnlyDictionary DecodeUsageTotals( - IReadOnlyList? documents) + IReadOnlyList documents) { var totals = new Dictionary(); - foreach (var document in documents ?? Array.Empty()) + foreach (var document in documents) { var cause = (GameSessionUsageCause)document.Cause; if (!totals.TryAdd(cause, document.Decode())) diff --git a/src/OpenGameAgent.Persistence/FileGameWorkflowCheckpointStore.cs b/src/OpenGameAgent.Persistence/FileGameWorkflowCheckpointStore.cs index ff140c5..045beb7 100644 --- a/src/OpenGameAgent.Persistence/FileGameWorkflowCheckpointStore.cs +++ b/src/OpenGameAgent.Persistence/FileGameWorkflowCheckpointStore.cs @@ -8,6 +8,7 @@ namespace OpenGameAgent.Persistence; public sealed class FileGameWorkflowCheckpointStore : IGameWorkflowCheckpointStore { + private const int CurrentFormatVersion = 2; private const string Suffix = ".workflow.json"; private readonly FileStore _files; @@ -113,7 +114,7 @@ public async ValueTask SaveAsync( private static CheckpointDocument Encode(GameWorkflowCheckpoint checkpoint) => new() { - FormatVersion = 2, + FormatVersion = CurrentFormatVersion, InstanceId = checkpoint.InstanceId, Workflow = checkpoint.Workflow, Revision = checkpoint.Revision, @@ -133,7 +134,7 @@ public async ValueTask SaveAsync( private static GameWorkflowCheckpoint Decode(CheckpointDocument document) { - if (document.FormatVersion is not (1 or 2)) + if (document.FormatVersion != CurrentFormatVersion) { throw new PersistenceException("The workflow checkpoint has an unsupported format."); } @@ -148,7 +149,7 @@ private static GameWorkflowCheckpoint Decode(CheckpointDocument document) document.StateJson, document.Completed, document.Error, - document.FormatVersion >= 2 && document.Invocation is not null + document.Invocation is not null ? new GameWorkflowInvocationResult( document.Invocation.InputId, (document.Invocation.Messages ?? throw new PersistenceException("Workflow invocation messages are missing.")) diff --git a/src/OpenGameAgent/Actions.cs b/src/OpenGameAgent/Actions.cs index 4156ca6..89d2452 100644 --- a/src/OpenGameAgent/Actions.cs +++ b/src/OpenGameAgent/Actions.cs @@ -29,30 +29,8 @@ public GameActionIntent( string action, string argumentsJson, GameMoment moment, - long? expectedRevision = null) - : this( - operationId, - inputId, - sessionId, - actorId, - action, - argumentsJson, - moment, - expectedRevision, - generationId: null) - { - } - - public GameActionIntent( - string operationId, - string inputId, - string sessionId, - string actorId, - string action, - string argumentsJson, - GameMoment moment, - long? expectedRevision, - string? generationId) + long? expectedRevision = null, + string? generationId = null) { OperationId = GameJson.RequireId(operationId, nameof(operationId)); InputId = GameJson.RequireId(inputId, nameof(inputId)); @@ -309,46 +287,6 @@ public static string CreateV2( return Version2Prefix + BitConverter.ToString(hash).Replace("-", string.Empty).ToLowerInvariant(); } - /// - /// Reproduces the pre-v2 default ID for a controlled migration window. Do not use it for new - /// save namespaces because it does not isolate sessions, actors, timelines, or actions. - /// - public static string CreateLegacyV1( - GameInput input, - JsonElement arguments, - ToolExecutionContext execution) - { - if (input is null) - { - throw new ArgumentNullException(nameof(input)); - } - - if (execution is null) - { - throw new ArgumentNullException(nameof(execution)); - } - _ = arguments; - return CreateLegacyV1(input.InputId, execution.Turn, execution.ToolCallIndex); - } - - public static string CreateLegacyV1(string inputId, int turn, int toolCallIndex) - { - if (turn < 0) - { - throw new ArgumentOutOfRangeException(nameof(turn)); - } - - if (toolCallIndex < 0) - { - throw new ArgumentOutOfRangeException(nameof(toolCallIndex)); - } - - return GameJson.JoinIds( - RequireComponent(inputId, nameof(inputId)), - turn.ToString(System.Globalization.CultureInfo.InvariantCulture), - toolCallIndex.ToString(System.Globalization.CultureInfo.InvariantCulture)); - } - public static bool IsVersion2(string operationId) => operationId?.StartsWith(Version2Prefix, StringComparison.Ordinal) == true && operationId.Length == Version2Prefix.Length + 64 @@ -909,30 +847,8 @@ public static AgentTool Create( ToolRisk risk = ToolRisk.NonIdempotentWrite, Func? conflictKey = null, long? expectedRevision = null, - GameActionOperationIdFactory? operationIdFactory = null) - => Create( - input, - action, - description, - inputSchemaJson, - dispatcher, - risk, - conflictKey, - expectedRevision, - operationIdFactory, - generationId: null); - - public static AgentTool Create( - GameInput input, - string action, - string description, - string inputSchemaJson, - DurableGameActionDispatcher dispatcher, - ToolRisk risk, - Func? conflictKey, - long? expectedRevision, - GameActionOperationIdFactory? operationIdFactory, - string? generationId) + GameActionOperationIdFactory? operationIdFactory = null, + string? generationId = null) { if (input is null) { diff --git a/src/OpenGameAgent/GameAgentRuntime.cs b/src/OpenGameAgent/GameAgentRuntime.cs index fc4c5a4..9eb8e47 100644 --- a/src/OpenGameAgent/GameAgentRuntime.cs +++ b/src/OpenGameAgent/GameAgentRuntime.cs @@ -769,8 +769,7 @@ await _extensions.PublishAsync( var systemPrompt = ComposeSystemPrompt(context, skills); var agentLimits = CopyAgentLimits(_agentLimits); var usageAccounting = new RunUsageAccounting(input.InputId, agentLimits.MaxTotalTokens); - var legacyUsageRecords = CreateLegacyUsageRecords(loaded); - var baseUsageLedger = loaded.UsageLedger.Append(legacyUsageRecords); + var baseUsageLedger = loaded.UsageLedger; IReadOnlyList initialMessages = loaded.Messages; var minimumMessageReserve = resumingCheckpoint ? 1 : 2; var preferredMessageReserve = activeTools.Count == 0 @@ -803,7 +802,7 @@ await _extensions.PublishAsync( { settled = await SaveUsageOnlyAsync( loaded, - legacyUsageRecords.Concat(usageRecords).ToArray(), + usageRecords, baseUsageLedger.Append(usageRecords), settlementCancellation.Token).ConfigureAwait(false); } @@ -922,9 +921,7 @@ await _extensions.PublishAsync( if (!checkpointSave.Saved) { checkpointConflict = checkpointSave; - checkpointConflictUsageRecords = commitBase.Revision == loaded.Revision - ? legacyUsageRecords.Concat(usageRecords).ToArray() - : usageRecords; + checkpointConflictUsageRecords = usageRecords; checkpointConflictUsageLedger = checkpoint.UsageLedger; throw new InvalidOperationException( "The session changed while a tool turn was being checkpointed."); @@ -1032,9 +1029,7 @@ await _extensions.PublishAsync( { settledSaveConflict = await SettleUsageAfterConflictAsync( save.Current, - commitBase.Revision == loaded.Revision - ? legacyUsageRecords.Concat(finalUsageRecords).ToArray() - : finalUsageRecords, + finalUsageRecords, usageLedger, settlementCancellation.Token).ConfigureAwait(false); } @@ -1159,7 +1154,7 @@ void ValidateWorkflowOutput(IReadOnlyList output) messages, extensionState, extensionContext, - loaded.UsageLedger.Append(CreateLegacyUsageRecords(loaded)), + loaded.UsageLedger, settlementCancellation.Token).ConfigureAwait(false); } return !save.Saved @@ -1356,32 +1351,6 @@ private static bool UsageLedgerEquals(GameSessionUsageLedger left, GameSessionUs && GameSessionUsageTotals.ValueEquals(pair.Value, total)) && left.Records.Zip(right.Records, GameSessionUsageRecord.ValueEquals).All(equal => equal); - private static IReadOnlyList CreateLegacyUsageRecords(GameSessionSnapshot session) - { - if (session.UsageLedger.TotalRecordCount != 0) - { - return Array.Empty(); - } - - var records = session.Messages - .Select((message, index) => new { Message = message, Index = index }) - .Where(item => item.Message.Usage is not null - && (item.Message.Usage.TotalTokens > 0 || item.Message.Usage.Cost.Total > 0) - && item.Message.Role is AgentRole.Assistant or AgentRole.Tool) - .Select(item => new GameSessionUsageRecord( - $"legacy-message-{item.Index}", - item.Message.Role == AgentRole.Assistant - ? GameSessionUsageCause.Assistant - : GameSessionUsageCause.Tool, - item.Message.Usage!, - inputId: item.Message.Metadata.TryGetValue("game.input_id", out var inputId) - && !string.IsNullOrWhiteSpace(inputId) - ? inputId - : null)) - .ToArray(); - return Array.AsReadOnly(records); - } - private string ComposeSystemPrompt( IReadOnlyList context, IReadOnlyList skills) diff --git a/src/OpenGameAgent/Sessions.cs b/src/OpenGameAgent/Sessions.cs index 3e52054..520c2f3 100644 --- a/src/OpenGameAgent/Sessions.cs +++ b/src/OpenGameAgent/Sessions.cs @@ -135,32 +135,6 @@ public sealed class GameSessionUsageTotals 0, costKnown: true); - public GameSessionUsageTotals( - long inputTokens, - long outputTokens, - long cacheReadTokens, - long cacheWriteTokens, - long reasoningTokens, - long cacheWriteOneHourTokens, - double inputCost, - double outputCost, - double cacheReadCost, - double cacheWriteCost) - : this( - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, - reasoningTokens, - cacheWriteOneHourTokens, - inputCost, - outputCost, - cacheReadCost, - cacheWriteCost, - costKnown: true) - { - } - public GameSessionUsageTotals( long inputTokens, long outputTokens, @@ -172,7 +146,7 @@ public GameSessionUsageTotals( double outputCost, double cacheReadCost, double cacheWriteCost, - bool costKnown) + bool costKnown = true) { if (inputTokens < 0 || outputTokens < 0 diff --git a/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs b/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs index 16b05cd..d9ca93b 100644 --- a/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs +++ b/tests/OpenGameAgent.Extensions.Tests/OfficialExtensionTests.cs @@ -244,6 +244,110 @@ await runtime.RunAsync( Assert.Contains("resumed", changes); } + [Fact] + public async Task GoalLoopRetainsActiveAndWaitingGoalsWhileBoundingTerminalAuditHistory() + { + var provider = new ScriptedProvider(call => call switch + { + 1 => ToolCall("create-waiting", "manage_goal", "{\"action\":\"create\",\"goalId\":\"waiting\",\"objective\":{}}"), + 2 => ToolCall("wait", "manage_goal", "{\"action\":\"wait\",\"goalId\":\"waiting\",\"expectedRevision\":1,\"eventTypes\":[\"future\"]}"), + 3 => ToolCall("create-old", "manage_goal", "{\"action\":\"create\",\"goalId\":\"old\",\"objective\":{}}"), + 4 => ToolCall("complete-old", "manage_goal", "{\"action\":\"complete\",\"goalId\":\"old\",\"expectedRevision\":1}"), + 5 => ToolCall("create-recent", "manage_goal", "{\"action\":\"create\",\"goalId\":\"recent\",\"objective\":{}}"), + 6 => ToolCall("complete-recent", "manage_goal", "{\"action\":\"complete\",\"goalId\":\"recent\",\"expectedRevision\":1}"), + 7 => ToolCall("create-active", "manage_goal", "{\"action\":\"create\",\"goalId\":\"active\",\"objective\":{}}"), + _ => TextResponse("created"), + }); + var store = new InMemoryGameSessionStore(); + await using var runtime = new GameAgentBuilder(provider, "model") + .UseSessionStore(store) + .UseExtension(new GoalLoopExtension(new GoalLoopOptions + { + MaximumActiveGoals = 2, + MaximumRetainedTerminalGoals = 1, + })) + .Build(); + + var result = await runtime.RunAsync(Input(), TestContext.Current.CancellationToken); + + Assert.True(result.Succeeded); + var snapshot = await store.LoadAsync( + new GameSessionKey("session", "actor"), + TestContext.Current.CancellationToken); + var goals = snapshot!.ExtensionState.Values + .Select(json => + { + using var document = System.Text.Json.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); + Assert.Equal(3, goals.Count); + Assert.Equal("Active", goals["active"]); + Assert.Equal("Waiting", goals["waiting"]); + Assert.Equal("Completed", goals["recent"]); + Assert.DoesNotContain("old", goals); + } + + [Fact] + public async Task ConcurrentGoalUpdatesUseSessionCasWithoutLosingExistingActiveGoals() + { + var store = new InMemoryGameSessionStore(); + await using (var seedRuntime = new GameAgentBuilder( + new ScriptedProvider(call => call == 1 + ? ToolCall("create-base", "manage_goal", "{\"action\":\"create\",\"goalId\":\"base\",\"objective\":{}}") + : TextResponse("seeded")), + "model") + .UseSessionStore(store) + .UseExtension(new GoalLoopExtension()) + .Build()) + { + Assert.True((await seedRuntime.RunAsync(Input(), TestContext.Current.CancellationToken)).Succeeded); + } + + var gate = new ConcurrentRunGate(2); + await using var leftRuntime = new GameAgentBuilder( + new FirstCallBarrierProvider( + gate, + ToolCall("create-left", "manage_goal", "{\"action\":\"create\",\"goalId\":\"left\",\"objective\":{}}")), + "model") + .UseSessionStore(store) + .UseExtension(new GoalLoopExtension()) + .Build(); + await using var rightRuntime = new GameAgentBuilder( + new FirstCallBarrierProvider( + gate, + ToolCall("create-right", "manage_goal", "{\"action\":\"create\",\"goalId\":\"right\",\"objective\":{}}")), + "model") + .UseSessionStore(store) + .UseExtension(new GoalLoopExtension()) + .Build(); + + var results = await Task.WhenAll( + leftRuntime.RunAsync( + new GameInput("session", "actor", "request", "{}", new GameMoment("world", 6), "left-input"), + TestContext.Current.CancellationToken), + rightRuntime.RunAsync( + 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); + var snapshot = await store.LoadAsync( + new GameSessionKey("session", "actor"), + TestContext.Current.CancellationToken); + var goalIds = snapshot!.ExtensionState.Values + .Select(json => + { + using var document = System.Text.Json.JsonDocument.Parse(json); + return document.RootElement.GetProperty("Id").GetString(); + }) + .ToArray(); + Assert.Contains("base", goalIds); + Assert.True(goalIds.Contains("left", StringComparer.Ordinal) ^ goalIds.Contains("right", StringComparer.Ordinal)); + } + [Fact] public async Task WorkflowGraphRunsIndependentNodesConcurrentlyAndJoinsInDeclarationOrder() { @@ -1507,6 +1611,57 @@ public async IAsyncEnumerable StreamAsync( } } + private sealed class ConcurrentRunGate + { + private readonly int _participantCount; + private readonly TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _arrivals; + + public ConcurrentRunGate(int participantCount) + { + _participantCount = participantCount; + } + + public async Task ArriveAsync(CancellationToken cancellationToken) + { + if (Interlocked.Increment(ref _arrivals) == _participantCount) + { + _release.TrySetResult(); + } + + await _release.Task.WaitAsync(cancellationToken); + } + } + + private sealed class FirstCallBarrierProvider : IModelProvider + { + private readonly ConcurrentRunGate _gate; + private readonly ModelResponse _firstResponse; + private int _calls; + + public FirstCallBarrierProvider(ConcurrentRunGate gate, ModelResponse firstResponse) + { + _gate = gate; + _firstResponse = firstResponse; + } + + public async IAsyncEnumerable StreamAsync( + ModelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var call = Interlocked.Increment(ref _calls); + if (call == 1) + { + await _gate.ArriveAsync(cancellationToken); + yield return ModelStreamEvent.Terminal(_firstResponse); + yield break; + } + + yield return ModelStreamEvent.Terminal(TextResponse("done")); + } + } + private sealed class FailingArtifactStore : IGameAgentArtifactStore { public ValueTask PutAsync(GameAgentArtifact artifact, CancellationToken cancellationToken) => diff --git a/tests/OpenGameAgent.Kernel.Tests/PublicApiCompatibilityTests.cs b/tests/OpenGameAgent.Kernel.Tests/PublicApiCompatibilityTests.cs index 5230427..eb0d519 100644 --- a/tests/OpenGameAgent.Kernel.Tests/PublicApiCompatibilityTests.cs +++ b/tests/OpenGameAgent.Kernel.Tests/PublicApiCompatibilityTests.cs @@ -6,7 +6,7 @@ namespace OpenGameAgent.Kernel.Tests; public sealed class PublicApiCompatibilityTests { - private const string ApprovedApiHash = "357AD3156EC99989A213D0184F050AADEDEEC8B82392CA29A8E0D9685A44E04C"; + private const string ApprovedApiHash = "7EC92B92D13B764CB0D3D3E8F71985220DBD6CF8C9D5DFC44C65C3D940CEEBBD"; [Fact] public void KernelPublicApiMatchesTheApprovedStableSurface() diff --git a/tests/OpenGameAgent.Models.Tests/PublicApiCompatibilityTests.cs b/tests/OpenGameAgent.Models.Tests/PublicApiCompatibilityTests.cs index c9dc2ec..97807a4 100644 --- a/tests/OpenGameAgent.Models.Tests/PublicApiCompatibilityTests.cs +++ b/tests/OpenGameAgent.Models.Tests/PublicApiCompatibilityTests.cs @@ -6,7 +6,7 @@ namespace OpenGameAgent.Models.Tests; public sealed class PublicApiCompatibilityTests { - private const string ApprovedApiHash = "8ECA4E20BBEE409CC73F57431CE89D23F5C5C99B2D98C38A31FF7BB8ADBD4F01"; + private const string ApprovedApiHash = "154F655CE1BCD1148BB2F20C25D9EED633FC2DE9E86029169C8BE9E454580583"; [Fact] public void ModelsPublicApiMatchesTheApprovedStableSurface() diff --git a/tests/OpenGameAgent.Persistence.Tests/GoalLoopPersistenceTests.cs b/tests/OpenGameAgent.Persistence.Tests/GoalLoopPersistenceTests.cs new file mode 100644 index 0000000..cebc729 --- /dev/null +++ b/tests/OpenGameAgent.Persistence.Tests/GoalLoopPersistenceTests.cs @@ -0,0 +1,133 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using OpenGameAgent.Extensions; +using OpenGameAgent.Kernel; +using Xunit; + +namespace OpenGameAgent.Persistence.Tests; + +public sealed class GoalLoopPersistenceTests +{ + [Fact] + public async Task TerminalRetentionAndActiveCapacitySurviveSessionStoreRestart() + { + using var directory = new TemporaryDirectory(); + var options = new GoalLoopOptions + { + MaximumActiveGoals = 2, + MaximumRetainedTerminalGoals = 1, + }; + + await using (var runtime = new GameAgentBuilder( + new ScriptedProvider(call => call switch + { + 1 => ToolCall("create-waiting", "{\"action\":\"create\",\"goalId\":\"waiting\",\"objective\":{}}"), + 2 => ToolCall("wait", "{\"action\":\"wait\",\"goalId\":\"waiting\",\"expectedRevision\":1,\"eventTypes\":[\"future\"]}"), + 3 => ToolCall("create-old", "{\"action\":\"create\",\"goalId\":\"old\",\"objective\":{}}"), + 4 => ToolCall("complete-old", "{\"action\":\"complete\",\"goalId\":\"old\",\"expectedRevision\":1}"), + 5 => ToolCall("create-recent", "{\"action\":\"create\",\"goalId\":\"recent\",\"objective\":{}}"), + 6 => ToolCall("complete-recent", "{\"action\":\"complete\",\"goalId\":\"recent\",\"expectedRevision\":1}"), + _ => TextResponse("saved"), + }), + "model") + .UseSessionStore(new FileGameSessionStore(directory.Path)) + .UseExtension(new GoalLoopExtension(options)) + .Build()) + { + var result = await runtime.RunAsync( + new GameInput("session", "actor", "request", "{}", new GameMoment("world", 1), "first"), + TestContext.Current.CancellationToken); + Assert.True(result.Succeeded); + } + + var restartedStore = new FileGameSessionStore(directory.Path); + await using (var restartedRuntime = new GameAgentBuilder( + new ScriptedProvider(call => call == 1 + ? ToolCall("create-active", "{\"action\":\"create\",\"goalId\":\"active\",\"objective\":{}}") + : TextResponse("restored")), + "model") + .UseSessionStore(restartedStore) + .UseExtension(new GoalLoopExtension(options)) + .Build()) + { + var result = await restartedRuntime.RunAsync( + new GameInput("session", "actor", "request", "{}", new GameMoment("world", 2), "second"), + TestContext.Current.CancellationToken); + Assert.True(result.Succeeded); + } + + var snapshot = await new FileGameSessionStore(directory.Path).LoadAsync( + 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); + Assert.Equal(3, goals.Count); + Assert.Equal("Waiting", goals["waiting"]); + Assert.Equal("Completed", goals["recent"]); + Assert.Equal("Active", goals["active"]); + Assert.DoesNotContain("old", goals); + } + + private static ModelResponse ToolCall(string id, string arguments) => + new(new AgentContent[] { new ToolCallContent(id, "manage_goal", 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); + } + } + } +} diff --git a/tests/OpenGameAgent.Persistence.Tests/PersistenceTests.cs b/tests/OpenGameAgent.Persistence.Tests/PersistenceTests.cs index 806c664..d4ee644 100644 --- a/tests/OpenGameAgent.Persistence.Tests/PersistenceTests.cs +++ b/tests/OpenGameAgent.Persistence.Tests/PersistenceTests.cs @@ -225,49 +225,6 @@ await restarted.SaveAsync( final.UsageLedger.Records.Select(record => record.RecordId)); } - [Fact] - public async Task VersionTwoSessionMigratesToAnEmptyLedgerAndCanUpgrade() - { - using var directory = new TemporaryDirectory(); - var key = new GameSessionKey("session", "actor"); - var store = new FileGameSessionStore(directory.Path); - await store.SaveAsync( - new GameSessionSnapshot(key, 1, new[] { AgentMessage.User("legacy") }), - 0, - TestContext.Current.CancellationToken); - var file = Assert.Single(Directory.GetFiles(directory.Path, "*.session.json")); - var document = JsonNode.Parse(await File.ReadAllTextAsync( - file, - TestContext.Current.CancellationToken))!.AsObject(); - document["FormatVersion"] = 2; - Assert.True(document.Remove("UsageRecords")); - await File.WriteAllTextAsync(file, document.ToJsonString(), TestContext.Current.CancellationToken); - - var restarted = new FileGameSessionStore(directory.Path); - var legacy = await restarted.LoadAsync(key, TestContext.Current.CancellationToken); - Assert.NotNull(legacy); - Assert.Empty(legacy.UsageLedger.Records); - - var record = new GameSessionUsageRecord( - "upgraded-usage", - GameSessionUsageCause.Assistant, - new ModelUsage(2, 1)); - Assert.True((await restarted.SaveAsync( - new GameSessionSnapshot( - key, - 2, - legacy.Messages, - usageLedger: legacy.UsageLedger.Append(new[] { record })), - 1, - TestContext.Current.CancellationToken)).Saved); - var upgraded = await new FileGameSessionStore(directory.Path) - .LoadAsync(key, TestContext.Current.CancellationToken); - - Assert.NotNull(upgraded); - Assert.Single(upgraded.UsageLedger.Records); - Assert.Equal(3, upgraded.UsageLedger.Stats.TotalTokens); - } - [Fact] public async Task SessionStoreRejectsUsageLedgerRemovalOrRewrite() { @@ -310,6 +267,51 @@ await store.SaveAsync( Assert.Equal(3, loaded.UsageLedger.Stats.TotalTokens); } + [Fact] + public async Task NonCurrentPersistenceFormatsAreRejectedInsteadOfSilentlyMigrated() + { + using (var sessionDirectory = new TemporaryDirectory()) + { + var key = new GameSessionKey("session", "actor"); + var store = new FileGameSessionStore(sessionDirectory.Path); + await store.SaveAsync( + new GameSessionSnapshot(key, 1), + 0, + TestContext.Current.CancellationToken); + var path = Assert.Single(Directory.GetFiles(sessionDirectory.Path, "*.session.json")); + await SetFormatVersionAsync(path, 3); + + await Assert.ThrowsAsync(async () => + await store.LoadAsync(key, TestContext.Current.CancellationToken)); + } + + using (var actionDirectory = new TemporaryDirectory()) + { + var intent = Intent("pre-release-action"); + var journal = new FileGameActionJournal(actionDirectory.Path); + await journal.ReserveAsync(intent, TestContext.Current.CancellationToken); + var path = Assert.Single(Directory.GetFiles(actionDirectory.Path, "*.action.json")); + await SetFormatVersionAsync(path, 1); + + await Assert.ThrowsAsync(async () => + await journal.FindAsync(intent.OperationId, TestContext.Current.CancellationToken)); + } + + using (var workflowDirectory = new TemporaryDirectory()) + { + var store = new FileGameWorkflowCheckpointStore(workflowDirectory.Path); + await store.SaveAsync( + new GameWorkflowCheckpoint("instance", "workflow", 1, 0, "{}"), + 0, + TestContext.Current.CancellationToken); + var path = Assert.Single(Directory.GetFiles(workflowDirectory.Path, "*.workflow.json")); + await SetFormatVersionAsync(path, 1); + + await Assert.ThrowsAsync(async () => + await store.LoadAsync("instance", TestContext.Current.CancellationToken)); + } + } + [Fact] public async Task SessionSaveUsesOptimisticRevisionAfterRestart() { @@ -436,27 +438,6 @@ await Assert.ThrowsAsync(async () => TestContext.Current.CancellationToken)); } - [Fact] - public async Task VersionOneActionJournalRemainsReadableWithoutGenerationBinding() - { - using var directory = new TemporaryDirectory(); - var intent = Intent("legacy-action"); - await new FileGameActionJournal(directory.Path).ReserveAsync( - intent, - TestContext.Current.CancellationToken); - var path = Assert.Single(Directory.GetFiles(directory.Path, "*.action.json")); - var document = JsonNode.Parse(await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken))!.AsObject(); - document["FormatVersion"] = 1; - document["Intent"]!.AsObject().Remove("GenerationId"); - await File.WriteAllTextAsync(path, document.ToJsonString(), TestContext.Current.CancellationToken); - - var restored = await new FileGameActionJournal(directory.Path).FindAsync( - intent.OperationId, - TestContext.Current.CancellationToken); - - Assert.Null(restored!.Intent.GenerationId); - } - [Fact] public async Task IndependentFileDispatchersNeverExecuteTheSameOperationTwice() { @@ -1247,6 +1228,18 @@ await restarted.PutAsync( private static GameActionIntent Intent(string operationId) => new(operationId, "input", "session", "actor", "move", "{\"x\":1.5}", new GameMoment("world", 4)); + private static async Task SetFormatVersionAsync(string path, int formatVersion) + { + var document = JsonNode.Parse(await File.ReadAllTextAsync( + path, + TestContext.Current.CancellationToken))!.AsObject(); + document["FormatVersion"] = formatVersion; + await File.WriteAllTextAsync( + path, + document.ToJsonString(), + TestContext.Current.CancellationToken); + } + private sealed class CallbackActionHandler : IGameActionHandler { private readonly Func> _execute; diff --git a/tests/OpenGameAgent.Tests/PublicApiCompatibilityTests.cs b/tests/OpenGameAgent.Tests/PublicApiCompatibilityTests.cs index 91a3921..04f57b6 100644 --- a/tests/OpenGameAgent.Tests/PublicApiCompatibilityTests.cs +++ b/tests/OpenGameAgent.Tests/PublicApiCompatibilityTests.cs @@ -5,7 +5,7 @@ namespace OpenGameAgent.Tests; public sealed class PublicApiCompatibilityTests { - private const string ApprovedApiHash = "356405F4CFB66C1CEC6D5F5BE5AB9B428EF3E46C85986D9BF7BF04940247D95B"; + private const string ApprovedApiHash = "2669F795459C54443CF3807963CAACB76AB5986709F1D3D2D46275E0CF4E6AD9"; [Fact] public void RuntimePublicApiMatchesTheApprovedStableSurface() diff --git a/tests/OpenGameAgent.Tests/RuntimeTests.cs b/tests/OpenGameAgent.Tests/RuntimeTests.cs index f0c5247..f696b21 100644 --- a/tests/OpenGameAgent.Tests/RuntimeTests.cs +++ b/tests/OpenGameAgent.Tests/RuntimeTests.cs @@ -231,9 +231,6 @@ static string Create( Assert.Equal( GameActionOperationIds.Version2Prefix.Length + 64, Create(session: new string('s', 16_384)).Length); - Assert.Equal("input:1:0", GameActionOperationIds.CreateLegacyV1("input", 1, 0)); - GameActionOperationIdFactory legacyFactory = GameActionOperationIds.CreateLegacyV1; - Assert.NotNull(legacyFactory); } [Fact] @@ -2087,52 +2084,6 @@ public async Task UsageLedgerSurvivesRepeatedCompactionAndRuntimeRestart() Assert.Equal(1.9, saved.UsageLedger.Stats.CostTotal, precision: 10); } - [Fact] - public async Task LegacyMessageUsageIsBootstrappedBeforeCompactionRemovesHistory() - { - static AgentMessage LegacyAssistant(string text, ModelUsage usage) => new( - AgentRole.Assistant, - new AgentContent[] { new TextContent(text) }, - DateTimeOffset.UnixEpoch, - model: "legacy-model", - stopReason: ModelStopReason.Stop, - usage: usage); - - var store = new InMemoryGameSessionStore(); - var key = new GameSessionKey("session", "actor"); - await store.SaveAsync( - new GameSessionSnapshot(key, 1, new AgentMessage[] - { - AgentMessage.User("one"), - LegacyAssistant("one", new ModelUsage(3, 1)), - AgentMessage.User("two"), - LegacyAssistant("two", new ModelUsage(4, 2)), - }), - 0, - TestContext.Current.CancellationToken); - var runtime = new GameAgentRuntime(new GameAgentRuntimeOptions( - new RecordingProvider(_ => Text("answer")), - "model") - { - SessionStore = store, - AgentLimits = new AgentLimits { MaxMessages = 5 }, - TranscriptCompactor = new SummarizingGameTranscriptCompactor((_, _, _) => - new ValueTask( - new GameTranscriptSummaryResult("summary", new ModelUsage(2, 1)))), - }); - - Assert.True((await runtime.RunAsync( - Input("chat", "{}", "legacy-compaction"), - TestContext.Current.CancellationToken)).Succeeded); - var saved = await store.LoadAsync(key, TestContext.Current.CancellationToken); - - Assert.NotNull(saved); - Assert.Equal(4, saved.UsageLedger.Records.Count); - Assert.Equal(15, saved.UsageLedger.Stats.TotalTokens); - Assert.Contains(saved.UsageLedger.Records, record => record.RecordId == "legacy-message-1"); - Assert.Contains(saved.UsageLedger.Records, record => record.RecordId == "legacy-message-3"); - } - [Fact] public async Task AppliedCasConflictRetryDoesNotDuplicateUsage() {