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 @@ -4,6 +4,7 @@

- 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.
- Add batched, payload-free mailbox pending-status queries that distinguish ready work from active leases without claiming delivery or incrementing attempts.

## 0.3.0-alpha.2

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ OpenGameAgent keeps the reusable agent machinery independent from the game while
- game-time memory filtering, expiry, and optional custom ranking;
- optional local/remote embeddings, rebuildable vector indexes, and lexical/vector hybrid recall;
- skills selected by input type and available tools;
- recurring game-time triggers and persistent actor mailboxes;
- recurring game-time triggers and persistent actor mailboxes with payload-free backlog queries;
- a typed extension API for tools, skills, routes, workflows, hooks, events, and services;
- capability-aware model catalogs and developer-hosted short-lived credentials;
- lazy external-tool discovery and large-result artifact spill;
Expand Down Expand Up @@ -110,7 +110,7 @@ Read [Architecture](docs/architecture.md) for the ownership and failure boundari
| Game runtime | Arbitrary JSON input, game clocks/timelines, fast/full/workflow routing, optimistic sessions, duplicate-input protection, actor concurrency, active-run steering/abort |
| Extension API | Immutable builder; prompt/context/tool/skill/route/workflow/hook/provider/service registration; typed lifecycle events and channels; namespaced persistent state |
| Official extensions | Tool policy and search, structured player questions/recommended replies, goals, host-verified ordered task plans, memory, artifacts, knowledge, delegation, tracing, and durable parallel workflow graphs |
| World primitives | Durable actions, resumable workflows, memories, skills, signals, game-time schedules, actor mailboxes |
| World primitives | Durable actions, resumable workflows, memories, skills, signals, game-time schedules, actor mailboxes with batch read-only pending status |
| Models and auth | Bundled capability/context/reasoning/cost directory, dynamic refresh, API-key/environment/stored/OAuth/local auth, developer-hosted short-lived credential gateway |
| External tools | Lazy on-demand search/describe/call by default; explicit direct exposure for small trusted catalogs |
| Portable plugins | [Agent Plugins 1.0.0](docs/agent-plugins.md) `plugin.json`, immediate-child `SKILL.md` discovery, MCP stdio/Streamable HTTP, client namespaces, containment, and component-level failure isolation |
Expand Down
4 changes: 2 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ OpenGameAgent 不替游戏规定玩法,而是提供可复用的游戏坐标与
- 按游戏时间过滤、过期并可自定义排序的记忆;
- 可选本地/远程嵌入、可重建向量索引与词法/向量混合召回;
- 根据输入类型和可用工具选择的 Skills;
- 游戏时间触发器与持久邮箱
- 游戏时间触发器,以及支持无 payload 积压查询的持久邮箱
- 可扩展工具、Skills、路由、Workflow、Hooks、事件与服务的类型化接口;
- 能力感知模型目录与开发者托管的短期凭证;
- 外部工具按需发现与大型结果产物化;
Expand Down Expand Up @@ -108,7 +108,7 @@ GameAgentRuntime
| 游戏 Runtime | 任意 JSON 输入、游戏时钟/时间线、快速/完整/Workflow 路由、乐观并发会话、输入去重、角色并发、运行中 steering/abort |
| 扩展 API | 不可变构建器;提示词/上下文/工具/Skills/路由/Workflow/Hooks/提供方/服务注册;类型化生命周期事件与通道;命名空间持久状态 |
| 官方扩展 | 工具策略与搜索、玩家结构化提问/推荐回复、目标、宿主证据校验的有序任务清单、记忆、产物、外部知识、委派、追踪和可持久并行工作流图 |
| 世界原语 | 可恢复动作、可续跑 Workflow、记忆、Skills、信号、游戏时间调度、角色邮箱 |
| 世界原语 | 可恢复动作、可续跑 Workflow、记忆、Skills、信号、游戏时间调度、支持批量只读待处理状态的角色邮箱 |
| 模型与认证 | 内置模型能力/上下文/推理级别/成本目录、动态刷新、API Key/环境/存储/OAuth/本地认证、开发者托管短期凭证网关 |
| 外部工具 | 默认按需搜索/描述/调用;小型可信目录可显式选择原生直连暴露 |
| 可移植插件 | [Agent Plugins 1.0.0](docs/agent-plugins.md) `plugin.json`、直接子目录 `SKILL.md` 发现、MCP stdio/Streamable HTTP、客户端命名空间、路径限制与组件级故障隔离 |
Expand Down
17 changes: 17 additions & 0 deletions docs/game-integration-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@ 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.

When an AI budget ends exactly at a game-time boundary, inspect mailbox backlog without claiming work or invoking a model:

```csharp
var recipients = activeActors
.Select(actorId => new GameMailboxRecipientKey(sessionId, actorId))
.ToArray();
var pending = await mailbox.GetPendingStatusAsync(
recipients,
DateTimeOffset.UtcNow,
cancellationToken);

var mustPauseAtBoundary = pending.Any(status => status.IncompleteCount > 0);
var canRunImmediately = pending.Any(status => status.ReadyCount > 0);
```

`GetPendingStatusAsync` is a typed, read-only snapshot. It returns one result per requested key in input order, including zero counts for missing mailboxes, and never returns message payloads. `ReadyCount` includes unleased messages and messages whose operational lease has expired; `LeasedCount` contains incomplete messages whose operational lease is still active; `IncompleteCount` is their sum. Querying does not acquire a lease, increment `Attempt`, complete or abandon a message, or call the model. The built-in file store evaluates the whole recipient batch in one directory pass rather than scanning all mailbox files once per NPC. Supply the same trusted operational clock used for `ClaimAsync`. A concurrent claim or settlement may make any snapshot stale, so use it for scheduling and causal-boundary admission, not as authority to complete a specific message.

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:
Expand Down
58 changes: 58 additions & 0 deletions src/OpenGameAgent.Persistence/FileGameMailbox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,64 @@ public ValueTask CompleteAsync(string messageId, string leaseToken, Cancellation
public ValueTask AbandonAsync(string messageId, string leaseToken, CancellationToken cancellationToken) =>
SettleAsync(messageId, leaseToken, complete: false, cancellationToken);

public async ValueTask<IReadOnlyList<GameMailboxPendingStatus>> GetPendingStatusAsync(
IReadOnlyList<GameMailboxRecipientKey> recipients,
DateTimeOffset operationalNow,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var requested = GameMailboxPendingQuery.Validate(recipients);
var counts = new Dictionary<GameMailboxRecipientKey, GameMailboxPendingQuery.Counts>();
foreach (var recipient in requested)
{
counts[recipient] = default;
}

if (counts.Count == 0)
{
return Array.Empty<GameMailboxPendingStatus>();
}

// A batch is deliberately evaluated with one directory pass. Hosts can inspect
// many actors without turning recipient count into recipient count x file count.
foreach (var path in Directory.EnumerateFiles(_files.DirectoryPath, "*" + Suffix, SearchOption.TopDirectoryOnly)
.Take(_capacity))
{
cancellationToken.ThrowIfCancellationRequested();
var document = await _files.ReadAsync<MailboxDocument>(path, cancellationToken).ConfigureAwait(false);
if (document is null)
{
continue;
}

_files.EnsurePathFor(path, document.MessageId, Suffix, "mailbox message");
var message = DecodeMessage(document);
if (document.Completed)
{
continue;
}

var recipient = new GameMailboxRecipientKey(message.SessionId, message.RecipientId);
if (!counts.TryGetValue(recipient, out var count))
{
continue;
}

if (document.LeaseToken is not null && document.OperationalLeaseExpiresAt > operationalNow)
{
count.Leased = checked(count.Leased + 1);
}
else
{
count.Ready = checked(count.Ready + 1);
}

counts[recipient] = count;
}

return GameMailboxPendingQuery.Materialize(requested, counts);
}

private async ValueTask SettleAsync(
string messageId,
string leaseToken,
Expand Down
172 changes: 172 additions & 0 deletions src/OpenGameAgent/Mailbox.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,74 @@ public GameMailboxDelivery(
public DateTimeOffset OperationalLeaseExpiresAt { get; }
}

public readonly struct GameMailboxRecipientKey : IEquatable<GameMailboxRecipientKey>
{
public GameMailboxRecipientKey(string sessionId, string recipientId)
{
SessionId = GameJson.RequireId(sessionId, nameof(sessionId));
RecipientId = GameJson.RequireId(recipientId, nameof(recipientId));
}

public string SessionId { get; }

public string RecipientId { get; }

public bool Equals(GameMailboxRecipientKey other) =>
string.Equals(SessionId, other.SessionId, StringComparison.Ordinal)
&& string.Equals(RecipientId, other.RecipientId, StringComparison.Ordinal);

public override bool Equals(object? obj) => obj is GameMailboxRecipientKey other && Equals(other);

public override int GetHashCode()
{
unchecked
{
return ((SessionId is null ? 0 : StringComparer.Ordinal.GetHashCode(SessionId)) * 397)
^ (RecipientId is null ? 0 : StringComparer.Ordinal.GetHashCode(RecipientId));
}
}

public override string ToString() => (SessionId ?? string.Empty) + ":" + (RecipientId ?? string.Empty);

public static bool operator ==(GameMailboxRecipientKey left, GameMailboxRecipientKey right) =>
left.Equals(right);

public static bool operator !=(GameMailboxRecipientKey left, GameMailboxRecipientKey right) =>
!left.Equals(right);

internal GameMailboxRecipientKey EnsureValid(string parameterName)
{
if (string.IsNullOrWhiteSpace(SessionId) || string.IsNullOrWhiteSpace(RecipientId))
{
throw new ArgumentException("A valid mailbox recipient key is required.", parameterName);
}

return this;
}
}

public sealed class GameMailboxPendingStatus
{
public GameMailboxPendingStatus(
GameMailboxRecipientKey recipient,
int readyCount,
int leasedCount)
{
Recipient = recipient.EnsureValid(nameof(recipient));
ReadyCount = readyCount >= 0 ? readyCount : throw new ArgumentOutOfRangeException(nameof(readyCount));
LeasedCount = leasedCount >= 0 ? leasedCount : throw new ArgumentOutOfRangeException(nameof(leasedCount));
IncompleteCount = checked(readyCount + leasedCount);
}

public GameMailboxRecipientKey Recipient { get; }

public int ReadyCount { get; }

public int LeasedCount { get; }

public int IncompleteCount { get; }
}

public interface IGameMailbox
{
ValueTask<bool> EnqueueAsync(GameMailboxMessage message, CancellationToken cancellationToken);
Expand All @@ -83,6 +151,11 @@ ValueTask<IReadOnlyList<GameMailboxDelivery>> ClaimAsync(
ValueTask CompleteAsync(string messageId, string leaseToken, CancellationToken cancellationToken);

ValueTask AbandonAsync(string messageId, string leaseToken, CancellationToken cancellationToken);

ValueTask<IReadOnlyList<GameMailboxPendingStatus>> GetPendingStatusAsync(
IReadOnlyList<GameMailboxRecipientKey> recipients,
DateTimeOffset operationalNow,
CancellationToken cancellationToken);
}

public sealed class InMemoryGameMailbox : IGameMailbox
Expand Down Expand Up @@ -214,6 +287,54 @@ public ValueTask AbandonAsync(string messageId, string leaseToken, CancellationT
return default;
}

public ValueTask<IReadOnlyList<GameMailboxPendingStatus>> GetPendingStatusAsync(
IReadOnlyList<GameMailboxRecipientKey> recipients,
DateTimeOffset operationalNow,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var requested = GameMailboxPendingQuery.Validate(recipients);
var counts = new Dictionary<GameMailboxRecipientKey, GameMailboxPendingQuery.Counts>();
foreach (var recipient in requested)
{
counts[recipient] = default;
}

lock (_gate)
{
foreach (var entry in _entries.Values)
{
cancellationToken.ThrowIfCancellationRequested();
if (entry.Completed)
{
continue;
}

var recipient = new GameMailboxRecipientKey(
entry.Message.SessionId,
entry.Message.RecipientId);
if (!counts.TryGetValue(recipient, out var count))
{
continue;
}

if (entry.LeaseToken is not null && entry.LeaseExpiresAt > operationalNow)
{
count.Leased = checked(count.Leased + 1);
}
else
{
count.Ready = checked(count.Ready + 1);
}

counts[recipient] = count;
}
}

return new ValueTask<IReadOnlyList<GameMailboxPendingStatus>>(
GameMailboxPendingQuery.Materialize(requested, counts));
}

private Entry RequireLease(string messageId, string leaseToken)
{
GameJson.RequireId(messageId, nameof(messageId));
Expand Down Expand Up @@ -272,3 +393,54 @@ public Entry(GameMailboxMessage message, long sequence)
public bool Completed { get; set; }
}
}

internal static class GameMailboxPendingQuery
{
internal const int MaximumRecipients = 4_096;

internal static GameMailboxRecipientKey[] Validate(
IReadOnlyList<GameMailboxRecipientKey> recipients)
{
if (recipients is null)
{
throw new ArgumentNullException(nameof(recipients));
}

if (recipients.Count > MaximumRecipients)
{
throw new GameRuntimeLimitException(
nameof(MaximumRecipients),
"A mailbox pending query contains too many recipients.");
}

var copy = new GameMailboxRecipientKey[recipients.Count];
for (var index = 0; index < recipients.Count; index++)
{
copy[index] = recipients[index].EnsureValid(nameof(recipients));
}

return copy;
}

internal static IReadOnlyList<GameMailboxPendingStatus> Materialize(
IReadOnlyList<GameMailboxRecipientKey> requested,
IReadOnlyDictionary<GameMailboxRecipientKey, Counts> counts)
{
var result = new GameMailboxPendingStatus[requested.Count];
for (var index = 0; index < requested.Count; index++)
{
var recipient = requested[index];
var count = counts[recipient];
result[index] = new GameMailboxPendingStatus(recipient, count.Ready, count.Leased);
}

return Array.AsReadOnly(result);
}

internal struct Counts
{
public int Ready;

public int Leased;
}
}
Loading
Loading