weiqingy commented on code in PR #964:
URL: https://github.com/apache/flink-agents/pull/964#discussion_r3793315732
##########
api/src/main/java/org/apache/flink/agents/api/resource/ResourceType.java:
##########
@@ -32,7 +32,8 @@ public enum ResourceType {
PROMPT("prompt"),
TOOL("tool"),
MCP_SERVER("mcp_server"),
- SKILLS("skills");
+ SKILLS("skills"),
+ MODEL_ROUTER("model_router");
Review Comment:
Java gets `MODEL_ROUTER` here, but the Python enum doesn't have it
(`python/flink_agents/api/resource.py:38-46`), and no Python files changed in
this PR. The Java plan is serialized over to Python and parsed against
`Dict[ResourceType, ...]` (`python/flink_agents/plan/agent_plan.py:77`), so a
Java agent using a router plus any Python action would fail at operator open
with a pydantic `ValidationError`. Full Python routing is a follow-up per the
description. Would adding just this one enum member be enough to keep mixed
jobs starting, or is there a reason to hold it for the follow-up?
##########
plan/src/main/java/org/apache/flink/agents/plan/AgentPlan.java:
##########
@@ -617,11 +617,39 @@ private ResourceProvider createResourceProvider(
/** Adds a resource provider to the resourceProviders map. */
private void addResourceProvider(ResourceProvider provider) {
+ checkNoRouterModelNameClash(provider);
resourceProviders
.computeIfAbsent(provider.getType(), k -> new HashMap<>())
.put(provider.getName(), provider);
}
+ /**
+ * A name must not be registered as both a {@link ResourceType#CHAT_MODEL}
and a {@link
+ * ResourceType#MODEL_ROUTER}: an agent references either by putting it in
{@code
+ * ChatRequestEvent.model}, so a name that is both would resolve
ambiguously. Fail clearly at
+ * plan-construction time rather than at request time.
+ */
+ private void checkNoRouterModelNameClash(ResourceProvider provider) {
Review Comment:
`checkNoRouterModelNameClash` doesn't have a test yet.
`RoutingResourceValidationTest.java:44-79` exercises the two `addResource` call
sites, but each of those only sees its own map. Registering the same name in
both places — `env.addResource("smart", CHAT_MODEL, ...)` and
`agent.addResource("smart", MODEL_ROUTER, ...)` — passes both checks, and
`Agent.addResourcesIfAbsent` (`Agent.java:104`) merges them without one, so
this is what actually catches it. That's the case `Agent.java:139` calls out as
the backstop. Should that path get a case of its own?
##########
plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java:
##########
@@ -352,106 +390,220 @@ public static void chat(
: 0;
}
- ChatMessage response = null;
- int actualRetryCount = 0;
- int totalWaitTimeSec = 0;
-
- DurableCallable<ChatMessage> callable =
- new DurableCallable<>() {
- @Override
- public String getId() {
- return "chat";
- }
-
- @Override
- public Class<ChatMessage> getResultClass() {
- return ChatMessage.class;
- }
-
- @Override
- public ChatMessage call() throws Exception {
- return chatModel.chat(messages, promptArgs, Map.of());
- }
- };
-
- for (int attempt = 0; attempt < numRetries + 1; attempt++) {
+ List<String> triedModels = new ArrayList<>();
+ Exception lastError = null;
+ for (String candidate : selection.attemptOrder()) {
+ triedModels.add(candidate);
try {
- response =
- chatAsync
- ? ctx.durableExecuteAsync(callable)
- : ctx.durableExecute(callable);
- recordChatTokenMetrics(chatModel, response,
requestMetricGroup);
- // only generate structured output for final response.
- if (outputSchema != null && response.getToolCalls().isEmpty())
{
- response = generateStructuredOutput(response,
outputSchema);
+ ChatModelInvoker.ChatAttemptResult result =
+ ChatModelInvoker.chatWithRetries(
+ initialRequestId,
+ candidate,
+ selection.durableChatCallId(candidate),
+ messages,
+ promptArgs,
+ outputSchema,
+ ctx,
+ strategy,
+ numRetries,
+ retryWaitIntervalSec);
+ recordAttemptRetryStats(
+ ctx,
+ initialRequestId,
+ result.chatModel,
+ result.retryCount,
+ result.totalRetryWaitSec);
+ if (selection.isRouter) {
+ 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));
+ }
}
- break;
- } catch (Exception e) {
- if (strategy == Agent.ErrorHandlingStrategy.IGNORE) {
- LOG.warn(
- "Chat request {} failed with error: {}, ignored.",
initialRequestId, e);
- return;
- } else if (strategy == Agent.ErrorHandlingStrategy.RETRY) {
- if (attempt == numRetries) {
- throw e;
+
+ // Routing metadata is observability-only and needed exactly
once, on the final
+ // response. If this response starts (or continues) a tool
loop, park the block
+ // in an initial-request-keyed context instead of stamping
intermediate messages
+ // and copying it through every tool round.
+ Map<String, Object> routingMetadata =
+ selection.isRouter
+ ?
selection.buildResponseMetadata(result.model, triedModels)
+ : null;
+ if
(!Objects.requireNonNull(result.response).getToolCalls().isEmpty()) {
+ if (routingMetadata != null) {
+ saveRoutingMetadata(
+ ctx.getSensoryMemory(), initialRequestId,
routingMetadata);
}
- actualRetryCount = attempt + 1;
- int currentWaitSec = retryWaitIntervalSec * (1 <<
(actualRetryCount - 1));
- LOG.warn(
- "Chat request {} failed with error: {}, retrying
{} / {}, waiting {} s.",
+ handleToolCalls(
+ result.response,
initialRequestId,
- e,
- actualRetryCount,
- numRetries,
- currentWaitSec);
- if (currentWaitSec > 0) {
- Thread.sleep(currentWaitSec * 1000L);
- totalWaitTimeSec += currentWaitSec;
- }
+ result.model,
+ result.chatModel,
+ messages,
+ promptArgs,
+ outputSchema,
+ ctx);
} else {
- LOG.debug(
- "Chat request {} failed, the input chat messages
are {}.",
- initialRequestId,
- messages);
- throw e;
+ if (routingMetadata == null) {
+ routingMetadata =
+ takeRoutingMetadata(ctx.getSensoryMemory(),
initialRequestId);
+ }
+ if (routingMetadata != null) {
+ result.response.getExtraArgs().put("model_routing",
routingMetadata);
+ }
+ 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 (ChatModelInvoker.ChatAttemptFailed e) {
+ recordAttemptRetryStats(
+ ctx, initialRequestId, e.chatModel, e.retryCount,
e.totalRetryWaitSec);
+ // Keep every candidate's failure: chain the previous error
into the new one so
+ // exhaustion surfaces A's and B's errors as suppressed of
C's, not just C's.
+ if (lastError != null && lastError != e.error) {
+ e.error.addSuppressed(lastError);
+ }
+ lastError = e.error;
+ LOG.debug(
+ "Chat request {} failed for model {} with error: {}.
The input chat messages are {}.",
+ initialRequestId,
+ e.model,
+ e.error.toString(),
+ messages);
}
}
- if (actualRetryCount > 0) {
- accumulateRetryStats(
- ctx.getSensoryMemory(), initialRequestId,
actualRetryCount, totalWaitTimeSec);
+ if (selection.isRouter && triedModels.size() > 1) {
+ LOG.warn(
+ "Chat request {} exhausted all candidates {} of router
'{}'; last error: {}.",
+ initialRequestId,
+ triedModels,
+ selection.requestedModel,
+ lastError == null ? null : lastError.toString());
+ }
+ // The reasoning loop is over; a routed loop that dies mid-way must
not leak its
+ // parked metadata (matters under IGNORE, where the job keeps running).
+ takeRoutingMetadata(ctx.getSensoryMemory(), initialRequestId);
+ if (strategy == Agent.ErrorHandlingStrategy.IGNORE) {
+ LOG.warn(
+ "Chat request {} failed with error: {}, ignored.",
initialRequestId, lastError);
+ return;
}
+ throw Objects.requireNonNull(lastError);
+ }
- 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();
+ /**
+ * Compatibility note: retry metrics are recorded per attempt (including
attempts on the failure
Review Comment:
The description still says the opposite of this — line 58 reads "Existing
agents are unaffected." Three things do change for a plain-chat agent, all only
when `error-handling-strategy` isn't the default: with `retry`, retry metrics
now count per attempt including failures, and the retry WARN moved to
`ChatModelInvoker`'s logger; with `ignore`, a request naming a missing chat
model is warned and dropped instead of failing the job. Default `FAIL` really
is unaffected, so the blast radius is small. `AGENTS.md` asks for compatibility
impact in the description — is line 58 still the message you want, or should it
name those three?
##########
api/src/main/java/org/apache/flink/agents/api/chat/model/routing/RoutingContext.java:
##########
@@ -0,0 +1,152 @@
+/*
+ * 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.
+ *
+ * <p>v1 exposes the request id, the request messages, prompt args, and the
router's candidates
+ * (name + description). It intentionally does <b>not</b> expose a
chat-invocation API, so a
+ * strategy cannot make a hidden synchronous model call; observable
LLM-as-router is a
+ * framework-managed follow-up.
+ */
+public final class RoutingContext {
+
+ private final UUID requestId;
+ private final String router;
+ private final List<ChatMessage> messages;
+ private final Map<String, Object> promptArgs;
+ private final List<RoutingCandidate> candidates;
+
+ public RoutingContext(
+ UUID requestId,
+ String router,
+ List<ChatMessage> messages,
+ Map<String, Object> promptArgs,
+ List<RoutingCandidate> candidates) {
+ this.requestId = requestId;
+ this.router = router;
+ // Deep copy: the wrapping list is unmodifiable, but ChatMessage is
mutable and the
+ // caller passes the same instances that go to the model — a strategy
calling
+ // setContent(...) on a shallow copy would silently rewrite the prompt
actually sent.
+ this.messages =
+ messages == null
+ ? Collections.emptyList()
+ : Collections.unmodifiableList(deepCopy(messages));
+ this.promptArgs =
Review Comment:
nit: the javadoc calls this a read-only view (`:32`), but each copy only
goes one level — `promptArgs` here, and in `deepCopy` both `extraArgs` (via
`ChatMessage`'s constructor, `ChatMessage.java:72`) and each tool-call map
(`:90`). Nested values stay shared with what gets sent
(`ChatModelAction.java:604-610`). No in-tree strategy mutates one, so this is
about the shape the API locks in. Is the top-level copy deliberate, or would
deep-copying these fit better?
##########
plan/src/main/java/org/apache/flink/agents/plan/actions/ModelRoutingResolver.java:
##########
@@ -0,0 +1,143 @@
+/*
+ * 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.plan.actions;
+
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.model.routing.ModelRouter;
+import org.apache.flink.agents.api.chat.model.routing.RoutingContext;
+import org.apache.flink.agents.api.chat.model.routing.RoutingDecision;
+import org.apache.flink.agents.api.context.DurableCallable;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.event.ModelRoutingEvent;
+import org.apache.flink.agents.api.metrics.FlinkAgentsMetricGroup;
+import org.apache.flink.agents.api.resource.ResourceType;
+
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * Resolves a chat request's target into a {@link ResolvedModelRoute}: if
{@code model} names a
+ * {@link ModelRouter}, runs its strategy inside a durable {@code
"route:<router>"} call, emits the
+ * observability-only {@link ModelRoutingEvent}, and normalizes the decision;
otherwise returns the
+ * direct route.
+ */
+final class ModelRoutingResolver {
+
+ private ModelRoutingResolver() {}
+
+ /**
+ * 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
{@code
+ * ChatModelAction#handleToolCalls}), so this method is only reached with
a router name on the
+ * initial request.
+ */
+ static ResolvedModelRoute resolve(
+ UUID requestId,
+ String model,
+ List<ChatMessage> messages,
+ Map<String, Object> promptArgs,
+ RunnerContext ctx)
+ throws Exception {
+ if (!ctx.hasResource(model, ResourceType.MODEL_ROUTER)) {
+ return ResolvedModelRoute.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() {
+ // Deterministic across recovery re-processing: the
durable store already
+ // scopes call results by (key, sequence number,
event, action), so the id
+ // must NOT embed the request id — event ids are
regenerated when Flink
+ // rolls back and re-processes, and a
non-deterministic id turns every
+ // replay lookup into a miss (measured: 0/138
decisions replayed).
+ return "route:" + model;
Review Comment:
nit: the description hasn't caught up with this line — body line 26 still
describes the id as `"route:<requestId>:<router>"`, the form this commit
removed. Nothing is broken, since this line and the javadoc at `:36` are
already right. It's just that durability is the claim people read closest here,
so worth syncing line 26 too?
##########
plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionRoutingTest.java:
##########
@@ -0,0 +1,725 @@
+/*
+ * 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.plan.actions;
+
+import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.agents.Agent;
+import org.apache.flink.agents.api.agents.AgentExecutionOptions;
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.chat.model.BaseChatModelSetup;
+import org.apache.flink.agents.api.chat.model.routing.ModelRouter;
+import org.apache.flink.agents.api.chat.model.routing.RoutingContext;
+import org.apache.flink.agents.api.chat.model.routing.RoutingDecision;
+import org.apache.flink.agents.api.chat.model.routing.RoutingStrategy;
+import org.apache.flink.agents.api.chat.model.routing.Strategies;
+import org.apache.flink.agents.api.configuration.ReadableConfiguration;
+import org.apache.flink.agents.api.context.DurableCallable;
+import org.apache.flink.agents.api.context.MemoryObject;
+import org.apache.flink.agents.api.context.MemoryRef;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.event.ChatRequestEvent;
+import org.apache.flink.agents.api.event.ChatResponseEvent;
+import org.apache.flink.agents.api.event.ModelRoutingEvent;
+import org.apache.flink.agents.api.event.ToolRequestEvent;
+import org.apache.flink.agents.api.event.ToolResponseEvent;
+import org.apache.flink.agents.api.memory.BaseLongTermMemory;
+import org.apache.flink.agents.api.metrics.FlinkAgentsMetricGroup;
+import org.apache.flink.agents.api.resource.Resource;
+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.junit.jupiter.api.Test;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Integration tests for model routing inside {@link ChatModelAction}. */
+public class ChatModelActionRoutingTest {
+
+ /** A strategy that returns a name that is not a candidate (to exercise
the invalid path). */
+ public static class SelectsUnknownStrategy implements RoutingStrategy {
+ public SelectsUnknownStrategy() {}
+
+ @Override
+ public RoutingDecision route(RoutingContext context) {
+ return RoutingDecision.of("nonexistent");
+ }
+ }
+
+ /**
+ * A chat model returning scripted outcomes per call: a {@link
ChatMessage} is returned, a
+ * {@link RuntimeException} is thrown. When the script is exhausted,
returns a default assistant
+ * reply.
+ */
+ static class FakeChatModel extends BaseChatModelSetup {
+ private final Deque<Object> outcomes = new ArrayDeque<>();
+
+ FakeChatModel(Object... outcomes) {
+ super(new ResourceDescriptor("fake", Map.of()), null);
+ Collections.addAll(this.outcomes, outcomes);
+ }
+
+ @Override
+ public Map<String, Object> getParameters() {
+ return Map.of();
+ }
+
+ @Override
+ public ChatMessage chat(
+ List<ChatMessage> messages,
+ Map<String, Object> promptArgs,
+ Map<String, Object> modelParams) {
+ Object next = outcomes.isEmpty() ? null : outcomes.poll();
+ if (next instanceof RuntimeException) {
+ throw (RuntimeException) next;
+ }
+ if (next instanceof ChatMessage) {
+ return (ChatMessage) next;
+ }
+ return new ChatMessage(MessageRole.ASSISTANT, "answer");
+ }
+ }
+
+ static class FakeRunnerContext implements RunnerContext {
+ final List<Event> sentEvents = new ArrayList<>();
+ final List<String> resolvedChatModels = new ArrayList<>();
+ final List<String> durableCallIds = new ArrayList<>();
+ final Map<String, BaseChatModelSetup> models = new HashMap<>();
+ final Set<String> unresolvable = new HashSet<>();
+ private final ModelRouter router;
+ private final MemoryObject sensoryMemory = new FakeMemoryObject(new
HashMap<>());
+ private final AgentConfiguration config = new
AgentConfiguration(Map.of());
+
+ FakeRunnerContext(ModelRouter router) {
+ this.router = router;
+ }
+
+ FakeRunnerContext register(String name, BaseChatModelSetup model) {
+ models.put(name, model);
+ return this;
+ }
+
+ /** Marks a chat-model name whose resource lookup fails (e.g. a typo'd
candidate). */
+ FakeRunnerContext unresolvable(String name) {
+ unresolvable.add(name);
+ return this;
+ }
+
+ FakeRunnerContext withErrorHandling(Agent.ErrorHandlingStrategy
strategy) {
+ config.set(AgentExecutionOptions.ERROR_HANDLING_STRATEGY,
strategy);
+ return this;
+ }
+
+ @Override
+ public boolean hasResource(String name, ResourceType type) {
+ return type == ResourceType.MODEL_ROUTER && "router".equals(name)
&& router != null;
+ }
+
+ @Override
+ public Resource getResource(String name, ResourceType type) {
+ if (type == ResourceType.MODEL_ROUTER) {
+ return router;
+ }
+ if (type == ResourceType.CHAT_MODEL) {
+ if (unresolvable.contains(name)) {
+ throw new IllegalArgumentException("resource not found: "
+ name);
+ }
+ resolvedChatModels.add(name);
+ return models.getOrDefault(name, new FakeChatModel());
+ }
+ throw new IllegalArgumentException("unexpected resource " + name +
" " + type);
+ }
+
+ @Override
+ public void sendEvent(Event event) {
+ sentEvents.add(event);
+ }
+
+ @Override
+ public MemoryObject getSensoryMemory() {
+ return sensoryMemory;
+ }
+
+ @Override
+ public MemoryObject getShortTermMemory() {
+ return null;
+ }
+
+ @Override
+ public BaseLongTermMemory getLongTermMemory() {
+ return null;
+ }
+
+ @Override
+ public FlinkAgentsMetricGroup getAgentMetricGroup() {
+ return null;
+ }
+
+ @Override
+ public FlinkAgentsMetricGroup getActionMetricGroup() {
+ return null;
+ }
+
+ @Override
+ public ReadableConfiguration getConfig() {
+ return config;
+ }
+
+ @Override
+ public Map<String, Object> getActionConfig() {
+ return Map.of();
+ }
+
+ @Override
+ public Object getActionConfigValue(String key) {
+ return null;
+ }
+
+ @Override
+ public <T> T durableExecute(DurableCallable<T> callable) throws
Exception {
+ durableCallIds.add(callable.getId());
+ return callable.call();
+ }
+
+ @Override
+ public <T> T durableExecuteAsync(DurableCallable<T> callable) throws
Exception {
+ durableCallIds.add(callable.getId());
+ return callable.call();
+ }
+
+ @Override
+ public void close() {}
+
+ ModelRoutingEvent routingEvent() {
+ return sentEvents.stream()
+ .filter(e ->
ModelRoutingEvent.EVENT_TYPE.equals(e.getType()))
+ .map(ModelRoutingEvent::fromEvent)
+ .findFirst()
+ .orElse(null);
+ }
+
+ long routingEventCount() {
+ return sentEvents.stream()
+ .filter(e ->
ModelRoutingEvent.EVENT_TYPE.equals(e.getType()))
+ .count();
+ }
+
+ ToolRequestEvent toolRequestEvent() {
+ return sentEvents.stream()
+ .filter(e ->
ToolRequestEvent.EVENT_TYPE.equals(e.getType()))
+ .map(ToolRequestEvent::fromEvent)
+ .findFirst()
+ .orElse(null);
+ }
+
+ ChatResponseEvent chatResponse() {
+ return sentEvents.stream()
+ .filter(e ->
ChatResponseEvent.EVENT_TYPE.equals(e.getType()))
+ .map(ChatResponseEvent::fromEvent)
+ .findFirst()
+ .orElse(null);
+ }
+
+ boolean hasChatResponse() {
+ return chatResponse() != null;
+ }
+ }
+
+ private static ModelRouter router() throws Exception {
+ return new ModelRouter(
+ ModelRouter.of("small", "big")
+ .strategy(Strategies.rules(Map.of("big",
"\\b(code|sql)\\b")))
+ .defaultModel("small")
+ .build(),
+ null);
+ }
+
+ @Test
+ void routesMatchingRequestToBigAndRunsNormalChat() throws Exception {
+ FakeRunnerContext ctx = new FakeRunnerContext(router());
+ ChatModelAction.processChatRequestOrToolResponse(
+ new ChatRequestEvent(
+ "router", List.of(new ChatMessage(MessageRole.USER,
"write some sql"))),
+ ctx);
+
+ ModelRoutingEvent event = ctx.routingEvent();
+ assertThat(event).isNotNull();
+ assertThat(event.getRouter()).isEqualTo("router");
+ assertThat(event.getSelectedModel()).isEqualTo("big");
+
assertThat(event.getDecisionSource()).isEqualTo(ModelRoutingEvent.SOURCE_STRATEGY);
+ assertThat(event.getCandidates()).containsExactly("small", "big");
+ // decision latency is stamped inside the durable route call
+ assertThat(event.getDecisionMs()).isNotNull();
+ assertThat(event.isFallbackEnabled()).isFalse();
+ // the selected concrete model was invoked via the normal chat path
+ assertThat(ctx.resolvedChatModels).containsExactly("big");
+ assertThat(ctx.hasChatResponse()).isTrue();
+ }
+
+ @Test
+ void abstainRoutesToDefaultModel() throws Exception {
+ FakeRunnerContext ctx = new FakeRunnerContext(router());
+ ChatModelAction.processChatRequestOrToolResponse(
+ new ChatRequestEvent(
+ "router", List.of(new ChatMessage(MessageRole.USER,
"hello there"))),
+ ctx);
+
+ ModelRoutingEvent event = ctx.routingEvent();
+ assertThat(event).isNotNull();
+ assertThat(event.getSelectedModel()).isEqualTo("small");
+
assertThat(event.getDecisionSource()).isEqualTo(ModelRoutingEvent.SOURCE_DEFAULT);
+ assertThat(ctx.resolvedChatModels).containsExactly("small");
+ }
+
+ @Test
+ void invalidCandidateFailsClearly() throws Exception {
+ ModelRouter router =
+ new ModelRouter(
+ ModelRouter.of("small", "big")
+
.strategy(Strategies.of(SelectsUnknownStrategy.class))
+ .defaultModel("small")
+ .build(),
+ null);
+ FakeRunnerContext ctx = new FakeRunnerContext(router);
+ assertThatThrownBy(
+ () ->
+
ChatModelAction.processChatRequestOrToolResponse(
+ new ChatRequestEvent(
+ "router",
+ List.of(new
ChatMessage(MessageRole.USER, "hi"))),
+ ctx))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("non-candidate");
+ }
+
+ @Test
+ void abstainWithoutDefaultUsesFirstCandidate() throws Exception {
+ // No default model configured; on abstain the router falls back to
the first candidate.
+ ModelRouter router =
+ new ModelRouter(
+ ModelRouter.of("small", "big")
+ .strategy(Strategies.rules(Map.of("big",
"\\bsql\\b")))
+ .build(),
+ null);
+ FakeRunnerContext ctx = new FakeRunnerContext(router);
+ ChatModelAction.processChatRequestOrToolResponse(
+ new ChatRequestEvent(
+ "router", List.of(new ChatMessage(MessageRole.USER,
"hello there"))),
+ ctx);
+
+ assertThat(ctx.routingEvent()).isNotNull();
+ assertThat(ctx.routingEvent().getSelectedModel()).isEqualTo("small");
+ assertThat(ctx.resolvedChatModels).containsExactly("small");
+ }
+
+ @Test
+ void nonRouterModelPassesThroughUnchanged() throws Exception {
+ FakeRunnerContext ctx = new FakeRunnerContext(null);
+ ChatModelAction.processChatRequestOrToolResponse(
+ new ChatRequestEvent(
+ "plainModel", List.of(new
ChatMessage(MessageRole.USER, "write some sql"))),
+ ctx);
+
+ assertThat(ctx.routingEvent()).isNull();
+ assertThat(ctx.resolvedChatModels).containsExactly("plainModel");
+ assertThat(ctx.hasChatResponse()).isTrue();
+ }
+
+ @Test
+ void directModelKeepsLegacyDurableCallId() throws Exception {
+ FakeRunnerContext ctx = new FakeRunnerContext(null);
+ ChatModelAction.processChatRequestOrToolResponse(
+ new ChatRequestEvent("plain",
List.of(ChatMessage.user("hi"))), ctx);
+ // a non-router request must keep the unchanged legacy durable
chat-call id
+ assertThat(ctx.durableCallIds).containsExactly("chat");
Review Comment:
This is the only place `durableCallIds` gets asserted, and it pins the
legacy `"chat"` id. The routed ones, `route:<router>` and
`chat:<router>:<candidate>`, go through the same list (`:207`, `:213`) but
nothing checks them — including
`fallsBackToRemainingCandidateWhenSelectedModelFails` (`:367`), where the two
candidates' journal entries need to stay distinct. Since `6ef088fae` had to
change that format once already, could the routed ids get an assertion here too?
##########
plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionRoutingTest.java:
##########
@@ -0,0 +1,725 @@
+/*
+ * 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.plan.actions;
+
+import org.apache.flink.agents.api.Event;
+import org.apache.flink.agents.api.agents.Agent;
+import org.apache.flink.agents.api.agents.AgentExecutionOptions;
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.chat.model.BaseChatModelSetup;
+import org.apache.flink.agents.api.chat.model.routing.ModelRouter;
+import org.apache.flink.agents.api.chat.model.routing.RoutingContext;
+import org.apache.flink.agents.api.chat.model.routing.RoutingDecision;
+import org.apache.flink.agents.api.chat.model.routing.RoutingStrategy;
+import org.apache.flink.agents.api.chat.model.routing.Strategies;
+import org.apache.flink.agents.api.configuration.ReadableConfiguration;
+import org.apache.flink.agents.api.context.DurableCallable;
+import org.apache.flink.agents.api.context.MemoryObject;
+import org.apache.flink.agents.api.context.MemoryRef;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.event.ChatRequestEvent;
+import org.apache.flink.agents.api.event.ChatResponseEvent;
+import org.apache.flink.agents.api.event.ModelRoutingEvent;
+import org.apache.flink.agents.api.event.ToolRequestEvent;
+import org.apache.flink.agents.api.event.ToolResponseEvent;
+import org.apache.flink.agents.api.memory.BaseLongTermMemory;
+import org.apache.flink.agents.api.metrics.FlinkAgentsMetricGroup;
+import org.apache.flink.agents.api.resource.Resource;
+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.junit.jupiter.api.Test;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Integration tests for model routing inside {@link ChatModelAction}. */
+public class ChatModelActionRoutingTest {
+
+ /** A strategy that returns a name that is not a candidate (to exercise
the invalid path). */
+ public static class SelectsUnknownStrategy implements RoutingStrategy {
+ public SelectsUnknownStrategy() {}
+
+ @Override
+ public RoutingDecision route(RoutingContext context) {
+ return RoutingDecision.of("nonexistent");
+ }
+ }
+
+ /**
+ * A chat model returning scripted outcomes per call: a {@link
ChatMessage} is returned, a
+ * {@link RuntimeException} is thrown. When the script is exhausted,
returns a default assistant
+ * reply.
+ */
+ static class FakeChatModel extends BaseChatModelSetup {
+ private final Deque<Object> outcomes = new ArrayDeque<>();
+
+ FakeChatModel(Object... outcomes) {
+ super(new ResourceDescriptor("fake", Map.of()), null);
+ Collections.addAll(this.outcomes, outcomes);
+ }
+
+ @Override
+ public Map<String, Object> getParameters() {
+ return Map.of();
+ }
+
+ @Override
+ public ChatMessage chat(
+ List<ChatMessage> messages,
+ Map<String, Object> promptArgs,
+ Map<String, Object> modelParams) {
+ Object next = outcomes.isEmpty() ? null : outcomes.poll();
+ if (next instanceof RuntimeException) {
+ throw (RuntimeException) next;
+ }
+ if (next instanceof ChatMessage) {
+ return (ChatMessage) next;
+ }
+ return new ChatMessage(MessageRole.ASSISTANT, "answer");
+ }
+ }
+
+ static class FakeRunnerContext implements RunnerContext {
+ final List<Event> sentEvents = new ArrayList<>();
+ final List<String> resolvedChatModels = new ArrayList<>();
+ final List<String> durableCallIds = new ArrayList<>();
+ final Map<String, BaseChatModelSetup> models = new HashMap<>();
+ final Set<String> unresolvable = new HashSet<>();
+ private final ModelRouter router;
+ private final MemoryObject sensoryMemory = new FakeMemoryObject(new
HashMap<>());
+ private final AgentConfiguration config = new
AgentConfiguration(Map.of());
+
+ FakeRunnerContext(ModelRouter router) {
+ this.router = router;
+ }
+
+ FakeRunnerContext register(String name, BaseChatModelSetup model) {
+ models.put(name, model);
+ return this;
+ }
+
+ /** Marks a chat-model name whose resource lookup fails (e.g. a typo'd
candidate). */
+ FakeRunnerContext unresolvable(String name) {
+ unresolvable.add(name);
+ return this;
+ }
+
+ FakeRunnerContext withErrorHandling(Agent.ErrorHandlingStrategy
strategy) {
Review Comment:
`withErrorHandling` only ever gets `IGNORE` (`:511`, `:634`), and
`MAX_RETRIES` isn't in this file at all, so every routing test runs with
`numRetries == 0`. Nothing here would notice if retry and fallback swapped
order, even though the class javadoc calls that ordering a guarantee — selected
model with its full retry budget first, then the rest
(`ChatModelAction.java:73-75`). `FakeChatModel` already takes a scripted
sequence of outcomes. Is one `RETRY` case with two failures scripted enough to
pin it?
--
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]