Repository navigation
[api][plan][java] Add a decision-model routing strategy for MODEL_ROUTER - #1330
Conversation
065f67e to
7c759df
Compare
wenjin272
left a comment
There was a problem hiding this comment.
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)
7c759df to
c216515
Compare
|
Added The three changes are separate commits on top of the original one, and the branch is rebased on current main. |
…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)
wenjin272
left a comment
There was a problem hiding this comment.
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.
|
Thanks for the review and for verifying the example end to end. |
Linked issue: #1325
Purpose of change
A
MODEL_ROUTERcan now route with a registered decision model.Strategies.decision("dm")asks theDECISION_MODELresource one choice question (options: the candidates; criteria: theirdescribe(...)texts) and routes on the returned choice. The choice's probability becomes the routingscore;withMinProbability(p)abstains to the default model belowp. 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
ModelRoutingResolverdispatchesDECISION_MODELtoDecisionModelRoutingExecutor, which declares internal durable calls and so runs before theroute:<router>record, like the judge.max_context_charscap, prompt-argument fallback; media dropped and counted) and builds oneChoiceQuestionnamedmodel.DecisionModelInvoker.decideWithRetries(new, shared with the built-in decision action) runssetup.decideinsidedurableExecuteunderdecide:<router>with the configured retry budget, execution reports andmodel_resourcetoken metrics; retries are accumulated on the routed request like the judge's.RoutingDecision(table below) with metadatadecision_model,decision_probabilities, optionalprovider_confidence, token counts,decision_context_truncated,decision_media_dropped, anddecision_choiceon abstain. The resolver persists, normalizes and emitsModelRoutingEvent(sourcedecision_model); the chat path is unchanged.Key decisions
provider_confidenceis recorded but not thresholded; not every provider reports it.RoutingCandidateValidatorrule shared by the constructor,build()and plan validation; plan validation also requires an explicitly declared setup connection to be registered.decideid would collide) but shares its retry loop throughDecisionModelInvoker, which the action now uses too.Behavioral Semantics
Interaction decisions
min_probabilityscore= probability, sourcedecision_modeldecision_choicerecordedprobabilitiesdecision_choicerecordedprobabilitiesscore, sourcedecision_modeldecision model refused[: reason]decision answer was not a choicemodeldecision model returned no answer for question 'model'Decision model '<name>' failed: …); no default, no fallbackBehavioral contracts
withMinProbabilityabstains to the default; choice and probability map stay in metadata.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 theroute:<router>record routes on the stored choice.min_probabilityoutside[0, 1]: rejected at declaration or plan construction.Failure behavior
Unresolvable decision model, a request that cannot be rendered, or an answer that cannot be read:
RoutingFailure(failedChatResponseEvent). Durable-state or recovery failures during the decision call propagate to the runner. Provider exception or null result: retried perMAX_RETRIES/RETRY_WAIT_INTERVAL, thenRoutingFailure; cancellation propagates, never persisted. Persistence or recovery errors propagate to the runner, as for the judge.Tests
DecisionModelRoutingExecutorTest, C =ChatModelActionRoutingTest)choiceSelects…AsScore,scoreIsTheChosenCandidateProbabilityNotTheMaximum; CdecisionModelChoiceRoutesToNamedCandidateprobabilityBelowMinimumAbstains…,probabilityAtTheMinimumIsAccepted,missingProbabilityForTheChoiceAbstainsWhenAMinimumIsSet,missingProbabilityForTheChoiceRoutesWithoutAScoreWhenNoMinimumIsSet; CdecisionBelowMinProbabilityAbstainsToDefaultrefusalAbstains…,nonChoiceAnswerAbstains,missingAnswerForTheQuestionAbstains,nonCandidateChoiceAbstains,emptyRequestAbstainsWithoutCallingTheProvider; CdecisionRefusalAbstainsToDefaultdecisionReplayReusesTheStoredAnswerWithoutCallingTheProvider,asyncDecisionCallRunsAsFlatSiblingBeforeTheRouteRecord; runtimeDecisionRoutingRecoveryTest(real journal serializer, sync and async: restore between the two stored decision attempts and the route record; full replay of a completed routed request)decisionModelUsesTheRetryBudget…,decisionModelFailureFailsTheRequest…,unresolvableDecisionModelFailsTheRequest,durableStateFailureDuringTheDecisionCallPropagatesToTheRunner,cancellationDuringDecisionInputPreparationPropagatesBeforeTheProviderCall,cancellationDuringJudgeInputPreparationPropagatesBeforeTheJudgeCall; EunrenderableRequestIsARoutingFailureRoutingTestdecision tests,AgentPlanDecisionModelValidationTest(10 tests)AgentPlanCrossLanguageTestdecision-router snapshot (two routers: the minimum and every optional argument); Pythontest_python_can_deserialize_java_plan_with_decision_routerchecks each key's name and JSON typeDecisionModelRoutingE2ETest(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 belowmin_probabilityto the default model, exhausted retries failing the request without candidate fallback, provider call count including the retry, rendered decision inputEnd 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 modelgpt-6-lunathrough the cross-language bridge,python.executablepointing at a Python with flink-agents and openai installed): job finished in 7.7 s; outputfinal_model=small … score=0.99for the greeting andfinal_model=big … score=0.87for the SQL request; no TaskManager errors. Runtime notes for the cross-language example: Flink'sopt/flink-pythonjar must be on the cluster classpath, and the embedded interpreter resolves modules throughPYTHONPATH(it also sees the base interpreter's site-packages, so a currentbrotlimust 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 flatdecideid; 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'sInvocationOutcome.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 byLlmJudgeRoutingExecutorTest.ModelRouter.CANDIDATE_DESCRIPTIONS_KEYnames the existingcandidate_descriptionsargument; the serialized plan is unchanged.withMaxContextCharsvalidation is shared by the judge and decision declarations.toolCallsAndResultsAreRenderedAsTextpins the line shape).connectionormodelpasses plan validation on purpose: a Python setup subclass may define them as class-level defaults (the cross-language fixtureBridgeDecisionSetupdoes), so only explicitly declared values are checked.mediaOnlyRequestFallsBackToPromptArgs,promptArgsAreTheInputWhenTheConversationCarriesNoRequestText).DecisionModelActionTest(32 tests) pins the built-in action's behavior across the move toDecisionModelInvoker.e2e-test/cross-language-agent-plan-snapshots/java/agent_plan_with_decision_router.jsongenerated with-Dregenerate.snapshots=true.API
New:
RoutingStrategyType.DECISION_MODEL(tagdecision_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.withMaxContextCharsalso accepts decision declarations. No existing signature changes; existing routers, plans and persisted decisions are unaffected. Routers remain Java-only (#1062).Documentation
doc-neededdoc-not-neededdoc-includedWas this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code 2.1.309 (Claude Fable 5.1)