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
{
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
};
}