Skip to content

Commit 0dd42a9

Browse files
Merge pull request #818 from kvandre12-commits/assist/pr-760-refresh
Refresh per-run model routing hook on current main
2 parents 41cb30b + 976bc92 commit 0dd42a9

7 files changed

Lines changed: 291 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ approval. With `fail_closed=True` its exception is reported as a block instead.
7676
| `invoke_agent` | Sub-agent invoked | `(*args, **kwargs) -> None` |
7777
| `agent_exception` | Unhandled agent error | `(exception, *args, **kwargs) -> None` |
7878
| `agent_run_start` | Before agent task | `(agent_name, model_name, session_id=None) -> None` |
79+
| `model_select` | Select a model for one run | `(*, agent_name, current_model, prompt, messages, session_id=None) -> str \| None` — first non-empty result wins |
7980
| `agent_run_end` | After agent run | `(agent_name, model_name, session_id=None, success=True, error=None, response_text=None, metadata=None) -> None` |
8081
| `load_prompt` | System prompt assembly | `() -> str \| None` |
8182
| `run_shell_command` | Before shell exec | `(context, command, cwd=None, timeout=60) -> dict \| None` (return `{"blocked": True}` to block, `{"rewrite": "<new cmd>"}` to transparently transform) |

code_puppy/agents/_runtime.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,20 @@ async def _run_with_mcp_impl(
693693
# Hook failures must never block the run.
694694
pass
695695

696+
# Let a ``model_select`` hook route THIS turn to a different model (e.g.
697+
# small-vs-large by complexity) before the pydantic agent is built. This
698+
# resets any prior turn's auto choice, respects an explicit runtime
699+
# override, and invalidates the cached agent when the model changes so the
700+
# build below picks it up. No-op (and near-zero cost) if no plugin
701+
# registered the hook.
702+
try:
703+
from code_puppy.model_switching import resolve_run_model_selection
704+
705+
resolve_run_model_selection(agent, prompt, agent._message_history, group_id)
706+
except Exception:
707+
# Selection must never block a run.
708+
pass
709+
696710
if agent._code_generation_agent is None:
697711
build_pydantic_agent(agent)
698712
pydantic_agent = agent._code_generation_agent

code_puppy/agents/base_agent.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ def __init__(self) -> None:
7272
self._last_model_name: Optional[str] = None
7373
self._runtime_model_name_override: Optional[str] = None
7474
self._runtime_system_prompt_additions: List[str] = []
75+
# Model chosen by a ``model_select`` hook for the current run. Slots
76+
# below an explicit runtime override but above pinned/JSON/global, and
77+
# is reset at the start of every run (see resolve_run_model_selection),
78+
# so it never leaks across turns.
79+
self._auto_model_override: Optional[str] = None
7580
self._puppy_rules: Optional[str] = None
7681
self._mcp_servers: List[Any] = []
7782
self.cur_model: Optional[pydantic_ai.models.Model] = None
@@ -125,6 +130,14 @@ def set_runtime_model_name_override(self, model_name: Optional[str]) -> None:
125130
"""
126131
self._runtime_model_name_override = model_name
127132

133+
def get_auto_model_override(self) -> Optional[str]:
134+
"""Return the model chosen by a ``model_select`` hook for this run."""
135+
return self._auto_model_override
136+
137+
def set_auto_model_override(self, model_name: Optional[str]) -> None:
138+
"""Set the ``model_select``-chosen model for this run (not persisted)."""
139+
self._auto_model_override = model_name
140+
128141
@contextmanager
129142
def temporary_model_name_override(
130143
self, model_name: Optional[str]
@@ -154,6 +167,9 @@ def get_model_name(self) -> Optional[str]:
154167
override = self.get_runtime_model_name_override()
155168
if override:
156169
return override
170+
auto = self.get_auto_model_override()
171+
if auto:
172+
return auto
157173
pinned = get_agent_pinned_model(self.name)
158174
return pinned if pinned else get_global_model_name()
159175

code_puppy/agents/json_agent.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,13 @@ def get_model_name(self) -> Optional[str]:
220220
if override:
221221
return override
222222

223+
# A ``model_select`` hook choice outranks the JSON ``model`` field so
224+
# per-turn routing works for JSON agents too (see get_model_name in
225+
# BaseAgent for the full precedence ladder).
226+
auto = self.get_auto_model_override()
227+
if auto:
228+
return auto
229+
223230
result = self._config.get("model")
224231
if result is None or (isinstance(result, str) and not result.strip()):
225232
result = super().get_model_name()

code_puppy/callbacks.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"agent_run_start",
4747
"agent_run_end",
4848
"agent_run_result",
49+
"model_select",
4950
"register_mcp_catalog_servers",
5051
"register_browser_types",
5152
"register_model_providers",
@@ -132,6 +133,7 @@ def __repr__(self) -> str:
132133
"agent_run_start": [],
133134
"agent_run_end": [],
134135
"agent_run_result": [],
136+
"model_select": [],
135137
"register_mcp_catalog_servers": [],
136138
"register_browser_types": [],
137139
"register_model_providers": [],
@@ -1154,6 +1156,50 @@ async def on_agent_run_start(
11541156
)
11551157

11561158

1159+
def on_model_select(
1160+
*,
1161+
agent_name: str,
1162+
current_model: str | None,
1163+
prompt: str,
1164+
messages: List[Any],
1165+
session_id: str | None = None,
1166+
) -> str | None:
1167+
"""Ask plugins to choose the model for the current run.
1168+
1169+
Fires once per run, before the pydantic agent is (re)built. Lets a plugin
1170+
route each turn to a different model based on the agent, the effective
1171+
("would-be") model, and the message history -- e.g. a small model for
1172+
trivial turns and a frontier model when it matters, or a cost/latency/
1173+
failover policy.
1174+
1175+
Precedence: an explicit runtime override still wins over this hook; this
1176+
hook wins over the pinned / JSON / global model. The first callback to
1177+
return a non-empty string wins; return ``None`` to defer.
1178+
1179+
Args:
1180+
agent_name: Name of the agent about to run.
1181+
current_model: The model that would be used absent any hook.
1182+
prompt: The current user prompt after submission hooks have rewritten it.
1183+
messages: The agent's prior message history for this run.
1184+
session_id: Optional per-run identifier.
1185+
1186+
Returns:
1187+
A model name to use for this run, or ``None`` to keep ``current_model``.
1188+
"""
1189+
results = _trigger_callbacks_sync(
1190+
"model_select",
1191+
agent_name=agent_name,
1192+
current_model=current_model,
1193+
prompt=prompt,
1194+
messages=messages,
1195+
session_id=session_id,
1196+
)
1197+
for result in results:
1198+
if isinstance(result, str) and result.strip():
1199+
return result
1200+
return None
1201+
1202+
11571203
async def on_agent_run_end(
11581204
agent_name: str,
11591205
model_name: str,

code_puppy/model_switching.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,65 @@
22

33
from __future__ import annotations
44

5-
from typing import Optional
5+
from typing import Any, List, Optional
66

77
from code_puppy.config import set_model_name
88

99

10+
def resolve_run_model_selection(
11+
agent: Any,
12+
prompt: str,
13+
messages: List[Any],
14+
session_id: Optional[str] = None,
15+
) -> Optional[str]:
16+
"""Apply the ``model_select`` hook for one run and return the chosen model.
17+
18+
Called once at the start of every run, BEFORE the pydantic agent is built.
19+
20+
Behaviour / precedence:
21+
* The per-run auto override is always cleared first, so a choice never
22+
leaks into a later turn.
23+
* An explicit runtime override (e.g. ``invoke_agent_with_model``) wins and
24+
short-circuits the hook entirely.
25+
* Otherwise the hook is asked to pick a model. If it returns a name that
26+
differs from the effective model, we install it as the run's auto
27+
override and invalidate the cached pydantic agent so the build picks up
28+
the new model.
29+
30+
Returns the chosen model name if the hook changed it, else ``None``.
31+
Never raises -- a misbehaving selector must not break a run.
32+
"""
33+
try:
34+
# Reset any previous turn's auto choice so it can't leak forward.
35+
if agent.get_auto_model_override():
36+
agent.set_auto_model_override(None)
37+
agent._code_generation_agent = None
38+
39+
# Explicit runtime override always wins -- don't even ask the hook.
40+
if agent.get_runtime_model_name_override():
41+
return None
42+
43+
from code_puppy.callbacks import on_model_select
44+
45+
current = agent.get_model_name()
46+
selected = on_model_select(
47+
agent_name=getattr(agent, "name", None),
48+
current_model=current,
49+
prompt=prompt,
50+
messages=messages or [],
51+
session_id=session_id,
52+
)
53+
if selected and selected != current:
54+
agent.set_auto_model_override(selected)
55+
# Force a rebuild so the new model is actually used this turn.
56+
agent._code_generation_agent = None
57+
return selected
58+
except Exception:
59+
# A broken selector must never block the agent run.
60+
pass
61+
return None
62+
63+
1064
def _get_effective_agent_model(agent) -> Optional[str]:
1165
"""Safely fetch the effective model name for an agent."""
1266
try:

tests/test_model_select_hook.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Tests for the ``model_select`` hook and per-run model resolution.
2+
3+
Covers ``callbacks.on_model_select`` and
4+
``model_switching.resolve_run_model_selection`` -- the two new pieces that let
5+
a plugin route each turn to a different model (small-vs-large auto mode, cost
6+
caps, failover, ...).
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import pytest
12+
13+
from code_puppy.callbacks import (
14+
clear_callbacks,
15+
on_model_select,
16+
register_callback,
17+
)
18+
from code_puppy.model_switching import resolve_run_model_selection
19+
20+
21+
@pytest.fixture(autouse=True)
22+
def _clean_hook():
23+
clear_callbacks("model_select")
24+
yield
25+
clear_callbacks("model_select")
26+
27+
28+
class FakeAgent:
29+
"""Minimal stand-in exposing the model-override surface resolve() uses."""
30+
31+
def __init__(self, base="global-model", runtime=None, pinned=None):
32+
self.name = "fake"
33+
self._base = base
34+
self._runtime = runtime
35+
self._auto = None
36+
self._code_generation_agent = object() # non-None = "cached build"
37+
self._message_history = []
38+
39+
def get_runtime_model_name_override(self):
40+
return self._runtime
41+
42+
def get_auto_model_override(self):
43+
return self._auto
44+
45+
def set_auto_model_override(self, name):
46+
self._auto = name
47+
48+
def get_model_name(self):
49+
# Mirrors BaseAgent precedence: runtime > auto > base.
50+
return self._runtime or self._auto or self._base
51+
52+
53+
# ---- on_model_select -------------------------------------------------------
54+
55+
56+
def test_on_model_select_no_callbacks_returns_none():
57+
assert (
58+
on_model_select(
59+
agent_name="fake",
60+
current_model="m",
61+
prompt="hello",
62+
messages=[],
63+
session_id=None,
64+
)
65+
is None
66+
)
67+
68+
69+
def test_on_model_select_returns_first_nonempty_result():
70+
register_callback("model_select", lambda **k: None)
71+
register_callback("model_select", lambda **k: "")
72+
register_callback("model_select", lambda **k: "small-model")
73+
74+
assert (
75+
on_model_select(
76+
agent_name="fake",
77+
current_model="big",
78+
prompt="fix the parser",
79+
messages=[],
80+
session_id="s",
81+
)
82+
== "small-model"
83+
)
84+
85+
86+
def test_on_model_select_passes_context_to_callback():
87+
seen = {}
88+
89+
def selector(**kwargs):
90+
seen.update(kwargs)
91+
return None
92+
93+
register_callback("model_select", selector)
94+
on_model_select(
95+
agent_name="orch",
96+
current_model="big",
97+
prompt="current turn",
98+
messages=[1, 2],
99+
session_id="x",
100+
)
101+
assert seen["agent_name"] == "orch"
102+
assert seen["current_model"] == "big"
103+
assert seen["prompt"] == "current turn"
104+
assert seen["messages"] == [1, 2]
105+
assert seen["session_id"] == "x"
106+
107+
108+
# ---- resolve_run_model_selection ------------------------------------------
109+
110+
111+
def test_resolve_applies_hook_choice_and_invalidates_cache():
112+
register_callback("model_select", lambda **k: "small-model")
113+
agent = FakeAgent(base="big-model")
114+
chosen = resolve_run_model_selection(agent, "current prompt", [], "s")
115+
assert chosen == "small-model"
116+
assert agent.get_auto_model_override() == "small-model"
117+
assert agent._code_generation_agent is None # forced rebuild
118+
119+
120+
def test_resolve_noop_when_hook_picks_same_model():
121+
register_callback("model_select", lambda **k: "big-model")
122+
agent = FakeAgent(base="big-model")
123+
assert resolve_run_model_selection(agent, "current prompt", [], "s") is None
124+
assert agent.get_auto_model_override() is None
125+
assert agent._code_generation_agent is not None # no rebuild
126+
127+
128+
def test_explicit_runtime_override_beats_hook():
129+
register_callback("model_select", lambda **k: "small-model")
130+
agent = FakeAgent(base="big-model", runtime="user-picked")
131+
assert resolve_run_model_selection(agent, "current prompt", [], "s") is None
132+
assert agent.get_auto_model_override() is None # hook never consulted
133+
134+
135+
def test_prior_auto_choice_is_reset_each_run():
136+
# No hook registered this run; a stale auto choice must be cleared.
137+
agent = FakeAgent(base="big-model")
138+
agent.set_auto_model_override("stale-small")
139+
assert resolve_run_model_selection(agent, "current prompt", [], "s") is None
140+
assert agent.get_auto_model_override() is None
141+
assert agent._code_generation_agent is None # invalidated on reset
142+
143+
144+
def test_broken_selector_never_raises():
145+
def boom(**k):
146+
raise RuntimeError("selector exploded")
147+
148+
register_callback("model_select", boom)
149+
agent = FakeAgent(base="big-model")
150+
# Must swallow the error and leave the run on its normal model.
151+
assert resolve_run_model_selection(agent, "current prompt", [], "s") is None
152+
assert agent.get_auto_model_override() is None

0 commit comments

Comments
 (0)