From 5717cafb074ad9a1b48ec71d37ba23bf1c5db60f Mon Sep 17 00:00:00 2001 From: Eric Sun <141227631+EricSun0218@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:46:00 +0800 Subject: [PATCH 1/2] feat: add automatic execution routing --- CHANGELOG.md | 12 + README.md | 4 +- README.zh-CN.md | 3 +- docs/architecture.md | 7 +- docs/execution-and-extension-reference.md | 29 +- docs/how-to-route-and-supervise-agents.md | 52 +- docs/runtime-capability-model.md | 10 +- .../godot/addons/game_agent_runtime/README.md | 15 +- .../Documentation~/index.md | 3 +- .../com.gameagent.runtime.unity/README.md | 7 +- .../AutomaticExecutionRouting.cs | 773 ++++++++++++++++++ src/GameAgent.Core/ExecutionRouting.cs | 213 ++++- .../GameAgentRuntimeBuilder.cs | 18 + .../GameAgent.Tests/ExecutionRoutingTests.cs | 545 ++++++++++++ 14 files changed, 1631 insertions(+), 60 deletions(-) create mode 100644 src/GameAgent.Core/AutomaticExecutionRouting.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 819356c..7a26037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +- Add bounded hybrid automatic routing across the shared runtime, Godot, and + Unity integrations: obvious dialogue uses durable one-turn Direct execution, + while actionable, structured, long, or ambiguous input retains Agent + capability. +- Preserve explicit path and capability requirements as authoritative, consult + an optional classifier only for ambiguous text, and fall back conservatively + on classifier failure or timeout. +- Add route-selected provider and inference profiles with independent per-run + override precedence. + ## 0.2.0-alpha.1 - Add bilingual English-first project documentation, contributor governance, diff --git a/README.md b/README.md index e1fb3b8..4bd75c6 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,9 @@ an assistant, or a group decision without forcing games into one data model. - Durable streaming model/tool loops with retries, route fallback, stale-stream fencing, crash recovery, and explicit reconciliation of uncertain writes. - Stateless completion plus durable `Direct`, full `Agent`, and fixed - `Workflow` execution paths with bounded deterministic routing. + `Workflow` paths with bounded hybrid routing: obvious dialogue stays fast, + actionable or structured input retains Agent capabilities, and declared + requirements always win. - Typed observations and structured tool results; natural language is optional. - Immutable tool and skill snapshots with bounded progressive disclosure. - Strict tool input validation, deterministic conflict scopes, parallel reads, diff --git a/README.zh-CN.md b/README.zh-CN.md index 426d0d1..36dfeda 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -39,7 +39,8 @@ OpenGameAgent 接收类型化游戏上下文,运行流式模型/工具循环 - 可持久化的流式模型/工具循环,支持重试、路由回退、过期流隔离、崩溃恢复和不确定写入 的显式对账。 - 无状态补全,以及可持久化的 `Direct`、完整 `Agent` 和固定 `Workflow` 执行路径; - 路由有确定且有界的决策过程。 + 采用有界混合自动路由,明确的短对话保持快速,动作或结构化输入保留 Agent 能力, + 显式能力要求始终优先。 - 类型化观察和结构化工具结果;自然语言只是可选输入之一。 - 不可变工具与 Skill 快照,以及有界的渐进式披露。 - 严格的工具输入校验、确定性冲突域、并行只读、冲突写入串行化和引擎主线程派发。 diff --git a/docs/architecture.md b/docs/architecture.md index 8f0f998..41b64b5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -59,8 +59,11 @@ The execution surfaces are deliberately distinct. Stateless completion avoids session and journal overhead for isolated model calls. Durable direct execution keeps context, memory, accounting, and recovery but performs one tool-free model turn. Agent execution owns the bounded tool loop. Workflow execution owns a -fixed recoverable graph around Agent steps. Routing therefore optimizes latency -without allowing a cheap path to silently omit required capabilities. +fixed recoverable graph around Agent steps. The default hybrid router classifies +obvious dialogue locally, escalates actionable, structured, long, or ambiguous +input, and consults an optional classifier only for ambiguous text. Routing +therefore optimizes latency without adding a classification call to every +request or allowing a cheap path to silently omit required capabilities. Game-semantic coordinates remain engine-neutral. Named clocks, timelines, save/state revisions, entity incarnations, observer perspective, spatial scope, diff --git a/docs/execution-and-extension-reference.md b/docs/execution-and-extension-reference.md index 279558d..e882f25 100644 --- a/docs/execution-and-extension-reference.md +++ b/docs/execution-and-extension-reference.md @@ -20,22 +20,37 @@ tool call. `Direct` is not stateless: it uses the same durable input, context, memory, provider resilience, accounting, and recovery contracts as `Agent`, but exposes no tools or skills and ends after one provider response. -### Deterministic routing +### Hybrid automatic routing `RoutedExecutionRuntime` accepts an `ExecutionRouteRequest`. The default -`DeterministicExecutionRoutePolicy` selects: +`AutomaticExecutionRoutePolicy` first applies immutable requirements: - `Workflow` when `ExecutionRequirements.Workflow` or `ParallelActors` is present; multi-actor work must use a workflow that coordinates participants; - `Agent` for tools, skills, durable effects, or multiple model turns; -- `Direct` when none of those capabilities is required. +- `Direct` remains the minimum path when none of those capabilities is + required. + +It then combines the bounded structured `Signal` with the latest normalized +user input. Short scalar dialogue stays on `Direct`; actionable terms, +structured or multipart input, and long input select `Agent`. Intermediate text +is ambiguous and conservatively selects `Agent` unless an optional +`IAutomaticExecutionClassifier` returns a valid, sufficiently confident +decision. Obvious cases never pay for a classifier call. A workflow hint may +select `Workflow` only when a workflow payload is present. + +The same policy can attach a `DirectModelProfile` or `AgentModelProfile` with +provider-route and inference defaults. Explicit `Inference` and +`RoutePreference` values on the durable run win independently, so automatic +selection never replaces a caller override. An explicit path is validated against the requirements. A custom `IExecutionRoutePolicy` receives an optional bounded structured `Signal`. -Policy execution is concurrency-limited and timed out. A failed, timed-out, or -invalid custom policy uses the least-capable deterministic path that satisfies -the immutable requirements: `Direct` for none, `Agent` for Agent capabilities, -and `Workflow` for workflow requirements. Configure it with +Policy execution is concurrency-limited and timed out. The automatic policy +uses its local conservative result when its optional classifier fails or times +out. Other failed, timed-out, or invalid custom policies use the least-capable +deterministic path that satisfies immutable requirements. Configure the built-in +policy with `WithAutomaticExecutionRouting(...)`, or replace it through `WithExecutionRoutePolicy(...)`. A workflow route additionally requires an `IRoutedWorkflowRuntime`. The diff --git a/docs/how-to-route-and-supervise-agents.md b/docs/how-to-route-and-supervise-agents.md index 4d8a1cd..aa0c8b0 100644 --- a/docs/how-to-route-and-supervise-agents.md +++ b/docs/how-to-route-and-supervise-agents.md @@ -45,10 +45,46 @@ var outcome = await built.Execution.RunAsync( }, cancellationToken); ``` -The default policy chooses `Agent` for this request. For a durable one-turn -conversation, set `Requirements = ExecutionRequirements.None`; it chooses -`Direct`. Use `ExplicitPath` only when the caller already knows the path and can -supply compatible requirements. +The default policy chooses `Agent` for this request because declared +requirements always win. When requirements are absent, it also reads the +bounded `Signal` and latest normalized user input: a short dialogue can use +`Direct`, while actionable, structured, long, or ambiguous work uses `Agent`. +Use `ExplicitPath` only when the caller already knows the path and can supply +compatible requirements. + +Configure automatic model tiers once at composition time: + +```csharp +builder.WithAutomaticExecutionRouting( + new AutomaticExecutionRoutingOptions + { + DirectModelProfile = new ExecutionRouteModelProfile + { + Inference = new ModelInferenceOptions + { + ReasoningEnabled = false, + ReasoningEffort = ModelReasoningEfforts.None + }, + RoutePreference = new ProviderRoutePreference + { + ProviderIds = new[] { "fast-dialogue" }, + AllowUnlistedFallback = true + } + }, + AgentModelProfile = new ExecutionRouteModelProfile + { + RoutePreference = new ProviderRoutePreference + { + ProviderIds = new[] { "capable-agent" }, + AllowUnlistedFallback = true + } + } + }); +``` + +Provider IDs are application configuration identities, not model-name guesses. +The runtime cannot infer which configured route is cheaper or faster. Explicit +per-run inference or provider preferences override the selected profile. For a fixed orchestration, compile and register workflows, then attach the routed workflow runtime: @@ -82,10 +118,10 @@ builder.WithExecutionRoutePolicy( ``` Do not ask a model merely to choose between `Direct` and `Agent` for every -request. Prefer deterministic requirements first. A custom policy failure -falls back from immutable requirements: capability-free work uses `Direct`, -Agent capabilities use `Agent`, and workflow requirements use `Workflow`. -Required tools or durability are therefore never skipped. +request. The built-in router resolves obvious inputs locally and invokes an +optional classifier only for ambiguous text. Prefer deterministic requirements +first. Classifier failure or timeout falls back to the conservative local +result; required tools or durability are never skipped. ## Run bounded child Agents diff --git a/docs/runtime-capability-model.md b/docs/runtime-capability-model.md index c1e27b5..81304cc 100644 --- a/docs/runtime-capability-model.md +++ b/docs/runtime-capability-model.md @@ -53,10 +53,12 @@ The runtime exposes four execution shapes: | Agent | Yes | Bounded loop | Yes | stateful NPC or world action | | Workflow | Yes | Declared | Per stage | deterministic orchestration | -The deterministic router selects the least-capable shape that satisfies the -request. A simple line of dialogue therefore does not need to enter a complex -tool loop. Games can supply a bounded custom routing policy; invalid or timed-out -decisions fall back to the least-capable valid route. +The automatic router first enforces declared capability requirements, then +combines a bounded structured signal with the latest user input. Obvious +dialogue uses Direct without entering a tool loop; actionable, structured, +long, or ambiguous work retains Agent capability. Games can configure model +profiles, add a classifier for ambiguous text, or replace the policy. Invalid +or timed-out custom policies still fall back to the least-capable valid route. ## Game-native runtime primitives diff --git a/engines/godot/addons/game_agent_runtime/README.md b/engines/godot/addons/game_agent_runtime/README.md index 5e46c98..a71ce32 100644 --- a/engines/godot/addons/game_agent_runtime/README.md +++ b/engines/godot/addons/game_agent_runtime/README.md @@ -88,7 +88,10 @@ GDScript callers may use the Variant-compatible methods on the Autoload: ```gdscript var request_id := GameAgent.start_agent_run(run_dictionary, observations) var routed_id := GameAgent.start_routed_run( - route_dictionary, + { + "operation_kind": "npc-input", + "signal": { "input": player_input }, + }, run_dictionary, observations, options, @@ -102,7 +105,7 @@ var completion_id := GameAgent.start_completion({ Available GDScript operations include starting and resuming durable runs, choosing Direct or Agent execution plus inference/provider-route options through -`start_agent_run_with_options`, deterministic Direct/Agent/Workflow routing, +`start_agent_run_with_options`, hybrid automatic Direct/Agent/Workflow routing, stateless completion, starting and cancelling child Agent runs, starting multi-actor batches, resuming or abandoning a participant, and posting cancel, interrupt, steer, or follow-up controls. Inputs are converted to strict protocol @@ -301,9 +304,11 @@ responsible for conflicts in authoritative state. ## Routing and child Agents -The built backend exposes stateless completion, durable Direct/Agent routing, -configured workflows, and bounded child supervision from the same in-process -runtime. Child completion uses the normal run-completed/run-failed signal path; +The built backend exposes stateless completion, durable hybrid Direct/Agent +routing, configured workflows, and bounded child supervision from the same +in-process runtime. Route signals may carry arbitrary bounded `input`; the +runtime also inspects the latest normalized user message. Child completion uses +the normal run-completed/run-failed signal path; validated root/parent/depth lineage is stored in the child run extensions. The game must still stage concurrent results and resolve them against authoritative state rather than applying them in network-completion order. diff --git a/engines/unity/com.gameagent.runtime.unity/Documentation~/index.md b/engines/unity/com.gameagent.runtime.unity/Documentation~/index.md index f1b641d..dac39d6 100644 --- a/engines/unity/com.gameagent.runtime.unity/Documentation~/index.md +++ b/engines/unity/com.gameagent.runtime.unity/Documentation~/index.md @@ -37,7 +37,8 @@ operation identifiers, and receipts stay in engine-neutral assemblies. With a `BuiltUnityAgentRuntimeBackend`, the host exposes: - `RunAsync` for normal durable Agent work; -- `RunRoutedAsync` for durable Direct/Agent/Workflow selection; +- `RunRoutedAsync` for bounded hybrid automatic Direct/Agent/Workflow + selection from structured signals and the latest normalized user input; - `CompleteAsync` for stateless single-provider-turn work; - `RunChildAsync` and `CancelChildren` for bounded delegation. - optional `SubmitGenerationAsync`, `RefreshGenerationAsync`, diff --git a/engines/unity/com.gameagent.runtime.unity/README.md b/engines/unity/com.gameagent.runtime.unity/README.md index e0d1e2a..3c2d585 100644 --- a/engines/unity/com.gameagent.runtime.unity/README.md +++ b/engines/unity/com.gameagent.runtime.unity/README.md @@ -130,8 +130,11 @@ integration checklist. The built runtime backend also exposes `RunRoutedAsync`, `CompleteAsync`, `RunChildAsync`, and `CancelChildren` through `UnityAgentRuntimeHost`. These use -the shared durable routing, per-operation inference/provider selection, and -bounded child-lineage contracts rather than Unity-specific Agent behavior. +the shared hybrid automatic routing, per-operation inference/provider +selection, and bounded child-lineage contracts rather than Unity-specific +Agent behavior. Obvious dialogue stays on the one-turn Direct path; actionable +or structured input retains Agent capabilities, and explicit requirements or +per-run model choices always win. Use the `RunChildAsync(AgentRun, ...)` overload when the parent was restored from durable storage or delegation continues after supervisor cache eviction; the string overload is intended for roots or currently supervised parents. diff --git a/src/GameAgent.Core/AutomaticExecutionRouting.cs b/src/GameAgent.Core/AutomaticExecutionRouting.cs new file mode 100644 index 0000000..841ce5f --- /dev/null +++ b/src/GameAgent.Core/AutomaticExecutionRouting.cs @@ -0,0 +1,773 @@ +using System.Globalization; +using System.Text.Json; + +namespace GameAgent.Core; + +/// +/// Optional provider and inference defaults selected with an execution path. +/// Explicit controls on the durable run always take precedence. +/// +public sealed class ExecutionRouteModelProfile +{ + public ModelInferenceOptions? Inference { get; set; } + + public ProviderRoutePreference? RoutePreference { get; set; } + + internal ExecutionRouteModelProfile Snapshot() => + new() + { + Inference = Inference?.CloneValidated(), + RoutePreference = RoutePreference?.CloneValidated() + }; +} + +/// +/// Bounded automatic-routing configuration. The defaults are conservative: +/// only short, scalar dialogue takes the direct path; structured, actionable, +/// long, or otherwise ambiguous input retains Agent capabilities. +/// +public sealed class AutomaticExecutionRoutingOptions +{ + private static readonly string[] DefaultAgentIntentTerms = + { + "analyze", "analyse", "attack", "build", "buy", "call", + "collect", "craft", "create", "delete", "equip", "execute", + "fight", "find", "gather", "investigate", "modify", "move", + "perform", "plan", "remember", "schedule", "search", "sell", + "send", "travel", "update", "use", + "分析", "安排", "攻击", "帮我做", "采集", "查找", "创建", "调用", + "调查", "发送", "更新", "购买", "行动", "计划", "记住", "建造", + "修改", "删除", "使用", "收集", "搜索", "移动", "战斗", "执行", + "制作", "装备" + }; + + /// + /// Text at or below this character count is direct when no Agent intent + /// term is present. + /// + public int DirectTextMaxCharacters { get; set; } = 160; + + /// + /// Text at or above this character count is automatically Agent work. + /// Values between the two thresholds are ambiguous. + /// + public int AgentTextMinCharacters { get; set; } = 512; + + public IReadOnlyList AgentIntentTerms { get; set; } = + DefaultAgentIntentTerms.ToArray(); + + /// + /// Conservative default used when an optional classifier is absent, + /// fails, times out, or returns a low-confidence result. + /// + public ExecutionPath AmbiguousFallbackPath { get; set; } = + ExecutionPath.Agent; + + public double MinimumClassifierConfidence { get; set; } = 0.75; + + public ExecutionRouteModelProfile? DirectModelProfile { get; set; } + + public ExecutionRouteModelProfile? AgentModelProfile { get; set; } + + internal AutomaticExecutionRoutingOptions Snapshot() + { + if (DirectTextMaxCharacters is < 1 or > 8_192) + { + throw new ArgumentOutOfRangeException( + nameof(DirectTextMaxCharacters)); + } + + if (AgentTextMinCharacters <= DirectTextMaxCharacters + || AgentTextMinCharacters > 32_768) + { + throw new ArgumentOutOfRangeException( + nameof(AgentTextMinCharacters)); + } + + if (AmbiguousFallbackPath is not ExecutionPath.Direct + and not ExecutionPath.Agent) + { + throw new ArgumentOutOfRangeException( + nameof(AmbiguousFallbackPath)); + } + + if (!double.IsFinite(MinimumClassifierConfidence) + || MinimumClassifierConfidence is < 0 or > 1) + { + throw new ArgumentOutOfRangeException( + nameof(MinimumClassifierConfidence)); + } + + var terms = RuntimeGuard.CopyStrings( + AgentIntentTerms + ?? throw new ArgumentNullException(nameof(AgentIntentTerms)), + maxItems: 128, + maxItemUtf8Bytes: 64, + nameof(AgentIntentTerms), + sort: false, + requireUnique: true); + + return new AutomaticExecutionRoutingOptions + { + DirectTextMaxCharacters = DirectTextMaxCharacters, + AgentTextMinCharacters = AgentTextMinCharacters, + AgentIntentTerms = terms, + AmbiguousFallbackPath = AmbiguousFallbackPath, + MinimumClassifierConfidence = MinimumClassifierConfidence, + DirectModelProfile = DirectModelProfile?.Snapshot(), + AgentModelProfile = AgentModelProfile?.Snapshot() + }; + } +} + +/// +/// Immutable input supplied to an optional application classifier only when +/// the built-in bounded rules consider an input ambiguous. +/// +public sealed class AutomaticExecutionClassificationRequest +{ + internal AutomaticExecutionClassificationRequest( + ExecutionRouteRequest route, + string? text, + bool hasStructuredInput, + bool hasWorkflowRequest) + { + Route = ExecutionRouteValidation.Snapshot(route); + Text = text; + HasStructuredInput = hasStructuredInput; + HasWorkflowRequest = hasWorkflowRequest; + } + + public ExecutionRouteRequest Route { get; } + + public string? Text { get; } + + public bool HasStructuredInput { get; } + + public bool HasWorkflowRequest { get; } +} + +public sealed class AutomaticExecutionClassification +{ + public AutomaticExecutionClassification( + ExecutionPath path, + double confidence) + { + if (!Enum.IsDefined(typeof(ExecutionPath), path)) + { + throw new ArgumentOutOfRangeException(nameof(path)); + } + + if (!double.IsFinite(confidence) || confidence is < 0 or > 1) + { + throw new ArgumentOutOfRangeException(nameof(confidence)); + } + + Path = path; + Confidence = confidence; + } + + public ExecutionPath Path { get; } + + public double Confidence { get; } +} + +/// +/// Optional local-rule or small-model classifier for ambiguous inputs. It is +/// executed inside the router's existing timeout, cancellation, and +/// concurrency boundary. +/// +public interface IAutomaticExecutionClassifier +{ + string ClassifierId { get; } + + string Version { get; } + + ValueTask ClassifyAsync( + AutomaticExecutionClassificationRequest request, + CancellationToken cancellationToken); +} + +/// +/// A runtime-owned, immutable summary of the latest game input. Natural +/// language is optional; structured content is represented by the flag rather +/// than copied into an unbounded classifier prompt. +/// +public sealed class ContextualExecutionRouteRequest +{ + internal ContextualExecutionRouteRequest( + ExecutionRouteRequest route, + string? latestText, + bool hasStructuredInput, + int inputPartCount, + bool hasWorkflowRequest) + { + Route = ExecutionRouteValidation.Snapshot(route); + LatestText = latestText; + HasStructuredInput = hasStructuredInput; + InputPartCount = inputPartCount; + HasWorkflowRequest = hasWorkflowRequest; + } + + public ExecutionRouteRequest Route { get; } + + public string? LatestText { get; } + + public bool HasStructuredInput { get; } + + public int InputPartCount { get; } + + public bool HasWorkflowRequest { get; } +} + +/// +/// Additive policy surface for routers that need the latest bounded input. +/// Existing implementations continue to +/// receive only the explicit route request. +/// +public interface IContextualExecutionRoutePolicy : IExecutionRoutePolicy +{ + ValueTask SelectAsync( + ContextualExecutionRouteRequest request, + CancellationToken cancellationToken); +} + +internal interface IContextualExecutionRouteFallbackPolicy +{ + ExecutionPath SelectFallback(ContextualExecutionRouteRequest request); +} + +/// +/// Hybrid automatic router. Declared requirements and explicit paths remain +/// authoritative. Bounded local rules keep obvious dialogue fast, while +/// actionable, structured, long, or ambiguous work retains Agent capability. +/// An optional classifier is consulted only for ambiguous text. +/// +public sealed class AutomaticExecutionRoutePolicy : + IContextualExecutionRoutePolicy, + IContextualExecutionRouteFallbackPolicy +{ + private const string BasePolicyId = "automatic-complexity-router"; + private const string BaseVersion = "1.0.0"; + + private readonly AutomaticExecutionRoutingOptions _options; + private readonly IAutomaticExecutionClassifier? _classifier; + + public AutomaticExecutionRoutePolicy( + AutomaticExecutionRoutingOptions? options = null, + IAutomaticExecutionClassifier? classifier = null) + { + _options = (options ?? new AutomaticExecutionRoutingOptions()) + .Snapshot(); + _classifier = classifier; + string? classifierId = null; + string? classifierVersion = null; + if (classifier is not null) + { + classifierId = RuntimeGuard.RequiredUtf8( + classifier.ClassifierId, + 128, + nameof(classifier)); + classifierVersion = RuntimeGuard.RequiredUtf8( + classifier.Version, + 64, + nameof(classifier)); + } + + PolicyId = BasePolicyId; + Version = BuildVersion(classifierId, classifierVersion); + } + + public string PolicyId { get; } + + public string Version { get; } + + public ValueTask SelectAsync( + ExecutionRouteRequest request, + CancellationToken cancellationToken) => + SelectAsync( + new ContextualExecutionRouteRequest( + request, + latestText: null, + hasStructuredInput: false, + inputPartCount: 0, + hasWorkflowRequest: false), + cancellationToken); + + public async ValueTask SelectAsync( + ContextualExecutionRouteRequest request, + CancellationToken cancellationToken) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + cancellationToken.ThrowIfCancellationRequested(); + var route = ExecutionRouteValidation.Snapshot(request.Route); + if (route.ExplicitPath.HasValue) + { + return Decision( + route.ExplicitPath.Value, + ExecutionRouteReasonCodes.Explicit); + } + + var minimum = ExecutionRouteValidation.MinimumPath( + route.Requirements); + if (minimum == ExecutionPath.Workflow) + { + return Decision( + ExecutionPath.Workflow, + ExecutionRouteReasonCodes.WorkflowRequired); + } + + if (minimum == ExecutionPath.Agent) + { + return Decision( + ExecutionPath.Agent, + ExecutionRouteReasonCodes.AgentCapabilitiesRequired); + } + + var signal = ClassifySignal( + route.Signal, + request.HasWorkflowRequest); + var input = ClassifyContextInput(request); + var classification = MoreCapable(signal, input); + if (classification == AutomaticRouteClass.None) + { + classification = AutomaticRouteClass.Direct; + } + + if (classification == AutomaticRouteClass.Ambiguous) + { + var classifierText = input == AutomaticRouteClass.Ambiguous + ? request.LatestText + : ExtractSignalText(route.Signal); + classification = await ResolveAmbiguousAsync( + route, + request, + classifierText, + cancellationToken) + .ConfigureAwait(false); + } + + return classification switch + { + AutomaticRouteClass.Workflow when request.HasWorkflowRequest => + Decision( + ExecutionPath.Workflow, + ExecutionRouteReasonCodes.AutomaticWorkflow), + AutomaticRouteClass.Agent or AutomaticRouteClass.Workflow => + Decision( + ExecutionPath.Agent, + ExecutionRouteReasonCodes.AutomaticAgent), + _ => Decision( + ExecutionPath.Direct, + ExecutionRouteReasonCodes.AutomaticDirect) + }; + } + + ExecutionPath IContextualExecutionRouteFallbackPolicy.SelectFallback( + ContextualExecutionRouteRequest request) + { + var route = ExecutionRouteValidation.Snapshot(request.Route); + if (route.ExplicitPath.HasValue) + { + return route.ExplicitPath.Value; + } + + var minimum = ExecutionRouteValidation.MinimumPath( + route.Requirements); + if (minimum != ExecutionPath.Direct) + { + return minimum; + } + + var classification = MoreCapable( + ClassifySignal(route.Signal, request.HasWorkflowRequest), + ClassifyContextInput(request)); + if (classification == AutomaticRouteClass.Ambiguous) + { + classification = FromPath(_options.AmbiguousFallbackPath); + } + + return classification switch + { + AutomaticRouteClass.Workflow when request.HasWorkflowRequest => + ExecutionPath.Workflow, + AutomaticRouteClass.Agent or AutomaticRouteClass.Workflow => + ExecutionPath.Agent, + _ => ExecutionPath.Direct + }; + } + + private async ValueTask ResolveAmbiguousAsync( + ExecutionRouteRequest route, + ContextualExecutionRouteRequest context, + string? classifierText, + CancellationToken cancellationToken) + { + if (_classifier is null) + { + return FromPath(_options.AmbiguousFallbackPath); + } + + try + { + var result = await _classifier.ClassifyAsync( + new AutomaticExecutionClassificationRequest( + route, + classifierText, + context.HasStructuredInput, + context.HasWorkflowRequest), + cancellationToken) + .ConfigureAwait(false); + if (result is null + || result.Confidence + < _options.MinimumClassifierConfidence + || result.Path == ExecutionPath.Workflow + && !context.HasWorkflowRequest + || !ExecutionRouteValidation.CanSatisfy( + result.Path, + route.Requirements)) + { + return FromPath(_options.AmbiguousFallbackPath); + } + + return FromPath(result.Path); + } + catch (OperationCanceledException) + { + cancellationToken.ThrowIfCancellationRequested(); + return FromPath(_options.AmbiguousFallbackPath); + } + catch (Exception exception) + when (exception is not OutOfMemoryException + and not StackOverflowException) + { + return FromPath(_options.AmbiguousFallbackPath); + } + } + + private AutomaticRouteClass ClassifySignal( + JsonElement? signal, + bool hasWorkflowRequest) + { + if (!signal.HasValue + || signal.Value.ValueKind is JsonValueKind.Null + or JsonValueKind.Undefined) + { + return AutomaticRouteClass.None; + } + + return ClassifyJson(signal.Value, hasWorkflowRequest); + } + + private AutomaticRouteClass ClassifyJson( + JsonElement value, + bool hasWorkflowRequest) + { + switch (value.ValueKind) + { + case JsonValueKind.String: + return ClassifyText(value.GetString()); + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + case JsonValueKind.Null: + return AutomaticRouteClass.Direct; + case JsonValueKind.Array: + return AutomaticRouteClass.Agent; + case JsonValueKind.Object: + { + var hinted = ClassifyHints(value, hasWorkflowRequest); + var content = value.TryGetProperty("input", out var input) + ? ClassifyJson(input, hasWorkflowRequest) + : AutomaticRouteClass.Agent; + return MoreCapable(hinted, content); + } + default: + return AutomaticRouteClass.Agent; + } + } + + private static AutomaticRouteClass ClassifyHints( + JsonElement value, + bool hasWorkflowRequest) + { + var result = AutomaticRouteClass.None; + if (TryReadString(value, "complexity", out var complexity)) + { + result = complexity switch + { + "simple" or "direct" => AutomaticRouteClass.Direct, + "standard" => AutomaticRouteClass.Ambiguous, + "complex" or "agent" => AutomaticRouteClass.Agent, + "workflow" or "parallel" when hasWorkflowRequest => + AutomaticRouteClass.Workflow, + "workflow" or "parallel" => AutomaticRouteClass.Agent, + _ => AutomaticRouteClass.None + }; + } + + if (AnyTrue( + value, + "requiresTools", + "requires_tools", + "requiresSkills", + "requires_skills", + "requiresAction", + "requires_action", + "multipleModelTurns", + "multiple_model_turns")) + { + result = MoreCapable(result, AutomaticRouteClass.Agent); + } + + if (AnyTrue( + value, + "parallelActors", + "parallel_actors", + "requiresWorkflow", + "requires_workflow")) + { + result = MoreCapable( + result, + hasWorkflowRequest + ? AutomaticRouteClass.Workflow + : AutomaticRouteClass.Agent); + } + + return result; + } + + private AutomaticRouteClass ClassifyContextInput( + ContextualExecutionRouteRequest request) + { + if (request.HasStructuredInput || request.InputPartCount > 1) + { + return AutomaticRouteClass.Agent; + } + + return ClassifyText(request.LatestText); + } + + private AutomaticRouteClass ClassifyText(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return AutomaticRouteClass.None; + } + + if (value.Length >= _options.AgentTextMinCharacters) + { + return AutomaticRouteClass.Agent; + } + + if (ContainsAgentIntent(value)) + { + return AutomaticRouteClass.Agent; + } + + if (value.Length <= _options.DirectTextMaxCharacters) + { + return AutomaticRouteClass.Direct; + } + + return AutomaticRouteClass.Ambiguous; + } + + private bool ContainsAgentIntent(string value) + { + foreach (var term in _options.AgentIntentTerms) + { + var start = 0; + while (start < value.Length) + { + var index = value.IndexOf( + term, + start, + StringComparison.OrdinalIgnoreCase); + if (index < 0) + { + break; + } + + var leftBoundary = index == 0 + || !IsAsciiWordCharacter(value[index - 1]) + || !IsAsciiWordCharacter(term[0]); + var end = index + term.Length; + var rightBoundary = end == value.Length + || !IsAsciiWordCharacter(value[end]) + || !IsAsciiWordCharacter(term[^1]); + if (leftBoundary && rightBoundary) + { + return true; + } + + start = index + 1; + } + } + + return false; + } + + private static bool IsAsciiWordCharacter(char value) => + value is >= 'a' and <= 'z' + or >= 'A' and <= 'Z' + or >= '0' and <= '9' + or '_'; + + private static string? ExtractSignalText(JsonElement? signal) + { + if (!signal.HasValue) + { + return null; + } + + var value = signal.Value; + if (value.ValueKind == JsonValueKind.String) + { + return value.GetString(); + } + + return value.ValueKind == JsonValueKind.Object + && value.TryGetProperty("input", out var input) + && input.ValueKind == JsonValueKind.String + ? input.GetString() + : null; + } + + private ExecutionRouteDecision Decision( + ExecutionPath path, + string reasonCode) => + new( + path, + reasonCode, + PolicyId, + Version, + path switch + { + ExecutionPath.Direct => _options.DirectModelProfile, + ExecutionPath.Agent => _options.AgentModelProfile, + _ => null + }); + + private string BuildVersion( + string? classifierId, + string? classifierVersion) + { + var digest = new CanonicalDigestBuilder(); + digest.Add("type", "automatic-execution-routing.v1"); + digest.Add("directTextMaxCharacters", _options.DirectTextMaxCharacters); + digest.Add("agentTextMinCharacters", _options.AgentTextMinCharacters); + digest.Add("ambiguousFallbackPath", _options.AmbiguousFallbackPath.ToString()); + digest.Add( + "minimumClassifierConfidence", + _options.MinimumClassifierConfidence.ToString( + "R", + CultureInfo.InvariantCulture)); + digest.Add("agentIntentTerms", _options.AgentIntentTerms); + digest.Add("classifierId", classifierId); + digest.Add("classifierVersion", classifierVersion); + AddProfileDigest(digest, "direct", _options.DirectModelProfile); + AddProfileDigest(digest, "agent", _options.AgentModelProfile); + return BaseVersion + "+" + digest.Finish().Substring(0, 16); + } + + private static void AddProfileDigest( + CanonicalDigestBuilder digest, + string name, + ExecutionRouteModelProfile? profile) + { + var inference = profile?.Inference; + var route = profile?.RoutePreference; + digest.Add(name + ".present", profile is null ? "false" : "true"); + digest.Add( + name + ".providerIds", + route?.ProviderIds ?? Array.Empty()); + digest.Add( + name + ".allowUnlistedFallback", + route?.AllowUnlistedFallback == true ? "true" : "false"); + digest.Add( + name + ".reasoningEnabled", + inference?.ReasoningEnabled?.ToString()); + digest.Add(name + ".reasoningEffort", inference?.ReasoningEffort); + digest.Add( + name + ".reasoningTokenBudget", + inference?.ReasoningTokenBudget?.ToString( + CultureInfo.InvariantCulture)); + digest.Add( + name + ".temperature", + inference?.Temperature?.ToString("R", CultureInfo.InvariantCulture)); + digest.Add( + name + ".topP", + inference?.TopP?.ToString("R", CultureInfo.InvariantCulture)); + digest.Add( + name + ".seed", + inference?.Seed?.ToString(CultureInfo.InvariantCulture)); + digest.Add( + name + ".promptCachingEnabled", + inference?.PromptCachingEnabled?.ToString()); + digest.Add(name + ".promptCacheKey", inference?.PromptCacheKey); + digest.Add( + name + ".promptCacheRetention", + inference?.PromptCacheRetention); + } + + private static bool TryReadString( + JsonElement value, + string name, + out string result) + { + if (value.TryGetProperty(name, out var property) + && property.ValueKind == JsonValueKind.String) + { + result = property.GetString()?.Trim().ToLowerInvariant() + ?? string.Empty; + return true; + } + + result = string.Empty; + return false; + } + + private static bool AnyTrue( + JsonElement value, + params string[] names) + { + foreach (var name in names) + { + if (value.TryGetProperty(name, out var property) + && property.ValueKind == JsonValueKind.True) + { + return true; + } + } + + return false; + } + + private static AutomaticRouteClass FromPath(ExecutionPath path) => + path switch + { + ExecutionPath.Direct => AutomaticRouteClass.Direct, + ExecutionPath.Agent => AutomaticRouteClass.Agent, + ExecutionPath.Workflow => AutomaticRouteClass.Workflow, + _ => AutomaticRouteClass.Agent + }; + + private static AutomaticRouteClass MoreCapable( + AutomaticRouteClass left, + AutomaticRouteClass right) => + (AutomaticRouteClass)Math.Max((int)left, (int)right); + + private enum AutomaticRouteClass + { + None = 0, + Direct = 1, + Ambiguous = 2, + Agent = 3, + Workflow = 4 + } +} diff --git a/src/GameAgent.Core/ExecutionRouting.cs b/src/GameAgent.Core/ExecutionRouting.cs index 5cac801..4fa000d 100644 --- a/src/GameAgent.Core/ExecutionRouting.cs +++ b/src/GameAgent.Core/ExecutionRouting.cs @@ -30,14 +30,18 @@ public sealed class ExecutionRouteRequest public ExecutionRequirements Requirements { get; set; } /// - /// Optional bounded structured routing signal. It is supplied to custom - /// policies as data and is never interpreted by the deterministic policy. + /// Optional bounded structured routing signal. The automatic policy reads + /// standard hints and arbitrary input; custom policies receive the owned + /// snapshot as data. /// public JsonElement? Signal { get; set; } } public static class ExecutionRouteReasonCodes { + public const string AutomaticDirect = "automatic_direct"; + public const string AutomaticAgent = "automatic_agent"; + public const string AutomaticWorkflow = "automatic_workflow"; public const string Explicit = "explicit_path"; public const string WorkflowRequired = "workflow_required"; public const string AgentCapabilitiesRequired = @@ -51,11 +55,28 @@ public static class ExecutionRouteReasonCodes public sealed class ExecutionRouteDecision { + private readonly ExecutionRouteModelProfile? _modelProfile; + public ExecutionRouteDecision( ExecutionPath path, string reasonCode, string policyId, string policyVersion) + : this( + path, + reasonCode, + policyId, + policyVersion, + modelProfile: null) + { + } + + public ExecutionRouteDecision( + ExecutionPath path, + string reasonCode, + string policyId, + string policyVersion, + ExecutionRouteModelProfile? modelProfile) { Path = path; ReasonCode = RuntimeGuard.RequiredReasonCode( @@ -69,6 +90,7 @@ public ExecutionRouteDecision( policyVersion, 64, nameof(policyVersion)); + _modelProfile = modelProfile?.Snapshot(); } public ExecutionPath Path { get; } @@ -78,6 +100,13 @@ public ExecutionRouteDecision( public string PolicyId { get; } public string PolicyVersion { get; } + + /// + /// Optional model defaults chosen by the policy. Explicit controls on the + /// run take precedence when the route is executed. + /// + public ExecutionRouteModelProfile? ModelProfile => + _modelProfile?.Snapshot(); } public interface IExecutionRoutePolicy @@ -337,7 +366,7 @@ internal RoutedExecutionRuntime( { _agent = agent ?? throw new ArgumentNullException(nameof(agent)); _workflow = workflow; - _policy = policy ?? new DeterministicExecutionRoutePolicy(); + _policy = policy ?? new AutomaticExecutionRoutePolicy(); _options = (options ?? new ExecutionRouterOptions()).Snapshot(); _shutdownDispatcher = shutdownDispatcher ?? throw new ArgumentNullException( @@ -394,7 +423,11 @@ public async ValueTask RunAsync( ? null : SnapshotWorkflowRequest(request.Workflow); using var active = EnterOperation(cancellationToken); - var decision = await SelectBoundedAsync(route, active.Token) + var decision = await SelectBoundedAsync( + route, + runRequest, + workflowRequest, + active.Token) .ConfigureAwait(false); switch (decision.Path) { @@ -416,7 +449,8 @@ public async ValueTask RunAsync( run, decision.Path == ExecutionPath.Direct ? DurableExecutionModes.Direct - : DurableExecutionModes.Agent); + : DurableExecutionModes.Agent, + decision.ModelProfile); var outcome = await _agent .RunAsync(routed, active.Token) .ConfigureAwait(false); @@ -459,11 +493,20 @@ public async ValueTask RunAsync( private async ValueTask SelectBoundedAsync( ExecutionRouteRequest request, + DurableRunRequest? runRequest, + RoutedWorkflowRequest? workflowRequest, CancellationToken cancellationToken) { var validationRequest = ExecutionRouteValidation.Snapshot(request); var policyRequest = ExecutionRouteValidation.Snapshot( validationRequest); + var contextual = _policy as IContextualExecutionRoutePolicy; + var contextualRequest = contextual is null + ? null + : CreateContextualRequest( + policyRequest, + runRequest, + workflowRequest); using var queueTimeout = new CancellationTokenSource( _options.PolicyTimeout); using var queue = CancellationTokenSource.CreateLinkedTokenSource( @@ -478,7 +521,8 @@ private async ValueTask SelectBoundedAsync( { return Fallback( ExecutionRouteReasonCodes.PolicyTimeoutFallback, - validationRequest); + validationRequest, + contextualRequest); } if (cancellationToken.IsCancellationRequested) @@ -504,9 +548,13 @@ private async ValueTask SelectBoundedAsync( EnterNestedOperation(); nestedEntered = true; if (!_callbackExecutionDispatcher.TryExecute( - () => _policy.SelectAsync( - policyRequest, - policyToken), + () => contextual is null + ? _policy.SelectAsync( + policyRequest, + policyToken) + : contextual.SelectAsync( + contextualRequest!, + policyToken), out var acceptedEvaluation)) { ExitOperation(); @@ -516,7 +564,8 @@ await policyCancellation.DisposeAsync() _policySlots.Release(); return Fallback( ExecutionRouteReasonCodes.PolicyErrorFallback, - validationRequest); + validationRequest, + contextualRequest); } evaluation = acceptedEvaluation; @@ -531,7 +580,8 @@ await policyCancellation.DisposeAsync() _policySlots.Release(); return Fallback( ExecutionRouteReasonCodes.PolicyErrorFallback, - validationRequest); + validationRequest, + contextualRequest); } using var signals = new OperationDeadlineSignals( @@ -561,26 +611,32 @@ await policyCancellation.DisposeAsync() cancellationToken.ThrowIfCancellationRequested(); return Fallback( ExecutionRouteReasonCodes.PolicyTimeoutFallback, - validationRequest); + validationRequest, + contextualRequest); } try { var decision = await evaluation.ConfigureAwait(false); - return ValidateDecision(decision, validationRequest); + return ValidateDecision( + decision, + validationRequest, + contextualRequest); } catch (OperationCanceledException) { cancellationToken.ThrowIfCancellationRequested(); return Fallback( ExecutionRouteReasonCodes.PolicyTimeoutFallback, - validationRequest); + validationRequest, + contextualRequest); } catch (Exception exception) when (exception is not OutOfMemoryException) { return Fallback( ExecutionRouteReasonCodes.PolicyErrorFallback, - validationRequest); + validationRequest, + contextualRequest); } finally { @@ -611,13 +667,15 @@ private async Task ReleasePolicySlotWhenSettledAsync( private ExecutionRouteDecision ValidateDecision( ExecutionRouteDecision? decision, - ExecutionRouteRequest request) + ExecutionRouteRequest request, + ContextualExecutionRouteRequest? contextualRequest) { if (decision is null || !Enum.IsDefined(typeof(ExecutionPath), decision.Path)) { return Fallback( ExecutionRouteReasonCodes.PolicyResultInvalidFallback, - request); + request, + contextualRequest); } try @@ -626,7 +684,8 @@ private ExecutionRouteDecision ValidateDecision( decision.Path, decision.ReasonCode, decision.PolicyId, - decision.PolicyVersion); + decision.PolicyVersion, + decision.ModelProfile); if (!string.Equals( decision.PolicyId, _policyId, @@ -638,7 +697,8 @@ private ExecutionRouteDecision ValidateDecision( { return Fallback( ExecutionRouteReasonCodes.PolicyResultInvalidFallback, - request); + request, + contextualRequest); } if (request.ExplicitPath.HasValue @@ -646,7 +706,8 @@ private ExecutionRouteDecision ValidateDecision( { return Fallback( ExecutionRouteReasonCodes.PolicyResultInvalidFallback, - request); + request, + contextualRequest); } if (!ExecutionRouteValidation.CanSatisfy( @@ -655,7 +716,8 @@ private ExecutionRouteDecision ValidateDecision( { return Fallback( ExecutionRouteReasonCodes.PolicyResultInvalidFallback, - request); + request, + contextualRequest); } return decision; @@ -664,19 +726,49 @@ private ExecutionRouteDecision ValidateDecision( { return Fallback( ExecutionRouteReasonCodes.PolicyResultInvalidFallback, - request); + request, + contextualRequest); } } private ExecutionRouteDecision Fallback( string reasonCode, - ExecutionRouteRequest request) => - new( - request.ExplicitPath - ?? ExecutionRouteValidation.MinimumPath(request.Requirements), + ExecutionRouteRequest request, + ContextualExecutionRouteRequest? contextualRequest) + { + var path = request.ExplicitPath + ?? ExecutionRouteValidation.MinimumPath( + request.Requirements); + if (!request.ExplicitPath.HasValue + && path == ExecutionPath.Direct + && contextualRequest is not null + && _policy is IContextualExecutionRouteFallbackPolicy fallback) + { + try + { + var candidate = fallback.SelectFallback(contextualRequest); + if (Enum.IsDefined(typeof(ExecutionPath), candidate) + && ExecutionRouteValidation.CanSatisfy( + candidate, + request.Requirements)) + { + path = candidate; + } + } + catch (Exception exception) + when (exception is not OutOfMemoryException + and not StackOverflowException) + { + path = ExecutionPath.Agent; + } + } + + return new ExecutionRouteDecision( + path, reasonCode, _policyId, _policyVersion); + } public async ValueTask StopAsync() { @@ -933,8 +1025,14 @@ public void Dispose() private static DurableRunRequest CopyRunRequest( DurableRunRequest source, - string executionMode) + string executionMode, + ExecutionRouteModelProfile? modelProfile) { + var routedInference = source.Inference?.CloneValidated() + ?? modelProfile?.Inference?.CloneValidated(); + var routedPreference = source.RoutePreference?.CloneValidated() + ?? modelProfile?.RoutePreference + ?.CloneValidated(); return new DurableRunRequest { Run = source.Run, @@ -944,12 +1042,69 @@ private static DurableRunRequest CopyRunRequest( LaneId = source.LaneId, WorkloadClass = source.WorkloadClass, ExecutionMode = executionMode, - Inference = source.Inference?.CloneValidated(), - RoutePreference = source.RoutePreference?.CloneValidated(), + Inference = routedInference, + RoutePreference = routedPreference, FinalOutputContract = source.FinalOutputContract }; } + private static ContextualExecutionRouteRequest CreateContextualRequest( + ExecutionRouteRequest route, + DurableRunRequest? runRequest, + RoutedWorkflowRequest? workflowRequest) + { + string? latestText = null; + var hasStructuredInput = false; + var inputPartCount = 0; + if (runRequest is not null) + { + for (var index = runRequest.InitialTranscript.Count - 1; + index >= 0; + index--) + { + var message = runRequest.InitialTranscript[index]; + if (!string.Equals( + message.Role, + NormalizedRoles.User, + StringComparison.Ordinal)) + { + continue; + } + + inputPartCount = message.Parts.Count; + if (inputPartCount == 1) + { + var part = message.Parts[0]; + if (string.Equals( + part.Type, + NormalizedPartTypes.Text, + StringComparison.Ordinal) + && part.Text is not null) + { + latestText = part.Text; + } + else + { + hasStructuredInput = true; + } + } + else if (inputPartCount > 0) + { + hasStructuredInput = true; + } + + break; + } + } + + return new ContextualExecutionRouteRequest( + route, + latestText, + hasStructuredInput, + inputPartCount, + workflowRequest is not null); + } + private static RoutedWorkflowRequest SnapshotWorkflowRequest( RoutedWorkflowRequest source) { diff --git a/src/GameAgent.Runtime/GameAgentRuntimeBuilder.cs b/src/GameAgent.Runtime/GameAgentRuntimeBuilder.cs index 34ec9b5..6c71af8 100644 --- a/src/GameAgent.Runtime/GameAgentRuntimeBuilder.cs +++ b/src/GameAgent.Runtime/GameAgentRuntimeBuilder.cs @@ -520,6 +520,24 @@ public GameAgentRuntimeBuilder WithExecutionRoutePolicy( return this; } + /// + /// Enables the built-in hybrid router with optional model tiers and an + /// optional classifier for ambiguous input. This is also the default + /// execution policy when no custom policy is registered. + /// + public GameAgentRuntimeBuilder WithAutomaticExecutionRouting( + AutomaticExecutionRoutingOptions? automaticOptions = null, + IAutomaticExecutionClassifier? classifier = null, + ExecutionRouterOptions? routerOptions = null) + { + ThrowIfFinished(); + _executionRoutePolicy = new AutomaticExecutionRoutePolicy( + automaticOptions, + classifier); + _executionRouterOptions = routerOptions; + return this; + } + public GameAgentRuntimeBuilder WithRoutedWorkflowRuntime( IRoutedWorkflowRuntime runtime) { diff --git a/tests/GameAgent.Tests/ExecutionRoutingTests.cs b/tests/GameAgent.Tests/ExecutionRoutingTests.cs index e0561d5..8dbacd2 100644 --- a/tests/GameAgent.Tests/ExecutionRoutingTests.cs +++ b/tests/GameAgent.Tests/ExecutionRoutingTests.cs @@ -15,6 +15,33 @@ public sealed class ProcessCancellationWorkerPoolCollection [Collection(ProcessCancellationWorkerPoolCollection.Name)] public sealed class ExecutionRoutingTests { + [Fact] + public void RouteDecisionRetainsItsFourArgumentConstructor() + { + var constructor = typeof(ExecutionRouteDecision).GetConstructor( + new[] + { + typeof(ExecutionPath), + typeof(string), + typeof(string), + typeof(string) + }); + + Assert.NotNull(constructor); + } + + [Fact] + public void AutomaticRouterRejectsOverlappingTextThresholds() + { + Assert.Throws( + () => new AutomaticExecutionRoutePolicy( + new AutomaticExecutionRoutingOptions + { + DirectTextMaxCharacters = 32, + AgentTextMinCharacters = 32 + })); + } + [Fact] public async Task CapabilityFreeRequestExecutesDurableDirectPath() { @@ -40,6 +67,433 @@ public async Task CapabilityFreeRequestExecutesDurableDirectPath() Assert.Equal(0, workflow.CallCount); } + [Fact] + public async Task AutomaticRouterKeepsShortDialogueOnDirectPath() + { + var agent = new RecordingAgentRuntime(); + var router = new RoutedExecutionRuntime(agent); + + var outcome = await router.RunAsync( + new RoutedExecutionRequest + { + Route = new ExecutionRouteRequest + { + OperationKind = "npc-dialogue" + }, + Run = RunRequestWithText( + "automatic-direct", + "你今天心情怎么样?") + }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(ExecutionPath.Direct, outcome.Decision.Path); + Assert.Equal( + ExecutionRouteReasonCodes.AutomaticDirect, + outcome.Decision.ReasonCode); + Assert.Equal( + DurableExecutionModes.Direct, + agent.LastRequest!.ExecutionMode); + } + + [Fact] + public async Task AutomaticRouterEscalatesActionableDialogueToAgent() + { + var agent = new RecordingAgentRuntime(); + var router = new RoutedExecutionRuntime(agent); + + var outcome = await router.RunAsync( + new RoutedExecutionRequest + { + Route = new ExecutionRouteRequest + { + OperationKind = "npc-dialogue" + }, + Run = RunRequestWithText( + "automatic-agent", + "请帮我建造一座仓库并收集需要的材料。") + }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(ExecutionPath.Agent, outcome.Decision.Path); + Assert.Equal( + ExecutionRouteReasonCodes.AutomaticAgent, + outcome.Decision.ReasonCode); + Assert.Equal( + DurableExecutionModes.Agent, + agent.LastRequest!.ExecutionMode); + } + + [Fact] + public async Task AutomaticRouterTreatsStructuredGameInputAsAgentWork() + { + var agent = new RecordingAgentRuntime(); + var router = new RoutedExecutionRuntime(agent); + var request = RunRequest("automatic-structured"); + request.InitialTranscript = new[] + { + UserMessage( + NormalizedContentPart.FromJson( + JsonDocument.Parse( + """{"self":{"hp":18},"legalActionIds":[2,5]}""") + .RootElement + .Clone())) + }; + + var outcome = await router.RunAsync( + new RoutedExecutionRequest + { + Route = new ExecutionRouteRequest(), + Run = request + }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(ExecutionPath.Agent, outcome.Decision.Path); + Assert.Equal( + DurableExecutionModes.Agent, + agent.LastRequest!.ExecutionMode); + } + + [Fact] + public async Task AutomaticRouterUsesClassifierOnlyForAmbiguousText() + { + var agent = new RecordingAgentRuntime(); + var classifier = new RecordingAutomaticClassifier( + new AutomaticExecutionClassification( + ExecutionPath.Direct, + confidence: 0.95)); + var policy = new AutomaticExecutionRoutePolicy( + new AutomaticExecutionRoutingOptions + { + DirectTextMaxCharacters = 8, + AgentTextMinCharacters = 128 + }, + classifier); + var router = new RoutedExecutionRuntime( + agent, + workflow: null, + policy); + + var outcome = await router.RunAsync( + new RoutedExecutionRequest + { + Route = new ExecutionRouteRequest(), + Run = RunRequestWithText( + "automatic-classifier", + "请介绍一下你看到的天气和周围环境。") + }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(ExecutionPath.Direct, outcome.Decision.Path); + Assert.Equal(1, classifier.CallCount); + Assert.Equal( + "请介绍一下你看到的天气和周围环境。", + classifier.LastRequest!.Text); + } + + [Fact] + public async Task AutomaticRouterDoesNotSpendClassifierOnObviousDialogue() + { + var agent = new RecordingAgentRuntime(); + var classifier = new RecordingAutomaticClassifier( + new AutomaticExecutionClassification( + ExecutionPath.Agent, + confidence: 1)); + var policy = new AutomaticExecutionRoutePolicy( + classifier: classifier); + var router = new RoutedExecutionRuntime( + agent, + workflow: null, + policy); + + var outcome = await router.RunAsync( + new RoutedExecutionRequest + { + Route = new ExecutionRouteRequest(), + Run = RunRequestWithText("automatic-no-classifier", "你好") + }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(ExecutionPath.Direct, outcome.Decision.Path); + Assert.Equal(0, classifier.CallCount); + } + + [Fact] + public async Task AutomaticRouterDoesNotMatchIntentInsideAnotherWord() + { + var agent = new RecordingAgentRuntime(); + var router = new RoutedExecutionRuntime(agent); + + var outcome = await router.RunAsync( + new RoutedExecutionRequest + { + Route = new ExecutionRouteRequest(), + Run = RunRequestWithText( + "automatic-word-boundary", + "Which planet is closest?") + }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(ExecutionPath.Direct, outcome.Decision.Path); + } + + [Fact] + public async Task AutomaticRouterClassifiesAmbiguousSignalText() + { + var agent = new RecordingAgentRuntime(); + var classifier = new RecordingAutomaticClassifier( + new AutomaticExecutionClassification( + ExecutionPath.Direct, + confidence: 0.95)); + var policy = new AutomaticExecutionRoutePolicy( + new AutomaticExecutionRoutingOptions + { + DirectTextMaxCharacters = 4, + AgentTextMinCharacters = 128 + }, + classifier); + var router = new RoutedExecutionRuntime( + agent, + workflow: null, + policy); + const string signalText = "Describe the nearby weather in one sentence."; + + var outcome = await router.RunAsync( + new RoutedExecutionRequest + { + Route = new ExecutionRouteRequest + { + Signal = JsonDocument.Parse( + "\"Describe the nearby weather in one sentence.\"") + .RootElement + .Clone() + }, + Run = RunRequest("automatic-signal-classifier") + }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(ExecutionPath.Direct, outcome.Decision.Path); + Assert.Equal(1, classifier.CallCount); + Assert.Equal(signalText, classifier.LastRequest!.Text); + } + + [Fact] + public async Task AutomaticRouterRejectsLowConfidenceClassification() + { + var agent = new RecordingAgentRuntime(); + var classifier = new RecordingAutomaticClassifier( + new AutomaticExecutionClassification( + ExecutionPath.Direct, + confidence: 0.5)); + var policy = new AutomaticExecutionRoutePolicy( + new AutomaticExecutionRoutingOptions + { + DirectTextMaxCharacters = 4, + AgentTextMinCharacters = 128, + MinimumClassifierConfidence = 0.75 + }, + classifier); + var router = new RoutedExecutionRuntime( + agent, + workflow: null, + policy); + + var outcome = await router.RunAsync( + new RoutedExecutionRequest + { + Route = new ExecutionRouteRequest(), + Run = RunRequestWithText( + "automatic-low-confidence", + "ordinary dialogue whose intent remains unclear") + }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(ExecutionPath.Agent, outcome.Decision.Path); + Assert.Equal(1, classifier.CallCount); + } + + [Fact] + public async Task DeclaredCapabilityRequirementPrecedesAutomaticClassification() + { + var agent = new RecordingAgentRuntime(); + var classifier = new RecordingAutomaticClassifier( + new AutomaticExecutionClassification( + ExecutionPath.Direct, + confidence: 1)); + var router = new RoutedExecutionRuntime( + agent, + workflow: null, + new AutomaticExecutionRoutePolicy(classifier: classifier)); + + var outcome = await router.RunAsync( + new RoutedExecutionRequest + { + Route = new ExecutionRouteRequest + { + Requirements = ExecutionRequirements.Tools + }, + Run = RunRequestWithText( + "automatic-requirement-first", + "hello") + }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(ExecutionPath.Agent, outcome.Decision.Path); + Assert.Equal(0, classifier.CallCount); + } + + [Fact] + public async Task AutomaticRouterFailsConservativelyWhenClassifierFails() + { + var agent = new RecordingAgentRuntime(); + var policy = new AutomaticExecutionRoutePolicy( + new AutomaticExecutionRoutingOptions + { + DirectTextMaxCharacters = 4, + AgentTextMinCharacters = 128 + }, + new ThrowingAutomaticClassifier()); + var router = new RoutedExecutionRuntime( + agent, + workflow: null, + policy); + + var outcome = await router.RunAsync( + new RoutedExecutionRequest + { + Route = new ExecutionRouteRequest(), + Run = RunRequestWithText( + "automatic-classifier-fallback", + "普通但无法由本地规则明确判断的对话") + }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(ExecutionPath.Agent, outcome.Decision.Path); + Assert.Equal( + DurableExecutionModes.Agent, + agent.LastRequest!.ExecutionMode); + } + + [Fact] + public async Task AutomaticRouterFailsConservativelyWhenClassifierTimesOut() + { + var agent = new RecordingAgentRuntime(); + var classifier = new IgnoringAutomaticClassifier(); + var policy = new AutomaticExecutionRoutePolicy( + new AutomaticExecutionRoutingOptions + { + DirectTextMaxCharacters = 4, + AgentTextMinCharacters = 128 + }, + classifier); + var router = new RoutedExecutionRuntime( + agent, + workflow: null, + policy, + new ExecutionRouterOptions + { + PolicyTimeout = TimeSpan.FromMilliseconds(50) + }); + + var outcome = await router.RunAsync( + new RoutedExecutionRequest + { + Route = new ExecutionRouteRequest(), + Run = RunRequestWithText( + "automatic-classifier-timeout", + "普通但无法由本地规则明确判断的对话") + }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(ExecutionPath.Agent, outcome.Decision.Path); + Assert.Equal( + ExecutionRouteReasonCodes.PolicyTimeoutFallback, + outcome.Decision.ReasonCode); + Assert.Equal( + DurableExecutionModes.Agent, + agent.LastRequest!.ExecutionMode); + classifier.Release.TrySetResult(); + await router.DisposeAsync(); + } + + [Fact] + public async Task AutomaticRouterAppliesModelTierWithoutOverridingCaller() + { + var agent = new RecordingAgentRuntime(); + var policy = new AutomaticExecutionRoutePolicy( + new AutomaticExecutionRoutingOptions + { + DirectModelProfile = new ExecutionRouteModelProfile + { + Inference = new ModelInferenceOptions + { + ReasoningEnabled = false, + ReasoningEffort = ModelReasoningEfforts.None + }, + RoutePreference = new ProviderRoutePreference + { + ProviderIds = new[] { "fast-dialogue" }, + AllowUnlistedFallback = true + } + } + }); + var router = new RoutedExecutionRuntime( + agent, + workflow: null, + policy); + + var first = await router.RunAsync( + new RoutedExecutionRequest + { + Route = new ExecutionRouteRequest(), + Run = RunRequestWithText("automatic-model-tier", "你好") + }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(ExecutionPath.Direct, first.Decision.Path); + Assert.Equal( + new[] { "fast-dialogue" }, + agent.LastRequest!.RoutePreference!.ProviderIds); + Assert.False(agent.LastRequest.Inference!.ReasoningEnabled); + + var explicitRequest = RunRequestWithText( + "automatic-model-override", + "你好"); + explicitRequest.RoutePreference = new ProviderRoutePreference + { + ProviderIds = new[] { "caller-selected" } + }; + explicitRequest.Inference = new ModelInferenceOptions + { + ReasoningEnabled = true, + ReasoningEffort = ModelReasoningEfforts.High + }; + _ = await router.RunAsync( + new RoutedExecutionRequest + { + Route = new ExecutionRouteRequest(), + Run = explicitRequest + }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal( + new[] { "caller-selected" }, + agent.LastRequest!.RoutePreference!.ProviderIds); + Assert.True(agent.LastRequest.Inference!.ReasoningEnabled); + Assert.Equal( + ModelReasoningEfforts.High, + agent.LastRequest.Inference.ReasoningEffort); + } + + [Fact] + public async Task AutomaticWorkflowHintRequiresAWorkflowPayload() + { + var workflow = new RecordingWorkflowRuntime(); + var router = new RoutedExecutionRuntime( + new RecordingAgentRuntime(), + workflow); + var signal = JsonDocument.Parse( + """{"input":"advance everyone","parallelActors":true}""") + .RootElement + .Clone(); + + var outcome = await router.RunAsync( + new RoutedExecutionRequest + { + Route = new ExecutionRouteRequest { Signal = signal }, + Workflow = WorkflowRequest() + }, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(ExecutionPath.Workflow, outcome.Decision.Path); + Assert.Equal(1, workflow.CallCount); + } + [Fact] public async Task ToolRequirementExecutesAgentPath() { @@ -861,6 +1315,28 @@ private static DurableRunRequest RunRequest(string runId) }; } + private static DurableRunRequest RunRequestWithText( + string runId, + string text) + { + var request = RunRequest(runId); + request.InitialTranscript = new[] + { + UserMessage(NormalizedContentPart.FromText(text)) + }; + return request; + } + + private static NormalizedMessage UserMessage( + NormalizedContentPart part) => + new() + { + MessageId = Guid.NewGuid().ToString("N"), + Role = NormalizedRoles.User, + CreatedAt = DateTimeOffset.UtcNow, + Parts = new List { part } + }; + private static RoutedWorkflowRequest WorkflowRequest() => new() { @@ -922,6 +1398,75 @@ public ValueTask RunAsync( } } + private sealed class RecordingAutomaticClassifier : + IAutomaticExecutionClassifier + { + private readonly AutomaticExecutionClassification _result; + private int _callCount; + + internal RecordingAutomaticClassifier( + AutomaticExecutionClassification result) + { + _result = result; + } + + public string ClassifierId => "test-classifier"; + + public string Version => "1"; + + internal int CallCount => Volatile.Read(ref _callCount); + + internal AutomaticExecutionClassificationRequest? LastRequest + { + get; + private set; + } + + public ValueTask ClassifyAsync( + AutomaticExecutionClassificationRequest request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + LastRequest = request; + Interlocked.Increment(ref _callCount); + return new ValueTask(_result); + } + } + + private sealed class ThrowingAutomaticClassifier : + IAutomaticExecutionClassifier + { + public string ClassifierId => "throwing-classifier"; + + public string Version => "1"; + + public ValueTask ClassifyAsync( + AutomaticExecutionClassificationRequest request, + CancellationToken cancellationToken) => + throw new InvalidOperationException("Injected classifier failure."); + } + + private sealed class IgnoringAutomaticClassifier : + IAutomaticExecutionClassifier + { + public string ClassifierId => "ignoring-classifier"; + + public string Version => "1"; + + internal TaskCompletionSource Release { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async ValueTask ClassifyAsync( + AutomaticExecutionClassificationRequest request, + CancellationToken cancellationToken) + { + await Release.Task; + return new AutomaticExecutionClassification( + ExecutionPath.Direct, + confidence: 1); + } + } + private sealed class BlockingCancellationAgentRuntime : IDurableAgentRuntime { From eb14217063d44e4232608bef11bf5830fc7fb402 Mon Sep 17 00:00:00 2001 From: Eric Sun <141227631+EricSun0218@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:08:37 +0800 Subject: [PATCH 2/2] test: stabilize terminal receipt integration timeout --- tests/GameAgent.Tests/RuntimeMemoryIntegrationTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/GameAgent.Tests/RuntimeMemoryIntegrationTests.cs b/tests/GameAgent.Tests/RuntimeMemoryIntegrationTests.cs index 7219ca9..b9eebb5 100644 --- a/tests/GameAgent.Tests/RuntimeMemoryIntegrationTests.cs +++ b/tests/GameAgent.Tests/RuntimeMemoryIntegrationTests.cs @@ -1516,7 +1516,7 @@ private static ToolDescriptor RememberTool() Effect = ToolEffects.WorldCommand, ConflictScopes = new List { "world" }, IdempotencyPolicy = ToolIdempotencyPolicies.Required, - TimeoutMs = 1_000 + TimeoutMs = 10_000 }; }