weiqingy commented on code in PR #964:
URL: https://github.com/apache/flink-agents/pull/964#discussion_r3726267216


##########
plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java:
##########
@@ -400,56 +669,199 @@ public ChatMessage call() throws Exception {
                         Thread.sleep(currentWaitSec * 1000L);
                         totalWaitTimeSec += currentWaitSec;
                     }
-                } else {
-                    LOG.debug(
-                            "Chat request {} failed, the input chat messages 
are {}.",
-                            initialRequestId,
-                            messages);
-                    throw e;
+                    continue;
                 }
+                throw new ChatAttemptFailed(
+                        model, chatModel, e, actualRetryCount, 
totalWaitTimeSec);
             }
         }
+        throw new IllegalStateException("Unreachable chat retry state.");
+    }
 
-        if (actualRetryCount > 0) {
-            accumulateRetryStats(
-                    ctx.getSensoryMemory(), initialRequestId, 
actualRetryCount, totalWaitTimeSec);
+    private static void recordAttemptRetryStats(
+            RunnerContext ctx,
+            UUID initialRequestId,
+            BaseChatModelSetup chatModel,
+            int retryCount,
+            int retryWaitSec)
+            throws Exception {
+        if (retryCount <= 0) {
+            return;
         }
+        accumulateRetryStats(ctx.getSensoryMemory(), initialRequestId, 
retryCount, retryWaitSec);
+        String metricModel = chatModel.getConnectionName();
+        recordRetryMetrics(
+                ctx,
+                metricModel == null || metricModel.isEmpty() ? "unknown" : 
metricModel,
+                retryCount,
+                retryWaitSec);
+    }
 
-        if (!Objects.requireNonNull(response).getToolCalls().isEmpty()) {
-            handleToolCalls(
-                    response,
-                    initialRequestId,
-                    model,
-                    chatModel,
-                    messages,
-                    promptArgs,
-                    outputSchema,
-                    ctx);
-        } else {
-            Map<String, Long> retryStats = 
getRetryStats(ctx.getSensoryMemory(), initialRequestId);
-            int totalRetryCount = retryStats.get(TOTAL_RETRY_COUNT).intValue();
-            int totalRetryWaitSec = 
retryStats.get(TOTAL_RETRY_WAIT_SEC).intValue();
+    private static List<String> candidateAttemptOrder(RoutingSelection 
selection) {
+        List<String> order = new ArrayList<>();
+        order.add(selection.selectedModel);
+        if (selection.isRouter && selection.fallbackEnabled) {
+            for (String candidate : selection.candidates) {
+                if (!candidate.equals(selection.selectedModel)) {
+                    order.add(candidate);
+                }
+            }
+        }
+        return order;
+    }
 
-            recordRetryMetrics(
-                    ctx, chatModel.getConnectionName(), totalRetryCount, 
totalRetryWaitSec);
+    private static String durableChatCallId(RoutingSelection selection, String 
candidate) {
+        if (!selection.isRouter) {
+            return "chat";
+        }
+        return "chat:" + selection.requestedModel + ":" + candidate;
+    }
 
-            ctx.sendEvent(
-                    new ChatResponseEvent(
-                            initialRequestId, response, totalRetryCount, 
totalRetryWaitSec));
+    private static void attachRoutingMetadata(
+            ChatMessage response,
+            RoutingSelection selection,
+            String finalModel,
+            List<String> triedModels) {
+        boolean fallbackAttempted = 
!finalModel.equals(selection.selectedModel);
+        List<String> fallbackModelsTried = new ArrayList<>();
+        for (int i = 1; i < triedModels.size(); i++) {
+            fallbackModelsTried.add(triedModels.get(i));
+        }
+        Map<String, Object> routing = new LinkedHashMap<>();
+        routing.put("router", selection.requestedModel);
+        routing.put("selected_model", selection.selectedModel);
+        routing.put("initial_selected_model", selection.selectedModel);
+        routing.put("final_model", finalModel);
+        routing.put("candidates", new ArrayList<>(selection.candidates));
+        routing.put(
+                "decision_source",
+                fallbackAttempted ? ModelRoutingEvent.SOURCE_FALLBACK : 
selection.decisionSource);
+        routing.put("fallback_enabled", selection.fallbackEnabled);
+        routing.put("fallback_attempted", fallbackAttempted);
+        routing.put("fallback_models_tried", fallbackModelsTried);
+        routing.put("metadata", new LinkedHashMap<>(selection.metadata));
+        if (selection.reason != null) {
+            routing.put("reason", selection.reason);
+        }
+        if (selection.score != null) {
+            routing.put("score", selection.score);
         }
+        response.getExtraArgs().put("model_routing", routing);
     }
 
     private static void processChatRequest(ChatRequestEvent event, 
RunnerContext ctx)
             throws Exception {
+        RoutingSelection selection =
+                resolveRouter(
+                        event.getId(),
+                        event.getModel(),
+                        event.getMessages(),
+                        event.getPromptArgs(),
+                        ctx);
         chat(
                 event.getId(),
-                event.getModel(),
+                selection,
                 event.getMessages(),
                 event.getPromptArgs(),
                 event.getOutputSchema(),
                 ctx);
     }
 
+    /**
+     * If {@code model} names a {@link ModelRouter}, run its strategy (as a 
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.
+     *
+     * <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 
{@link
+     * #handleToolCalls}), so this method is only reached with a router name 
on the initial request.
+     */
+    private static RoutingSelection resolveRouter(
+            UUID requestId,
+            String model,
+            List<ChatMessage> messages,
+            Map<String, Object> promptArgs,
+            RunnerContext ctx)
+            throws Exception {
+        if (!ctx.hasResource(model, ResourceType.MODEL_ROUTER)) {
+            return RoutingSelection.direct(model);
+        }
+        ModelRouter router = (ModelRouter) ctx.getResource(model, 
ResourceType.MODEL_ROUTER);
+        RoutingContext routingContext =
+                new RoutingContext(requestId, model, messages, promptArgs, 
router.getCandidates());
+
+        DurableCallable<RoutingDecision> routeCallable =
+                new DurableCallable<>() {
+                    @Override
+                    public String getId() {
+                        return "route:" + requestId + ":" + model;
+                    }
+
+                    @Override
+                    public Class<RoutingDecision> getResultClass() {
+                        return RoutingDecision.class;
+                    }
+
+                    @Override
+                    public RoutingDecision call() throws Exception {
+                        // Timed inside the durable call so the latency is 
persisted with the
+                        // decision: a replayed run reports the original 
strategy wall time.
+                        long start = System.nanoTime();
+                        RoutingDecision decision = 
router.route(routingContext);
+                        return decision.withDecisionMs((System.nanoTime() - 
start) / 1_000_000.0);
+                    }
+                };
+
+        RoutingDecision decision = ctx.durableExecute(routeCallable);

Review Comment:
   `resolveRouter` (781-863) has no try/catch and runs before `chat()`, so a 
strategy exception escapes `error-handling-strategy`: a job set to `ignore` 
still dies.
   
   It's reachable from the built-in. `RuleBasedRoutingStrategy.java:61-66` 
throws on a rule key that isn't a candidate, and `Builder` validates names in 
`describe()` (`ModelRouter.java:176-185`) but not in `strategy()`, so 
`Strategies.rules(Map.of("bg", "..."))` builds fine and throws per record.
   
   Could rule keys be checked in `build()`, where both lists are already in 
hand? And should a strategy failure honor `error-handling-strategy`, or is 
fail-fast the contract?



##########
plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java:
##########
@@ -344,15 +505,129 @@ public static void chat(
                             : 0;
         }
 
-        ChatMessage response = null;
+        List<String> triedModels = new ArrayList<>();
+        Exception lastError = null;
+        for (String candidate : candidateAttemptOrder(selection)) {
+            triedModels.add(candidate);
+            try {
+                ChatAttemptResult result =
+                        chatWithRetries(
+                                initialRequestId,
+                                candidate,
+                                durableChatCallId(selection, candidate),
+                                messages,
+                                promptArgs,
+                                outputSchema,
+                                ctx,
+                                strategy,
+                                numRetries,
+                                retryWaitIntervalSec);
+                recordAttemptRetryStats(
+                        ctx,
+                        initialRequestId,
+                        result.chatModel,
+                        result.retryCount,
+                        result.totalRetryWaitSec);
+                if (selection.isRouter) {
+                    attachRoutingMetadata(result.response, selection, 
result.model, triedModels);
+                    if (!result.model.equals(selection.selectedModel)) {
+                        // The strategy's pick failed and another candidate 
answered; record the
+                        // outcome in the event log, not just on the response.
+                        ctx.sendEvent(
+                                new ModelRoutingEvent(
+                                        initialRequestId,
+                                        selection.requestedModel,
+                                        selection.candidates,
+                                        result.model,
+                                        ModelRoutingEvent.SOURCE_FALLBACK,
+                                        selection.fallbackEnabled,
+                                        String.format(
+                                                "fallback after selected model 
'%s' failed",
+                                                selection.selectedModel),
+                                        null,
+                                        selection.metadata,
+                                        null));
+                    }
+                } else if (selection.carriedRouting != null) {
+                    result.response
+                            .getExtraArgs()
+                            .put("model_routing", new 
LinkedHashMap<>(selection.carriedRouting));
+                }
+
+                if 
(!Objects.requireNonNull(result.response).getToolCalls().isEmpty()) {
+                    handleToolCalls(
+                            result.response,
+                            initialRequestId,
+                            result.model,
+                            result.chatModel,
+                            messages,
+                            promptArgs,
+                            outputSchema,
+                            ctx);
+                } else {
+                    Map<String, Long> retryStats =
+                            getRetryStats(ctx.getSensoryMemory(), 
initialRequestId);
+                    int totalRetryCount = 
retryStats.get(TOTAL_RETRY_COUNT).intValue();
+                    int totalRetryWaitSec = 
retryStats.get(TOTAL_RETRY_WAIT_SEC).intValue();
+
+                    ctx.sendEvent(
+                            new ChatResponseEvent(
+                                    initialRequestId,
+                                    result.response,
+                                    totalRetryCount,
+                                    totalRetryWaitSec));
+                }
+                return;
+            } catch (ChatAttemptFailed e) {
+                recordAttemptRetryStats(
+                        ctx, initialRequestId, e.chatModel, e.retryCount, 
e.totalRetryWaitSec);
+                lastError = e.error;

Review Comment:
   `lastError = e.error` overwrites and 598 throws only the last, so with `[A, 
B, C]` all failing you see C's. The debug line at 585 logs the model and 
messages but not the error, so A's and B's are gone, including the routed 
model's own failure. The second `ModelRoutingEvent` only fires on success 
(536-549), so exhaustion records nothing about what was tried.
   
   Something like this before the overwrite, if it helps:
   
   ```java
   if (lastError != null && lastError != e.error) {
       e.error.addSuppressed(lastError);
   }
   ```
   
   `fallbackExhaustedRethrows` can't tell today: both scripted errors contain 
"down" (`ChatModelActionRoutingTest.java:408-410`). Would distinct messages 
help?



##########
plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java:
##########
@@ -344,15 +505,129 @@ public static void chat(
                             : 0;
         }
 
-        ChatMessage response = null;
+        List<String> triedModels = new ArrayList<>();
+        Exception lastError = null;
+        for (String candidate : candidateAttemptOrder(selection)) {
+            triedModels.add(candidate);
+            try {
+                ChatAttemptResult result =
+                        chatWithRetries(
+                                initialRequestId,
+                                candidate,
+                                durableChatCallId(selection, candidate),
+                                messages,
+                                promptArgs,
+                                outputSchema,
+                                ctx,
+                                strategy,
+                                numRetries,
+                                retryWaitIntervalSec);
+                recordAttemptRetryStats(
+                        ctx,
+                        initialRequestId,
+                        result.chatModel,
+                        result.retryCount,
+                        result.totalRetryWaitSec);
+                if (selection.isRouter) {
+                    attachRoutingMetadata(result.response, selection, 
result.model, triedModels);
+                    if (!result.model.equals(selection.selectedModel)) {
+                        // The strategy's pick failed and another candidate 
answered; record the
+                        // outcome in the event log, not just on the response.
+                        ctx.sendEvent(
+                                new ModelRoutingEvent(
+                                        initialRequestId,
+                                        selection.requestedModel,
+                                        selection.candidates,
+                                        result.model,
+                                        ModelRoutingEvent.SOURCE_FALLBACK,
+                                        selection.fallbackEnabled,
+                                        String.format(
+                                                "fallback after selected model 
'%s' failed",
+                                                selection.selectedModel),
+                                        null,
+                                        selection.metadata,
+                                        null));
+                    }
+                } else if (selection.carriedRouting != null) {
+                    result.response
+                            .getExtraArgs()
+                            .put("model_routing", new 
LinkedHashMap<>(selection.carriedRouting));
+                }
+
+                if 
(!Objects.requireNonNull(result.response).getToolCalls().isEmpty()) {
+                    handleToolCalls(
+                            result.response,
+                            initialRequestId,
+                            result.model,
+                            result.chatModel,
+                            messages,
+                            promptArgs,
+                            outputSchema,
+                            ctx);
+                } else {
+                    Map<String, Long> retryStats =
+                            getRetryStats(ctx.getSensoryMemory(), 
initialRequestId);
+                    int totalRetryCount = 
retryStats.get(TOTAL_RETRY_COUNT).intValue();
+                    int totalRetryWaitSec = 
retryStats.get(TOTAL_RETRY_WAIT_SEC).intValue();
+
+                    ctx.sendEvent(
+                            new ChatResponseEvent(
+                                    initialRequestId,
+                                    result.response,
+                                    totalRetryCount,
+                                    totalRetryWaitSec));
+                }
+                return;
+            } catch (ChatAttemptFailed e) {
+                recordAttemptRetryStats(
+                        ctx, initialRequestId, e.chatModel, e.retryCount, 
e.totalRetryWaitSec);
+                lastError = e.error;
+                LOG.debug(
+                        "Chat request {} failed for model {}, the input chat 
messages are {}.",
+                        initialRequestId,
+                        e.model,
+                        messages);
+            }
+        }
+
+        if (strategy == Agent.ErrorHandlingStrategy.IGNORE) {
+            LOG.warn(
+                    "Chat request {} failed with error: {}, ignored.", 
initialRequestId, lastError);
+            return;
+        }
+        throw Objects.requireNonNull(lastError);
+    }
+
+    private static ChatAttemptResult chatWithRetries(
+            UUID initialRequestId,
+            String model,
+            String durableCallId,
+            List<ChatMessage> messages,
+            Map<String, Object> promptArgs,
+            @Nullable Object outputSchema,
+            RunnerContext ctx,
+            Agent.ErrorHandlingStrategy strategy,
+            int numRetries,
+            int retryWaitIntervalSec)
+            throws ChatAttemptFailed, Exception {
+        BaseChatModelSetup chatModel =
+                (BaseChatModelSetup) ctx.getResource(model, 
ResourceType.CHAT_MODEL);

Review Comment:
   This lookup sits outside the try/catch that produces `ChatAttemptFailed` 
(644-676), and the call site catches only that (581). So an unresolvable 
candidate B propagates straight out of `chat()`: A's real failure is discarded, 
the caller sees "resource not found", and the candidate loop is bypassed, 
including under `IGNORE`.
   
   Nothing catches the typo upstream either, since `ModelRouter`'s constructor 
(57-89) never touches the registry.
   
   Could the lookup move inside the conversion, so an unresolvable candidate 
just counts as that candidate failing? And is there room to cross-validate 
candidate names at plan construction, the way `Agent.java:135-139` argues for 
the clash check?



##########
api/src/main/java/org/apache/flink/agents/api/chat/model/routing/RoutingContext.java:
##########
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.api.chat.model.routing;
+
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.MessageRole;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Read-only view a {@link RoutingStrategy} sees when deciding which model to 
route to.

Review Comment:
   The collections are unmodifiable (55-58, 59-62) but the copy is shallow, and 
`ChatMessage` is mutable on main. These are the same instances that go to the 
model: `event.getMessages()` is passed into `resolveRouter` (758) and into 
`chat(...)` (764). So a `setContent(...)` inside a strategy rewrites the prompt 
that's actually sent, with no event and no trace.
   
   The strategy's reach to other resources is already closed by the type. This 
is the same thing one level down, and narrowing it is free before v1 ships and 
breaking after.
   
   `firstUserMessage()` and `lastUserMessage()` (100, 114) are what a selection 
strategy needs. Deep-copy the list, or drop `getMessages()` until something 
needs more than the text?



##########
api/src/main/java/org/apache/flink/agents/api/EventType.java:
##########
@@ -39,6 +39,8 @@ public final class EventType {
             
org.apache.flink.agents.api.event.ContextRetrievalRequestEvent.EVENT_TYPE;
     public static final String ContextRetrievalResponseEvent =
             
org.apache.flink.agents.api.event.ContextRetrievalResponseEvent.EVENT_TYPE;
+    public static final String ModelRoutingEvent =

Review Comment:
   After a rebase, `ModelRoutingEvent` won't be in `main`'s `ALL_CONSTANTS` 
(`EventType.java:45-54`), and `ConditionExpressionValidator.java:125` rejects 
any `EventType.X` that isn't a key of it. So `@Action("type == 
EventType.ModelRoutingEvent && ...")` compiles, then fails plan validation. 
Plain `@Action(EventType.ModelRoutingEvent)` is unaffected.
   
   Worth adding the entry when you rebase?



##########
api/src/main/java/org/apache/flink/agents/api/chat/model/routing/RuleBasedRoutingStrategy.java:
##########
@@ -0,0 +1,84 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.api.chat.model.routing;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+/**
+ * Built-in keyword/regex routing strategy. Configured with a map of {@code 
candidateModel ->
+ * regex}; the first candidate whose regex matches the most recent user 
message (case-insensitive
+ * find) wins, evaluated in the map's iteration order (pass a {@code 
LinkedHashMap} when precedence
+ * matters). If nothing matches, the strategy abstains so the router uses its 
default model.
+ *
+ * <p>Constructed reflectively from a {@link RoutingStrategyDescriptor} via 
the {@code
+ * (Map<String,Object>)} constructor; use {@link Strategies#rules(Map)} to 
build one.
+ */
+public class RuleBasedRoutingStrategy implements RoutingStrategy {
+
+    private static final long serialVersionUID = 1L;
+
+    private final Map<String, Pattern> rules;
+
+    @SuppressWarnings("unchecked")
+    public RuleBasedRoutingStrategy(Map<String, Object> args) {
+        this.rules = new LinkedHashMap<>();
+        Object raw = args == null ? null : args.get("rules");
+        if (raw instanceof Map) {
+            for (Map.Entry<String, ?> entry : ((Map<String, ?>) 
raw).entrySet()) {
+                String candidate = entry.getKey();
+                String regex = String.valueOf(entry.getValue());

Review Comment:
   `String.valueOf(entry.getValue())` returns `"null"` for a null value, never 
`null`, so the `regex != null` guard below is dead and the entry compiles to 
`Pattern.compile("null", CASE_INSENSITIVE)`. Any message containing "null" then 
routes there. Non-String values coerce silently too, and `rules` arrives as 
`Map<String,Object>` off the plan JSON.
   
   Should a null or non-String value be rejected instead?



##########
plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java:
##########
@@ -51,14 +55,43 @@
 import static org.apache.flink.agents.api.agents.Agent.STRUCTURED_OUTPUT;
 import static org.apache.flink.agents.plan.actions.Utils.supportAsync;
 
-/** Built-in action for processing chat request and tool call result. */
+/**
+ * Built-in action for processing chat request and tool call result.
+ *
+ * <h2>Model routing overview</h2>
+ *
+ * <p>When a {@link ChatRequestEvent} names a {@code MODEL_ROUTER} instead of 
a chat model, this
+ * action layers five jobs on top of the normal chat path; each is localized 
to one place:
+ *
+ * <ol>
+ *   <li><b>Decide</b> — {@code resolveRouter} runs the router's strategy and 
normalizes the result
+ *       (abstain → default model; non-candidate → fail).
+ *   <li><b>Durably</b> — the strategy runs inside a durable call ({@code 
"route:<requestId>:

Review Comment:
   The guarantee reads unconditional here (69-71) and on 
`RoutingDecision.java:93-95`, but the journal is off by default: 
`actionStateStoreBackend` defaults to null (`AgentConfigOptions.java:65-66`), 
and `setupDurableExecutionContext` returns early when the store is null 
(`DurableExecutionManager.java:289-292`).
   
   The default path is still self-consistent, since the decision and the chat 
call re-run together. It's the javadoc that promises more than a default 
deployment gets. Worth a qualifying clause?



##########
plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java:
##########
@@ -344,15 +505,129 @@ public static void chat(
                             : 0;
         }
 
-        ChatMessage response = null;
+        List<String> triedModels = new ArrayList<>();
+        Exception lastError = null;
+        for (String candidate : candidateAttemptOrder(selection)) {
+            triedModels.add(candidate);
+            try {
+                ChatAttemptResult result =
+                        chatWithRetries(
+                                initialRequestId,
+                                candidate,
+                                durableChatCallId(selection, candidate),
+                                messages,
+                                promptArgs,
+                                outputSchema,
+                                ctx,
+                                strategy,
+                                numRetries,
+                                retryWaitIntervalSec);
+                recordAttemptRetryStats(
+                        ctx,
+                        initialRequestId,
+                        result.chatModel,
+                        result.retryCount,
+                        result.totalRetryWaitSec);
+                if (selection.isRouter) {
+                    attachRoutingMetadata(result.response, selection, 
result.model, triedModels);
+                    if (!result.model.equals(selection.selectedModel)) {
+                        // The strategy's pick failed and another candidate 
answered; record the
+                        // outcome in the event log, not just on the response.
+                        ctx.sendEvent(
+                                new ModelRoutingEvent(
+                                        initialRequestId,
+                                        selection.requestedModel,
+                                        selection.candidates,
+                                        result.model,
+                                        ModelRoutingEvent.SOURCE_FALLBACK,
+                                        selection.fallbackEnabled,
+                                        String.format(
+                                                "fallback after selected model 
'%s' failed",
+                                                selection.selectedModel),
+                                        null,
+                                        selection.metadata,
+                                        null));
+                    }
+                } else if (selection.carriedRouting != null) {
+                    result.response
+                            .getExtraArgs()
+                            .put("model_routing", new 
LinkedHashMap<>(selection.carriedRouting));
+                }
+
+                if 
(!Objects.requireNonNull(result.response).getToolCalls().isEmpty()) {
+                    handleToolCalls(
+                            result.response,
+                            initialRequestId,
+                            result.model,
+                            result.chatModel,
+                            messages,
+                            promptArgs,
+                            outputSchema,
+                            ctx);
+                } else {
+                    Map<String, Long> retryStats =
+                            getRetryStats(ctx.getSensoryMemory(), 
initialRequestId);
+                    int totalRetryCount = 
retryStats.get(TOTAL_RETRY_COUNT).intValue();
+                    int totalRetryWaitSec = 
retryStats.get(TOTAL_RETRY_WAIT_SEC).intValue();
+
+                    ctx.sendEvent(
+                            new ChatResponseEvent(
+                                    initialRequestId,
+                                    result.response,
+                                    totalRetryCount,
+                                    totalRetryWaitSec));
+                }
+                return;
+            } catch (ChatAttemptFailed e) {
+                recordAttemptRetryStats(

Review Comment:
   Retry metrics moved: `origin/main:433-434` recorded once on the final 
response with cumulative totals; this records per attempt (681-698), including 
on the failure path. Totals over a completed request are unchanged, but 
requests that ultimately throw now increment where they didn't.
   
   `AGENTS.md` asks for compatibility impact in the description. Worth a line?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to