Skip to content

[api][plan][java] Add a decision-model routing strategy for MODEL_ROUTER - #1330

Merged
wenjin272 merged 5 commits into
apache:mainfrom
purushah:decision-model-routing
Nov 5, 2026
Merged

wenjin272 merged 5 commits into
apache:mainfrom
purushah:decision-model-routing

Conversation

@purushah

@purushah purushah commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Linked issue: #1325

Purpose of change

A MODEL_ROUTER can now route with a registered decision model. Strategies.decision("dm") asks the DECISION_MODEL resource one choice question (options: the candidates; criteria: their describe(...) texts) and routes on the returned choice. The choice's probability becomes the routing score; withMinProbability(p) abstains to the default model below p. Rules, the LLM judge and custom executors are unchanged.

The LLM judge returns a candidate name and nothing else, so a router cannot tell a confident selection from a coin flip or decline a weak one. Decision models return typed answers with probabilities, so the strategy reuses them instead of parsing a verdict out of free text.

Runtime flow

  1. ModelRoutingResolver dispatches DECISION_MODEL to DecisionModelRoutingExecutor, which declares internal durable calls and so runs before the route:<router> record, like the judge.
  2. The executor resolves the setup, renders the conversation to text with the judge's rendering (anchor candidate's bound prompt, roles, tool calls and results, optional max_context_chars cap, prompt-argument fallback; media dropped and counted) and builds one ChoiceQuestion named model.
  3. DecisionModelInvoker.decideWithRetries (new, shared with the built-in decision action) runs setup.decide inside durableExecute under decide:<router> with the configured retry budget, execution reports and model_resource token metrics; retries are accumulated on the routed request like the judge's.
  4. The answer maps to a RoutingDecision (table below) with metadata decision_model, decision_probabilities, optional provider_confidence, token counts, decision_context_truncated, decision_media_dropped, and decision_choice on abstain. The resolver persists, normalizes and emits ModelRoutingEvent (source decision_model); the chat path is unchanged.

Key decisions

  • The score is the provider's probability for its choice, not the maximum of the map; the framework does not normalize distributions.
  • provider_confidence is recorded but not thresholded; not every provider reports it.
  • Every candidate must carry a description, and every description must be a String, each enforced by one RoutingCandidateValidator rule shared by the constructor, build() and plan validation; plan validation also requires an explicitly declared setup connection to be registered.
  • The call bypasses the built-in decision action (durable calls cannot nest and its flat decide id would collide) but shares its retry loop through DecisionModelInvoker, which the action now uses too.

Behavioral Semantics

Interaction decisions

Answer min_probability Result
choice, probability ≥ min or no min any route to choice, score = probability, source decision_model
choice, probability < min set abstain to default; reason names probability and threshold; decision_choice recorded
choice missing from probabilities set abstain to default; reason names the choice and threshold; decision_choice recorded
choice missing from probabilities not set route to choice with no score, source decision_model
choice naming a non-candidate any abstain to default (defensive)
refusal any abstain to default, reason decision model refused[: reason]
predicate or score answer any abstain to default, reason decision answer was not a choice
no answer for question model any abstain to default, reason decision model returned no answer for question 'model'
request carries no text and no prompt arguments any abstain to default without calling the provider
provider error after retries any request fails (Decision model '<name>' failed: …); no default, no fallback

Behavioral contracts

  1. A choice routes to its candidate with the reported probability as score.
  2. A choice below withMinProbability abstains to the default; choice and probability map stay in metadata.
  3. A refusal, a non-choice answer or a missing answer abstains to the default; so does a request with no text and no prompt arguments, without a provider call.
  4. The call is durable under decide:<router>: on replay the stored outcomes (including a failed attempt) are reused positionally, the provider not called; a restore between the decision call and the route:<router> record routes on the stored choice.
  5. Provider failures use the retry budget; exhausting it fails the request, no candidate fallback. Cancellation or interruption raised while the anchor renders the input propagates before the provider is called; it is not treated as an unresolvable anchor (the shared judge helper had this gap; fixed here for both strategies).
  6. Fewer than two candidates, an undescribed or blank-described candidate, an unregistered decision model, a declared connection that is blank or unregistered, a declared blank model, or min_probability outside [0, 1]: rejected at declaration or plan construction.
  7. Tool rounds, candidate fallback and the other strategies behave as before.

Failure behavior

Unresolvable decision model, a request that cannot be rendered, or an answer that cannot be read: RoutingFailure (failed ChatResponseEvent). Durable-state or recovery failures during the decision call propagate to the runner. Provider exception or null result: retried per MAX_RETRIES / RETRY_WAIT_INTERVAL, then RoutingFailure; cancellation propagates, never persisted. Persistence or recovery errors propagate to the runner, as for the judge.

Tests

Contract Tests (E = DecisionModelRoutingExecutorTest, C = ChatModelActionRoutingTest)
1 E choiceSelects…AsScore, scoreIsTheChosenCandidateProbabilityNotTheMaximum; C decisionModelChoiceRoutesToNamedCandidate
2 E probabilityBelowMinimumAbstains…, probabilityAtTheMinimumIsAccepted, missingProbabilityForTheChoiceAbstainsWhenAMinimumIsSet, missingProbabilityForTheChoiceRoutesWithoutAScoreWhenNoMinimumIsSet; C decisionBelowMinProbabilityAbstainsToDefault
3 E refusalAbstains…, nonChoiceAnswerAbstains, missingAnswerForTheQuestionAbstains, nonCandidateChoiceAbstains, emptyRequestAbstainsWithoutCallingTheProvider; C decisionRefusalAbstainsToDefault
4 C decisionReplayReusesTheStoredAnswerWithoutCallingTheProvider, asyncDecisionCallRunsAsFlatSiblingBeforeTheRouteRecord; runtime DecisionRoutingRecoveryTest (real journal serializer, sync and async: restore between the two stored decision attempts and the route record; full replay of a completed routed request)
5 C decisionModelUsesTheRetryBudget…, decisionModelFailureFailsTheRequest…, unresolvableDecisionModelFailsTheRequest, durableStateFailureDuringTheDecisionCallPropagatesToTheRunner, cancellationDuringDecisionInputPreparationPropagatesBeforeTheProviderCall, cancellationDuringJudgeInputPreparationPropagatesBeforeTheJudgeCall; E unrenderableRequestIsARoutingFailure
6 RoutingTest decision tests, AgentPlanDecisionModelValidationTest (10 tests)
7 existing routing suites; AgentPlanCrossLanguageTest decision-router snapshot (two routers: the minimum and every optional argument); Python test_python_can_deserialize_java_plan_with_decision_router checks each key's name and JSON type
1, 2, 5 end to end DecisionModelRoutingE2ETest (e2e integration module): one real Flink job with a scripted Java decision connection and two echo chat connections registered on the agent; confident selection of each candidate, abstention below min_probability to the default model, exhausted retries failing the request without candidate fallback, provider call count including the retry, rendered decision input
mvn -pl api,plan,runtime test   # api 836 pass; runtime 1515 pass; plan 695 run, only AgentPlanDeclareMCPServerTest fails (talks to a port-8000 server on the build host; fails identically on unmodified main)
uv run pytest flink_agents/plan/tests/test_agent_plan_cross_language.py   # 21 passed, 1 skipped
mvn -pl e2e-test/...-integration,e2e-test/...-resource-cross-language test   # 71 pass; cross-language: 4 Python-bridge tests fail with 'No module named flink_agents' / 'ollama' from the system python3, unrelated to routing; DecisionModelCrossLanguageTest passes

End to end on a standalone Flink 2.3.0 cluster with the new examples/openai/OpenAiDecisionRoutingExample (gpt-4o-mini and gpt-4o candidates, the built-in Python OpenAI decision model gpt-6-luna through the cross-language bridge, python.executable pointing at a Python with flink-agents and openai installed): job finished in 7.7 s; output final_model=small … score=0.99 for the greeting and final_model=big … score=0.87 for the SQL request; no TaskManager errors. Runtime notes for the cross-language example: Flink's opt/flink-python jar must be on the cluster classpath, and the embedded interpreter resolves modules through PYTHONPATH (it also sees the base interpreter's site-packages, so a current brotli must be first on the path or the OpenAI client's response decoder fails).

Not verified: Python-side routing (routers remain Java-only), cancellation during the decision call's retry backoff, and a full cluster failover (recovery is covered by the runtime journal test, not a restarted TaskManager).

Implementation invariants and supporting detail
  • decide:<router> is distinct from the built-in decision action's flat decide id; they belong to different actions and cannot collide in one journal.
  • DecisionOutcome (result or error text) is the durable record, JSON-serializable like the chat invoker's InvocationOutcome.
  • The input helpers both strategies use (effective messages with the anchor's bound prompt, context budget, content projection) live in the package-private RoutingInput, so neither executor depends on the other's internals; the anchor fallback rethrows cancellation and interruption before falling back to the raw messages (a pre-existing gap in the judge path), otherwise behavior unchanged and still pinned by LlmJudgeRoutingExecutorTest.
  • ModelRouter.CANDIDATE_DESCRIPTIONS_KEY names the existing candidate_descriptions argument; the serialized plan is unchanged.
  • withMaxContextChars validation is shared by the judge and decision declarations.
  • Text blocks of one message are joined with a newline in the decision input, without doubling the newlines the tool framing already carries, so a message split around media does not run together and tool rounds do not produce blank lines (toolCallsAndResultsAreRenderedAsText pins the line shape).
  • Token counts in the routing metadata are stored in JSON shape (Integer when the value fits an int), so a fresh decision and one replayed from the route record carry the same types; the recovery test asserts them on both paths.
  • A setup descriptor that declares no connection or model passes plan validation on purpose: a Python setup subclass may define them as class-level defaults (the cross-language fixture BridgeDecisionSetup does), so only explicitly declared values are checked.
  • A media-only, empty or SYSTEM-only conversation contributes no request text, so the prompt arguments become the decision input (mediaOnlyRequestFallsBackToPromptArgs, promptArgsAreTheInputWhenTheConversationCarriesNoRequestText).
  • DecisionModelActionTest (32 tests) pins the built-in action's behavior across the move to DecisionModelInvoker.
  • Snapshot e2e-test/cross-language-agent-plan-snapshots/java/agent_plan_with_decision_router.json generated with -Dregenerate.snapshots=true.

API

New: RoutingStrategyType.DECISION_MODEL (tag decision_model), Strategies.decision(String) / decision(String, String), RoutingStrategy.withMinProbability(double), ARG_DECISION_MODEL / ARG_INSTRUCTIONS / ARG_MIN_PROBABILITY, ModelRoutingEvent.SOURCE_DECISION_MODEL, ModelRouter.CANDIDATE_DESCRIPTIONS_KEY, RoutingCandidateValidator.requireDescriptionsForDecisionStrategy. withMaxContextChars also accepts decision declarations. No existing signature changes; existing routers, plans and persisted decisions are unaffected. Routers remain Java-only (#1062).

Documentation

  • doc-needed
  • doc-not-needed
  • doc-included

Was this patch authored or co-authored using generative AI tooling?

  • Yes
  • No

Generated-by: Claude Code 2.1.309 (Claude Fable 5.1)

@github-actions github-actions Bot added doc-included Your PR already contains the necessary documentation updates. fixVersion/0.4.0 priority/major Default priority of the PR or issue. labels Oct 27, 2026
@purushah
purushah force-pushed the decision-model-routing branch from 065f67e to 7c759df Compare October 27, 2026 17:47
@github-actions github-actions Bot added doc-included Your PR already contains the necessary documentation updates. and removed doc-included Your PR already contains the necessary documentation updates. labels Oct 27, 2026
@purushah purushah mentioned this pull request Oct 29, 2026
1 of 9 tasks
@wenjin272
wenjin272 self-requested a review November 2, 2026 06:36

@wenjin272 wenjin272 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the implementation!

Could we add an E2E test for decision-model routing through a running Flink job? A deterministic mock decision model and mock candidate chat models would be sufficient; it does not need live API credentials or the Python bridge. The test should exercise resource registration and initialization, routing, candidate invocation, and final output, covering selection, low-confidence abstention to the default model, and exhausted decision-model retries.

Strategies.decision(setup) routes with a registered DECISION_MODEL: one
choice question over the candidates, their describe(...) texts as the
criteria, the returned choice as the selected model and its probability
as the routing score. withMinProbability(p) abstains to the default model
when the chosen candidate's probability is below p; a refusal or a
non-choice answer abstains as well. The call runs under the durable id
decide:<router> with the configured retry budget, execution reports and
model_resource metrics; exhausting the retries fails the request like a
failed judge call. Every candidate must carry a description, enforced by
the router, build() and plan validation. withMaxContextChars now applies
to decision declarations too.

Adds DecisionModelRoutingExecutor, the decision_model routing event
source, plan-time validation, docs, an OpenAI decision routing example
with a native Java decision connection, and the cross-language plan
snapshot for the new strategy type.

Closes apache#1325

Generated-by: Claude Code 2.1.309 (Claude Fable 5.1)
Address a review comment on the decision-model routing strategy: the
decision executor used the judge executor's package-private methods for
message preparation, context budgeting and content projection. Move
those helpers into RoutingInput, a package-private class both strategies
use, so neither depends on the other's internals. Behaviour is unchanged
and stays pinned by LlmJudgeRoutingExecutorTest.

Generated-by: Claude Code 2.1.309 (Claude Fable 5.1)
Address a review comment on the decision-model routing strategy: run the
strategy through a real Flink job with a deterministic decision model
and two mock candidate chat models, all Java resources registered on
the agent, so no credentials or Python bridge are needed. One job sends
four requests and checks a confident selection of each candidate, a
choice below the configured minimum probability abstaining to the
default model, and a provider that keeps failing until the retry budget
is exhausted, which fails the request without candidate fallback. The
test also checks the provider call count, including the retry, and the
rendered decision input.

Generated-by: Claude Code 2.1.309 (Claude Fable 5.1)
…example

Address a review comment on the decision-model routing strategy: the
example shipped its own Java HTTP adapter for the OpenAI Decisions API,
which put provider-specific request and response handling into a
routing example. Declare the built-in Python OpenAI decision model
connection and setup through Python resource descriptors instead, as
other cross-language examples do, and drop the adapter. Native Java
support for the Decisions API, if wanted, belongs in a separate
integration. The example now needs a Python environment with
flink-agents on the TaskManagers, which its documentation states.

Generated-by: Claude Code 2.1.309 (Claude Fable 5.1)
@purushah
purushah force-pushed the decision-model-routing branch from 7c759df to c216515 Compare November 2, 2026 17:46
@purushah

purushah commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Added DecisionModelRoutingE2ETest in the e2e integration module. It runs a real Flink job with a scripted Java decision connection and two echo chat models registered on the agent, no credentials and no Python bridge. One job sends four requests and checks a confident selection of each candidate, a choice below the minimum probability abstaining to the default model, and a provider that keeps failing until the retry budget is exhausted, which fails the request without candidate fallback. It also checks the provider call count including the retry and the rendered decision input.

The three changes are separate commits on top of the original one, and the branch is rebased on current main.

@github-actions github-actions Bot added doc-included Your PR already contains the necessary documentation updates. and removed doc-included Your PR already contains the necessary documentation updates. labels Nov 2, 2026

@sangkyoonnam sangkyoonnam left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please extend the snapshot and the behavior table so a later Python decision-model strategy can follow them. I haven't run anything; I read the diff at c21651514b58. Two gaps are inline.

…cument a missing probability

Address review comments on the decision-model routing strategy. The
cross-language plan snapshot carried only the required strategy
arguments; add a second router that sets instructions and
max_context_chars as well, and have the Python test check each key's
name and JSON type, so a later Python implementation of the strategy
has the contract in front of it. Document what happens when the
provider omits the chosen candidate from the probability map: with
min_probability set the request abstains to the default model, since
there is nothing to compare; without it the choice routes with no
score. The second case gains a unit test next to the existing one, and
the Javadoc of Strategies.decision and of the executor say the same.

Generated-by: Claude Code 2.1.309 (Claude Fable 5.1)
@github-actions github-actions Bot added doc-included Your PR already contains the necessary documentation updates. and removed doc-included Your PR already contains the necessary documentation updates. labels Nov 3, 2026

@wenjin272 wenjin272 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the feedback. LGTM.

I re-reviewed the latest changes and ran the focused tests successfully. I also verified the updated example with a real OpenAI API key through the Python bridge: both requests completed successfully and routed to the expected candidates.

@purushah

purushah commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the review and for verifying the example end to end.

@wenjin272 wenjin272 added fixVersion/1.0.0 The feature or bug should be implemented/fixed in the 1.0.0 version. and removed fixVersion/0.4.0 labels Nov 5, 2026
@wenjin272
wenjin272 merged commit 5177500 into apache:main Nov 5, 2026
32 of 33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

doc-included Your PR already contains the necessary documentation updates. fixVersion/1.0.0 The feature or bug should be implemented/fixed in the 1.0.0 version. priority/major Default priority of the PR or issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants