[api][plan][python][examples] Framework-managed LLM-as-judge routing (Strategies.llm) - #1042
[api][plan][python][examples] Framework-managed LLM-as-judge routing (Strategies.llm)#1042purushah wants to merge 6 commits into
Conversation
…(Strategies.llm) The follow-up promised in discussion apache#897 and the v1 javadoc: the engine — not the strategy — executes the judge call, on the same durable, metered, observable chat path as any model call. - Strategies.llm(judgeModel[, promptTemplate]): declarative built-in; the judge is any registered CHAT_MODEL; candidate describe(...) lines are its decision criteria. - LlmJudgeRoutingStrategy carries config plus two pure functions (prompt build, verdict parse); route() throws — it is never invoked. The verdict parser scans every "model" match for a candidate, so a chatty judge that quotes the format contract still parses. - ModelRoutingResolver runs the judge via the invoker under durable id "judge:<router>" (engine retries, trace events, token attribution to the judge model), derives the decision as a pure function of the verdict, and persists it — with its source and judge-inclusive decision_ms — under the standard "route:<router>" id; replayed decisions are guarded against candidate-set changes like the strategy path. - Failure policy: unparseable or non-candidate verdicts abstain to the default model; a judge that exhausts its retries honors the request's error-handling strategy (FAIL is loud, IGNORE degrades to the default with the cause recorded); interrupts propagate. - Shared retry-policy helpers on ChatModelInvoker (used by both the chat path and the judge path). - Python: add_resource(MODEL_ROUTER) now raises an explicit not-yet-supported error instead of dropping silently (per apache#964 review). - ModelRoutingJudgeExample mirrors ModelRoutingExample with a judge. Generated-by: Claude Code 2.1.239 (Claude Fable 5)
A typo'd judge model name would not fail the job: every judge call would fail and (under IGNORE) abstain to the default model, silently disabling routing. All resources are known at plan construction, so fail there instead — same fail-fast standard as the router/chat-model name-clash check. Validation instantiates the strategy exactly as the runtime does (instanceof dispatch + getJudgeModel()), so subclasses are judged by what they actually return; anything not instantiable at plan time is left to the runtime's own error. Null-tolerant for previously-legal constructor inputs. Generated-by: Claude Code 2.1.239 (Claude Fable 5)
1c16662 to
bb615cb
Compare
|
Thanks for opening the follow-up PR @purushah. Could you please create a corresponding github tracking issue and link the design discussion, #964, and this PR? The issue should capture the complete scope: what was delivered in V1, the planned V2 implementation, Python-side parity, and user documentation. Any additional follow-ups can then be linked to it so we can track the feature end to end. I’ll start reviewing this PR as soon as possible. |
|
Done — opened #1062 to track the feature end to end (design discussion #897, v1 #964, this PR, plus Python parity and user documentation as follow-ups). @wenjin272 |
wenjin272
left a comment
There was a problem hiding this comment.
Thanks for the work on this—the implementation looks solid overall. I’ve left a few minor comments.
Since LLM-based routing has a special framework-managed execution model, it also prompted some broader thoughts about the current routing API design, particularly the separation between API-level strategy declarations and Plan-level execution. It would be good to align on this design before finalizing the PR.
| public static final String STRATEGY_CLAZZ_KEY = "strategy_clazz"; | ||
|
|
||
| /** Descriptor key carrying the strategy construction arguments. */ | ||
| public static final String STRATEGY_ARGS_KEY = "strategy_args"; |
There was a problem hiding this comment.
Could we move STRATEGY_CLAZZ_KEY and STRATEGY_ARGS_KEY to the beginning of the class, before the instance fields? That would make the class-level constants easier to discover and follow the usual Java class layout.
There was a problem hiding this comment.
Agreed — will move STRATEGY_CLAZZ_KEY and STRATEGY_ARGS_KEY above the instance fields in the next push.
| } | ||
| } | ||
| return false; | ||
| } |
There was a problem hiding this comment.
Could we avoid treating every non-SocketTimeoutException InterruptedIOException as cancellation? Okio may use a plain InterruptedIOException("timeout") for ordinary HTTP timeouts. Under IGNORE, this branch sets the thread’s interrupt flag and rethrows, bypassing the intended abstain-to-default behavior, so no candidate model is invoked.
I suggest determining cancellation from the thread state and explicit cancellation types instead:
if (Thread.currentThread().isInterrupted()) {
return true;
}
if (t instanceof InterruptedException
|| t instanceof ClosedByInterruptException
|| t instanceof CancellationException) {
return true;
}A bare InterruptedIOException should follow the normal FAIL/RETRY/IGNORE handling. It may also be clearer to rename containsInterrupt() to isCancellation().
Could we also add a regression test where the judge throws new InterruptedIOException("timeout") under IGNORE, verifying that the default model is invoked and the thread is not marked as interrupted?
There was a problem hiding this comment.
Great catch — will fix as you suggested, including the rename and the regression test.
| failure.totalRetryWaitSec); | ||
| // Cancellation surfacing from inside the judge attempt (the invoker wraps every | ||
| // attempt exception): it must propagate, never persist as a routing outcome. | ||
| if (containsInterrupt(failure)) { |
There was a problem hiding this comment.
The judge call can persist an interruption before it is identified as cancellation, but this comes from the existing shared durable execution path and also affects direct chat calls. I’ve opened #1070 to track the common fix, so this does not need to block this PR.
There was a problem hiding this comment.
Thanks for scoping this and opening #1070 — agreed it's the shared durable-execution path (direct chat calls hit it the same way) and best fixed there. Happy to help on that issue after this PR settles.
| // user message (a setup-bound Prompt renders it later), and a judge that only reads the | ||
| // message text would judge an empty string. | ||
| StringBuilder request = new StringBuilder(); | ||
| String lastUser = context.lastUserMessage(); |
There was a problem hiding this comment.
The judge currently keeps only the last USER message and serializes promptArgs as raw key: value pairs. This can lose context in two ways:
- Message history: given
SYSTEM: "You are reviewing Java concurrency code",USER: "Focus on race conditions", andUSER: "<code>", the judge sees only<code>, while the selected model receives the complete message list. - Bound prompt semantics: given a prompt template
Review this SQL for performance issues: {input}, an empty user message, andpromptArgs = {input: "SELECT ..."}, the judge sees onlyinput: SELECT ..., while the selected model receives the fully rendered review instruction.
The judge may therefore route based on a materially different request from what the selected model receives. Could we preserve the complete message list and include the effective prompt/template semantics when constructing the judge input?
There was a problem hiding this comment.
You're right. Honestly, sending only the last message was me trying to keep the judge cheap — the whole point of a cheap judge is that the decision costs less than the answer, and I was worried about long conversations and tool outputs blowing that up. But your examples show the flip side: the judge can end up routing on something very different from what the model actually gets, and the bound-prompt case is just broken — the task lives in the template and the judge never sees it.
So let's do it your way by default: the judge gets the complete message list, and the rendered request (template + args) when a prompt is bound. For anyone who does need to cap the routing cost, I'll add an optional knob — something like Strategies.llm("judge").withMaxContextChars(n). Not set → everything goes to the judge. Set → we keep the rendered request and the SYSTEM message, fill the rest newest-first, and mark the decision metadata when anything got dropped, so the event log stays honest about what the judge saw.
| * {@code null} when the setup is plain (or cannot be resolved — an unresolvable judge takes the | ||
| * ChatAttemptFailed path with its normal policy). | ||
| */ | ||
| private static String judgeSetupMisconfiguration(String judgeModel, RunnerContext ctx) { |
There was a problem hiding this comment.
validateLlmJudgeReferences() already verifies that the referenced judge model exists. Could we also validate there that its descriptor has no bound prompt, tools, or skills, and remove judgeSetupMisconfiguration() from the request path?
These are static configuration constraints and should fail when constructing the AgentPlan, independently of the runtime error-handling strategy.
There was a problem hiding this comment.
Agreed — these are static constraints and should fail at plan construction. Will move the no-prompt/tools/skills checks into validateLlmJudgeReferences() and remove judgeSetupMisconfiguration() from the request path.
| * fail loudly at the first request instead of quietly routing everything to the default. | ||
| */ | ||
| @Override | ||
| public RoutingDecision route(RoutingContext context) { |
There was a problem hiding this comment.
I think the current abstraction mixes two different concepts:
RoutingStrategyis documented as executable selection logic whose primary contract isroute(RoutingContext).LlmJudgeRoutingStrategyis actually framework-managed configuration plus prompt/verdict helpers. Itsroute()method always throwsUnsupportedOperationException.
Because of this mismatch:
ModelRouterinstantiates every strategy as aRoutingStrategy, even when it cannot executeroute().ModelRouter.route()is not valid for every successfully constructed router.ModelRoutingResolvermust inspect the concrete implementation withinstanceof LlmJudgeRoutingStrategyand bypass the public strategy contract.- Future framework-managed strategies would require more concrete-type branches.
- Built-in strategy identity is represented by Java implementation class names, which is difficult to align with a future native Python implementation.
Could we separate declaration from execution more explicitly?
API layer: RoutingStrategy as an immutable declaration
The current RoutingStrategyDescriptor already serves this purpose, so I suggest renaming it to RoutingStrategy and removing route() from the API layer entirely.
For example:
public final class RoutingStrategy implements Serializable {
private final RoutingStrategyType type;
private final Map<String, Object> arguments;
// Only present for CUSTOM.
@Nullable private final String executorClass;
}The built-in factories would continue returning this API object:
Strategies.rules(rules);
Strategies.llm("judge");
Strategies.llm("judge", promptTemplate);
Strategies.custom(executorClass, arguments);ModelRouter would store this declaration directly instead of reflectively instantiating an executable strategy:
private final RoutingStrategy strategy;
public RoutingStrategy getStrategy() {
return strategy;
}Therefore, ModelRouter.instantiateStrategy(), ModelRouter.route(), and the current API-level LlmJudgeRoutingStrategy implementation would no longer be needed.
Plan layer: RoutingExecutor as the execution contract
Execution belongs in Plan because this layer has access to RunnerContext, ChatModelInvoker, durable execution, retry configuration, metrics, tracing, and error handling.
For example:
Provide RoutingExecutor interface in API layer:
public interface RoutingExecutor {
RoutingDecision route(
RoutingStrategy strategy,
RoutingContext routingContext,
RunnerContext runnerContext)
throws Exception;
}The built-in implementations would live in Plan:
RuleBasedRoutingExecutor
LlmJudgeRoutingExecutor
RuleBasedRoutingExecutor would own the current rule evaluation and durable route-decision call.
LlmJudgeRoutingExecutor would own:
- construction of the judge messages;
- invocation through
ChatModelInvoker; - judge retries and error handling;
- verdict parsing;
- judge and route durable IDs;
- routing metrics and tracing metadata.
This also keeps buildJudgeMessages() and parseVerdict() next to the framework-managed judge execution instead of exposing them through an API class that pretends to be directly executable.
Executor resolution
Plan could resolve built-in executors through a registry keyed by a language-neutral strategy type:
RoutingExecutor executor =
routingExecutorRegistry.get(router.getStrategy().getType());
RoutingDecision decision =
executor.route(router.getStrategy(), routingContext, runnerContext);For example:
RULE_BASED -> RuleBasedRoutingExecutor
LLM_JUDGE -> LlmJudgeRoutingExecutor
CUSTOM -> executor class carried by the strategy declaration
This removes the concrete instanceof branch from ModelRoutingResolver. It also avoids encoding built-in strategies using Java FQCNs; Java and Python can serialize the same strategy type and provide their own Plan-level executor.
Custom executors
A custom strategy declaration can carry the user-provided executor class and constructor arguments:
Strategies.custom(
CostAwareRoutingExecutor.class,
Map.of("threshold", 1000));The custom implementation would implement the Plan-level contract:
public class CostAwareRoutingExecutor implements RoutingExecutor {
public CostAwareRoutingExecutor(Map<String, Object> arguments) {
// Initialize custom configuration.
}
@Override
public RoutingDecision route(
RoutingStrategy strategy,
RoutingContext context,
RunnerContext runnerContext)
throws Exception {
// Custom routing execution.
}
}During AgentPlan construction, the framework should validate that:
- the custom executor class exists;
- it implements
RoutingExecutor; - it has the supported
Map<String, Object>or no-argument constructor; - built-in strategy configuration and judge-model references are valid.
A Plan-side typed factory can accept Class<? extends RoutingExecutor> while still returning the API-level RoutingStrategy.
The user-facing router declaration remains essentially unchanged:
ModelRouter.of("small", "large")
.describe("small", "Fast model for simple requests")
.describe("large", "Stronger model for complex requests")
.strategy(Strategies.llm("judge"))
.defaultModel("small")
.build();This gives the two concepts clear responsibilities:
RoutingStrategy: serializable API declaration of what routing behavior is configured.RoutingExecutor: Plan-level implementation of how that behavior is executed.
It removes the unsupported route() method, avoids concrete-type dispatch, keeps built-in execution in the correct module, allows user-defined executors, and provides a cleaner path for the future Python implementation.
There was a problem hiding this comment.
You're right — I worked the proposal through in code and it's the better architecture: declaration at the API layer, executors in Plan, dispatch by a language-neutral type. It removes the throwing route(), the instanceof, and the FQCN identity that blocks Python parity (#1062). Since 0.4.0 is unreleased, I'll adopt it in this PR.
Two amendments within the design, both to keep #964's guarantees structural: (1) the resolver keeps the single durableExecute wrap around the executor dispatch, so no executor can skip persistence and replay never re-invokes the judge; (2) custom executors get the data-only RoutingContext instead of RunnerContext — otherwise a custom executor can call a chat model directly, an unmetered cascade inside the decision step, which v1 deliberately made impossible.
If that works for you: RoutingStrategy becomes the serializable declaration, Strategies.* return it, both built-in executors move to Plan (prompt/verdict helpers package-private), plan JSON gets strategy_type tags, user-facing builder unchanged. Your other comments ride along in the same push.
| // every shipped payload key is read or asserted somewhere (v1 review lesson) | ||
| assertThat(event.getMetadata()).containsKey("decision_source"); | ||
| // judge call is durable under its own id; the decision persists under the route id | ||
| assertThat(ctx.durableCallIds).contains("judge:router", "route:router"); |
There was a problem hiding this comment.
This checks the durable ids. But FakeRunnerContext (:212-221) always calls through in both durableExecute and durableExecuteAsync, and there is no way to seed a stored result. So no test takes the replay path.
That leaves two things unproven: a stored abstain resolving to the router's current default (ModelRoutingResolver.java:279-281), and decision_ms surviving replay (:290-292), which is the reason the PR body gives for the second durable write.
A small map in the fake keyed on callable.getId() would open that path. Would that be worth adding here, or is replay meant to be covered end-to-end?
There was a problem hiding this comment.
Great catch — yes, worth covering here. Will add a seedable store to FakeRunnerContext and tests for both replay behaviors.
| } | ||
| return (LlmJudgeRoutingStrategy) | ||
| ModelRouter.instantiateStrategy(strategyClazz, strategyArgs); | ||
| } catch (Exception | LinkageError notInstantiableHere) { |
There was a problem hiding this comment.
This catch treats "not a judge" and "a judge with bad arguments" the same way. A judge subclass without judge_model throws from super(args), lands here, and returns null. So validateLlmJudgeReferences skips that router, including the judge-model check. build() misses it too, because the guard at ModelRouter.java:266 compares the exact class name. judgeSubclassIsValidatedByAssignability passes valid args, so this case is untested.
Under the default FAIL you get a loud error per record. Under IGNORE the request is dropped with a warning, so routing is quietly off for the whole job. That is what the javadoc at :699-703 is trying to prevent.
Would it help for instantiateIfLlmJudge to tell the two apart, returning null only when the class is absent or not a judge, and letting a bad-args failure through?
There was a problem hiding this comment.
Great find — agreed. Will split the two cases so a judge subclass with bad arguments fails at plan construction, and add the missing test.
| routing.put("candidates", new ArrayList<>(this.candidates)); | ||
| routing.put( | ||
| "decision_source", | ||
| org.apache.flink.agents.api.event.ModelRoutingEvent.DECISION_SOURCE_KEY, |
There was a problem hiding this comment.
ModelRoutingResolver.java:272-273 and :283-284 already put decision_source into the decision metadata, and that map goes in under "metadata" five lines below. So when a judge-routed request falls back, the same block says decision_source = "fallback" here and metadata.decision_source = "llm_judge" inside. The fallback event at ChatModelAction.java:413-424 splits the same way.
Nothing in production reads the nested one. Reads go to the event attribute (ModelRoutingEvent.java:153) or to this top-level key (ChatModelActionRoutingTest.java:702, :909). The only assertion on the nested map is the containsKey at :377.
Since the PR body treats decision_source as consumer-visible, would dropping the two resolver puts be enough? Or does the judge path want its own key there, under a name that cannot clash?
There was a problem hiding this comment.
Good catch — dropping the two resolver puts is enough; the event attribute and top-level key stay the single consumer-visible story. Will fix.
| Object promptTokens = reply.getExtraArgs().get("promptTokens"); | ||
| Object completionTokens = reply.getExtraArgs().get("completionTokens"); | ||
| if (promptTokens != null) { | ||
| judgeMetadata.put("judge_prompt_tokens", promptTokens); |
There was a problem hiding this comment.
judge_prompt_tokens and judge_completion_tokens are new here and appear only at these two writes. FakeChatModel.chat (ChatModelActionRoutingTest.java:95-107) never sets extraArgs, so both branches are dead in every test and neither key has been produced once.
The PR body lists both under compatibility impact, and the comment at :376 says every shipped payload key is read or asserted somewhere.
One FakeChatModel outcome with extraArgs = Map.of("promptTokens", 12, "completionTokens", 3) plus an assertion would close it. Would you rather do that, or narrow what the comment claims?
There was a problem hiding this comment.
Good catch — will add the extraArgs outcome and assertions so both keys are actually exercised.
| assert list(restored.actions) == ["first", "second"] | ||
|
|
||
|
|
||
| def test_python_can_deserialize_plan_with_java_llm_judge_router() -> None: |
There was a problem hiding this comment.
This goes Python out and Python back in: model_dump_json() then model_validate_json(). Java is never involved. If Java emitted a different key or shape for strategy_clazz / strategy_args, this would still pass. So it does not yet back the PR body's line about proving Java plans deserialize in Python.
The snapshot helper in this file (_SNAPSHOT_DIR at :52, used by the test at :293) could give that claim something real. Is pointing this at a Java-produced plan practical, or would narrowing the claim be easier?
There was a problem hiding this comment.
Fair point — will commit a Java-produced plan snapshot for the Python test to deserialize (cut from the new strategy_type format once the restructure in the other thread lands, so we don't snapshot an obsolete shape).
Adopts the declaration/executor split from the PR apache#1042 review (wenjin272), with two amendments keeping apache#964's guarantees structural, and folds in the remaining review items from both reviewers. Architecture (review thread on LlmJudgeRoutingStrategy.java:84): - RoutingStrategy is now an immutable, serializable declaration: RoutingStrategyType (RULE_BASED | LLM_JUDGE | CUSTOM) + arguments (+ executorClass for CUSTOM). No route() exists at the API layer; the throwing UnsupportedOperationException class is gone. - Plan JSON encodes the strategy as a language-neutral tag (strategy_type: "llm_judge") + strategy_args — no Java FQCNs for built-ins, unblocking Python parity (apache#1062). Java-produced snapshot committed; the Python cross-language test now deserializes it verbatim. - Execution lives in Plan: rule matching in the resolver over patterns precompiled by the router; judge prompt/verdict logic in LlmJudgeRoutingExecutor (package-private pure helpers); dispatch by type. - Amendment 1: the resolver owns the durable route: boundary for every strategy type — no executor can skip persistence; replay never re-invokes the strategy or the judge. - Amendment 2: user-defined executors implement CustomRoutingExecutor with the data-only RoutingContext (v1's select-not-delegate fence, verbatim); the engine-facing signature stays plan-internal. Review fixes: - isCancellation() replaces containsInterrupt(): thread-state + explicit cancellation types only; a bare InterruptedIOException (Okio timeout shape) follows the normal FAIL/IGNORE policy. Regression test included. - Judge input is now full-fidelity: complete message list plus the rendered request (anchor candidate's bound prompt + args), with an opt-in Strategies.llm(...).withMaxContextChars(n) cap (SYSTEM + newest pinned, newest-first fill, judge_context_truncated flagged in metadata). - Judge setup constraints (no prompt/tools/skills) validate at plan construction; judgeSetupMisconfiguration() removed from the request path. - Strategy argument validation lives in the declaration constructor, so a bad-args judge fails Strategies.llm(...)/plan construction — the reflective instantiateIfLlmJudge() bypass is structurally gone. - decision_source is no longer duplicated into decision metadata (it contradicted the top-level key on judge+fallback). - FakeRunnerContext gained a seedable durable store; new tests cover the replay path (stored abstain -> current default; decision_ms survives) and judge token metadata (extraArgs-driven). Generated-by: Claude Code 2.1.239 (Claude Fable 5)
Fixes the 10 findings from the fresh review pass over the restructure commit (2 of which were holes the restructure itself opened): - Restore the runtime judge-plainness backstop: plan-time validation only sees descriptor-carried bindings, so instance-level (or non-Java) bound prompt/tools/skills on the judge are again caught at request time (FAIL loud, IGNORE abstain). Tests restored. - Judge promptArgs fallback now keys on missing REQUEST text (no non-empty USER/rendered message) instead of a fully-empty conversation — SYSTEM + empty USER + args no longer routes blind. Regression test added. - The context cap now pins messages generated by the anchor's request shaping (rendered template, skill prompt) via identity-tracked indices, honoring withMaxContextChars' documented 'rendered request always kept'. - Prompt application extracted to BaseChatModelSetup.prepareRequestMessages and shared by chat() and the judge input path — the judge's view can no longer drift from the chat path's (now also includes the skill-discovery prompt); reads getPrompt() so subclass overrides are honored. - Plan validation now fails loudly on a router descriptor without strategy_type instead of deferring to a per-record TaskManager error. - Rule validation/compilation consolidated to one path (compileRules), shared by build() and the constructor; identical diagnostics either way. - Dead disjunct removed in ChatModelAction's cancellation check; stale comment refreshed. - RoutingStrategy.validate() throws on unhandled future types instead of silently passing them (mirrors the executor dispatch). - selectWithinBudget early-returns when no cap is configured. Known, documented limitation (reviewed, deferred): the judge's rendered view anchors to the default candidate's bound prompt; candidates binding different prompts see their own rendering only at answer time. Generated-by: Claude Code 2.1.239 (Claude Fable 5)
weiqingy
left a comment
There was a problem hiding this comment.
Thanks for working through the five earlier points. All five look closed to me. A few new questions inline on the restructure.
| * router uses its default model. | ||
| */ | ||
| private static RoutingDecision executeRules(ModelRouter router, RoutingContext context) { | ||
| String text = context.lastUserMessage(); |
There was a problem hiding this comment.
The rule evaluator lost its multi-turn test in the move. RoutingTest.ruleMatchesLatestUserMessageNotFirst (old RoutingTest.java:64) matched the regex on the oldest USER turn and asserted abstain. Its replacement (RoutingTest.java:69-82) only checks the RoutingContext accessors, so nothing runs the rule evaluator now.
Every rule-routed request left in the suite has one user message, so firstUserMessage() and lastUserMessage() return the same string. Nothing pins the choice on this line. Would one multi-turn case be worth adding back here?
There was a problem hiding this comment.
Great catch — the restructure dropped exactly the test that pinned last-vs-first. Will add a multi-turn case that runs the rule evaluator end-to-end and asserts the match is against the latest user message.
| case CUSTOM: | ||
| validateCustomExecutor(provider.getName(), strategy); | ||
| break; | ||
| default: |
There was a problem hiding this comment.
RULE_BASED falls to default: break here, so rule keys are never checked at plan time. The javadoc at :701 says static routing constraints fail at plan construction and never per record, and a rule key naming a non-candidate is just as static.
Today it only surfaces at request time, at the candidate check in ModelRoutingResolver.java:153, inside the durable call. IGNORE then drops every matching record.
The note at RoutingStrategy.java:111-112 says the builder validates rule keys "where the candidates are in hand". That covers the fluent path, but a descriptor read back from a plan skips the builder. Worth a check in this arm too?
There was a problem hiding this comment.
You're right — a rule key naming a non-candidate is just as static as a missing judge model, and the descriptor path skips the builder check. Will add a RULE_BASED arm that validates rule keys against the candidates at plan construction, with a test for the descriptor-built case.
| private static RoutingStrategy instantiateStrategy(String clazz, Map<String, Object> args) | ||
| /** | ||
| * Instantiates the user's {@link CustomRoutingExecutor} once per router instance (routers are | ||
| * cached per TaskManager), preserving executor instance state across requests. The construction |
There was a problem hiding this comment.
"cached per TaskManager" does not match the lifecycle. ResourceCache is an instance field of ActionExecutionOperator.java:101 on main, assigned at :187, so the scope is one subtask. At parallelism 8 on one TaskManager a user gets 8 executor instances, not 1.
This is the only place documenting the new extension point's lifetime, and the same sentence invites executors to keep instance state. :152 repeats it. Is "cached per subtask" the accurate phrase for both?
There was a problem hiding this comment.
Good catch — "per subtask" is the accurate phrase. Will fix both spots; since that sentence is what tells executor authors how instance state behaves, it should be precise.
| // judge-inclusive wall time) replays; the judge chat above replays from its own durable | ||
| // record, so the recomputation feeding this call is deterministic. | ||
| RoutingDecision decision = | ||
| ctx.durableExecute( |
There was a problem hiding this comment.
The two new replay tests (ChatModelActionRoutingTest.java:1062, :1090) seed a custom-strategy router, so they take the durable call at :122. The judge path stores its decision here instead, and the judge work runs before this call. Nothing seeds route:<router> for a judge router. Does the judge path want its own test, or do you read it as covered by the shared code?
There was a problem hiding this comment.
Fair question — it deserves its own test. The judge path has enough of its own orchestration before the shared store that "covered by shared code" is exactly the kind of claim I shouldn't leave untested. Will add a replay test that seeds route: for a judge router and asserts the judge is never invoked.
| ChatMessage reply = judgeResult.response; | ||
| Object promptTokens = reply.getExtraArgs().get("promptTokens"); | ||
| Object completionTokens = reply.getExtraArgs().get("completionTokens"); | ||
| if (promptTokens != null) { |
There was a problem hiding this comment.
nit: these two guards check != null only, so a non-Number value under promptTokens or completionTokens goes into the durable metadata as-is. The existing reader of the same two extraArgs keys checks the type first: ChatModelAction.java:225-232 uses instanceof Number. The new test passes Integer, so it does not reach the gap. Should this line and :293 match that guard?
There was a problem hiding this comment.
Good catch — will match the instanceof Number guard the reader already uses, and extend the test with a non-Number value.
| AgentsExecutionEnvironment | ||
| The environment to register the resource. | ||
| """ | ||
| if resource_type == ResourceType.MODEL_ROUTER: |
There was a problem hiding this comment.
nit: this guard is byte-identical to agent.py:168-175, message text included, but only the Agent path has a test (test_model_router_not_supported.py:28-30). The examples reach this copy through env.add_resource, for example rag_agent_example.py:42. One shared helper plus a parametrized test over both entry points would cover it. Would you rather do that here, or leave it for the Python routing work?
There was a problem hiding this comment.
Nice find. The shared helper is cheap, so I'll do it in this PR — one helper plus a parametrized test over both entry points, rather than leaving a known duplication for the Python routing work to trip over.
…, replay and multi-turn tests - Validate RULE_BASED declarations at plan construction via the shared ModelRouter.compileRules path (now public): invalid patterns, non-String values and rule keys naming non-candidates all fail with the builder's diagnostics instead of per record inside the durable call (descriptor-built plans previously skipped the builder check) - Restore the multi-turn rule test lost in the restructure: rules match the most recent user message, not the first - Add a judge-router replay test: both durable records (judge:<router>, route:<router>) replay and the judge is never re-invoked - Guard judge token metadata with the same both-or-neither instanceof-Number conjunction as the metrics reader, so metrics and routing metadata always agree - Rename validateLlmJudgeReferences to validateRoutingStrategies (it now dispatches all three strategy types) and add CANDIDATES_KEY - Fix ModelRouter javadoc: routers are cached per subtask, not per TaskManager - Python: extract the shared MODEL_ROUTER registration guard into check_registrable_from_python, parametrize the test over both entry points Generated-by: Claude Code 2.1.239 (Claude Fable 5)
weiqingy
left a comment
There was a problem hiding this comment.
Thanks for working through these. All six from the last round check out. Three small things inline, all in the new code.
| */ | ||
| private static void validateRuleKeys( | ||
| String routerName, RoutingStrategy strategy, Object candidates) { | ||
| if (!(candidates instanceof List)) { |
There was a problem hiding this comment.
This return also skips compileRules, and those pattern checks (empty key, non-String value, bad regex at ModelRouter.java:173-196) never look at candidates. The javadoc you added at :718-720 promises them for RULE_BASED.
What made me look twice is :742-751 just above: a missing strategy_type throws there, on the same "the constructor rejects it anyway" reasoning. Reachability is low either way. Would hoisting the compile above the guard be worth it?
Something like:
var ruleKeys = ModelRouter.compileRules(strategy).keySet();
if (!(candidates instanceof List)) {
return;
}
for (String ruleKey : ruleKeys) { ... }There was a problem hiding this comment.
Good catch — the guard order defeats the exact promise the javadoc makes. Will hoist compileRules above the guard as you wrote it, and add a test for the mis-shaped-candidates + bad-pattern case.
| // Same both-or-neither guard as the metrics reader of these extraArgs keys | ||
| // (ChatModelAction#recordChatTokenMetrics): a half-populated or non-Number pair | ||
| // must not leak into the durable decision metadata, so metrics and routing | ||
| // metadata always agree about the same judge call. |
There was a problem hiding this comment.
Does the last clause hold? recordChatTokenMetrics adds three conditions this site does not: null metric group (ChatModelAction.java:222), non-empty model_name (:229-230), both counts > 0 (:235). A judge reply with promptTokens: 0 records no metric, but both keys still land in the metadata.
The instanceof Number half reads right. Worth trimming just the "always agree" sentence?
There was a problem hiding this comment.
You're right, "always agree" overclaims — the metrics side has conditions this site doesn't. Will trim the sentence to just the type-guard parity.
| * ({@code build()}), the router constructor, and plan-time validation ({@code | ||
| * AgentPlan#validateRuleKeys}). Called once per router instance (routers are cached per | ||
| * subtask), so rule evaluation stays regex-match-only per request. Patterns were validated at | ||
| * build(); this re-validates defensively for descriptors constructed outside the builder. |
There was a problem hiding this comment.
nit: does the last sentence still fit? The first one says compileRules is the single validation path, and build() does validate by calling it (:319). So "Patterns were validated at build(); this re-validates" seems to argue with it.
There was a problem hiding this comment.
Stale sentence from before the rewrite — will drop it.
… guard, shape checks, comment fixes - validateRuleKeys compiles the rule map before the 'candidates' shape guard, so invalid patterns fail at plan construction even when the candidates argument is unusable (regression test included) - A non-List 'candidates' and a non-Map 'rules' now fail at plan construction with named diagnostics instead of a raw per-record ClassCastException / silently-empty rule set at request time - Trim the judge-token comment: the metrics reader has extra conditions (metric group, model_name, counts > 0), so type-guard parity is the only claim - Drop the stale compileRules javadoc sentence that contradicted the single-validation-path rewrite - Extract ruleBasedRouterArgs/ruleBasedProviders test helpers (four hand-rolled descriptor maps collapsed into one template) Generated-by: Claude Code 2.1.239 (Claude Fable 5)
What
Implements the
Strategies.llm(...)follow-up promised in discussion #897: the engine — not the strategy — executes the judge call, on the same durable, metered, observable chat path as any model call. Tracking: #1062.Revised after review (thanks @wenjin272, @weiqingy): the PR now adopts the declaration/executor architecture proposed in review — one serializable
RoutingStrategydeclaration at the API layer, executors in Plan, dispatch by a language-neutral strategy type — plus all eleven review items. The user-facing builder is unchanged:Architecture (revised per review)
RoutingStrategyis a declaration, not behavior:RoutingStrategyType(RULE_BASED|LLM_JUDGE|CUSTOM) + arguments (+ executor class forCUSTOM). There is noroute()at the API layer — the previousUnsupportedOperationException-throwing judge class is gone.LlmJudgeRoutingExecutor(package-private pure helpers). Noinstanceofon concrete classes.strategy_type: "llm_judge"+strategy_args— no Java class names for built-ins. A Java-emitted snapshot is committed and the Python cross-language test deserializes it verbatim, so Python parity ([Umbrella] Pluggable model routing (MODEL_ROUTER): delivery status, Python parity, documentation, and follow-ups #1062) needs only Python-side executors.route:<router>) for every strategy type — no executor can skip persistence; replay short-circuits execution, so neither the strategy nor the judge is re-invoked on recovery.CustomRoutingExecutorwith the data-onlyRoutingContext(v1's select-don't-delegate fence, verbatim): a custom executor cannot make hidden model calls. The engine-facing signature stays plan-internal.Judge semantics (unchanged from the original design, per #897)
"judge:<router>"(engine retries, trace events, token attribution); decision persisted with source and judge-inclusivedecision_msunder"route:<router>"; abstains persist as abstains, so replay after a candidate-set change resolves to the current default.FAILloud,IGNOREabstain-with-cause). Cancellation is determined from the thread's interrupt state plus explicit cancellation types (isCancellation); a bareInterruptedIOException(the shape Okio uses for ordinary HTTP timeouts) follows the normal failure policy — with a regression test.BaseChatModelSetup.prepareRequestMessagesthe chat path uses, so the judge's view cannot drift from what the selected model receives. Opt-in cost cap:withMaxContextChars(n)(rendered request + SYSTEM pinned, newest-first fill, truncation flagged asjudge_context_truncated).Strategies.llm(...)/plan construction — no reflective validation path to bypass). Plan construction verifies the judge model is registered and its descriptor binds no prompt/tools/skills; a slim runtime backstop catches instance-level bindings plan-time cannot see. Custom executor classes are checked (exists / implements / constructor) without instantiation.add_resource(..., MODEL_ROUTER)raises an explicit not-yet-supported error.Deliberate trade-offs (reviewed, kept)
route:in addition tojudge:) to keep the route-record schema uniform and preserve replayeddecision_ms.Claims → tests
llmJudgeVerdictRoutesToNamedCandidatellmJudgeUnparseableVerdictAbstainsToDefault,llmJudgeVerdictOutsideCandidatesAbstainsToDefault,parseVerdictAcceptsOnlyCandidatesstoredAbstainReplaysToCurrentDefaultWithoutRerunningStrategydecision_msreportedstoredDecisionReplaysWithOriginalDecisionMsInterruptedIOException) under IGNORE abstains, thread not left interruptedjudgeInterruptedIOTimeoutAbstainsToDefaultUnderIgnorejudgeSeesFullConversation,judgeSeesRenderedBoundPromptmaxContextCharsCapsJudgeInputAndFlagsTruncation,contextCapPinsRenderedTemplateMessagesjudgeMessagesFallBackToPromptArgsWithSystemAndEmptyUserjudgeTokenCountsLandInDecisionMetadatajudgeWithMissingJudgeModelFailsAtPlanConstruction,typoedJudgeModelFailsAtPlanConstruction,judgeWithBoundPrompt/ToolsFailsAtPlanConstructionllmJudgeRejectsInstanceLevelPromptBoundJudgeSetupUnderFail,llmJudgePromptBoundSetupAbstainsToDefaultUnderIgnorecustomExecutorIsNotInstantiatedDuringPlanConstructiontest_python_can_deserialize_java_plan_with_judge_routerCompatibility impact
CustomRoutingExecutor.route(strategy, context)instead ofRoutingStrategy.route(context);Strategies.of(...)is nowStrategies.custom(...); the plan wire format usesstrategy_type/strategy_args(+strategy_executor_classfor CUSTOM) instead ofstrategy_clazz.decision_sourcevaluellm_judgeand optional metadata keysjudge_model,judge_prompt_tokens,judge_completion_tokens,judge_context_truncated. (decision_sourceis no longer duplicated inside the metadata map — it contradicted the top-level key on the judge+fallback path.)Test evidence
flink runofModelRoutingJudgeExample, local Ollama): judge verdicts routed with full metadata; fallback composition exercised live.Generative AI was used for this change.
Generated-by: Claude Code 2.1.239 (Claude Fable 5)