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]