sangkyoonnam commented on code in PR #1330:
URL: https://github.com/apache/flink-agents/pull/1330#discussion_r4451254527
##########
e2e-test/cross-language-agent-plan-snapshots/java/agent_plan_with_decision_router.json:
##########
@@ -0,0 +1,88 @@
+{
+ "agent_name" : null,
+ "actions" : { },
+ "resource_providers" : {
+ "model_router" : {
+ "router" : {
+ "name" : "router",
+ "type" : "model_router",
+ "descriptor" : {
+ "language" : "java",
+ "target_module" : "",
+ "target_clazz" :
"org.apache.flink.agents.api.chat.model.routing.ModelRouter",
+ "arguments" : {
+ "candidates" : [ "small", "big" ],
+ "candidate_descriptions" : {
+ "small" : "short factual questions",
+ "big" : "code and sql"
+ },
+ "strategy_type" : "decision_model",
+ "default_model" : "small",
+ "fallback" : true,
+ "strategy_args" : {
+ "decision_model" : "dm",
+ "min_probability" : 0.6
Review Comment:
`strategy_args` here has `decision_model` and `min_probability` only.
`RoutingStrategy` also reads `instructions` and `max_context_chars`
(`ARG_INSTRUCTIONS`, `ARG_MAX_CONTEXT_CHARS`). A second router in the snapshot
that sets both would give Python their key names and JSON types to compare.
##########
plan/src/main/java/org/apache/flink/agents/plan/routing/DecisionModelRoutingExecutor.java:
##########
@@ -0,0 +1,434 @@
+/*
+ * 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 org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.DataContentBlock;
+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.model.routing.RoutingCandidate;
+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.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.event.ModelRoutingEvent;
+import org.apache.flink.agents.api.resource.ResourceType;
+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.utils.CancellationUtils;
+
+import javax.annotation.Nullable;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Executes the framework-managed decision-model strategy ({@code
Strategies.decision(...)}): the
+ * engine — not the strategy — asks a registered {@code DECISION_MODEL}
resource one choice question
+ * whose options are the router's candidates and whose criteria are their
{@code describe(...)}
+ * descriptions, through a flat durable call with the id {@code
"decide:<router>"} (issued
+ * <i>before</i> the resolver's decision record — see the sequencing contract
on {@link
+ * RoutingExecutor}), then derives the decision from the answer as a pure
function.
+ *
+ * <p>The answer is constrained by construction: the provider must pick one of
the requested
+ * choices, so the decision model cannot steer routing outside the declared
candidates. The choice
+ * becomes the selected model and its reported probability the routing score.
A refusal, a
+ * non-choice answer, a result without an answer for the question, or a
probability below the
+ * declared {@code min_probability} abstains to the router's default model
with the cause recorded.
+ * The decision model sees the conversation as text only (tool calls and
results included, media and
+ * reasoning excluded), rendered like the LLM judge's input and subject to the
same optional context
+ * cap.
+ *
+ * <p>Failure policy: a decision call that exhausts its retries fails the
request, like a throwing
+ * rule/custom strategy or a failed judge call. Cancellation propagates and is
never persisted as a
+ * routing outcome. The call shares {@link DecisionModelInvoker} with the
built-in decision action
+ * but runs under its own durable id, so it does not collide with the action's
flat {@code "decide"}
+ * record; retries are accumulated on the routed request like the judge's.
+ */
+final class DecisionModelRoutingExecutor implements RoutingExecutor {
+
+ /** The name of the single choice question in the decision request and its
answer. */
+ static final String QUESTION_NAME = "model";
+
+ /** The built-in choice instructions, used unless the declaration carries
its own. */
+ static final String DEFAULT_INSTRUCTIONS =
+ "Choose which ONE candidate model should answer the conversation
below. Each option"
+ + " describes the requests its model should handle; pick
the option whose"
+ + " description fits the conversation best.";
+
+ /** Decision-metadata key: the decision-model setup that produced the
choice. */
+ static final String DECISION_MODEL_KEY = "decision_model";
+
+ /** Decision-metadata key: the provider's probability per candidate, as
reported. */
+ static final String PROBABILITIES_KEY = "decision_probabilities";
+
+ /** Decision-metadata key: the provider's separate confidence measure,
when it reports one. */
+ static final String PROVIDER_CONFIDENCE_KEY = "provider_confidence";
+
+ /** Decision-metadata key: the chosen candidate when the choice abstained
to the default. */
+ static final String CHOICE_KEY = "decision_choice";
+
+ /** Decision-metadata flag set when the context cap dropped part of the
conversation. */
+ static final String CONTEXT_TRUNCATED_KEY = "decision_context_truncated";
+
+ /** Decision-metadata key: media blocks left out of the text-only decision
input. */
+ static final String MEDIA_DROPPED_KEY = "decision_media_dropped";
+
+ /** Decision-metadata keys: the decision call's token usage, when the
provider reports both. */
+ static final String INPUT_TOKENS_KEY = "decision_input_tokens";
+
+ static final String OUTPUT_TOKENS_KEY = "decision_output_tokens";
+
+ @Override
+ public boolean usesDurableExecutionInternally() {
+ return true;
+ }
+
+ @Override
+ public String decisionSource() {
+ return ModelRoutingEvent.SOURCE_DECISION_MODEL;
+ }
+
+ @Override
+ public RoutingDecision route(
+ RoutingStrategy strategy, RoutingContext context, RunnerContext
ctx) throws Exception {
+ String decisionModel = decisionModel(strategy);
+ Map<String, Object> metadata = new LinkedHashMap<>();
+ metadata.put(DECISION_MODEL_KEY, decisionModel);
+ DecisionRequest request;
+ try {
+ request = buildRequest(strategy, context, ctx, metadata);
+ } catch (Exception e) {
+ throw routingFailure(context, "could not build the decision
request", e);
+ }
+ if (String.valueOf(request.getInput()).isEmpty()) {
+ // Nothing to decide on (a media-only or empty request with no
prompt arguments):
+ // abstain to the default model instead of paying for a call the
provider rejects.
+ // Resolved before the setup, so an unreachable provider cannot
fail such a request.
+ return abstain("request carries no text for the decision model",
metadata);
+ }
+ BaseDecisionModelSetup setup;
+ try {
+ setup =
+ (BaseDecisionModelSetup)
+ ctx.getResource(decisionModel,
ResourceType.DECISION_MODEL);
+ } catch (Exception e) {
+ CancellationUtils.rethrowIfCancellation(e);
+ // An unresolvable decision model is an ordinary routing failure
(failed response),
+ // not a runner failure: the request cannot be routed, the job
keeps running.
+ throw new ModelRoutingResolver.RoutingFailure(
+ "Decision model '"
+ + decisionModel
+ + "' could not be resolved: "
+ + ChatModelInvoker.errorText(e));
+ }
+ DecisionModelInvoker.DecisionAttemptResult result =
+ DecisionModelInvoker.decideWithRetries(
+ ctx,
+ decisionModel,
+ setup,
+ request,
+ Map.of(),
+ "decide:" + context.getRouter(),
+ "for router '" + context.getRouter() + "'",
+ false);
+ // Retries count against the routed request, like the judge's:
response-level totals and
+ // the model_resource counters both come from this one call.
+ ChatModelAction.recordAttemptRetryStats(
+ ctx, context.getRequestId(), decisionModel, result.retryCount,
result.totalWaitSec);
+ if (!result.isSuccess()) {
+ throw new ModelRoutingResolver.RoutingFailure(
+ "Decision model '" + decisionModel + "' failed: " +
result.error);
+ }
+ try {
+ return toDecision(strategy, context, result.response, metadata);
+ } catch (Exception e) {
+ throw routingFailure(context, "could not read the decision
answer", e);
+ }
+ }
+
+ /**
+ * An ordinary routing failure (failed chat response, no candidate
fallback) for a request that
+ * could not be prepared or whose answer could not be read. Runtime
failures of the durable
+ * substrate never pass through here: they surface from the invoker and
propagate to the runner,
+ * as the {@link RoutingExecutor} contract requires. Cancellation is
rethrown as is.
+ */
+ private static ModelRoutingResolver.RoutingFailure routingFailure(
+ RoutingContext context, String what, Exception cause) throws
Exception {
+ CancellationUtils.rethrowIfCancellation(cause);
+ return new ModelRoutingResolver.RoutingFailure(
+ "Decision routing for router '"
+ + context.getRouter()
+ + "' "
+ + what
+ + ": "
+ + ChatModelInvoker.errorText(cause));
+ }
+
+ /**
+ * Builds the decision request: the conversation rendered as text (the
judge's view, so the
+ * decision model routes on what the selected model will actually receive,
including a bound
+ * prompt rendered by the anchor candidate) plus one choice question over
the candidates.
+ * Truncation and dropped media are recorded in {@code metadata}.
+ */
+ static DecisionRequest buildRequest(
+ RoutingStrategy strategy,
+ RoutingContext context,
+ RunnerContext ctx,
+ Map<String, Object> metadata)
+ throws Exception {
+ List<ChatMessage> effective = RoutingInput.effectiveMessages(context,
ctx);
+ Set<Integer> pinned =
RoutingInput.pinnedRenderedIndices(context.getMessages(), effective);
+ boolean[] truncated = new boolean[1];
+ String input = renderInput(strategy, context, effective, pinned,
truncated, metadata);
+ if (truncated[0]) {
+ metadata.put(CONTEXT_TRUNCATED_KEY, true);
+ }
+ return new DecisionRequest(
+ input,
+ Map.of(
+ QUESTION_NAME,
+ DecisionRequest.Question.choice(
+ criteria(context), instructions(strategy))));
+ }
+
+ /**
+ * The choice criteria: candidate name to description. Descriptions are
required by the router
+ * declaration ({@code
RoutingCandidateValidator#requireDescriptionsForDecisionStrategy}), so
+ * every option carries one.
+ */
+ static Map<String, Object> criteria(RoutingContext context) {
+ Map<String, Object> criteria = new LinkedHashMap<>();
+ for (RoutingCandidate candidate : context.getCandidates()) {
+ criteria.put(candidate.getName(), candidate.getDescription());
+ }
+ return criteria;
+ }
+
+ /**
+ * Renders the (optionally capped) conversation to text for the decision
input, one line per
+ * message prefixed with its role, with tool interactions described as
data. Media blocks are
+ * counted and left out: decision models take text and JSON evidence only.
When the request
+ * itself contributes no text (no USER or rendered-template text: a
media-only, empty or
+ * SYSTEM-only conversation), the prompt arguments are the only signal and
are appended; with
+ * none, the input is empty and the strategy abstains.
+ */
+ static String renderInput(
+ RoutingStrategy strategy,
+ RoutingContext context,
+ List<ChatMessage> effectiveMessages,
+ Set<Integer> pinnedIndices,
+ boolean[] truncatedOut,
+ Map<String, Object> metadata) {
+ List<ChatMessage> selected =
+ RoutingInput.selectWithinBudget(
+ effectiveMessages,
+ RoutingInput.maxContextChars(strategy),
+ pinnedIndices,
+ truncatedOut);
+ // Whether the request carries text is judged on the complete
effective list, as the judge
+ // does, so a context cap that drops the USER message does not by
itself trigger the
+ // prompt-argument fallback. Messages generated by the anchor's
request shaping (the
+ // rendered template) carry the request like USER text does.
+ boolean requestHasText = false;
+ for (int i = 0; i < effectiveMessages.size(); i++) {
+ ChatMessage message = effectiveMessages.get(i);
+ if (message.getRole() != MessageRole.USER &&
!pinnedIndices.contains(i)) {
+ continue;
+ }
+ for (DataContentBlock block : RoutingInput.content(message)) {
+ if (block instanceof TextBlock && !((TextBlock)
block).getText().isEmpty()) {
+ requestHasText = true;
+ }
+ }
+ }
+ StringBuilder text = new StringBuilder();
+ int mediaDropped = 0;
+ for (ChatMessage message : selected) {
+ List<DataContentBlock> content = RoutingInput.content(message);
+ if (content.isEmpty()) {
+ continue;
+ }
+ appendLine(text, message.getRole().name() + ": ");
+ boolean firstBlock = true;
+ for (DataContentBlock block : content) {
+ if (block instanceof TextBlock) {
+ // Blocks of one message are separate pieces of text; keep
them apart so a
+ // message split around media does not read as one run-on
word.
+ String blockText = ((TextBlock) block).getText();
+ if (firstBlock) {
+ text.append(blockText);
+ } else {
+ appendLine(text, blockText);
+ }
+ firstBlock = false;
+ } else {
+ mediaDropped++;
+ }
+ }
+ }
+ if (mediaDropped > 0) {
+ metadata.put(MEDIA_DROPPED_KEY, mediaDropped);
+ }
+ if (!requestHasText && context.getPromptArgs().isEmpty()) {
+ // No request text and no arguments: there is nothing to decide
on, and role framing
+ // alone is not evidence. An empty input makes the executor
abstain.
+ return "";
+ }
+ if (!requestHasText && !context.getPromptArgs().isEmpty()) {
+ text.append(text.length() == 0 ? "" : "\n").append("Request
arguments:");
+ for (Map.Entry<String, Object> arg :
context.getPromptArgs().entrySet()) {
+ text.append('\n').append(arg.getKey()).append(":
").append(arg.getValue());
+ }
+ }
+ return text.toString();
+ }
+
+ /**
+ * Turns the decision result into a routing decision. A pure function of
the answer and the
+ * declaration: the choice selects its candidate with the reported
probability as score;
+ * anything else abstains with the cause in the reason and the provider's
answer in the
+ * metadata.
+ */
+ static RoutingDecision toDecision(
+ RoutingStrategy strategy,
+ RoutingContext context,
+ DecisionResult result,
+ Map<String, Object> metadata) {
+ DecisionResult.TokenUsage usage = result.getUsage();
+ if (usage != null && usage.getInputTokens() != null &&
usage.getOutputTokens() != null) {
+ metadata.put(INPUT_TOKENS_KEY, jsonNumber(usage.getInputTokens()));
+ metadata.put(OUTPUT_TOKENS_KEY,
jsonNumber(usage.getOutputTokens()));
+ }
+ DecisionResult.Answer answer = result.getAnswers().get(QUESTION_NAME);
+ if (answer == null) {
+ return abstain(
+ "decision model returned no answer for question '" +
QUESTION_NAME + "'",
+ metadata);
+ }
+ if (answer instanceof DecisionResult.RefusalAnswer) {
+ String reason = ((DecisionResult.RefusalAnswer)
answer).getReason();
+ return abstain(
+ "decision model refused" + (reason == null ? "" : ": " +
reason), metadata);
+ }
+ if (!(answer instanceof DecisionResult.ChoiceAnswer)) {
+ return abstain("decision answer was not a choice", metadata);
+ }
+ DecisionResult.ChoiceAnswer choice = (DecisionResult.ChoiceAnswer)
answer;
+ metadata.put(PROBABILITIES_KEY, new
LinkedHashMap<>(choice.getProbabilities()));
+ if (choice.getProviderConfidence() != null) {
+ metadata.put(PROVIDER_CONFIDENCE_KEY,
choice.getProviderConfidence());
+ }
+ String selected = choice.getChoice();
+ if (!isCandidate(context, selected)) {
+ metadata.put(CHOICE_KEY, selected);
+ return abstain("decision choice '" + selected + "' is not a
candidate", metadata);
+ }
+ Double score = choice.getProbabilities().get(selected);
+ Double minProbability = minProbability(strategy);
Review Comment:
With `min_probability` set, a choice that's missing from `probabilities`
abstains (`missingProbabilityForTheChoiceAbstainsWhenAMinimumIsSet`). The
behavior table and `model_routing.md` don't list that case. Please add a row
for it, including the expected result without `min_probability`. Python's
`ChoiceAnswer` schema allows this shape too: `choice` is a `StrictStr` and
`probabilities` only has `min_length=1`.
--
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]