Skip to content

Commit 579a5cb

Browse files
committed
Route root model on the launch-time seed prompt when present
When the user's first prompt is on the command line (`codex "<prompt>"`, `codex exec "<prompt>"`, or after `--`), route the root-session model on that actual prompt. A bare interactive launch carries no prompt, so routing is skipped entirely and the user's configured default model is kept — with no task signal the router could only return its floor arm, so routing there would add a round-trip and silently downgrade the default. Routing a typed-in first prompt is out of scope (no hook/MCP can retarget the root model mid-session on Codex or Claude Code). Adds a shared, conservative `extract_seed_prompt` to routing.py: it skips value-taking options and their values, treats unknown flags as booleans, and honors the `--` positional separator, returning None (→ skip routing) unless it is confident it found the prompt. Co-authored-by: Isaac
1 parent 8b9957f commit 579a5cb

3 files changed

Lines changed: 140 additions & 11 deletions

File tree

src/ucode/smart_routing/codex_routing.py

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
# ``codex_routing.urllib.request`` — the actual call lives in ``routing``, but
1414
# Python modules are singletons so patching this name patches the one call site.
1515
import urllib.request # noqa: F401
16-
from pathlib import Path
1716
from typing import Any
1817

1918
from ucode.config_io import APP_DIR
@@ -43,7 +42,18 @@
4342

4443

4544
def route_launch_model(state: dict, tool_args: list[str]):
46-
"""Route a root Codex launch before the Codex process starts."""
45+
"""Route a root Codex launch on the launch-time prompt, if there is one.
46+
47+
Returns (None, None) when the launch carries no prompt (a bare interactive
48+
session): with no task signal the router can only return its floor arm, so
49+
routing would just add a round-trip and silently override the user's default
50+
model. In that case we don't route and keep the configured default. Routing
51+
on a typed-in first prompt is out of scope — no hook/MCP can retarget the
52+
root model once the session is running.
53+
"""
54+
task = _launch_routing_task(tool_args)
55+
if task is None:
56+
return None, None
4757
workspace = state.get("workspace")
4858
models = state.get("codex_models")
4959
if not isinstance(workspace, str) or not isinstance(models, list):
@@ -52,18 +62,43 @@ def route_launch_model(state: dict, tool_args: list[str]):
5262
token = get_databricks_token(workspace, state.get("profile"))
5363
except RuntimeError as exc:
5464
return None, f"could not authenticate the routing request: {exc}"
55-
task = _launch_routing_task(tool_args)
5665
return request_routing_decision(workspace, token, task, models)
5766

5867

59-
def _launch_routing_task(tool_args: list[str]) -> str:
60-
if "exec" in tool_args:
61-
prompt_parts = tool_args[tool_args.index("exec") + 1 :]
62-
if prompt_parts:
63-
return " ".join(prompt_parts)
64-
if tool_args:
65-
return "Start a Codex session with options: " + " ".join(tool_args)
66-
return f"Start an interactive Codex coding session in {Path.cwd().name}."
68+
# Codex CLI options that consume a following value (from `codex --help`); their
69+
# values must not be mistaken for the seed prompt when parsing launch args.
70+
_CODEX_VALUE_OPTIONS = frozenset(
71+
{
72+
"-c",
73+
"--config",
74+
"-i",
75+
"--image",
76+
"-m",
77+
"--model",
78+
"-p",
79+
"--profile",
80+
"-s",
81+
"--sandbox",
82+
"-a",
83+
"--ask-for-approval",
84+
"-C",
85+
"--cd",
86+
"--add-dir",
87+
"--enable",
88+
"--disable",
89+
"--local-provider",
90+
"--remote",
91+
"--remote-auth-token-env",
92+
}
93+
)
94+
95+
96+
def _launch_routing_task(tool_args: list[str]) -> str | None:
97+
# The routing task is the user's real first prompt when it's on the command
98+
# line (`codex "<prompt>"`, `codex exec "<prompt>"`, or after `--`). A bare
99+
# interactive launch has no prompt yet → None, and the caller skips routing
100+
# (the root model can't be re-routed once the TUI is running).
101+
return routing.extract_seed_prompt(tool_args, _CODEX_VALUE_OPTIONS)
67102

68103

69104
def request_routing_decision(

src/ucode/smart_routing/routing.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,60 @@ def normalize_model(model: str) -> str:
4848
return tail.lower()
4949

5050

51+
def extract_seed_prompt(tool_args: list[str], value_options: frozenset[str]) -> str | None:
52+
"""Recover a launch-time seed prompt from the passthrough CLI args.
53+
54+
A coding agent launched as ``<tool> [OPTIONS] [PROMPT]`` (or with an explicit
55+
``exec <PROMPT>`` subcommand) may carry the user's first prompt on the command
56+
line. When it does, routing the root-session model on that real prompt beats
57+
the generic placeholder. Everything after a ``--`` separator is treated as
58+
positional (the agent's own convention for "stop parsing flags").
59+
60+
``value_options`` is the set of the tool's flags that consume a following
61+
value (e.g. ``-m``/``--model``); their values are skipped so a flag argument
62+
is never mistaken for the prompt. Returns the joined positional tokens, or
63+
None when the args carry no unambiguous prompt (bare launch, or only flags) —
64+
the caller then falls back to its placeholder task.
65+
66+
Conservative by design: an unrecognized ``--flag`` (not in ``value_options``)
67+
is treated as a boolean and skipped, and ``--flag=value`` forms are skipped
68+
whole. We only return text we are confident is the user's prompt.
69+
"""
70+
if "exec" in tool_args:
71+
after = tool_args[tool_args.index("exec") + 1 :]
72+
positionals = _positional_args(after, value_options)
73+
return " ".join(positionals) if positionals else None
74+
positionals = _positional_args(tool_args, value_options)
75+
return " ".join(positionals) if positionals else None
76+
77+
78+
def _positional_args(args: list[str], value_options: frozenset[str]) -> list[str]:
79+
positionals: list[str] = []
80+
i = 0
81+
seen_double_dash = False
82+
while i < len(args):
83+
arg = args[i]
84+
if seen_double_dash:
85+
positionals.append(arg)
86+
i += 1
87+
continue
88+
if arg == "--":
89+
seen_double_dash = True
90+
i += 1
91+
continue
92+
if arg.startswith("-") and arg != "-":
93+
# `--opt=value` carries its own value; a bare option in value_options
94+
# consumes the next token. Anything else is a boolean flag we skip.
95+
if "=" not in arg and arg in value_options:
96+
i += 2
97+
else:
98+
i += 1
99+
continue
100+
positionals.append(arg)
101+
i += 1
102+
return positionals
103+
104+
51105
def select_route(
52106
workspace: str,
53107
token: str,

tests/test_agent_codex.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,46 @@ def test_disable_removes_only_ucode_hooks(self, tmp_path, monkeypatch):
366366
def test_launch_task_uses_exec_prompt(self):
367367
assert codex_routing._launch_routing_task(["exec", "fix the parser"]) == "fix the parser"
368368

369+
def test_launch_task_uses_positional_interactive_prompt(self):
370+
# `codex "fix the parser"` — the seed prompt is routed directly, not
371+
# wrapped in a placeholder.
372+
assert codex_routing._launch_routing_task(["fix the parser"]) == "fix the parser"
373+
374+
def test_launch_task_skips_value_option_before_prompt(self):
375+
# `-m <model>` consumes its value; the model id must not be taken as the
376+
# prompt.
377+
assert (
378+
codex_routing._launch_routing_task(["-m", "gpt-5", "refactor the parser"])
379+
== "refactor the parser"
380+
)
381+
382+
def test_launch_task_honors_double_dash(self):
383+
assert (
384+
codex_routing._launch_routing_task(["--", "--not-a-flag prompt"])
385+
== "--not-a-flag prompt"
386+
)
387+
388+
def test_launch_task_bare_launch_returns_none(self):
389+
# No prompt on the command line → None, so the caller skips routing and
390+
# keeps the user's default model (root model can't be re-routed once the
391+
# TUI is up).
392+
assert codex_routing._launch_routing_task([]) is None
393+
394+
def test_launch_task_flags_only_returns_none(self):
395+
assert codex_routing._launch_routing_task(["--search", "-m", "gpt-5"]) is None
396+
397+
def test_route_launch_model_skips_routing_without_prompt(self, monkeypatch):
398+
# Bare launch: no router call at all, no decision, no error.
399+
def fail(*args, **kwargs):
400+
raise AssertionError("router must not be called on a bare launch")
401+
402+
monkeypatch.setattr(codex_routing, "request_routing_decision", fail)
403+
decision, error = codex_routing.route_launch_model(
404+
{"workspace": WS, "codex_models": ["system.ai.gpt-5-6-sol"]}, []
405+
)
406+
assert decision is None
407+
assert error is None
408+
369409

370410
class TestCodexRemoveLegacyProfile:
371411
def test_drops_provider_block_on_modern_path(self, tmp_path, monkeypatch):

0 commit comments

Comments
 (0)