This is an automated email from the ASF dual-hosted git repository.

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git


The following commit(s) were added to refs/heads/main by this push:
     new 0cce08277 [api][plan] Abstain on runtime non-candidate model 
selections (#1205)
0cce08277 is described below

commit 0cce0827731eeb616a8972b1faf8b9021702f3f6
Author: Sangkyoon Nam <[email protected]>
AuthorDate: Thu Oct 8 14:57:02 2026 +0900

    [api][plan] Abstain on runtime non-candidate model selections (#1205)
    
    Generated-by: Claude Code 2.1.292 (Claude Opus 5.5)
---
 .../chat/model/routing/CustomRoutingExecutor.java  |   6 +-
 .../agents/api/chat/model/routing/ModelRouter.java |   3 +-
 .../api/chat/model/routing/RoutingDecision.java    |   8 +-
 docs/content/docs/development/model_routing.md     |  10 +-
 .../flink/agents/plan/actions/ChatModelAction.java |   2 +-
 .../plan/routing/LlmJudgeRoutingExecutor.java      |   6 +-
 .../agents/plan/routing/ModelRoutingResolver.java  |  58 +++++--
 .../plan/actions/ChatModelActionRoutingTest.java   | 170 ++++++++++++++++++++-
 8 files changed, 229 insertions(+), 34 deletions(-)

diff --git 
a/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/CustomRoutingExecutor.java
 
b/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/CustomRoutingExecutor.java
index 6f1ec847c..d539e9b66 100644
--- 
a/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/CustomRoutingExecutor.java
+++ 
b/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/CustomRoutingExecutor.java
@@ -42,8 +42,10 @@ public interface CustomRoutingExecutor extends Serializable {
      *
      * @param strategy the declaration this executor was configured with (type 
+ arguments)
      * @param context the request messages, prompt args, and candidates
-     * @return the routing decision (selected candidate or abstain)
-     * @throws Exception if the executor fails
+     * @return the routing decision (selected candidate or abstain); a name 
outside the candidates
+     *     is treated as abstain
+     * @throws Exception if the executor fails; the request then fails and 
does not fall back to the
+     *     default model
      */
     RoutingDecision route(RoutingStrategy strategy, RoutingContext context) 
throws Exception;
 }
diff --git 
a/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/ModelRouter.java
 
b/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/ModelRouter.java
index a9de9b0c1..bdab0fad9 100644
--- 
a/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/ModelRouter.java
+++ 
b/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/ModelRouter.java
@@ -45,7 +45,8 @@ import java.util.Optional;
  * engine's plan layer (the {@code RoutingExecutor} implementations).
  *
  * <p>Abstain ({@link RoutingDecision#abstain()}) → {@link 
#getDefaultModel()}. A returned name that
- * is not a candidate is an invalid decision and is failed clearly by the 
caller.
+ * is not a candidate is treated as abstain, with the rejected name kept in 
the routing event's
+ * metadata. Declaration errors (a non-candidate default or rule target) still 
fail at build time.
  */
 public class ModelRouter extends Resource {
 
diff --git 
a/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/RoutingDecision.java
 
b/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/RoutingDecision.java
index c711eea6a..c52cbfffd 100644
--- 
a/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/RoutingDecision.java
+++ 
b/api/src/main/java/org/apache/flink/agents/api/chat/model/routing/RoutingDecision.java
@@ -33,9 +33,11 @@ import java.util.Map;
  * explanation (reason, score, metadata), or signals abstention so the router 
falls back to its
  * default model.
  *
- * <p>Returning a name that is not one of the router's candidates is an 
<b>invalid</b> decision and
- * is failed clearly by the router (not silently defaulted). Abstention 
({@link #abstain()}) is the
- * intended way to defer to the default model.
+ * <p>Abstention ({@link #abstain()}) is the intended way to defer to the 
default model. A name that
+ * is not one of the router's candidates is treated as abstention: the router 
selects its default
+ * model and records the rejected name and reason in the routing event's 
metadata ({@code
+ * rejected_model}, {@code rejected_reason}). This applies to fresh decisions 
and to persisted
+ * decisions replayed after the candidate set changed.
  *
  * <p>JSON-serializable so it can be persisted/replayed as a durable {@code 
"route"} call result.
  */
diff --git a/docs/content/docs/development/model_routing.md 
b/docs/content/docs/development/model_routing.md
index 27864e32f..de706bc53 100644
--- a/docs/content/docs/development/model_routing.md
+++ b/docs/content/docs/development/model_routing.md
@@ -127,7 +127,7 @@ Resources can equally be registered on the execution 
environment with `agentsEnv
 
 ## Routing Strategies
 
-A strategy is declared with a `Strategies` factory and travels with the agent 
plan. Every strategy either selects a candidate or abstains. A rule or custom 
strategy that selects a name that is not a candidate is an error: the request 
produces a failed `ChatResponseEvent`, and never falls back to the default. The 
LLM judge is the exception, because its reply is untrusted model output: a 
verdict naming a non-candidate abstains.
+A strategy is declared with a `Strategies` factory and travels with the agent 
plan. Every strategy either selects a candidate or abstains. A runtime result 
that names a model outside the candidates is treated as abstention and lands on 
the default model, whichever strategy produced it. A fresh judge verdict 
outside the candidates abstains inside the judge with the reason `judge verdict 
was not a candidate name`. Any other non-candidate decision is normalized by 
the router: the routing ev [...]
 
 ### Rules
 
@@ -250,7 +250,7 @@ An attempt fails when the model call throws, when the 
candidate does not resolve
 
 Each candidate and the routing judge have up to `1 + max-retries` attempts, 
with exponential backoff from `retry-wait-interval`. The default retry budget 
is 0. Candidate fallback is independent of retries.
 
-A judge call that exhausts its retries, a throwing rule/custom strategy, or a 
strategy selecting a non-candidate produces a failed `ChatResponseEvent`; these 
failures do not count as abstention. A normal abstaining verdict still selects 
the default model. If all attempted candidates fail, the terminal failed 
response contains the last candidate's exception type and message. Earlier 
failures remain available in diagnostics. The retry settings are the job-level 
options in [Configuration]({ [...]
+A judge call that exhausts its retries or a throwing rule/custom strategy 
produces a failed `ChatResponseEvent`; these failures do not count as 
abstention. A normal abstaining verdict still selects the default model. If all 
attempted candidates fail, the terminal failed response contains the last 
candidate's exception type and message. Earlier failures remain available in 
diagnostics. The retry settings are the job-level options in 
[Configuration]({{< ref "docs/operations/configuration#c [...]
 
 ## Observability
 
@@ -262,7 +262,7 @@ Every accepted routing decision emits a 
`ModelRoutingEvent`, event type `_model_
 | `selected_model` | The model the decision selected. |
 | `decision_source` | `strategy` for a rule or custom selection, `llm_judge` 
for a judge verdict, `default` when the strategy abstained, `fallback` on the 
second event. |
 | `fallback_enabled` | Whether fallback is configured, not whether it 
happened. |
-| `reason`, `score`, `metadata` | Set by the strategy. Rules record `matched 
rule: <pattern>`; the judge records `judge_model`, the judge's token counts 
when reported, and `judge_context_truncated` when history was dropped. |
+| `reason`, `score`, `metadata` | Set by the strategy. Rules record `matched 
rule: <pattern>`; the judge records `judge_model`, the judge's token counts 
when reported, and `judge_context_truncated` when history was dropped. When the 
router normalizes a non-candidate decision to its default, `metadata` adds 
`rejected_model` and, if the strategy gave one, `rejected_reason`; the 
strategy's score is dropped. |
 | `decision_ms` | Decision latency. For the judge it includes the judge call 
and its retries. |
 
 The final response message carries a `model_routing` map in its extra 
arguments with the routing details and the fallback result: `final_model`, the 
model that answered, `fallback_attempted`, and `fallback_models_tried`.
@@ -289,9 +289,9 @@ Decision latency is recorded as the 
`action.chat_model_action.routingDecisionLat
 
 ## Advanced
 
-**Validation.** Declaration mistakes such as an invalid rule pattern, an 
unknown candidate in a rule or description, or a missing strategy fail at the 
builder calls. Whether the judge is a plain chat model and whether a custom 
executor class can be loaded is checked when the plan is built, before any 
record is processed. Whether the default model is a candidate is only checked 
on the TaskManager, when the router first handles a request; an invalid router 
configuration then produces a fai [...]
+**Validation.** Declaration mistakes such as an invalid rule pattern, an 
unknown candidate in a rule or description, or a missing strategy fail at the 
builder calls. Whether the judge is a plain chat model and whether a custom 
executor class can be loaded is checked when the plan is built, before any 
record is processed. Whether the default model is a candidate is checked at 
`build()` and again when the plan is built, so a descriptor that skipped the 
builder also fails before any record  [...]
 
-**Recovery.** Routing runs once per request. When an [action state store]({{< 
ref "docs/operations/configuration#action-state-store" >}}) is configured, the 
routing decision, the judge call, and each candidate attempt of the initial 
request are persisted and replayed on recovery, so a custom executor or judge 
is not run again for a request that already has a decision. Without a store, 
which is the default, the decision is recomputed on recovery, and a 
non-deterministic strategy may pick  [...]
+**Recovery.** Routing runs once per request. When an [action state store]({{< 
ref "docs/operations/configuration#action-state-store" >}}) is configured, the 
routing decision, the judge call, and each candidate attempt of the initial 
request are persisted and replayed on recovery, so a rule or custom executor is 
not run again for a request that already has a decision. The judge executor 
does run again, but it reads the persisted judge call instead of invoking the 
judge model, and the stor [...]
 
 ## Examples
 
diff --git 
a/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java 
b/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java
index 53d86c7f0..0e6df1ddb 100644
--- 
a/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java
+++ 
b/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java
@@ -60,7 +60,7 @@ import static 
org.apache.flink.agents.api.agents.Agent.STRUCTURED_OUTPUT;
  *
  * <ol>
  *   <li><b>Decide</b> — {@link ModelRoutingResolver} runs the router's 
strategy and normalizes the
- *       result (abstain → default model; non-candidate → fail).
+ *       result (abstain or non-candidate → default model; a strategy 
exception → fail).
  *   <li><b>Durably</b> — the strategy runs inside a durable call ({@code 
"route:<router>"};
  *       per-request uniqueness comes from the store's (key, sequence, event, 
action) scoping, and
  *       the id must stay deterministic across recovery re-processing), so 
recovery replays the
diff --git 
a/plan/src/main/java/org/apache/flink/agents/plan/routing/LlmJudgeRoutingExecutor.java
 
b/plan/src/main/java/org/apache/flink/agents/plan/routing/LlmJudgeRoutingExecutor.java
index 864371e30..c25d2666c 100644
--- 
a/plan/src/main/java/org/apache/flink/agents/plan/routing/LlmJudgeRoutingExecutor.java
+++ 
b/plan/src/main/java/org/apache/flink/agents/plan/routing/LlmJudgeRoutingExecutor.java
@@ -163,9 +163,9 @@ final class LlmJudgeRoutingExecutor implements 
RoutingExecutor {
             }
             return builder.build();
         }
-        // Persisted as a real abstain: replay resolves to the router's 
*current* default, so
-        // a candidate-set change across a restart degrades gracefully (like 
the strategy
-        // path) instead of failing the non-candidate guard.
+        // Persisted as a real abstain: replay resolves to the router's 
*current* default. A
+        // stored verdict whose candidate was removed before a restart gets 
the same treatment
+        // from the resolver's non-candidate normalization.
         return new RoutingDecision(
                 null, true, abstainReason, null, new HashMap<>(judgeMetadata), 
null);
     }
diff --git 
a/plan/src/main/java/org/apache/flink/agents/plan/routing/ModelRoutingResolver.java
 
b/plan/src/main/java/org/apache/flink/agents/plan/routing/ModelRoutingResolver.java
index c79047d43..977a82a3d 100644
--- 
a/plan/src/main/java/org/apache/flink/agents/plan/routing/ModelRoutingResolver.java
+++ 
b/plan/src/main/java/org/apache/flink/agents/plan/routing/ModelRoutingResolver.java
@@ -29,6 +29,7 @@ import 
org.apache.flink.agents.api.metrics.FlinkAgentsMetricGroup;
 import org.apache.flink.agents.api.resource.ResourceType;
 import org.apache.flink.agents.plan.actions.ChatModelInvoker;
 
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.UUID;
@@ -55,6 +56,15 @@ public final class ModelRoutingResolver {
         return "route:" + model;
     }
 
+    /**
+     * Decision-metadata key naming the model a strategy selected outside the 
router's candidates
+     * when that selection was normalized to abstain.
+     */
+    public static final String REJECTED_MODEL_KEY = "rejected_model";
+
+    /** Decision-metadata key carrying the strategy's own reason for a 
rejected selection. */
+    public static final String REJECTED_REASON_KEY = "rejected_reason";
+
     private ModelRoutingResolver() {}
 
     /** An ordinary routing failure captured at the strategy boundary. */
@@ -91,9 +101,9 @@ public final class ModelRoutingResolver {
     /**
      * If {@code model} names a {@link ModelRouter}, execute its declared 
strategy (persisted under
      * the durable {@code "route"} call so the decision replays 
deterministically on recovery),
-     * normalize the result (abstain -> default model, non-candidate -> fail 
clearly), emit an
-     * observability-only {@link ModelRoutingEvent}, and return the selected 
concrete model.
-     * Otherwise returns a direct selection.
+     * normalize the result (abstain or non-candidate -> default model), emit 
an observability-only
+     * {@link ModelRoutingEvent}, and return the selected concrete model. 
Otherwise returns a direct
+     * selection.
      *
      * <p>Routing runs once for the initial chat request; tool-call rounds 
reuse the selected
      * concrete model because it is saved in the tool-request context (see 
{@code
@@ -231,10 +241,13 @@ public final class ModelRoutingResolver {
     }
 
     /**
-     * Shared post-durable handling for all executors: abstain resolves to the 
router's
-     * <i>current</i> default (so a persisted abstain replays gracefully 
across candidate changes),
-     * a concrete selection is guarded against the current candidate set, and 
the observability
-     * event and resolved route are emitted.
+     * Shared post-durable handling for all executors, applied identically to 
fresh and replayed
+     * decisions against the router's <i>current</i> candidates and default. A 
concrete selection
+     * outside the candidate set is normalized to abstain, the same contract 
the judge applies to a
+     * non-candidate verdict; abstain resolves to the current default, so a 
persisted decision
+     * replays gracefully across candidate changes. The rejected name and the 
strategy's reason are
+     * kept in the decision metadata for the routing event. Normalization 
happens after persistence,
+     * so the stored record keeps the strategy's original decision.
      */
     private static ResolvedModelRoute normalizeAndFinish(
             UUID requestId,
@@ -243,6 +256,9 @@ public final class ModelRoutingResolver {
             RoutingDecision decision,
             String concreteSource,
             RunnerContext ctx) {
+        if (!decision.isAbstain() && 
!router.isCandidate(decision.getSelectedModel())) {
+            decision = rejectNonCandidate(decision);
+        }
         String selectedModel;
         String decisionSource;
         if (decision.isAbstain()) {
@@ -250,18 +266,32 @@ public final class ModelRoutingResolver {
             decisionSource = ModelRoutingEvent.SOURCE_DEFAULT;
         } else {
             selectedModel = decision.getSelectedModel();
-            if (!router.isCandidate(selectedModel)) {
-                throw new RoutingFailure(
-                        String.format(
-                                "Routing decision for router '%s' selected 
non-candidate model '%s'; candidates are %s.",
-                                model, selectedModel, 
router.getCandidateNames()));
-            }
             decisionSource = concreteSource;
         }
         return finish(requestId, model, router, decision, selectedModel, 
decisionSource, ctx);
     }
 
-    /** Records the decision latency histogram sample (also for decisions the 
guards reject). */
+    /**
+     * An abstaining copy of a non-candidate decision. The score is dropped 
because it rated the
+     * rejected selection, not the default; the latency and the strategy's 
metadata are kept.
+     */
+    private static RoutingDecision rejectNonCandidate(RoutingDecision 
decision) {
+        String rejected = decision.getSelectedModel();
+        Map<String, Object> metadata = new HashMap<>(decision.getMetadata());
+        metadata.put(REJECTED_MODEL_KEY, rejected);
+        if (decision.getReason() != null) {
+            metadata.put(REJECTED_REASON_KEY, decision.getReason());
+        }
+        return new RoutingDecision(
+                null,
+                true,
+                "selected model '" + rejected + "' is not a candidate",
+                null,
+                metadata,
+                decision.getDecisionMs());
+    }
+
+    /** Records the decision latency histogram sample (also for decisions 
normalized to abstain). */
     private static void recordDecisionLatency(RunnerContext ctx, 
RoutingDecision decision) {
         Double decisionMs = decision.getDecisionMs();
         FlinkAgentsMetricGroup actionMetrics = ctx.getActionMetricGroup();
diff --git 
a/plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionRoutingTest.java
 
b/plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionRoutingTest.java
index 73e37a750..fb79bfee9 100644
--- 
a/plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionRoutingTest.java
+++ 
b/plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionRoutingTest.java
@@ -47,6 +47,7 @@ import 
org.apache.flink.agents.api.resource.ResourceDescriptor;
 import org.apache.flink.agents.api.resource.ResourceType;
 import org.apache.flink.agents.api.tools.ToolResponse;
 import org.apache.flink.agents.plan.AgentConfiguration;
+import org.apache.flink.agents.plan.routing.ModelRoutingResolver;
 import org.apache.flink.metrics.Counter;
 import org.apache.flink.metrics.Histogram;
 import org.junit.jupiter.api.Test;
@@ -70,13 +71,17 @@ import static org.mockito.Mockito.when;
 /** Integration tests for model routing inside {@link ChatModelAction}. */
 public class ChatModelActionRoutingTest {
 
-    /** An executor that returns a name that is not a candidate (to exercise 
the invalid path). */
+    /** An executor that returns a name that is not a candidate (to exercise 
the abstain path). */
     public static class SelectsUnknownStrategy implements 
CustomRoutingExecutor {
         public SelectsUnknownStrategy() {}
 
         @Override
         public RoutingDecision route(RoutingStrategy strategy, RoutingContext 
context) {
-            return RoutingDecision.of("nonexistent");
+            return RoutingDecision.builder("nonexistent")
+                    .reason("tenant override")
+                    .score(0.9)
+                    .metadata("tenant", "t1")
+                    .build();
         }
     }
 
@@ -445,8 +450,12 @@ public class ChatModelActionRoutingTest {
                 .isEqualTo(ModelRoutingEvent.SOURCE_STRATEGY);
     }
 
+    /**
+     * A custom strategy naming a non-candidate abstains to the default, like 
a non-candidate judge
+     * verdict; the rejected name and the strategy's own reason stay visible 
on the routing event.
+     */
     @Test
-    void invalidCandidateFailsClearly() throws Exception {
+    void customNonCandidateSelectionAbstainsToDefault() throws Exception {
         ModelRouter router =
                 new ModelRouter(
                         ModelRouter.of("small", "big")
@@ -458,8 +467,41 @@ public class ChatModelActionRoutingTest {
         ChatModelAction.processChatRequestOrToolResponse(
                 new ChatRequestEvent("router", List.of(new 
ChatMessage(MessageRole.USER, "hi"))),
                 ctx);
-        assertThat(ctx.chatResponse().isFailed()).isTrue();
-        assertThat(ctx.chatResponse().getError()).contains("non-candidate");
+
+        assertThat(ctx.chatResponse().isFailed()).isFalse();
+        ModelRoutingEvent event = ctx.routingEvent();
+        assertThat(event.getSelectedModel()).isEqualTo("small");
+        
assertThat(event.getDecisionSource()).isEqualTo(ModelRoutingEvent.SOURCE_DEFAULT);
+        assertThat(event.getReason()).contains("not a candidate");
+        assertThat(event.getMetadata())
+                .containsEntry(ModelRoutingResolver.REJECTED_MODEL_KEY, 
"nonexistent")
+                .containsEntry(ModelRoutingResolver.REJECTED_REASON_KEY, 
"tenant override")
+                .containsEntry("tenant", "t1");
+        // the score belonged to the rejected selection, not to the default
+        assertThat(event.getScore()).isNull();
+        assertThat(event.getDecisionMs()).isNotNull();
+        assertThat(ctx.resolvedChatModels).containsExactly("small");
+    }
+
+    /** Without a configured default, a non-candidate selection lands on the 
first candidate. */
+    @Test
+    void customNonCandidateSelectionWithoutDefaultUsesFirstCandidate() throws 
Exception {
+        ModelRouter router =
+                new ModelRouter(
+                        ModelRouter.of("small", "big")
+                                
.strategy(Strategies.custom(SelectsUnknownStrategy.class))
+                                .build(),
+                        null);
+        FakeRunnerContext ctx = new FakeRunnerContext(router);
+        ChatModelAction.processChatRequestOrToolResponse(
+                new ChatRequestEvent("router", List.of(new 
ChatMessage(MessageRole.USER, "hi"))),
+                ctx);
+
+        assertThat(ctx.chatResponse().isFailed()).isFalse();
+        assertThat(ctx.routingEvent().getSelectedModel()).isEqualTo("small");
+        assertThat(ctx.routingEvent().getMetadata())
+                .containsEntry(ModelRoutingResolver.REJECTED_MODEL_KEY, 
"nonexistent");
+        assertThat(ctx.resolvedChatModels).containsExactly("small");
     }
 
     @Test
@@ -577,6 +619,11 @@ public class ChatModelActionRoutingTest {
         assertThat(ctx.routingEvent().getSelectedModel()).isEqualTo("small");
         assertThat(ctx.routingEvent().getDecisionSource())
                 .isEqualTo(ModelRoutingEvent.SOURCE_DEFAULT);
+        assertThat(ctx.routingEvent().getReason())
+                .isEqualTo("judge verdict was not a candidate name");
+        // the judge abstains on its own; the resolver's non-candidate 
normalization is not involved
+        assertThat(ctx.routingEvent().getMetadata())
+                .doesNotContainKey(ModelRoutingResolver.REJECTED_MODEL_KEY);
     }
 
     @Test
@@ -1273,6 +1320,119 @@ public class ChatModelActionRoutingTest {
         assertThat(ctx.resolvedChatModels).containsExactly("big");
     }
 
+    /**
+     * A stored concrete decision whose model was removed from the candidates 
before recovery is
+     * normalized like a fresh non-candidate selection: it abstains to the 
router's current default,
+     * keeps its original latency, and records the rejected name. The strategy 
is not re-run.
+     */
+    @Test
+    void storedDecisionForRemovedCandidateAbstainsToCurrentDefault() throws 
Exception {
+        ModelRouter router =
+                new ModelRouter(
+                        ModelRouter.of("small", "medium")
+                                
.strategy(Strategies.custom(ExplodingStrategy.class))
+                                .defaultModel("medium")
+                                .build(),
+                        null);
+        RoutingDecision stored =
+                
RoutingDecision.builder("big").reason("stored").build().withDecisionMs(42.0);
+        FakeRunnerContext ctx = new 
FakeRunnerContext(router).seedDurable("route:router", stored);
+
+        ChatModelAction.processChatRequestOrToolResponse(
+                new ChatRequestEvent("router", List.of(new 
ChatMessage(MessageRole.USER, "hi"))),
+                ctx);
+
+        assertThat(ctx.chatResponse().isFailed()).isFalse();
+        ModelRoutingEvent event = ctx.routingEvent();
+        assertThat(event.getSelectedModel()).isEqualTo("medium");
+        
assertThat(event.getDecisionSource()).isEqualTo(ModelRoutingEvent.SOURCE_DEFAULT);
+        assertThat(event.getMetadata())
+                .containsEntry(ModelRoutingResolver.REJECTED_MODEL_KEY, "big")
+                .containsEntry(ModelRoutingResolver.REJECTED_REASON_KEY, 
"stored");
+        assertThat(event.getDecisionMs()).isEqualTo(42.0);
+        assertThat(ctx.resolvedChatModels).containsExactly("medium");
+    }
+
+    /**
+     * The same stored non-candidate decision follows whatever default the 
router has at recovery: a
+     * changed default, or the first candidate when none is configured.
+     */
+    @Test
+    void storedNonCandidateDecisionFollowsCurrentDefault() throws Exception {
+        RoutingDecision stored = RoutingDecision.of("removed");
+
+        ModelRouter changedDefault =
+                new ModelRouter(
+                        ModelRouter.of("small", "big")
+                                
.strategy(Strategies.custom(ExplodingStrategy.class))
+                                .defaultModel("big")
+                                .build(),
+                        null);
+        FakeRunnerContext first =
+                new 
FakeRunnerContext(changedDefault).seedDurable("route:router", stored);
+        ChatModelAction.processChatRequestOrToolResponse(
+                new ChatRequestEvent("router", List.of(new 
ChatMessage(MessageRole.USER, "hi"))),
+                first);
+        assertThat(first.chatResponse().isFailed()).isFalse();
+        assertThat(first.routingEvent().getSelectedModel()).isEqualTo("big");
+        assertThat(first.routingEvent().getMetadata())
+                .containsEntry(ModelRoutingResolver.REJECTED_MODEL_KEY, 
"removed");
+        assertThat(first.resolvedChatModels).containsExactly("big");
+
+        ModelRouter noDefault =
+                new ModelRouter(
+                        ModelRouter.of("small", "big")
+                                
.strategy(Strategies.custom(ExplodingStrategy.class))
+                                .build(),
+                        null);
+        FakeRunnerContext second =
+                new FakeRunnerContext(noDefault).seedDurable("route:router", 
stored);
+        ChatModelAction.processChatRequestOrToolResponse(
+                new ChatRequestEvent("router", List.of(new 
ChatMessage(MessageRole.USER, "hi"))),
+                second);
+        assertThat(second.chatResponse().isFailed()).isFalse();
+        
assertThat(second.routingEvent().getSelectedModel()).isEqualTo("small");
+        assertThat(second.resolvedChatModels).containsExactly("small");
+    }
+
+    /**
+     * A stored judge verdict naming a candidate that was removed before 
recovery gets the same
+     * treatment as a custom strategy's: abstain to the current default, 
rejected name recorded. The
+     * judge chat replays from its own record and the judge model is not 
re-invoked.
+     */
+    @Test
+    void storedJudgeVerdictForRemovedCandidateAbstainsToCurrentDefault() 
throws Exception {
+        ModelRouter router =
+                new ModelRouter(
+                        ModelRouter.of("small", "medium")
+                                .strategy(Strategies.llm("judge"))
+                                .defaultModel("medium")
+                                .build(),
+                        null);
+        RoutingDecision stored = RoutingDecision.builder("big").reason("llm 
judge verdict").build();
+        FakeRunnerContext ctx =
+                new FakeRunnerContext(router)
+                        .register(
+                                "judge",
+                                new FakeChatModel(
+                                        new RuntimeException("judge re-invoked 
on replay")))
+                        .seedDurable(
+                                "judge:router",
+                                new ChatMessage(MessageRole.ASSISTANT, 
"{\"model\": \"big\"}"))
+                        .seedDurable("route:router", stored);
+        ChatModelAction.processChatRequestOrToolResponse(
+                new ChatRequestEvent("router", List.of(new 
ChatMessage(MessageRole.USER, "hard"))),
+                ctx);
+
+        assertThat(ctx.chatResponse().isFailed()).isFalse();
+        ModelRoutingEvent event = ctx.routingEvent();
+        assertThat(event.getSelectedModel()).isEqualTo("medium");
+        
assertThat(event.getDecisionSource()).isEqualTo(ModelRoutingEvent.SOURCE_DEFAULT);
+        assertThat(event.getMetadata())
+                .containsEntry(ModelRoutingResolver.REJECTED_MODEL_KEY, "big")
+                .containsEntry(ModelRoutingResolver.REJECTED_REASON_KEY, "llm 
judge verdict");
+    }
+
     /**
      * Replay for a judge router (review: both replay tests seeded a CUSTOM 
strategy, which takes
      * the simple durable path). The judge path persists two records — the 
judge chat under {@code

Reply via email to