Skip to content

Commit db235b4

Browse files
committed
Surface the router rationale in the shown message, and log it
Both "Using Smart Routing" messages — the launch-time notice and the subagent-routing hook's systemMessage — now include the router's rationale, so the "why" is visible, not just the "what". Previously the launch notice never showed it and the subagent hook put it only in permissionDecisionReason (a field the harness doesn't surface in that line). Centralized in RoutingDecision.display_message() so both paths format identically. Also persist the rationale in the decisions.jsonl record, so an empty value cleanly distinguishes "the gateway returned no rationale" from a display-side placement bug. Co-authored-by: Isaac
1 parent 579a5cb commit db235b4

4 files changed

Lines changed: 71 additions & 7 deletions

File tree

src/ucode/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1140,7 +1140,7 @@ def _launch_tool(
11401140
decision, routing_error = route_launch_model(state, ctx.args)
11411141
if decision is not None:
11421142
resolved_model = decision.model
1143-
print_note(f"Using Smart Routing. Routing to {resolved_model}.")
1143+
print_note(decision.display_message())
11441144
elif routing_error:
11451145
print_warning(
11461146
f"Smart routing was unavailable ({routing_error}); using {resolved_model}."

src/ucode/smart_routing/routing.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ class RoutingDecision:
3333
raw_model: str
3434
rationale: str = ""
3535

36+
def display_message(self, model_label: str | None = None) -> str:
37+
"""User-facing "Using Smart Routing" line, with the router's rationale.
38+
39+
Used by both the launch-time notice and the subagent-routing hook so the
40+
"what" (model) and the "why" (rationale) are surfaced consistently.
41+
``model_label`` overrides the shown model id (e.g. a harness-translated
42+
id); defaults to ``model``. The rationale is appended when present.
43+
"""
44+
message = f"Using Smart Routing. Routing to {model_label or self.model}."
45+
if self.rationale:
46+
message += f" {self.rationale}"
47+
return message
48+
3649

3750
def normalize_model(model: str) -> str:
3851
"""Strip provider prefixes so router arms and workspace ids compare equal.
@@ -199,15 +212,16 @@ def route_spawn_tool(
199212
routed_model = model_id_mapper(decision.model)
200213
if record_decision is not None:
201214
record_decision(payload, task, decision, routed_model)
202-
routing_message = f"Using Smart Routing. Routing to {routed_model}."
215+
# Surface the router's rationale in BOTH the systemMessage (the line the
216+
# harness shows the user) and permissionDecisionReason — the "why", not just
217+
# the "what". The shown model is the harness-translated id (routed_model).
218+
routing_message = decision.display_message(model_label=routed_model)
203219
output: dict[str, Any] = {
204220
"hookEventName": "PreToolUse",
205221
"permissionDecision": "allow",
206222
"updatedInput": {**tool_input, "model": routed_model},
207223
"permissionDecisionReason": routing_message,
208224
}
209-
if decision.rationale:
210-
output["permissionDecisionReason"] += f" {decision.rationale}"
211225
return {"systemMessage": routing_message, "hookSpecificOutput": output}
212226

213227

@@ -271,6 +285,9 @@ def write_decision_record(
271285
"task_name": task_name,
272286
"router_model": decision.raw_model,
273287
"requested_model": requested_model,
288+
# Persisted for diagnosis: an empty value means the gateway returned
289+
# no rationale (vs. a placement bug in how we surface it).
290+
"rationale": decision.rationale,
274291
"at": time.time(),
275292
},
276293
)

tests/test_cli.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from typer.testing import CliRunner
1212

1313
from ucode.cli import app
14+
from ucode.smart_routing.routing import RoutingDecision
1415

1516
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
1617

@@ -226,7 +227,11 @@ def test_enabled_codex_launch_uses_routed_root_model(self):
226227
"smart_routing_enabled": True,
227228
"codex_models": ["databricks-gpt-5", "databricks-gpt-5-5"],
228229
}
229-
decision = MagicMock(model="databricks-gpt-5-5")
230+
decision = RoutingDecision(
231+
model="databricks-gpt-5-5",
232+
raw_model="gpt-5-6-sol",
233+
rationale="Cross-cutting refactor.",
234+
)
230235
with (
231236
patch("ucode.cli.ensure_bootstrap_dependencies"),
232237
patch("ucode.cli.load_state", return_value=state),
@@ -244,7 +249,11 @@ def test_enabled_codex_launch_uses_routed_root_model(self):
244249

245250
assert result.exit_code == 0, result.output
246251
assert mock_configure.call_args.args[2] == "databricks-gpt-5-5"
247-
assert "Using Smart Routing. Routing to databricks-gpt-5-5." in _strip_ansi(result.output)
252+
# The launch notice surfaces both the routed model and the rationale.
253+
assert (
254+
"Using Smart Routing. Routing to databricks-gpt-5-5. Cross-cutting refactor."
255+
in _strip_ansi(result.output)
256+
)
248257

249258

250259
class TestMcpSubcommands:

tests/test_codex_routing.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,11 @@ def test_spawn_rewrite_preserves_original_input(monkeypatch):
143143
)
144144

145145
hook = output["hookSpecificOutput"]
146-
assert output["systemMessage"] == "Using Smart Routing. Routing to gpt-5.5."
146+
# The rationale is surfaced in BOTH the systemMessage (shown to the user) and
147+
# permissionDecisionReason, so the "why" is visible, not just the "what".
148+
assert output["systemMessage"] == (
149+
"Using Smart Routing. Routing to gpt-5.5. Review needs deeper reasoning."
150+
)
147151
assert hook["permissionDecision"] == "allow"
148152
assert hook["updatedInput"] == {
149153
"task_name": "reviewer",
@@ -276,3 +280,37 @@ def test_decision_is_reconciled_with_actual_subagent_model(tmp_path, monkeypatch
276280
assert record["router_model"] == "gpt-5-6-luna"
277281
assert record["requested_model"] == "gpt-5.6-luna"
278282
assert record["matches_router_decision"] is True
283+
284+
285+
def test_decision_record_persists_rationale(tmp_path, monkeypatch):
286+
decisions = tmp_path / "decisions.jsonl"
287+
monkeypatch.setattr(codex_routing, "DECISIONS_PATH", decisions)
288+
monkeypatch.setattr(
289+
codex_routing,
290+
"request_routing_decision",
291+
lambda *args, **kwargs: (
292+
codex_routing.RoutingDecision(
293+
model="system.ai.gpt-5-6-sol",
294+
raw_model="gpt-5-6-sol",
295+
rationale="Cross-cutting refactor needs the strongest model.",
296+
),
297+
None,
298+
),
299+
)
300+
301+
codex_routing.route_pre_tool_use(
302+
{
303+
"session_id": "s1",
304+
"tool_name": "collaborationspawn_agent",
305+
"tool_input": {"task_name": "refactor", "message": "encrypted"},
306+
},
307+
workspace=WS,
308+
token="token",
309+
available_models=["system.ai.gpt-5-6-sol", "system.ai.gpt-5-6-luna"],
310+
audit_decision=True,
311+
)
312+
313+
# The rationale is persisted so an empty value distinguishes "gateway
314+
# returned none" from a display-placement bug.
315+
record = json.loads(decisions.read_text().strip())
316+
assert record["rationale"] == "Cross-cutting refactor needs the strongest model."

0 commit comments

Comments
 (0)