wenjin272 commented on code in PR #1363:
URL: https://github.com/apache/flink-agents/pull/1363#discussion_r4522188169
##########
plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java:
##########
@@ -357,8 +368,24 @@ private static void chat(
int retryWaitIntervalSec =
ChatModelInvoker.configuredRetryWaitSec(ctx);
List<String> triedModels = new ArrayList<>();
+ // Candidates whose answer a quality signal rejected this round:
fallback skips them, since
+ // they already answered the request.
+ Set<String> rejected = new HashSet<>();
+ List<Map<String, Object>> hops = new ArrayList<>();
+ long escalatedTokens = 0L;
+ int escalations = selection.getEscalationsSoFar();
+ Deque<String> pending = new ArrayDeque<>(selection.attemptOrder());
Review Comment:
Could we keep the routing-specific orchestration out of `ChatModelAction`?
This change adds candidate queues, rejected-model tracking, escalation limits,
fallback transitions, and routing metadata handling to an already shared chat
execution path.
My main concern is the growing complexity of `ChatModelAction`. It should
remain focused on the common chat and tool-call lifecycle. The routing layer
should encapsulate model selection, fallback, escalation, and the routing state
carried across tool rounds, returning an accepted response for the action to
process.
Some integration changes in `ChatModelAction` are expected, but adding
routing capabilities should not require it to understand and maintain the
individual routing policies.
##########
api/src/main/java/org/apache/flink/agents/api/chat/model/routing/ModelRouter.java:
##########
@@ -81,18 +84,31 @@ public ModelRouter(ResourceDescriptor descriptor,
ResourceContext resourceContex
// with the candidate message instead of failing the cast here.
Object defaultModel = descriptor.getArgument(DEFAULT_MODEL_KEY);
RoutingCandidateValidator.validate("ModelRouter", names, defaultModel);
+ this.escalation =
+ Escalation.fromArguments("ModelRouter",
descriptor.getInitialArguments())
+ .orElse(null);
+ if (this.escalation != null) {
+ this.escalation.requireTiersAreCandidates("ModelRouter", names);
Review Comment:
Could we separate the user-facing router declaration from the framework
resource implementation?
`ModelRouter` currently serves both as the fluent builder entry point and as
the `Resource` instantiated from a `ResourceDescriptor`. I suggest keeping
`ModelRouter.of(...).build()` and the declaration types in `api`, and moving
the resource implementation into `plan`. Both classes can retain the name
`ModelRouter` in their respective packages, preserving the existing builder
API. The plan-side constructor would continue to accept `ResourceDescriptor`
and `ResourceContext`, following the existing resource convention, with
descriptor-to-resource resolution updated accordingly.
Along with this split, could we consolidate descriptor validation in
`AgentPlan`? Currently, both `AgentPlan` and the `ModelRouter` constructor call
`Escalation.fromArguments(...)` and `requireTiersAreCandidates(...)`. Once the
descriptor has been validated during plan construction, the resource
constructor should only decode the configuration and initialize its fields.
This would require separating parsing from validation in the current helpers
and ensuring every supported construction path passes through plan validation.
It should not require introducing a separate configuration object in place of
`ResourceDescriptor`.
##########
plan/src/main/java/org/apache/flink/agents/plan/routing/EscalationGate.java:
##########
@@ -0,0 +1,582 @@
+/*
+ * 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.routing;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.networknt.schema.Schema;
+import com.networknt.schema.SchemaRegistry;
+import com.networknt.schema.SpecificationVersion;
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.ChatResult;
+import org.apache.flink.agents.api.chat.messages.ContentBlock;
+import org.apache.flink.agents.api.chat.messages.DataContentBlock;
+import org.apache.flink.agents.api.chat.messages.MediaBlock;
+import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.chat.messages.TextBlock;
+import org.apache.flink.agents.api.chat.messages.ToolCallBlock;
+import org.apache.flink.agents.api.chat.model.BaseChatModelSetup;
+import org.apache.flink.agents.api.chat.model.routing.Escalation;
+import org.apache.flink.agents.api.chat.model.routing.QualityContext;
+import org.apache.flink.agents.api.chat.model.routing.QualitySignal;
+import org.apache.flink.agents.api.chat.model.routing.QualityVerdict;
+import org.apache.flink.agents.api.context.DurableCallable;
+import org.apache.flink.agents.api.context.RunnerContext;
+import org.apache.flink.agents.api.decision.BaseDecisionModelSetup;
+import org.apache.flink.agents.api.decision.DecisionRequest;
+import org.apache.flink.agents.api.decision.DecisionResult;
+import org.apache.flink.agents.api.resource.ResourceType;
+import org.apache.flink.agents.api.tools.Tool;
+import org.apache.flink.agents.plan.actions.ChatModelAction;
+import org.apache.flink.agents.plan.actions.ChatModelInvoker;
+import org.apache.flink.agents.plan.actions.DecisionModelInvoker;
+import org.apache.flink.agents.plan.resource.python.PythonChatModelSetup;
+import org.apache.flink.agents.plan.utils.CancellationUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Runs an {@link Escalation} policy after one model round: the structural
checks first, then the
+ * declared signals in order. The first escalating verdict names the next
tier; the caller discards
+ * the round's answer before any of its tool calls is dispatched and re-sends
the conversation to
+ * that tier.
+ *
+ * <p>Structural checks are pure functions of the response and recomputed on
replay. A
+ * decision-model signal runs through {@link DecisionModelInvoker} under {@code
+ * escalate:<router>:<candidate>}; a custom signal's outcome (verdict or
failure) is persisted under
+ * {@code escalate:<router>:<candidate>:<n>}. Both are reused on replay, so a
recovered request
+ * reaches the same tier without re-judging.
+ */
+public final class EscalationGate {
+
+ private static final Logger LOG =
LoggerFactory.getLogger(EscalationGate.class);
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ /** Serializes tool arguments with object keys sorted, so equal arguments
render alike. */
+ private static final ObjectMapper CANONICAL =
+ new
ObjectMapper().enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS);
+
+ private static final SchemaRegistry SCHEMAS =
+
SchemaRegistry.withDefaultDialect(SpecificationVersion.DRAFT_2020_12);
+
+ /** Signal name recorded for the built-in checks. */
+ public static final String STRUCTURAL = "structural";
+
+ /** Question key used for the decision-model signal. */
+ static final String QUESTION_KEY = "quality";
+
+ static final String DEFAULT_INSTRUCTIONS =
+ "Does the ANSWER fully and correctly satisfy the REQUEST, with no
missing steps,"
+ + " refusals, or placeholders? Judge the answer, not the
question.";
+
+ private EscalationGate() {}
+
+ /** The decision to escalate: where to, which signal said so, and why. */
+ public static final class Outcome {
+ public final String fromTier;
+ public final String toTier;
+ public final String signal;
+ public final String reason;
+ @Nullable public final Double score;
+
+ Outcome(
+ String fromTier,
+ String toTier,
+ String signal,
+ String reason,
+ @Nullable Double score) {
+ this.fromTier = fromTier;
+ this.toTier = toTier;
+ this.signal = signal;
+ this.reason = reason;
+ this.score = score;
+ }
+ }
+
+ /**
+ * Decide whether the round produced by {@code candidate} should escalate.
+ *
+ * @return the escalation to perform, or empty to accept the round
+ * @throws Exception a failing signal (resource lookup, provider error
after retries, a custom
+ * signal throwing) fails the request; cancellation propagates
+ */
+ public static Optional<Outcome> check(
+ Escalation policy,
+ UUID requestId,
+ String router,
+ String candidate,
+ int escalationsSoFar,
+ List<ChatMessage> messages,
+ Map<String, Object> promptArgs,
+ ChatModelInvoker.ChatAttemptResult result,
+ RunnerContext ctx)
+ throws Exception {
+ int tierIndex = policy.tierIndex(candidate);
+ if (tierIndex < 0) {
+ // A fallback candidate outside the tiers, or a model the policy
does not know.
+ return Optional.empty();
+ }
+ Optional<String> next = policy.nextTier(candidate);
+ if (next.isEmpty() || escalationsSoFar >= policy.getMaxEscalations()) {
+ // The last tier always accepts on quality; so does a request out
of hops.
+ return Optional.empty();
+ }
+ ChatResult response = Objects.requireNonNull(result.response,
"response");
+ // A Python-declared candidate binds its tools on the Python side; the
Java setup never
+ // opens them, so the tool checks cannot be applied to it.
+ boolean toolsKnown =
+ result.chatModel != null && !(result.chatModel instanceof
PythonChatModelSetup);
+ List<Tool> tools = toolsKnown ? safeTools(result.chatModel) :
List.of();
+ QualityContext context =
+ new QualityContext(
+ requestId,
+ router,
+ candidate,
+ tierIndex,
+ policy.getTiers(),
+ effectiveMessages(result.chatModel, messages,
promptArgs),
+ promptArgs,
+ response,
+ tools);
+
+ QualityVerdict structural = structural(context, toolsKnown);
+ if (structural.isEscalate()) {
+ return Optional.of(outcome(candidate, next.get(), STRUCTURAL,
structural));
+ }
+ int index = 0;
+ for (Escalation.Signal signal : policy.getSignals()) {
+ QualityVerdict verdict;
+ String name;
+ if
(Escalation.Signal.TYPE_DECISION_MODEL.equals(signal.getType())) {
+ name = Escalation.Signal.TYPE_DECISION_MODEL + ":" +
signal.decisionModel();
+ verdict = decisionSignal(signal, context, router, candidate,
ctx);
Review Comment:
Could we skip the decision-model quality signal for tool-only responses by
default, as discussed in #1328?
Currently, a valid tool call passes the structural checks and then reaches
`decisionSignal(...)` before the tool is executed. The default verifier
question asks whether the answer fully satisfies the request, but a tool-only
response is an intermediate step. A low score therefore discards a legitimate
tool call and escalates the request before the model has had a chance to use
its result.
The structural checks should still run, and custom signals can define their
own behavior. The default decision-model signal should evaluate the answer
produced after the tools return.
Please also add a regression test through `ChatModelAction`: a valid
tool-only response should dispatch the tool without invoking the verifier,
while the subsequent answer should invoke 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]