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 @@ -5,6 +5,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.
- Add backward-compatible durable task-plan pause/resume with revision checks, preserved in-progress steps, non-runnable paused routing, typed change reasons, and restart coverage.

## 0.3.0-alpha.2

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Read [Architecture](docs/architecture.md) for the ownership and failure boundari
| Tool execution | Provider-request schema preflight plus execution-time validation over a bounded JSON Schema subset, guaranteed result for every accepted call, safe parallel reads, conflict-key serialization, policy blocking/termination, timeouts, uncertain write outcomes |
| 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 |
| Official extensions | Tool policy and search, structured player questions/recommended replies, goals, host-verified ordered task plans with durable pause/resume, memory, artifacts, knowledge, delegation, tracing, and durable parallel workflow graphs |
| 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 |
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ GameAgentRuntime
| 工具执行 | provider 请求前 schema 预检及执行期有界 JSON Schema 子集校验、每个已接受调用都有结果、安全并行读、冲突键串行、策略拦截/终止、超时与写入结果未知语义 |
| 游戏 Runtime | 任意 JSON 输入、游戏时钟/时间线、快速/完整/Workflow 路由、乐观并发会话、输入去重、角色并发、运行中 steering/abort |
| 扩展 API | 不可变构建器;提示词/上下文/工具/Skills/路由/Workflow/Hooks/提供方/服务注册;类型化生命周期事件与通道;命名空间持久状态 |
| 官方扩展 | 工具策略与搜索、玩家结构化提问/推荐回复、目标、宿主证据校验的有序任务清单、记忆、产物、外部知识、委派、追踪和可持久并行工作流图 |
| 官方扩展 | 工具策略与搜索、玩家结构化提问/推荐回复、目标、支持持久暂停/恢复且由宿主校验证据的有序任务清单、记忆、产物、外部知识、委派、追踪和可持久并行工作流图 |
| 世界原语 | 可恢复动作、可续跑 Workflow、记忆、Skills、信号、游戏时间调度、支持批量只读待处理状态的角色邮箱 |
| 模型与认证 | 内置模型能力/上下文/推理级别/成本目录、动态刷新、API Key/环境/存储/OAuth/本地认证、开发者托管短期凭证网关 |
| 外部工具 | 默认按需搜索/描述/调用;小型可信目录可显式选择原生直连暴露 |
Expand Down
2 changes: 1 addition & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ This page maps product needs to the smallest reusable OpenGameAgent primitive.
| Search a large tool catalog on demand | `ToolCatalogExtension`, `IGameToolCatalog` |
| Ask the player structured questions and recommend choices | `StructuredInteractionExtension`, `IGameInteractionBroker` |
| Track goals and resume them after game-time waits | `GoalLoopExtension` |
| Keep a persistent ordered checklist with host-verified progress | `TaskPlanExtension` |
| Keep a persistent ordered checklist with host-verified progress and durable pause/resume | `TaskPlanExtension` |
| Delegate bounded foreground or background work | `AgentDelegationExtension` |
| Query a game-owned knowledge source | `ExternalKnowledgeExtension` |
| Capture bounded lifecycle traces | `GameAgentTracingExtension` |
Expand Down
8 changes: 6 additions & 2 deletions docs/game-integration-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ 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.
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 or paused plan always retains 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
var plans = new TaskPlanExtension(
Expand Down Expand Up @@ -96,7 +96,9 @@ var runtime = new GameAgentBuilder(provider, model)
.Build();
```

`advance` requires the plan revision and accepted evidence and can succeed only once per input. `replace_remaining` preserves completed steps and replaces only unfinished work. `fail` and `cancel` are terminal. Active plans contribute pending work to routing; terminal retention is independently bounded and never consumes active-plan capacity. State is namespaced by the runtime's session/actor key and persists through any `IGameSessionStore`.
`advance` requires the plan revision and accepted evidence and can succeed only once per input. `replace_remaining` preserves completed steps and replaces only unfinished work. `pause` changes `Active` to `Paused` without changing any step, and `resume` restores that same plan to `Active`; both require `expectedRevision`. A repeated pause of an already paused plan, or resume of an already active plan, is an idempotent success only when the supplied revision still matches: it does not write state, increment the revision, or emit another change event. A stale revision is always a conflict. `fail` and `cancel` remain terminal and terminal plans cannot resume.

Paused plans remain visible through `list_task_plans` and `TaskPlanExtension.ReadAsync` without requesting terminal records. They continue to count toward `MaximumActivePlans`, but do not contribute pending work. While paused, every mutation except idempotent `pause` and `resume` is rejected; the checklist must resume before it can advance, replan, fail, or cancel. A successful transition increments the plan revision, persists the current game moment, and publishes `GameTaskPlanChanged` with reason `pause` or `resume`; as with every extension change event, wait for the matching `SessionSaved` event before treating it as committed UI state. State is namespaced by the runtime's session/actor key and persists through any `IGameSessionStore`.

The tool payload cannot select an owner, session, or actor scope. Plans always use the already-authorized `GameInput`/`GameSessionKey`; a server host must resolve and authorize that key before invoking the runtime.

Expand All @@ -108,6 +110,8 @@ The evidence validator is a read-only authority check, not another world mutatio

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.

Existing task-plan documents remain valid without migration. `Paused` was appended to the public status enum and is serialized by name; the numeric values and stored JSON names of `Active`, `Completed`, `Failed`, and `Cancelled` are unchanged. Hosts that switch exhaustively on plan status should add `Paused` as a visible, non-terminal, non-runnable state.

## Monthly or turn-based evolution

Represent the calendar in `GameMoment.CalendarJson` while using `Tick` for ordering. A monthly advance can be a named `DurableGameWorkflow`:
Expand Down
68 changes: 54 additions & 14 deletions src/OpenGameAgent.Extensions/TaskPlanExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public enum GameTaskPlanStatus
Completed,
Failed,
Cancelled,
Paused,
}

[JsonConverter(typeof(JsonStringEnumConverter))]
Expand Down Expand Up @@ -213,7 +214,7 @@ public sealed class TaskPlanExtension : IGameAgentExtension
"type":"object",
"required":["action","planId"],
"properties":{
"action":{"type":"string","enum":["create","advance","replace_remaining","fail","cancel"]},
"action":{"type":"string","enum":["create","advance","replace_remaining","pause","resume","fail","cancel"]},
"planId":{"type":"string","minLength":1,"maxLength":128},
"expectedRevision":{"type":"integer","minimum":1},
"objective":{"type":"string","minLength":1,"maxLength":4096},
Expand Down Expand Up @@ -244,9 +245,9 @@ public TaskPlanExtension(

public GameAgentExtensionDescriptor Descriptor { get; } = new(
ExtensionId,
"1.0.0",
"Persistent ordered task checklists with host-validated advancement.",
new[] { "task-plan", "checklist", "pending-work", "evidence" });
"1.1.0",
"Persistent ordered task checklists with host-validated advancement and durable pause/resume.",
new[] { "task-plan", "checklist", "pending-work", "evidence", "pause-resume" });

public static async ValueTask<GameTaskPlanQueryResult> ReadAsync(
IGameSessionStore sessionStore,
Expand Down Expand Up @@ -275,7 +276,7 @@ public static async ValueTask<GameTaskPlanQueryResult> ReadAsync(
var plans = ReadAll(
StoredExtensionStateReader.Read(snapshot, ExtensionId),
AbsoluteMaximumStepsPerPlan)
.Where(plan => includeTerminal || plan.Status == GameTaskPlanStatus.Active)
.Where(plan => includeTerminal || !IsTerminal(plan.Status))
.OrderBy(plan => plan.Id, StringComparer.Ordinal)
.ToArray();
return new GameTaskPlanQueryResult(key, snapshot.Revision, plans);
Expand All @@ -285,7 +286,7 @@ public void Configure(GameAgentExtensionApi api)
{
api.RegisterPromptFragment(
"task-plan-guidance",
"Use manage_task_plan for multi-step work that must survive later inputs. An active plan has exactly one in-progress step. Advance only with evidence the host can verify, never by assertion. Use replace_remaining when new world state invalidates unfinished work; completed steps remain immutable.");
"Use manage_task_plan for multi-step work that must survive later inputs. An active or paused plan retains exactly one in-progress step. Paused plans do not drive pending work and must be resumed before advancing. Advance only with evidence the host can verify, never by assertion. Use replace_remaining when new world state invalidates unfinished work; completed steps remain immutable.");
api.RegisterToolProvider(
"task-plan-tools",
(context, _) => new ValueTask<IReadOnlyList<AgentTool>>(new[]
Expand All @@ -307,7 +308,7 @@ private AgentTool CreateManageTool(GameAgentExtensionApi api, GameAgentExtension
new(
new ToolDefinition(
"manage_task_plan",
"Create, advance, replan, fail, or cancel a persistent ordered checklist for the current actor session. Advancing the final step completes the plan.",
"Create, advance, replan, pause, resume, fail, or cancel a persistent ordered checklist for the current actor session. Advancing the final step completes the plan.",
ManageSchema),
async (arguments, _, cancellationToken) =>
{
Expand All @@ -323,11 +324,11 @@ private AgentTool CreateManageTool(GameAgentExtensionApi api, GameAgentExtension
}

PruneTerminalPlans(context.State);
var activeCount = ReadAll(context.State).Count(plan => plan.Status == GameTaskPlanStatus.Active);
var activeCount = ReadAll(context.State).Count(plan => !IsTerminal(plan.Status));
if (activeCount >= _options.MaximumActivePlans)
{
return ToolResult.Error(
$"At most {_options.MaximumActivePlans} active task plans may exist in one actor session.");
$"At most {_options.MaximumActivePlans} active or paused task plans may exist in one actor session.");
}

if (!arguments.TryGetProperty("objective", out var objectiveElement)
Expand Down Expand Up @@ -370,7 +371,7 @@ private AgentTool CreateManageTool(GameAgentExtensionApi api, GameAgentExtension
}

document = existing;
if (document.Status != GameTaskPlanStatus.Active)
if (IsTerminal(document.Status))
{
return ToolResult.Error($"Task plan '{planId}' is terminal and immutable.");
}
Expand All @@ -382,6 +383,14 @@ private AgentTool CreateManageTool(GameAgentExtensionApi api, GameAgentExtension
$"Task plan '{planId}' revision conflict. Current revision is {document.Revision}.");
}

if (document.Status == GameTaskPlanStatus.Paused
&& action is not "pause" and not "resume")
{
return ToolResult.Error(
$"Task plan '{planId}' is paused and must be resumed before it can change.");
}

var changed = true;
switch (action)
{
case "advance":
Expand Down Expand Up @@ -447,6 +456,28 @@ private AgentTool CreateManageTool(GameAgentExtensionApi api, GameAgentExtension
: GameTaskPlanStepStatus.Pending,
}));
document.Steps = completed;
break;
case "pause":
if (document.Status == GameTaskPlanStatus.Paused)
{
changed = false;
}
else
{
document.Status = GameTaskPlanStatus.Paused;
}

break;
case "resume":
if (document.Status == GameTaskPlanStatus.Active)
{
changed = false;
}
else
{
document.Status = GameTaskPlanStatus.Active;
}

break;
case "fail":
document.Status = GameTaskPlanStatus.Failed;
Expand All @@ -464,6 +495,11 @@ private AgentTool CreateManageTool(GameAgentExtensionApi api, GameAgentExtension
return ToolResult.Error($"Unsupported task-plan action '{action}'.");
}

if (!changed)
{
return JsonResult(new GameTaskPlanSnapshot(document));
}

document.Revision = checked(document.Revision + 1);
document.LastTimelineId = context.Input.Moment.TimelineId;
document.LastTick = context.Input.Moment.Tick;
Expand Down Expand Up @@ -504,7 +540,7 @@ private AgentTool CreateListTool(GameAgentExtensionRunContext context) =>
var includeTerminal = arguments.TryGetProperty("includeTerminal", out var include)
&& include.GetBoolean();
var plans = ReadAll(context.State)
.Where(plan => includeTerminal || plan.Status == GameTaskPlanStatus.Active)
.Where(plan => includeTerminal || !IsTerminal(plan.Status))
.OrderBy(plan => plan.Id, StringComparer.Ordinal)
.ToArray();
return new ValueTask<ToolResult>(JsonResult(new { plans }));
Expand Down Expand Up @@ -667,12 +703,16 @@ private static void ValidateDocument(TaskPlanDocument document, string expectedI
}

var inProgress = document.Steps.Count(step => step.Status == GameTaskPlanStepStatus.InProgress);
if ((document.Status == GameTaskPlanStatus.Active && inProgress != 1)
|| (document.Status != GameTaskPlanStatus.Active && inProgress != 0)
var resumable = document.Status is GameTaskPlanStatus.Active or GameTaskPlanStatus.Paused;
if ((resumable && inProgress != 1)
|| (!resumable && inProgress != 0)
|| (document.Status == GameTaskPlanStatus.Completed
&& document.Steps.Any(step => step.Status != GameTaskPlanStepStatus.Completed))
|| IsTerminal(document.Status) != (document.TerminalSequence > 0)
|| ((document.Status is GameTaskPlanStatus.Active or GameTaskPlanStatus.Completed) && document.Error is not null))
|| ((document.Status is GameTaskPlanStatus.Active
or GameTaskPlanStatus.Paused
or GameTaskPlanStatus.Completed)
&& document.Error is not null))
{
throw new InvalidOperationException("The task-plan status does not match its checklist.");
}
Expand Down
1 change: 1 addition & 0 deletions src/OpenGameAgent.Models/Credentials.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ public async ValueTask<bool> RemoveAsync(GameCredentialKey key, CancellationToke
CancellationToken cancellationToken)
{
using var lease = await AcquireAsync(key, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();
GameCredential? current;
lock (_stateGate)
{
Expand Down
Loading
Loading